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
242 lines
7.0 KiB
Go
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
|
|
}
|