Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

242 lines
7.0 KiB
Go

package svc
import (
"context"
"strings"
"github.com/zzet/gortex/internal/contracts"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/llm"
"github.com/zzet/gortex/internal/query"
)
// InProcessBackend implements Backend by calling gortex's in-process
// graph engine + contract registry directly — no MCP subprocess, no
// JSON round-trip. Used when the LLM service runs inside the same
// process as the gortex daemon (the common deployment).
//
// All methods are read-only and concurrent-safe (they delegate to
// the engine and registry, both of which already handle their own
// locking).
type InProcessBackend struct {
engine *query.Engine
contractsFn func() *contracts.Registry
}
// NewInProcessBackend constructs an in-process backend. engine must
// not be nil. contractsFn is called per ListContracts request so the
// backend always sees the latest registry — gortex swaps the
// registry pointer when indexes re-extract contracts.
func NewInProcessBackend(engine *query.Engine, contractsFn func() *contracts.Registry) *InProcessBackend {
return &InProcessBackend{engine: engine, contractsFn: contractsFn}
}
var _ llm.Backend = (*InProcessBackend)(nil)
// SearchSymbols delegates to engine.SearchSymbolsScoped and adapts
// the result. WorkspaceID + ProjectID on QueryOptions absorb the
// Scope's Repo/Project; Ref is not yet plumbed through the engine
// query layer.
func (b *InProcessBackend) SearchSymbols(_ context.Context, q string, scope llm.Scope, limit int) ([]llm.Match, error) {
if limit <= 0 {
limit = 20
}
opts := scopeToOptions(scope)
opts.Limit = limit
nodes := b.engine.SearchSymbolsScoped(q, limit, opts)
out := make([]llm.Match, 0, len(nodes))
for _, n := range nodes {
if n == nil {
continue
}
out = append(out, nodeToMatch(n))
}
return out, nil
}
// GetCallers returns every node in the get_callers subgraph that's
// not the queried symbol — i.e. all callers (direct and transitive
// up to QueryOptions.Depth).
func (b *InProcessBackend) GetCallers(_ context.Context, id string, scope llm.Scope, limit int) ([]llm.Caller, error) {
opts := scopeToOptions(scope)
if limit > 0 {
opts.Limit = limit
}
sg := b.engine.GetCallers(id, opts)
if sg == nil {
return nil, nil
}
out := make([]llm.Caller, 0, len(sg.Nodes))
for _, n := range sg.Nodes {
if n == nil || n.ID == id {
continue
}
out = append(out, nodeToCaller(n))
}
return out, nil
}
// GetDependencies returns every node the queried symbol depends on
// — direct and transitive up to depth. The Kind field on each Dep
// is filled from the edge kind when an edge from id→dep exists in
// the subgraph; otherwise left empty.
func (b *InProcessBackend) GetDependencies(_ context.Context, id string, scope llm.Scope, depth, limit int) ([]llm.Dep, error) {
opts := scopeToOptions(scope)
if depth > 0 {
opts.Depth = depth
}
if limit > 0 {
opts.Limit = limit
}
sg := b.engine.GetDependencies(id, opts)
if sg == nil {
return nil, nil
}
// Map id → edge.Kind for one-hop direct deps so we can attribute
// the dep kind ("calls", "imports", "references") when the agent
// inspects the result.
directKind := make(map[string]string, len(sg.Edges))
for _, e := range sg.Edges {
if e == nil || e.From != id {
continue
}
if _, ok := directKind[e.To]; !ok {
directKind[e.To] = string(e.Kind)
}
}
out := make([]llm.Dep, 0, len(sg.Nodes))
for _, n := range sg.Nodes {
if n == nil || n.ID == id {
continue
}
out = append(out, llm.Dep{
ID: n.ID,
Kind: directKind[n.ID], // empty for transitive deps
File: n.FilePath,
Repo: n.RepoPrefix,
})
}
return out, nil
}
// ListContracts iterates the contract registry and applies the
// filter client-side. Scope is honoured by matching on
// Contract.EffectiveWorkspace / EffectiveProject.
func (b *InProcessBackend) ListContracts(_ context.Context, f llm.ContractFilter, scope llm.Scope) ([]llm.Contract, error) {
if b.contractsFn == nil {
return nil, nil
}
reg := b.contractsFn()
if reg == nil {
return nil, nil
}
all := reg.All()
out := make([]llm.Contract, 0, len(all))
for _, c := range all {
if f.Type != "" && string(c.Type) != f.Type {
continue
}
if f.Role != "" && string(c.Role) != f.Role {
continue
}
method, path := contractMethodPath(c)
if f.Method != "" && method != f.Method {
continue
}
if f.Path != "" && path != f.Path {
continue
}
if scope.Repo != "" && c.RepoPrefix != scope.Repo {
continue
}
if scope.Project != "" && c.EffectiveProject() != scope.Project {
continue
}
out = append(out, llm.Contract{
ID: c.ID,
Type: string(c.Type),
Role: string(c.Role),
Repo: c.RepoPrefix,
Method: method,
Path: path,
File: c.FilePath,
Line: c.Line,
SymbolID: c.SymbolID,
})
if f.Limit > 0 && len(out) >= f.Limit {
break
}
}
return out, nil
}
// ListRepos isn't exposed by the engine directly. Returns nil for
// now — the agent doesn't currently rely on this, and chain-mode
// tracing finds cross-repo edges via Dep.Repo on dependency results.
// A real implementation would query the daemon's MultiIndexer; that
// can be added if a future feature needs it.
func (b *InProcessBackend) ListRepos(_ context.Context) ([]llm.Repo, error) {
return nil, nil
}
// nodeToMatch converts an indexed graph node into the agent-facing
// Match shape. Field mapping follows the daemon's canonical JSON:
// kind / path / line / repo come straight from the node's metadata.
func nodeToMatch(n *graph.Node) llm.Match {
return llm.Match{
ID: n.ID,
Name: n.Name,
Kind: string(n.Kind),
Path: n.FilePath,
Line: n.StartLine,
Repo: n.RepoPrefix,
}
}
func nodeToCaller(n *graph.Node) llm.Caller {
return llm.Caller{ID: n.ID, File: n.FilePath, Repo: n.RepoPrefix}
}
// scopeToOptions maps our backend Scope into the query engine's
// QueryOptions. Engine.QueryOptions uses WorkspaceID/ProjectID which
// fall back to RepoPrefix at filter time (see
// internal/query/subgraph.go::scopeAllows), so passing scope.Repo
// to WorkspaceID works for repo-level scoping in the common case
// where each repo is its own workspace.
func scopeToOptions(scope llm.Scope) query.QueryOptions {
opts := query.DefaultOptions()
if scope.Repo != "" {
opts.WorkspaceID = scope.Repo
}
if scope.Project != "" {
opts.ProjectID = scope.Project
}
return opts
}
// contractMethodPath pulls HTTP method/path out of a Contract. They
// usually live in Meta for non-DI types; HTTP contracts also encode
// them in the ID as `http::METHOD::/path`. Try meta first, then ID.
func contractMethodPath(c contracts.Contract) (method, path string) {
if m := c.Meta; m != nil {
if v, ok := m["method"].(string); ok {
method = v
}
if v, ok := m["path"].(string); ok {
path = v
}
}
if (method == "" || path == "") && strings.HasPrefix(c.ID, "http::") {
parts := strings.SplitN(c.ID, "::", 3)
if len(parts) == 3 {
if method == "" {
method = parts[1]
}
if path == "" {
path = parts[2]
}
}
}
return method, path
}