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

192 lines
5.9 KiB
Go

package codeindex
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"github.com/yan1h/agent-coding-workflow/internal/infra/git"
"github.com/yan1h/agent-coding-workflow/internal/infra/llm"
"github.com/yan1h/agent-coding-workflow/internal/jobs"
)
// EmbedBatchSize bounds how many chunks are sent to the embedder per request.
const EmbedBatchSize = 64
// FailureNotifier is an optional best-effort callback invoked when a build run
// fails, so app.go can broadcast to admins without the runner importing user/notify.
type FailureNotifier func(ctx context.Context, run *Run, cause error)
// BuildRunner is the jobs.Handler that materializes a code_index_runs row into
// embedded code_chunks. It is event-driven (enqueued by EnqueueBuild), never a
// periodic ticker. Idempotent: re-running for the same run replaces its chunks.
type BuildRunner struct {
repo Repository
locator WorkspaceLocator
gitr git.Runner
embedSel EmbedderSelector
chunker *Chunker
notify FailureNotifier
log *slog.Logger
}
// NewBuildRunner constructs a BuildRunner. notify and log may be nil.
func NewBuildRunner(repo Repository, locator WorkspaceLocator, gitr git.Runner, embedSel EmbedderSelector, chunker *Chunker, notify FailureNotifier, log *slog.Logger) *BuildRunner {
if chunker == nil {
chunker = NewChunker()
}
if log == nil {
log = slog.Default()
}
return &BuildRunner{repo: repo, locator: locator, gitr: gitr, embedSel: embedSel, chunker: chunker, notify: notify, log: log}
}
var _ jobs.Handler = (*BuildRunner)(nil)
// Type returns the job type this handler processes.
func (h *BuildRunner) Type() jobs.JobType { return JobTypeBuild }
// Handle builds the index for the run referenced in the payload. On any error it
// marks the run failed and notifies admins; the returned error drives jobs retry.
func (h *BuildRunner) Handle(ctx context.Context, job jobs.Job) error {
var p buildPayload
if err := json.Unmarshal(job.Payload, &p); err != nil {
return jobs.Permanent(fmt.Errorf("codeindex.build: bad payload: %w", err))
}
run, err := h.repo.GetRun(ctx, p.RunID)
if err != nil {
return jobs.Permanent(fmt.Errorf("codeindex.build: load run %s: %w", p.RunID, err))
}
// Skip runs superseded by a newer one (stale) — no-op success.
if run.Status == StatusStale || run.Status == StatusCompleted {
return nil
}
if err := h.build(ctx, run); err != nil {
if markErr := h.repo.MarkFailed(ctx, run.ID, truncErr(err)); markErr != nil {
h.log.Error("codeindex.build.mark_failed", "run_id", run.ID, "err", markErr.Error())
}
if h.notify != nil {
h.notify(ctx, run, err)
}
return err // retryable unless wrapped Permanent inside build
}
return nil
}
func (h *BuildRunner) build(ctx context.Context, run *Run) error {
emb, _, model, dims, err := h.embedSel.Embedder(ctx)
if err != nil {
return jobs.Permanent(fmt.Errorf("codeindex.build: embedder unavailable: %w", err))
}
// Validate dims match the fixed pgvector column to avoid corrupt inserts.
if d := emb.EmbedDims(model); d != 0 && d != llm.PlatformEmbeddingDims {
return jobs.Permanent(fmt.Errorf("codeindex.build: embedder dims %d != platform %d", d, llm.PlatformEmbeddingDims))
}
if dims != llm.PlatformEmbeddingDims {
return jobs.Permanent(fmt.Errorf("codeindex.build: run dims %d != platform %d", dims, llm.PlatformEmbeddingDims))
}
// Resolve the worktree/main directory holding the commit's files.
dir := ""
if run.WorktreeID != nil {
path, _, _, lerr := h.locator.LocateWorktree(ctx, *run.WorktreeID)
if lerr != nil {
return fmt.Errorf("locate worktree: %w", lerr)
}
dir = path
} else {
info, lerr := h.locator.LocateWorkspace(ctx, run.WorkspaceID)
if lerr != nil {
return fmt.Errorf("locate workspace: %w", lerr)
}
dir = info.MainPath
}
if err := h.repo.MarkRunning(ctx, run.ID); err != nil {
return fmt.Errorf("mark running: %w", err)
}
files, err := h.gitr.ListTrackedFiles(ctx, dir)
if err != nil {
return fmt.Errorf("git ls-files: %w", err)
}
var rows []ChunkRow
filesIndexed := 0
for _, rel := range files {
abs := filepath.Join(dir, filepath.FromSlash(rel))
fi, serr := os.Stat(abs)
if serr != nil || fi.IsDir() {
continue
}
if int(fi.Size()) > h.chunker.maxFileBytes() || isVendored(rel) {
continue
}
content, rerr := os.ReadFile(abs)
if rerr != nil {
continue
}
chunks := h.chunker.Chunk(rel, content)
if len(chunks) == 0 {
continue
}
filesIndexed++
for _, c := range chunks {
rows = append(rows, ChunkRow{
FilePath: rel,
Lang: c.Lang,
StartLine: c.StartLine,
EndLine: c.EndLine,
Content: c.Content,
ContentSHA: c.ContentSHA,
TokenCount: llm.EstimateTokens(c.Content),
})
}
}
// Batch-embed chunk contents (input order preserved per batch).
for start := 0; start < len(rows); start += EmbedBatchSize {
end := start + EmbedBatchSize
if end > len(rows) {
end = len(rows)
}
inputs := make([]string, 0, end-start)
for i := start; i < end; i++ {
inputs = append(inputs, rows[i].Content)
}
vecs, eerr := emb.Embed(ctx, model, inputs)
if eerr != nil {
return fmt.Errorf("embed batch [%d:%d]: %w", start, end, eerr)
}
if len(vecs) != end-start {
return fmt.Errorf("embed batch [%d:%d]: got %d vectors", start, end, len(vecs))
}
for i := start; i < end; i++ {
rows[i].Embedding = vecs[i-start]
}
}
if err := h.repo.ReplaceChunks(ctx, run.ID, run.WorkspaceID, run.CommitSHA, rows); err != nil {
return fmt.Errorf("insert chunks: %w", err)
}
if err := h.repo.MarkCompleted(ctx, run.ID, filesIndexed, len(rows)); err != nil {
return fmt.Errorf("mark completed: %w", err)
}
h.log.Info("codeindex.build.completed",
"run_id", run.ID, "workspace_id", run.WorkspaceID, "commit", run.CommitSHA,
"files", filesIndexed, "chunks", len(rows))
return nil
}
func truncErr(err error) string {
s := err.Error()
if len(s) > 1000 {
return s[:1000]
}
return s
}