Files
2026-06-22 08:55:57 +08:00

295 lines
9.4 KiB
Go

package codeindex
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/pgvector/pgvector-go"
codeindexsqlc "github.com/yan1h/agent-coding-workflow/internal/codeindex/sqlc"
"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
)
// ChunkRow is a fully-populated chunk ready for bulk insert (embedding included).
type ChunkRow struct {
FilePath string
Lang string
StartLine int
EndLine int
Content string
ContentSHA []byte
TokenCount int
Embedding []float32
}
// Repository is the codeindex persistence contract.
type Repository interface {
InsertRun(ctx context.Context, r *Run) (*Run, error)
GetRun(ctx context.Context, id uuid.UUID) (*Run, error)
GetRunByCommit(ctx context.Context, wsID uuid.UUID, commitSHA, model string) (*Run, error)
GetLatestCompletedRun(ctx context.Context, wsID uuid.UUID) (*Run, error)
MarkRunning(ctx context.Context, id uuid.UUID) error
MarkCompleted(ctx context.Context, id uuid.UUID, files, chunks int) error
MarkFailed(ctx context.Context, id uuid.UUID, errMsg string) error
MarkStale(ctx context.Context, wsID uuid.UUID) error
MarkStuckStale(ctx context.Context, runningBefore time.Time) ([]uuid.UUID, error)
// ReplaceChunks deletes any existing chunks for run then bulk-inserts rows.
ReplaceChunks(ctx context.Context, runID, wsID uuid.UUID, commitSHA string, rows []ChunkRow) error
// SearchKNN returns the top-k chunks of the latest completed run for wsID
// ordered by cosine similarity to queryVec, with optional path/lang filters.
SearchKNN(ctx context.Context, wsID uuid.UUID, queryVec []float32, topK int, pathPrefix, lang string) ([]Snippet, error)
}
// PgRepository is the production Repository on a pgxpool.Pool.
type PgRepository struct {
pool *pgxpool.Pool
q *codeindexsqlc.Queries
}
// NewPostgresRepository builds a PgRepository.
func NewPostgresRepository(pool *pgxpool.Pool) *PgRepository {
return &PgRepository{pool: pool, q: codeindexsqlc.New(pool)}
}
var _ Repository = (*PgRepository)(nil)
func pgUUID(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: u, Valid: true} }
func pgUUIDPtr(u *uuid.UUID) pgtype.UUID {
if u == nil {
return pgtype.UUID{}
}
return pgtype.UUID{Bytes: *u, Valid: true}
}
func uuidPtr(p pgtype.UUID) *uuid.UUID {
if !p.Valid {
return nil
}
id := uuid.UUID(p.Bytes)
return &id
}
func runFromRow(r codeindexsqlc.CodeIndexRun) *Run {
out := &Run{
ID: uuid.UUID(r.ID.Bytes),
WorkspaceID: uuid.UUID(r.WorkspaceID.Bytes),
WorktreeID: uuidPtr(r.WorktreeID),
Branch: r.Branch,
CommitSHA: r.CommitSha,
Status: r.Status,
EmbeddingEndpointID: uuidPtr(r.EmbeddingEndpointID),
EmbeddingModel: r.EmbeddingModel,
Dims: int(r.Dims),
FilesIndexed: int(r.FilesIndexed),
ChunksIndexed: int(r.ChunksIndexed),
Error: r.Error,
}
if r.CreatedAt.Valid {
out.CreatedAt = r.CreatedAt.Time
}
return out
}
func (r *PgRepository) InsertRun(ctx context.Context, run *Run) (*Run, error) {
row, err := r.q.InsertRun(ctx, codeindexsqlc.InsertRunParams{
ID: pgUUID(run.ID),
WorkspaceID: pgUUID(run.WorkspaceID),
WorktreeID: pgUUIDPtr(run.WorktreeID),
Branch: run.Branch,
CommitSha: run.CommitSHA,
Status: StatusPending,
EmbeddingEndpointID: pgUUIDPtr(run.EmbeddingEndpointID),
EmbeddingModel: run.EmbeddingModel,
Dims: int32(run.Dims),
})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "insert code index run")
}
return runFromRow(row), nil
}
func (r *PgRepository) GetRun(ctx context.Context, id uuid.UUID) (*Run, error) {
row, err := r.q.GetRun(ctx, pgUUID(id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, errs.New(errs.CodeNotFound, "code index run not found")
}
return nil, errs.Wrap(err, errs.CodeInternal, "get code index run")
}
return runFromRow(row), nil
}
func (r *PgRepository) GetRunByCommit(ctx context.Context, wsID uuid.UUID, commitSHA, model string) (*Run, error) {
row, err := r.q.GetRunByCommit(ctx, codeindexsqlc.GetRunByCommitParams{
WorkspaceID: pgUUID(wsID),
CommitSha: commitSHA,
EmbeddingModel: model,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, errs.Wrap(err, errs.CodeInternal, "get run by commit")
}
return runFromRow(row), nil
}
func (r *PgRepository) GetLatestCompletedRun(ctx context.Context, wsID uuid.UUID) (*Run, error) {
row, err := r.q.GetLatestCompletedRun(ctx, pgUUID(wsID))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, errs.Wrap(err, errs.CodeInternal, "get latest completed run")
}
return runFromRow(row), nil
}
func (r *PgRepository) MarkRunning(ctx context.Context, id uuid.UUID) error {
if err := r.q.MarkRunRunning(ctx, pgUUID(id)); err != nil {
return errs.Wrap(err, errs.CodeInternal, "mark run running")
}
return nil
}
func (r *PgRepository) MarkCompleted(ctx context.Context, id uuid.UUID, files, chunks int) error {
if err := r.q.MarkRunCompleted(ctx, codeindexsqlc.MarkRunCompletedParams{
ID: pgUUID(id),
FilesIndexed: int32(files),
ChunksIndexed: int32(chunks),
}); err != nil {
return errs.Wrap(err, errs.CodeInternal, "mark run completed")
}
return nil
}
func (r *PgRepository) MarkFailed(ctx context.Context, id uuid.UUID, errMsg string) error {
msg := errMsg
if err := r.q.MarkRunFailed(ctx, codeindexsqlc.MarkRunFailedParams{
ID: pgUUID(id),
Error: &msg,
}); err != nil {
return errs.Wrap(err, errs.CodeInternal, "mark run failed")
}
return nil
}
func (r *PgRepository) MarkStale(ctx context.Context, wsID uuid.UUID) error {
if err := r.q.MarkRunsStale(ctx, pgUUID(wsID)); err != nil {
return errs.Wrap(err, errs.CodeInternal, "mark runs stale")
}
return nil
}
func (r *PgRepository) MarkStuckStale(ctx context.Context, runningBefore time.Time) ([]uuid.UUID, error) {
rows, err := r.q.MarkStuckRunsStale(ctx, pgtype.Timestamptz{Time: runningBefore, Valid: true})
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "mark stuck runs stale")
}
out := make([]uuid.UUID, 0, len(rows))
for _, p := range rows {
out = append(out, uuid.UUID(p.Bytes))
}
return out, nil
}
// ReplaceChunks deletes prior chunks for the run and bulk-inserts the given rows
// in a single transaction. The embedding column uses the pgvector text literal.
func (r *PgRepository) ReplaceChunks(ctx context.Context, runID, wsID uuid.UUID, commitSHA string, rows []ChunkRow) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return errs.Wrap(err, errs.CodeInternal, "begin chunk tx")
}
defer func() { _ = tx.Rollback(ctx) }()
if _, err := tx.Exec(ctx, `DELETE FROM code_chunks WHERE run_id = $1`, pgUUID(runID)); err != nil {
return errs.Wrap(err, errs.CodeInternal, "delete prior chunks")
}
const insertSQL = `INSERT INTO code_chunks (
id, run_id, workspace_id, commit_sha, file_path, lang,
start_line, end_line, content, content_sha, token_count, embedding
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::vector)`
batch := &pgx.Batch{}
for _, row := range rows {
var lang *string
if row.Lang != "" {
l := row.Lang
lang = &l
}
var tok *int32
if row.TokenCount > 0 {
t := int32(row.TokenCount)
tok = &t
}
batch.Queue(insertSQL,
pgUUID(uuid.New()), pgUUID(runID), pgUUID(wsID), commitSHA,
row.FilePath, lang, int32(row.StartLine), int32(row.EndLine),
row.Content, row.ContentSHA, tok, pgvector.NewVector(row.Embedding),
)
}
br := tx.SendBatch(ctx, batch)
for range rows {
if _, err := br.Exec(); err != nil {
_ = br.Close()
return errs.Wrap(err, errs.CodeInternal, "insert chunk")
}
}
if err := br.Close(); err != nil {
return errs.Wrap(err, errs.CodeInternal, "close chunk batch")
}
if err := tx.Commit(ctx); err != nil {
return errs.Wrap(err, errs.CodeInternal, "commit chunk tx")
}
return nil
}
// SearchKNN runs cosine-distance KNN scoped to the latest completed run for the
// workspace. Score is 1 - cosine_distance (so higher is more similar). Optional
// path_prefix / lang filters narrow the candidate set.
func (r *PgRepository) SearchKNN(ctx context.Context, wsID uuid.UUID, queryVec []float32, topK int, pathPrefix, lang string) ([]Snippet, error) {
if topK <= 0 {
topK = 10
}
q := `
WITH latest AS (
SELECT id FROM code_index_runs
WHERE workspace_id = $1 AND status = 'completed'
ORDER BY created_at DESC LIMIT 1
)
SELECT c.file_path, c.start_line, c.end_line, c.content, c.commit_sha,
1 - (c.embedding <=> $2::vector) AS score
FROM code_chunks c
JOIN latest ON c.run_id = latest.id
WHERE ($3::text = '' OR c.file_path LIKE $3 || '%')
AND ($4::text = '' OR c.lang = $4)
ORDER BY c.embedding <=> $2::vector
LIMIT $5`
rows, err := r.pool.Query(ctx, q, pgUUID(wsID), pgvector.NewVector(queryVec), pathPrefix, lang, int32(topK))
if err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "knn search")
}
defer rows.Close()
var out []Snippet
for rows.Next() {
var s Snippet
var start, end int32
if err := rows.Scan(&s.FilePath, &start, &end, &s.Content, &s.CommitSHA, &s.Score); err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "scan knn row")
}
s.StartLine, s.EndLine = int(start), int(end)
out = append(out, s)
}
if err := rows.Err(); err != nil {
return nil, errs.Wrap(err, errs.CodeInternal, "knn rows")
}
return out, nil
}