Files
zzet--gortex/cmd/gortex/daemon_controller.go
T
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

1199 lines
42 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/blame"
"github.com/zzet/gortex/internal/churn"
"github.com/zzet/gortex/internal/cochange"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/coverage"
"github.com/zzet/gortex/internal/daemon"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
"github.com/zzet/gortex/internal/pathkey"
"github.com/zzet/gortex/internal/releases"
"github.com/zzet/gortex/internal/search"
"github.com/zzet/gortex/internal/semantic"
"github.com/zzet/gortex/internal/semantic/lsp"
)
// realController is the production daemon.Controller implementation. It
// wraps the MultiIndexer and ConfigManager so track/untrack/reload/status
// operations go through the same code paths the current `gortex mcp`
// command uses.
//
// Methods are serialized via a mutex — track/reload can race with status
// otherwise. The mutex is coarse; finer locking is a later optimization.
type realController struct {
mu sync.Mutex
graph graph.Store
indexer *indexer.Indexer
multiIndexer *indexer.MultiIndexer
configManager *config.ConfigManager
multiWatcher *indexer.MultiWatcher
logger *zap.Logger
// liveRouter is the multi-server Router currently wired into the
// dispatch path (nil for a local-only daemon with no roster).
// localExecute + publishRouter let ReloadServers build and publish
// a router live when the first remote is added after startup, or
// tear it down when the last remote is removed — all without a
// daemon restart. Guarded by mu.
liveRouter *daemon.Router
localExecute daemon.LocalExecutor
publishRouter func(*daemon.Router)
// onShutdown is invoked by the Shutdown method. Used by the daemon
// main to flush savings, close the snapshot store, etc.
onShutdown func() error
// toolSurface reports the active tool-surface preset + mode and the
// per-workspace learned-promotion count for `gortex daemon status`.
// Nil when the MCP server isn't wired (control-only daemon).
toolSurface func() (preset, mode string, learned int)
// ready flips to true once references are resolved and the graph is
// queryable — find_usages / get_callers return complete results from
// this point. The socket accepts connections before this; queries
// against not-yet-resolved repos return partial results until ready.
// warmupSeconds records how long the parse + resolve stage took.
//
// enriched flips to true once the slow semantic-enrichment pass and the
// graph-wide derivation passes finish in the background, after ready.
// Background timers that must not fight the enrichment pipeline for
// shard locks (the periodic snapshotter) gate on enriched, not ready.
// enrichSeconds records the full warmup duration.
ready atomic.Bool
warmupSeconds atomic.Int64
enriched atomic.Bool
enrichSeconds atomic.Int64
}
// Track indexes a new repository and persists it to the global config.
// Path is resolved to an absolute form before the MultiIndexer sees it.
func (c *realController) Track(ctx context.Context, p daemon.TrackParams) (json.RawMessage, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.multiIndexer == nil {
return nil, fmt.Errorf("multi-repo indexer not initialized")
}
absPath, err := filepath.Abs(p.Path)
if err != nil {
return nil, fmt.Errorf("resolve path: %w", err)
}
entry := config.RepoEntry{Path: absPath, Name: p.Name, Ref: p.Ref, AsWorktree: p.AsWorktree}
result, err := c.multiIndexer.TrackRepoCtx(ctx, entry)
if err != nil {
return nil, err
}
if result == nil {
// Already tracked — idempotent.
return json.RawMessage(fmt.Sprintf(`{"status":"already_tracked","path":%q}`, absPath)), nil
}
// TrackRepoCtx may have derived a worktree-instance prefix that the
// by-value entry above can't see — read the prefix it actually
// registered under for the watcher attach and the response.
prefix := result.RepoPrefix
if prefix == "" {
prefix = config.ResolvePrefix(entry)
}
// Project association from TrackParams.Project isn't wired yet — the
// config package doesn't expose an AddRepoToProject helper. Callers
// who need project scoping can edit ~/.gortex/config.yaml and
// run `gortex daemon reload`; track from the daemon-v1 surface just
// adds to the top-level repo list.
// Attach a watcher to the newly-tracked repo so file edits in it
// flow back into the graph live without a manual reload. Failures
// here are logged but don't fail the track — an indexed-but-
// unwatched repo is still queryable, just stale if edited.
if c.multiWatcher != nil && c.configManager != nil {
wcfg := c.configManager.GetRepoConfig(prefix).Watch
if err := c.multiWatcher.AddRepo(prefix, wcfg); err != nil {
c.logger.Warn("track: attach watcher failed",
zap.String("prefix", prefix), zap.Error(err))
}
}
// Persist the config change. TrackRepoCtx mutates the in-memory
// GlobalConfig via AddRepo but does not flush to disk; without this
// Save the new repo vanishes on daemon restart. Mirrors Untrack.
if c.configManager != nil {
if err := c.configManager.Global().Save(); err != nil {
c.logger.Warn("track: save config failed", zap.Error(err))
}
}
return json.Marshal(map[string]any{
"status": "tracked",
"path": absPath,
"prefix": prefix,
"file_count": result.FileCount,
"node_count": result.NodeCount,
"edge_count": result.EdgeCount,
})
}
// EnrichChurn runs the churn enricher in-process against the daemon's
// graph. We hold c.mu for the duration so a concurrent Track/Untrack
// can't reshape the set of files while the enricher walks them. The
// caller (CLI / git hook) picks the params; an empty Path means "every
// tracked repo", an empty Branch means "resolve each repo's default
// branch from its working tree".
func (c *realController) EnrichChurn(ctx context.Context, p daemon.EnrichChurnParams) (daemon.EnrichChurnResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.graph == nil {
return daemon.EnrichChurnResult{}, fmt.Errorf("graph not initialized")
}
if c.multiIndexer == nil {
return daemon.EnrichChurnResult{}, fmt.Errorf("multi-repo indexer not initialized")
}
// Resolve the set of repo roots the call targets. Empty Path =
// every tracked repo. A path or prefix narrows to one.
type target struct {
prefix string
root string
}
var targets []target
want := strings.TrimSpace(p.Path)
for prefix, meta := range c.multiIndexer.AllMetadata() {
if want != "" && want != prefix && want != meta.RootPath {
continue
}
targets = append(targets, target{prefix: prefix, root: meta.RootPath})
}
if len(targets) == 0 {
return daemon.EnrichChurnResult{}, fmt.Errorf("no tracked repo matches %q", p.Path)
}
started := time.Now()
var combined daemon.EnrichChurnResult
for _, t := range targets {
branch := strings.TrimSpace(p.Branch)
if branch == "" {
branch = gitDefaultBranch(t.root)
}
if branch == "" {
c.logger.Warn("enrich churn: no default branch resolved",
zap.String("prefix", t.prefix), zap.String("root", t.root))
continue
}
res, err := churn.EnrichGraph(ctx, c.graph, t.root, churn.Options{Branch: branch})
if err != nil {
return daemon.EnrichChurnResult{}, fmt.Errorf("enrich %s: %w", t.prefix, err)
}
combined.Files += res.Files
combined.Symbols += res.Symbols
combined.Branch = res.Branch
combined.HeadSHA = res.HeadSHA
}
combined.DurationMS = time.Since(started).Milliseconds()
return combined, nil
}
// EnrichReleases runs the per-file release enricher against the
// daemon's graph. Mirrors EnrichChurn — c.mu is held for the duration,
// targets resolve via the multi-indexer, and an empty Branch lets
// each repo's default branch be resolved on demand (so feature-branch
// tags don't leak into the timeline).
func (c *realController) EnrichReleases(ctx context.Context, p daemon.EnrichReleasesParams) (daemon.EnrichReleasesResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.graph == nil {
return daemon.EnrichReleasesResult{}, fmt.Errorf("graph not initialized")
}
if c.multiIndexer == nil {
return daemon.EnrichReleasesResult{}, fmt.Errorf("multi-repo indexer not initialized")
}
type target struct {
prefix string
root string
}
var targets []target
want := strings.TrimSpace(p.Path)
for prefix, meta := range c.multiIndexer.AllMetadata() {
if want != "" && want != prefix && want != meta.RootPath {
continue
}
targets = append(targets, target{prefix: prefix, root: meta.RootPath})
}
if len(targets) == 0 {
return daemon.EnrichReleasesResult{}, fmt.Errorf("no tracked repo matches %q", p.Path)
}
_ = ctx // graph mutation is synchronous; no cancellation surface today
started := time.Now()
var combined daemon.EnrichReleasesResult
for _, t := range targets {
branch := strings.TrimSpace(p.Branch)
if branch == "" {
branch = gitDefaultBranch(t.root)
// Empty branch is still legal — releases.EnrichGraphForBranch
// treats "" as "every tag", which is the right default when
// no default branch can be resolved (e.g. a clone without
// origin/HEAD set yet).
}
count, err := releases.EnrichGraphForBranch(c.graph, t.root, t.prefix, branch)
if err != nil {
return daemon.EnrichReleasesResult{}, fmt.Errorf("enrich %s: %w", t.prefix, err)
}
combined.Files += count
combined.Branch = branch
}
combined.DurationMS = time.Since(started).Milliseconds()
return combined, nil
}
// enrichTarget is one (prefix, root) pair the enrichers run against.
type enrichTarget struct {
prefix string
root string
}
// resolveEnrichTargets maps the caller-supplied path scope onto the set
// of tracked repos to enrich. An empty path means "every tracked repo";
// a non-empty path narrows to the one repo whose prefix or root matches.
// Returns an error when nothing matches so the control caller gets a
// clear "no tracked repo" message rather than a silent zero-count
// success. Caller must hold c.mu.
func (c *realController) resolveEnrichTargets(path string) ([]enrichTarget, error) {
if c.graph == nil {
return nil, fmt.Errorf("graph not initialized")
}
if c.multiIndexer == nil {
return nil, fmt.Errorf("multi-repo indexer not initialized")
}
var targets []enrichTarget
want := strings.TrimSpace(path)
for prefix, meta := range c.multiIndexer.AllMetadata() {
if meta == nil || meta.RootPath == "" {
continue
}
if want != "" && want != prefix && want != meta.RootPath {
continue
}
targets = append(targets, enrichTarget{prefix: prefix, root: meta.RootPath})
}
if len(targets) == 0 {
return nil, fmt.Errorf("no tracked repo matches %q", path)
}
return targets, nil
}
// EnrichBlame runs the git-blame authorship enricher against the
// daemon's graph. Mirrors EnrichChurn — c.mu is held for the duration
// and targets resolve via the multi-indexer.
func (c *realController) EnrichBlame(_ context.Context, p daemon.EnrichBlameParams) (daemon.EnrichBlameResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
targets, err := c.resolveEnrichTargets(p.Path)
if err != nil {
return daemon.EnrichBlameResult{}, err
}
started := time.Now()
var combined daemon.EnrichBlameResult
for _, t := range targets {
count, err := blame.EnrichGraph(c.graph, t.root)
if err != nil {
return daemon.EnrichBlameResult{}, fmt.Errorf("enrich %s: %w", t.prefix, err)
}
combined.Nodes += count
}
combined.DurationMS = time.Since(started).Milliseconds()
return combined, nil
}
// EnrichCoverage projects the caller-parsed cover-profile segments onto
// the daemon's graph. The CLI parses the profile (the path is relative
// to the caller's cwd, not the daemon's), so the daemon only needs the
// segments and resolves each repo's module path from its working tree.
func (c *realController) EnrichCoverage(_ context.Context, p daemon.EnrichCoverageParams) (daemon.EnrichCoverageResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
targets, err := c.resolveEnrichTargets(p.Path)
if err != nil {
return daemon.EnrichCoverageResult{}, err
}
segments := make([]coverage.Segment, len(p.Segments))
for i, s := range p.Segments {
segments[i] = coverage.Segment{
File: s.File,
StartLine: s.StartLine,
EndLine: s.EndLine,
NumStmt: s.NumStmt,
Count: s.Count,
}
}
started := time.Now()
var combined daemon.EnrichCoverageResult
combined.Segments = len(segments)
for _, t := range targets {
modulePath := coverage.ReadModulePath(t.root)
combined.Symbols += coverage.EnrichGraph(c.graph, segments, modulePath)
}
combined.DurationMS = time.Since(started).Milliseconds()
return combined, nil
}
// EnrichCochange mines co-change edges against the daemon's graph.
// Mirrors EnrichChurn — c.mu is held for the duration and targets
// resolve via the multi-indexer. The repo prefix scopes the file-node
// match in multi-repo graphs.
func (c *realController) EnrichCochange(ctx context.Context, p daemon.EnrichCochangeParams) (daemon.EnrichCochangeResult, error) {
c.mu.Lock()
defer c.mu.Unlock()
targets, err := c.resolveEnrichTargets(p.Path)
if err != nil {
return daemon.EnrichCochangeResult{}, err
}
_ = ctx // mining is synchronous; no cancellation surface today
started := time.Now()
var combined daemon.EnrichCochangeResult
for _, t := range targets {
count, err := cochange.EnrichGraph(c.graph, t.root, t.prefix)
if err != nil {
return daemon.EnrichCochangeResult{}, fmt.Errorf("enrich %s: %w", t.prefix, err)
}
combined.Edges += count
}
combined.DurationMS = time.Since(started).Milliseconds()
return combined, nil
}
// Untrack evicts a repo from the graph and drops it from config.
// PathOrPrefix accepts either an absolute path or a repo prefix.
func (c *realController) Untrack(_ context.Context, p daemon.UntrackParams) (json.RawMessage, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.multiIndexer == nil {
return nil, fmt.Errorf("multi-repo indexer not initialized")
}
prefix := p.PathOrPrefix
// Resolve path → prefix if an absolute path was given. Absolutise (to
// Clean) and compare with fold-aware EqualPaths so a case-variant
// spelling of a tracked root on a case-insensitive filesystem still
// resolves to the right prefix.
if filepath.IsAbs(p.PathOrPrefix) {
abs, err := filepath.Abs(p.PathOrPrefix)
if err != nil {
abs = p.PathOrPrefix
}
for pfx, meta := range c.multiIndexer.AllMetadata() {
if pathkey.EqualPaths(meta.RootPath, abs) {
prefix = pfx
break
}
}
}
// Detach the watcher before evicting from the graph — otherwise a
// late fsnotify event could race the eviction and try to re-index
// files whose nodes are already gone.
if c.multiWatcher != nil {
if err := c.multiWatcher.RemoveRepo(prefix); err != nil {
c.logger.Debug("untrack: detach watcher",
zap.String("prefix", prefix), zap.Error(err))
}
}
nodesRemoved, edgesRemoved := c.multiIndexer.UntrackRepo(prefix)
// Persist the config change.
if c.configManager != nil {
_ = c.configManager.Global().RemoveRepo(prefix)
if err := c.configManager.Global().Save(); err != nil {
c.logger.Warn("untrack: save config failed", zap.Error(err))
}
}
return json.Marshal(map[string]any{
"status": "untracked",
"prefix": prefix,
"nodes_removed": nodesRemoved,
"edges_removed": edgesRemoved,
})
}
// Reload re-reads the global config, indexes new repos that were added
// via direct config-file edits, and untracks any that were removed.
// Existing, unchanged tracked repos keep their current state.
// ReloadServers re-reads servers.toml and applies the change to the
// running daemon's Router without a restart: an in-place atomic swap
// when a router already exists, a fresh build-and-publish when the first
// remote is added after a router-less startup, or a teardown when the
// last remote is removed.
func (c *realController) ReloadServers(_ context.Context) (json.RawMessage, error) {
c.mu.Lock()
defer c.mu.Unlock()
scfg, err := daemon.LoadServersConfig("")
if err != nil {
return nil, fmt.Errorf("reload servers.toml: %w", err)
}
count := 0
if scfg != nil {
count = len(scfg.Server)
}
wired := false
switch {
case count == 0 && c.liveRouter != nil:
// Last remote removed — tear the router down so local dispatch
// returns to the direct in-process path.
c.liveRouter = nil
if c.publishRouter != nil {
c.publishRouter(nil)
}
case count == 0:
// No router and no remotes — nothing to wire.
case c.liveRouter != nil:
// In-place atomic swap; the stable *Router pointer keeps every
// dispatch site (and any in-flight call) consistent.
c.liveRouter.ReloadConfig(scfg, daemon.NewWorkspaceRosterCache(60*time.Second))
wired = true
default:
// First remote added after a router-less startup — build and
// publish a fresh router into the dispatch path.
c.liveRouter = daemon.NewRouter(daemon.RouterConfig{
Servers: scfg,
Rosters: daemon.NewWorkspaceRosterCache(60 * time.Second),
LocalSlug: daemon.LocalServerSentinel,
LocalExecute: c.localExecute,
Logger: c.logger,
Federation: resolveFederationConfig(),
})
if c.publishRouter != nil {
c.publishRouter(c.liveRouter)
}
wired = true
}
return json.Marshal(map[string]any{"servers": count, "router_wired": wired})
}
func (c *realController) Reload(ctx context.Context) (json.RawMessage, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.configManager == nil {
return nil, fmt.Errorf("config manager not initialized")
}
if err := c.configManager.Reload(); err != nil {
return nil, fmt.Errorf("reload config: %w", err)
}
var added, removed int
// Match configured entries to currently-tracked instances by ROOT
// PATH, not by a recomputed prefix. A worktree tracked as an
// independent instance registers under a derived `<base>@<workspace>`
// prefix, so keying the diff on config.ResolvePrefix(entry) (the bare
// basename) would fail to recognise it as wanted and untrack it on
// every reload. The root path is the stable identity of a checkout.
trackedByRoot := make(map[string]string) // absolute RootPath → prefix
for prefix, meta := range c.multiIndexer.AllMetadata() {
if meta != nil {
trackedByRoot[meta.RootPath] = prefix
}
}
wantedPrefixes := make(map[string]bool)
for _, entry := range c.configManager.Global().Repos {
abs, err := filepath.Abs(entry.Path)
if err != nil {
abs = entry.Path
}
if prefix, ok := trackedByRoot[abs]; ok {
// Already tracked (under whatever prefix it registered) — keep it.
wantedPrefixes[prefix] = true
continue
}
res, trackErr := c.multiIndexer.TrackRepoCtx(ctx, entry)
if trackErr != nil {
c.logger.Warn("reload: track failed",
zap.String("path", entry.Path), zap.Error(trackErr))
continue
}
added++
if res != nil && res.RepoPrefix != "" {
wantedPrefixes[res.RepoPrefix] = true
}
}
for prefix := range c.multiIndexer.AllMetadata() {
if wantedPrefixes[prefix] {
continue
}
c.multiIndexer.UntrackRepo(prefix)
removed++
}
return json.Marshal(map[string]any{
"added": added,
"removed": removed,
})
}
// searchBackendInfo bundles the daemon.SearchBackendStats payload with
// the separate text/vector byte counts we need to split per-repo.
type searchBackendInfo struct {
daemon.SearchBackendStats
vectorBytes uint64
}
// resolveSearchBackend inspects the live search backend and produces
// the stats needed by status rendering: which backend is active, total
// document count, its heap footprint, and (for disk-backed Bleve) the
// on-disk size.
//
// Real-world unwrap order: Swappable → HybridBackend → (text, vector).
// The text side is itself a concrete BM25/Bleve/SymbolSearcherBackend.
// Both layers have to be peeled; if we stop early we fall into the
// default branch and the status reports "unknown" — which was the bug
// users saw. When the store implements graph.SymbolSearcher, the
// indexer wires up a *search.SymbolSearcherBackend instead of building
// an in-process BM25/Bleve index at all (see initialSearchBackend in
// internal/indexer/indexer.go) — that case has to be matched
// explicitly too, or it falls into the same "unknown" default.
func resolveSearchBackend(b search.Backend) searchBackendInfo {
out := searchBackendInfo{}
if b == nil {
return out
}
// 1) Unwrap Swappable so we see the currently-active inner.
inner := b
if sw, ok := inner.(*search.Swappable); ok {
inner = sw.Inner()
}
// 2) If Hybrid is in play, split its text/vector sizes and keep
// drilling into the text side for name/doc-count identification.
if hyb, ok := inner.(*search.HybridBackend); ok {
out.vectorBytes = hyb.VectorSizeBytes()
inner = hyb.TextBackend()
// TextBackend() itself could be a Swappable in some setups —
// unlikely today but cheap to guard.
if sw, ok := inner.(*search.Swappable); ok {
inner = sw.Inner()
}
}
switch back := inner.(type) {
case *search.BleveBackend:
if path := back.DiskPath(); path != "" {
out.Name = "bleve-disk"
out.DiskPath = path
out.DiskBytes = back.DiskBytes()
} else {
out.Name = "bleve-memory"
}
out.DocCount = back.Count()
out.Bytes = back.SizeBytes()
case *search.BM25Backend:
out.Name = "bm25"
out.DocCount = back.Count()
out.Bytes = back.SizeBytes()
case *search.SymbolSearcherBackend:
// The FTS5 index lives inside the graph store's own file, not a
// separate in-memory structure — there is no honest byte count
// to report here (Count() is only a since-construction delta,
// documented as non-authoritative on the adapter itself). Report
// the backend truthfully as disk-resident instead of printing a
// fabricated "heap=0 B".
out.Name = "sqlite-fts5"
out.DocCount = back.Count()
out.DiskResident = true
default:
out.Name = "unknown"
out.DocCount = b.Count()
out.Bytes = search.BackendSize(b)
}
return out
}
// Status gathers per-repo stats and basic process metrics. Daemon-level
// fields (PID, uptime, socket, session count) are filled in by the
// daemon itself before the response goes out.
func (c *realController) Status(_ context.Context) (daemon.StatusResponse, error) {
// Compute the per-repo memory estimate BEFORE taking the coarse
// controller mutex. On the SQLite backend AllRepoMemoryEstimates is a
// COUNT … GROUP BY scan that turns pathologically slow under
// enrichment write load; holding c.mu across it stalls every other
// control request (status / track / reload) queued on the mutex — the
// daemon-looks-crashed symptom. Snapshot the graph handle under a
// brief lock, then run the (store-memoised) estimate lock-free.
c.mu.Lock()
g := c.graph
c.mu.Unlock()
var memEstimates map[string]graph.RepoMemoryEstimate
if g != nil {
memEstimates = g.AllRepoMemoryEstimates()
}
c.mu.Lock()
defer c.mu.Unlock()
var (
tracked []daemon.TrackedRepoStatus
searchBackendForResponse daemon.SearchBackendStats
totalNodes int
)
if c.multiIndexer != nil {
// memEstimates (per-repo node/edge counts + byte estimates) was
// computed above, before the controller mutex was taken — see the
// note at the top of Status. The SQLite store memoises it so a
// burst of status polls collapses onto one COUNT … GROUP BY scan;
// the in-memory store serves maintained shard counters directly.
// Diagnostic: when AllMetadata has tracked repos but
// AllRepoMemoryEstimates returns nothing (or a much smaller
// set), some path has cleared the per-repo counters without
// clearing the underlying nodes. The meta fallback below keeps
// the table usable in the meantime. A workspace with exactly
// one Unprefixed repo legitimately runs one bucket short —
// its nodes carry repo_prefix="" and AllRepoMemoryEstimates'
// GROUP BY excludes that key by design (handled separately
// below) — so that expected gap must not trip this warning.
// A workspace with a single tracked repo owns the entire store:
// every node and edge belongs to it, whether or not those nodes
// carry a repo prefix. g.NodeCount()/g.EdgeCount() is therefore the
// exact per-repo count, and reporting it keeps `daemon status` in
// agreement with `gortex query stats` (which reports the same
// whole-store totals — the inconsistency users report in #261/#270).
//
// This covers both ways a lone repo's nodes land under
// repo_prefix="", neither of which produces a usable per-prefix
// bucket (the in-memory shard counters skip empty-prefix nodes —
// shard.repoNodeAdd is a deliberate no-op — and the SQLite GROUP BY
// excludes repo_prefix="" rows via `WHERE repo_prefix <> ''`):
// 1. Indexed unprefixed (RepoMetadata.Unprefixed) while it was the
// workspace's sole tracked repo — the willBeMultiRepo gate in
// TrackRepoCtx/ReconcileRepoCtx.
// 2. Desynced to a prefixed metadata: a second config entry (e.g. a
// macOS path-case duplicate) flips willBeMultiRepo true at the
// next warm restart, so the metadata is stamped prefixed
// (Unprefixed=false) but the existing repo_prefix="" nodes are
// never restamped (migrateLoneUnprefixedRepoCtx's guard needs
// len(repos)==1, which fails mid-warmup-loop) — leaving an empty
// per-prefix bucket.
// Both used to fall back to the frozen RepoMetadata.NodeCount (stale,
// often ~1) and render the near-empty row of #261/#270. Byte
// estimates stay at their zero value here — advisory display detail,
// not the reported bug.
allMeta := c.multiIndexer.AllMetadata()
soleRepo := len(allMeta) == 1
var wholeStoreNodes, wholeStoreEdges int
if g != nil {
wholeStoreNodes = g.NodeCount()
wholeStoreEdges = g.EdgeCount()
}
// Diagnostic: when AllMetadata has tracked repos but
// AllRepoMemoryEstimates returns nothing (or a much smaller
// set), some path has cleared the per-repo counters without
// clearing the underlying nodes. The meta fallback below keeps
// the table usable in the meantime.
if c.logger != nil {
tracked := len(allMeta)
counted := len(memEstimates)
// A sole tracked repo (or one indexed unprefixed) legitimately
// contributes no per-prefix bucket — its nodes carry
// repo_prefix="" — so the expected shortfall must not trip this
// warning; the whole-store attribution below covers it.
expectedGap := 0
if soleRepo {
expectedGap = 1
} else {
for _, meta := range allMeta {
if meta != nil && meta.Unprefixed {
expectedGap = 1
break
}
}
}
if tracked > 0 && counted < tracked-expectedGap {
c.logger.Warn("daemon: per-repo counters below tracked-repo count — graph mutation cleared per-repo index?",
zap.Int("tracked_repos", tracked),
zap.Int("counter_buckets", counted),
zap.Int("graph_total_nodes", c.graph.NodeCount()))
}
}
// One repo in a multi-repo workspace can still hold all its nodes
// under repo_prefix="" while its metadata says prefixed — the same
// desync as the sole-repo case but with a second repo present (it was
// indexed solo, then another repo joined while the daemon was down, so
// warmup stamped it prefixed without restamping its nodes; the
// migrateLoneUnprefixedRepoCtx guard needs len(repos)==1, which is
// false mid-warmup-loop). Its per-prefix bucket is then empty and it
// would under-count. Attribute the unaccounted ("") node pool to it —
// but only when exactly one tracked repo lacks a live bucket, because
// the "" pool is a single undifferentiated pool and more than one
// empty-bucket repo is ambiguous. The pool also carries a small number
// of synthetic cross-repo/global nodes (<1% of a real graph), so the
// attribution is approximate, not exact.
var orphanOwner string
orphanOwnerCount := 0
if !soleRepo {
for prefix, meta := range allMeta {
if meta == nil {
continue
}
if est, ok := memEstimates[prefix]; !ok || est.NodeCount == 0 {
orphanOwner = prefix
orphanOwnerCount++
}
}
}
orphanNodes, orphanEdges := 0, 0
if orphanOwnerCount == 1 && g != nil {
orphanNodes, orphanEdges = wholeStoreNodes, wholeStoreEdges
for _, est := range memEstimates {
orphanNodes -= est.NodeCount
orphanEdges -= est.EdgeCount
}
if orphanNodes < 0 {
orphanNodes = 0
}
if orphanEdges < 0 {
orphanEdges = 0
}
}
// Search and vector backends are process-wide (one shared index
// across all repos), so we compute the global size once and
// split it proportionally to each repo's node share. Not exact,
// but it's the best attribution we can make without indexing
// per-repo which would double storage for the sake of a status
// breakdown.
backendStats := resolveSearchBackend(c.multiIndexer.Search())
// totalNodes drives the SearchBytes share split below. A sole repo
// owns the whole store; otherwise sum the per-prefix buckets and, if
// every counter is empty (the post-warmup-wipe case described above),
// fall back to per-repo meta so the share denominator stays nonzero
// and the search budget gets attributed instead of falling on the floor.
if soleRepo {
totalNodes = wholeStoreNodes
} else {
for _, est := range memEstimates {
totalNodes += est.NodeCount
}
totalNodes += orphanNodes
if totalNodes == 0 {
for _, meta := range allMeta {
if meta != nil {
totalNodes += meta.NodeCount
}
}
}
}
for prefix, meta := range allMeta {
nodes := meta.NodeCount
edges := meta.EdgeCount
var mem daemon.MemoryBreakdown
switch {
case soleRepo || meta.Unprefixed:
// The whole store is this repo's graph — see the note
// above. This keeps `daemon status` in agreement with
// `gortex query stats` for a single-repo workspace, and
// corrects the near-empty row when a lone repo's nodes
// carry repo_prefix="" (indexed unprefixed, or desynced to
// a prefixed metadata whose per-prefix bucket is empty).
// Byte estimates stay zero — advisory detail, not the bug.
nodes = wholeStoreNodes
edges = wholeStoreEdges
default:
est, ok := memEstimates[prefix]
switch {
case orphanOwnerCount == 1 && prefix == orphanOwner:
// This repo's nodes are the unaccounted "" pool — see the
// note above. Approximate (includes a little synthetic
// cross-repo overhead), but far closer than the frozen
// near-empty RepoMetadata fallback.
nodes = orphanNodes
edges = orphanEdges
case ok:
nodes = est.NodeCount
edges = est.EdgeCount
mem.NodesBytes = est.NodeBytes
mem.EdgesBytes = est.EdgeBytes
}
}
if totalNodes > 0 && nodes > 0 {
share := float64(nodes) / float64(totalNodes)
mem.SearchBytes = uint64(float64(backendStats.Bytes) * share)
mem.VectorsBytes = uint64(float64(backendStats.vectorBytes) * share)
mem.DiskBytes = uint64(float64(backendStats.DiskBytes) * share)
}
mem.TotalBytes = mem.NodesBytes + mem.EdgesBytes + mem.SearchBytes + mem.VectorsBytes
// Pull the workspace/project slugs straight off the
// per-repo Indexer — that's the source of truth that
// stamps every node emitted by this repo. Falls back to
// the prefix on legacy setups where no .gortex.yaml
// declares them (the resolveWorkspaceID default).
var ws, wsProj string
if idx := c.multiIndexer.GetIndexer(prefix); idx != nil {
ws = idx.WorkspaceID()
wsProj = idx.ProjectID()
}
if ws == "" {
ws = prefix
}
if wsProj == "" {
wsProj = prefix
}
tracked = append(tracked, daemon.TrackedRepoStatus{
Prefix: prefix,
Path: meta.RootPath,
Workspace: ws,
WorkspaceProject: wsProj,
Files: meta.FileCount,
Nodes: nodes,
Edges: edges,
LastIndex: meta.LastIndexTime.Unix(),
Memory: mem,
})
}
searchBackendForResponse = backendStats.SearchBackendStats
}
// Aggregate per-workspace stats so the renderer can emit a
// "workspaces" block. Hidden when every repo defaults to its own
// slug (the legacy single-workspace-per-repo case where the
// summary just duplicates the table).
wsAgg := make(map[string]*daemon.WorkspaceSummary)
wsKeys := make([]string, 0)
for _, r := range tracked {
s, ok := wsAgg[r.Workspace]
if !ok {
s = &daemon.WorkspaceSummary{Slug: r.Workspace}
wsAgg[r.Workspace] = s
wsKeys = append(wsKeys, r.Workspace)
}
s.Repos = append(s.Repos, r.Prefix)
seenProj := false
for _, p := range s.Projects {
if p == r.WorkspaceProject {
seenProj = true
break
}
}
if !seenProj {
s.Projects = append(s.Projects, r.WorkspaceProject)
}
s.Files += r.Files
s.Nodes += r.Nodes
s.Edges += r.Edges
}
// Always populate the per-workspace rollup — even when every
// workspace is a default singleton. Hiding it on legacy setups
// makes the boundary feature invisible, which is the opposite
// of what users want when they're trying to migrate. Renderer-
// side compaction (single-line hint vs full table) keeps the
// output tidy when there's nothing meaningful to summarise.
sort.Strings(wsKeys)
workspaces := make([]daemon.WorkspaceSummary, 0, len(wsKeys))
for _, k := range wsKeys {
workspaces = append(workspaces, *wsAgg[k])
}
var mem runtime.MemStats
runtime.ReadMemStats(&mem)
resp := daemon.StatusResponse{
TrackedRepos: tracked,
MemoryBytes: mem.Alloc,
SearchBackend: searchBackendForResponse,
Runtime: daemon.RuntimeStats{
Alloc: mem.Alloc,
Sys: mem.Sys,
HeapInuse: mem.HeapInuse,
HeapIdle: mem.HeapIdle,
HeapReleased: mem.HeapReleased,
StackInuse: mem.StackInuse,
NumGC: mem.NumGC,
NumGoroutine: runtime.NumGoroutine(),
},
PProfAddr: daemonPProfAddr(),
Ready: c.ready.Load(),
WarmupSeconds: c.warmupSeconds.Load(),
EnrichmentComplete: c.enriched.Load(),
EnrichSeconds: c.enrichSeconds.Load(),
Workspaces: workspaces,
ConfiguredServers: c.collectConfiguredServers(),
LocalServerSlug: c.localServerSlug(),
LSPRouter: c.collectLSPRouterStatus(),
Enrichment: c.collectEnrichmentProgress(),
}
if c.toolSurface != nil {
resp.ToolPreset, resp.ToolPresetMode, resp.LearnedTools = c.toolSurface()
}
return resp, nil
// MCPSessions is populated by the daemon Server (it owns the
// SessionRegistry — the controller doesn't have a back-pointer).
// See internal/daemon/server.go around the ControlStatus handler.
}
// collectConfiguredServers reads `~/.gortex/servers.toml` (best
// effort — a missing or malformed file just returns nil) and
// projects it onto the status response. Auth tokens are NOT
// included; the HasAuth flag is enough for the human-facing
// "yes/no" decision.
func (c *realController) collectConfiguredServers() []daemon.ConfiguredServerStatus {
cfg, err := daemon.LoadServersConfig("")
if err != nil || cfg == nil || len(cfg.Server) == 0 {
return nil
}
local := c.localServerSlug()
out := make([]daemon.ConfiguredServerStatus, 0, len(cfg.Server))
for _, s := range cfg.Server {
out = append(out, daemon.ConfiguredServerStatus{
Slug: s.Slug,
URL: s.URL,
Default: s.Default,
Local: s.Slug == local,
Workspaces: s.Workspaces,
HasAuth: s.AuthToken != "" || s.AuthTokenEnv != "",
})
}
return out
}
// localServerSlug returns the reserved sentinel identifying the
// daemon's own in-process graph. It is intentionally NOT derived from
// DefaultServer().Slug: a roster row is always a remote now, so no
// roster entry is ever "local" (the status Local flag is false for
// every row), and a remote marked default=true is still proxied.
func (c *realController) localServerSlug() string {
return daemon.LocalServerSentinel
}
// collectLSPRouterStatus reflects the daemon's LSP router (when
// wired) into a status payload. Returns nil when no router is wired
// (semantic enrichment disabled in `.gortex.yaml`).
func (c *realController) collectLSPRouterStatus() *daemon.LSPRouterStatus {
if c.indexer == nil {
return nil
}
semMgr := c.indexer.SemanticManager()
if semMgr == nil {
return nil
}
router, ok := semMgr.LSPRouter().(*lsp.Router)
if !ok || router == nil {
return nil
}
out := &daemon.LSPRouterStatus{
DefaultWorkspace: router.DefaultWorkspace(),
}
for _, name := range router.EnabledSpecNames() {
out.EnabledSpecs = append(out.EnabledSpecs, daemon.LSPSpecStatus{
Name: name,
Available: router.SpecAvailable(name),
Languages: strings.Join(router.SpecLanguages(name), ","),
})
}
for _, s := range router.Stats() {
out.ActiveProviders = append(out.ActiveProviders, daemon.LSPActiveProvider{
Spec: s.Spec,
Workspace: s.Workspace,
LastUsed: s.LastUsed.Format(time.RFC3339),
})
}
return out
}
// collectEnrichmentProgress reflects the semantic manager's per-(repo,
// provider) enrichment statuses into the compact summary the daemon
// status line needs. Returns nil when no semantic manager is wired, or
// it has never recorded a pass — the "enrichment in progress" state
// with nothing behind it is exactly the bug this closes.
func (c *realController) collectEnrichmentProgress() *daemon.EnrichmentProgress {
if c.indexer == nil {
return nil
}
semMgr := c.indexer.SemanticManager()
if semMgr == nil {
return nil
}
return enrichmentProgressFromStatuses(semMgr.EnrichmentStatuses())
}
// enrichmentProgressFromStatuses is the pure reduction behind
// collectEnrichmentProgress, split out so it can be unit tested
// against literal semantic.EnrichmentStatus rows without wiring a
// live indexer + semantic manager. A repo counts as done once every
// provider recorded for it has reached a terminal state; the first
// running row (in the manager's stable repo/provider order) becomes
// Current.
func enrichmentProgressFromStatuses(statuses []semantic.EnrichmentStatus) *daemon.EnrichmentProgress {
if len(statuses) == 0 {
return nil
}
out := &daemon.EnrichmentProgress{}
repoDone := make(map[string]bool)
repoSeen := make(map[string]bool)
for _, st := range statuses {
repoSeen[st.Repo] = true
if _, ok := repoDone[st.Repo]; !ok {
repoDone[st.Repo] = true // assume done until a non-terminal provider says otherwise
}
switch st.State {
case semantic.EnrichStateRunning:
out.Running = true
repoDone[st.Repo] = false
if out.Current == nil {
cur := &daemon.EnrichmentCurrent{
Repo: st.Repo,
Provider: st.Provider,
DeadlineSeconds: st.DeadlineSeconds,
}
if !st.StartedAt.IsZero() {
cur.ElapsedSeconds = time.Since(st.StartedAt).Seconds()
}
out.Current = cur
}
}
}
out.ReposTotal = len(repoSeen)
for _, done := range repoDone {
if done {
out.ReposDone++
}
}
return out
}
// SearchSymbols runs a substring match over node names and returns the
// matching symbols. It's the cheap probe path for clients (notably the
// Grep-redirect hook) that need a fast yes/no without setting up a full
// MCP session. File and Import nodes are excluded — the hook only cares
// about real symbol matches.
func (c *realController) SearchSymbols(_ context.Context, p daemon.SearchSymbolsParams) (daemon.SearchSymbolsResult, error) {
c.mu.Lock()
g := c.graph
c.mu.Unlock()
if g == nil || p.Query == "" {
return daemon.SearchSymbolsResult{}, nil
}
limit := p.Limit
if limit <= 0 || limit > 50 {
limit = 20
}
needle := strings.ToLower(p.Query)
hits := make([]daemon.SymbolHit, 0, limit)
for _, n := range g.AllNodes() {
if n == nil {
continue
}
if n.Kind == graph.KindFile || n.Kind == graph.KindImport {
continue
}
if p.Repo != "" && n.RepoPrefix != p.Repo {
continue
}
if !strings.Contains(strings.ToLower(n.Name), needle) {
continue
}
hits = append(hits, daemon.SymbolHit{
Name: n.Name,
Kind: string(n.Kind),
FilePath: n.FilePath,
Line: n.StartLine,
})
if len(hits) >= limit {
break
}
}
return daemon.SearchSymbolsResult{Hits: hits}, nil
}
// AttachWatcher is called by warmup to hand over the MultiWatcher once
// it has been initialized. Until this is called, realController.Track
// skips the per-repo watcher attach — a newly-tracked repo gets its
// watcher when the warmup-constructed MultiWatcher iterates
// mi.AllMetadata() at startup.
func (c *realController) AttachWatcher(mw *indexer.MultiWatcher) {
c.mu.Lock()
defer c.mu.Unlock()
c.multiWatcher = mw
}
// MarkReady flips the ready flag once references are resolved and the graph
// is queryable, recording how long the parse + resolve stage took. Safe to
// call concurrently with Status (atomic loads on the read side).
func (c *realController) MarkReady(d time.Duration) {
c.warmupSeconds.Store(int64(d.Seconds()))
c.ready.Store(true)
}
// IsReady reports whether the graph is resolved and queryable. The socket
// accepts connections before this; callers waiting to issue queries should
// wait for IsReady.
func (c *realController) IsReady() bool {
return c.ready.Load()
}
// MarkEnriched flips the enrichment-complete flag once semantic enrichment
// and the graph-wide derivation passes finish in the background, recording
// the full warmup duration. It also sets ready, so the degenerate path where
// MarkReady was skipped still reports a usable daemon.
func (c *realController) MarkEnriched(d time.Duration) {
c.enrichSeconds.Store(int64(d.Seconds()))
c.enriched.Store(true)
c.ready.Store(true)
}
// IsEnriched reports whether the background enrichment + derivation passes
// have finished. Background timers (the periodic snapshotter) gate on this
// rather than IsReady so they don't fight the enrichment pipeline for shard
// locks and GC budget.
func (c *realController) IsEnriched() bool {
return c.enriched.Load()
}
// Shutdown gives the caller (the daemon main) a chance to flush any
// per-instance stores. The actual socket teardown is the Server's job.
func (c *realController) Shutdown(_ context.Context) error {
c.mu.Lock()
hook := c.onShutdown
c.mu.Unlock()
if hook != nil {
return hook()
}
return nil
}