package codeindex import ( "context" "encoding/json" "errors" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "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" ) // ErrEmbeddingDisabled is returned by Search/EnqueueBuild when no embedding // endpoint is configured. Callers degrade to keyword discovery (fs/grep) and // expose this as a typed, non-fatal "feature off" condition. var ErrEmbeddingDisabled = errors.New("codeindex: no embedding endpoint configured") // WorkspaceInfo is the minimal workspace data the indexer needs. Provided by a // locator implemented in app.go over the workspace repository (no import cycle). type WorkspaceInfo struct { ID uuid.UUID MainPath string DefaultBranch string } // WorkspaceLocator resolves a workspace's on-disk path and default branch. type WorkspaceLocator interface { LocateWorkspace(ctx context.Context, wsID uuid.UUID) (WorkspaceInfo, error) // LocateWorktree resolves a worktree's on-disk path + branch (for branch builds). LocateWorktree(ctx context.Context, worktreeID uuid.UUID) (path, branch string, wsID uuid.UUID, err error) } // EmbedderSelector resolves the configured platform embedder, or nil + a typed // error when embeddings are disabled (no endpoint configured / unsupported). type EmbedderSelector interface { // Embedder returns the embedder, the embedding endpoint id, the model id and // the platform dims. Returns ErrEmbeddingDisabled when not configured. Embedder(ctx context.Context) (emb llm.Embedder, endpointID uuid.UUID, model string, dims int, err error) } // Enqueuer is the subset of jobs.Repository the service needs. type Enqueuer interface { Enqueue(ctx context.Context, typ jobs.JobType, payload json.RawMessage, scheduledAt time.Time, maxAttempts int32) (*jobs.Job, error) } // Service is the codeindex application service. type Service interface { // EnqueueBuild resolves the build commit (HEAD of the worktree/main) and // enqueues a codeindex.build job. Idempotent on (workspace, commit, model): // a re-enqueue at the same commit returns the existing run without rebuild. EnqueueBuild(ctx context.Context, wsID uuid.UUID, worktreeID *uuid.UUID, commitSHA string) (*Run, error) Search(ctx context.Context, wsID uuid.UUID, q SearchQuery) ([]Snippet, error) MarkStale(ctx context.Context, wsID uuid.UUID) error } type service struct { repo Repository locator WorkspaceLocator gitr git.Runner embedSel EmbedderSelector jobs Enqueuer maxTopK int } // NewService constructs the codeindex Service. maxTopK caps Search top_k. func NewService(repo Repository, locator WorkspaceLocator, gitr git.Runner, embedSel EmbedderSelector, jobsRepo Enqueuer, maxTopK int) Service { if maxTopK <= 0 { maxTopK = 50 } return &service{repo: repo, locator: locator, gitr: gitr, embedSel: embedSel, jobs: jobsRepo, maxTopK: maxTopK} } func (s *service) EnqueueBuild(ctx context.Context, wsID uuid.UUID, worktreeID *uuid.UUID, commitSHA string) (*Run, error) { emb, endpointID, model, dims, err := s.embedSel.Embedder(ctx) if err != nil { return nil, err // ErrEmbeddingDisabled or config error } _ = emb branch := "" dir := "" if worktreeID != nil { path, b, locWS, lerr := s.locator.LocateWorktree(ctx, *worktreeID) if lerr != nil { return nil, lerr } dir, branch, wsID = path, b, locWS } else { info, lerr := s.locator.LocateWorkspace(ctx, wsID) if lerr != nil { return nil, lerr } dir, branch = info.MainPath, info.DefaultBranch } // Resolve HEAD commit when not supplied. if commitSHA == "" { commits, lerr := s.gitr.Log(ctx, dir, 1) if lerr != nil { return nil, errs.Wrap(lerr, errs.CodeGitCmdFailed, "resolve HEAD") } if len(commits) == 0 { return nil, errs.New(errs.CodeInvalidInput, "empty repository: no commit to index") } commitSHA = commits[0].Hash } // Idempotency: an existing run at this (ws, commit, model) is reused unless it // previously failed/stale (in which case we re-enqueue against the same row). existing, lerr := s.repo.GetRunByCommit(ctx, wsID, commitSHA, model) if lerr != nil { return nil, lerr } if existing != nil { switch existing.Status { case StatusPending, StatusRunning, StatusCompleted: return existing, nil } // failed/stale: fall through and create a fresh run is blocked by UNIQUE; // re-enqueue the existing run id instead. if err := s.enqueueJob(ctx, existing.ID); err != nil { return nil, err } return existing, nil } run := &Run{ ID: uuid.New(), WorkspaceID: wsID, WorktreeID: worktreeID, Branch: branch, CommitSHA: commitSHA, Status: StatusPending, EmbeddingEndpointID: &endpointID, EmbeddingModel: model, Dims: dims, } saved, err := s.repo.InsertRun(ctx, run) if err != nil { return nil, err } if err := s.enqueueJob(ctx, saved.ID); err != nil { return nil, err } return saved, nil } func (s *service) enqueueJob(ctx context.Context, runID uuid.UUID) error { payload, _ := json.Marshal(buildPayload{RunID: runID}) if _, err := s.jobs.Enqueue(ctx, JobTypeBuild, payload, time.Now(), 3); err != nil { return errs.Wrap(err, errs.CodeInternal, "enqueue codeindex.build") } return nil } func (s *service) Search(ctx context.Context, wsID uuid.UUID, q SearchQuery) ([]Snippet, error) { emb, _, model, _, err := s.embedSel.Embedder(ctx) if err != nil { return nil, err } if q.Text == "" { return nil, errs.New(errs.CodeInvalidInput, "query text required") } topK := q.TopK if topK <= 0 { topK = 10 } if topK > s.maxTopK { topK = s.maxTopK } // Ensure there is a completed run before paying for an embedding call. latest, err := s.repo.GetLatestCompletedRun(ctx, wsID) if err != nil { return nil, err } if latest == nil { return nil, errs.New(errs.CodeNotFound, "no completed code index for workspace; reindex first") } vecs, err := emb.Embed(ctx, model, []string{q.Text}) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "embed query") } if len(vecs) != 1 { return nil, errs.New(errs.CodeInternal, "embed query: unexpected vector count") } return s.repo.SearchKNN(ctx, wsID, vecs[0], topK, q.PathPrefix, q.Lang) } func (s *service) MarkStale(ctx context.Context, wsID uuid.UUID) error { return s.repo.MarkStale(ctx, wsID) }