You've already forked agentic-coding-workflow
192 lines
5.9 KiB
Go
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
|
|
}
|