This commit is contained in:
2026-06-22 08:55:57 +08:00
parent dbb87823e8
commit 6ade6e8fa9
325 changed files with 41131 additions and 855 deletions
+195
View File
@@ -0,0 +1,195 @@
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)
}