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

557 lines
24 KiB
Go

// Package daemon implements the long-living Gortex daemon plus the tiny
// stdio proxy that relays MCP traffic to it.
package daemon
import (
"encoding/json"
"fmt"
"io"
)
// ProtocolVersion is the daemon wire-protocol version. Both ends of a
// connection must agree; the handshake exchange rejects mismatches so an
// older proxy talking to a newer daemon (or vice versa) fails loudly
// instead of corrupting state.
const ProtocolVersion = 1
// ConnectionMode identifies the traffic shape the client wants after the
// handshake completes.
//
// ModeMCP — MCP JSON-RPC 2.0 pass-through (newline-delimited). The proxy
// dumps whatever its MCP client sent on stdin straight to the socket.
// ModeControl — Gortex control RPC (track, untrack, status, reload,
// shutdown). Used by the CLI and other daemon subcommands.
type ConnectionMode string
const (
ModeMCP ConnectionMode = "mcp"
ModeControl ConnectionMode = "control"
)
// Handshake is the first message every client sends after dialing the
// socket. Everything flowing through after the ACK is mode-dependent.
//
// CWD lets the daemon derive a default repo scope when the client is an
// MCP proxy. Empty is acceptable for control clients.
type Handshake struct {
Version int `json:"version"`
Mode ConnectionMode `json:"mode"`
CWD string `json:"cwd,omitempty"`
ClientName string `json:"client,omitempty"` // e.g. "claude-code", "kiro", "cli"
PID int `json:"pid,omitempty"`
// Tools / ToolsMode carry the client-side tool-surface preference
// (GORTEX_TOOLS / --tools and GORTEX_TOOLS_MODE / --tools-mode of the
// `gortex mcp` proxy). The daemon serves a shared graph, so a per-client
// preset can only be honoured if the client hands its choice over at
// connect time — the proxy's own byte-pump filter can subtract from the
// daemon's list but never widen it. Forwarding the spec lets the daemon
// resolve the effective surface for THIS session authoritatively (env
// override wins), covering both narrower and wider presets. Empty means
// "no client preference — use the daemon default / client-name default".
Tools string `json:"tools,omitempty"`
ToolsMode string `json:"tools_mode,omitempty"`
}
// HandshakeAck is the daemon's reply to a handshake. On failure ErrorCode
// is non-empty and the connection is closed after the ack is written.
type HandshakeAck struct {
// Protocol / status.
OK bool `json:"ok"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMsg string `json:"error_msg,omitempty"`
// Populated on success.
SessionID string `json:"session_id,omitempty"`
DefaultRepo string `json:"default_repo,omitempty"`
ActiveProject string `json:"active_project,omitempty"`
// For clients that want to compare before trusting the connection.
DaemonVersion string `json:"daemon_version,omitempty"`
// Warming is true when the handshake completed but the daemon has not
// finished its warmup pipeline — the graph is still filling, so tool
// results may be partial. The session is fully usable; this is advisory
// so a connecting proxy / CLI can wait for full data (or just log it)
// instead of guessing. WarmupPhase carries the last-published phase name
// (snapshot_loaded → parallel_parse → … → ready) when known.
Warming bool `json:"warming,omitempty"`
WarmupPhase string `json:"warmup_phase,omitempty"`
}
// Error codes reported in HandshakeAck.ErrorCode. Kept small and stable so
// client code can branch on them without parsing prose.
const (
ErrProtocolMismatch = "protocol_mismatch"
ErrUnsupportedMode = "unsupported_mode"
ErrRepoNotTracked = "repo_not_tracked"
ErrInternal = "internal"
)
// ControlRequest is the envelope for every message sent in ModeControl.
// Kind identifies which operation; Params is operation-specific JSON.
type ControlRequest struct {
Kind string `json:"kind"`
Params json.RawMessage `json:"params,omitempty"`
}
// ControlResponse is the paired envelope from the daemon. One response per
// request, in order. Non-empty ErrorCode means the operation failed.
type ControlResponse struct {
OK bool `json:"ok"`
ErrorCode string `json:"error_code,omitempty"`
ErrorMsg string `json:"error_msg,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
}
// Control operation kinds. One constant per kind so callers can't typo.
const (
ControlTrack = "track"
ControlUntrack = "untrack"
ControlReload = "reload"
// ControlProxy reloads servers.toml and rebuilds + atomically swaps
// the daemon's multi-server Router, then invalidates the roster
// cache — so `gortex proxy on/off/add/remove` apply to a running
// daemon without a restart. Distinct from ControlReload, which runs
// a full repo track/untrack reconcile and never touches the router.
ControlProxy = "proxy"
ControlStatus = "status"
ControlShutdown = "shutdown"
ControlSearchSymbols = "search_symbols"
// ControlEnrichChurn dispatches to Controller.EnrichChurn — the daemon
// runs the churn enricher against its in-process graph so the CLI
// (and the post-commit / post-merge git hooks) don't have to fight
// the on-disk store's write lock the daemon holds.
ControlEnrichChurn = "enrich_churn"
// ControlEnrichReleases dispatches to Controller.EnrichReleases.
// Same routing rationale as ControlEnrichChurn — the CLI hands the
// enrichment to the daemon when one is up so the write lock stays
// uncontested.
ControlEnrichReleases = "enrich_releases"
// ControlEnrichBlame dispatches to Controller.EnrichBlame — git-blame
// authorship stamping against the daemon's in-process graph. Same
// routing rationale as ControlEnrichChurn.
ControlEnrichBlame = "enrich_blame"
// ControlEnrichCoverage dispatches to Controller.EnrichCoverage —
// Go cover-profile projection onto the daemon's in-process graph.
// The CLI parses the profile and hands the raw segments to the
// daemon so the daemon never has to read the caller's filesystem.
ControlEnrichCoverage = "enrich_coverage"
// ControlEnrichCochange dispatches to Controller.EnrichCochange —
// co-change edge mining against the daemon's in-process graph.
ControlEnrichCochange = "enrich_cochange"
)
// TrackParams is the payload for ControlTrack.
type TrackParams struct {
Path string `json:"path"`
Name string `json:"name,omitempty"`
Project string `json:"project,omitempty"`
Ref string `json:"ref,omitempty"`
// AsWorktree forces a linked git worktree of an already-tracked repo
// to be registered as an independent instance (the `--as-worktree`
// flag) rather than coalescing into the canonical checkout.
AsWorktree bool `json:"as_worktree,omitempty"`
}
// UntrackParams is the payload for ControlUntrack.
type UntrackParams struct {
PathOrPrefix string `json:"path_or_prefix"`
}
// StatusResponse is the payload returned under Result on a successful
// ControlStatus call.
type StatusResponse struct {
Version string `json:"version"`
PID int `json:"pid"`
UptimeSeconds int64 `json:"uptime_seconds"`
SocketPath string `json:"socket_path"`
TrackedRepos []TrackedRepoStatus `json:"tracked_repos"`
Sessions int `json:"sessions"`
// MemoryBytes is runtime.MemStats.Alloc — live allocated heap.
// Retained for backwards compatibility with older clients; new
// clients should read from Runtime.
MemoryBytes uint64 `json:"memory_bytes"`
Runtime RuntimeStats `json:"runtime"`
SearchBackend SearchBackendStats `json:"search_backend"`
// PProfAddr is set when the daemon has opened an HTTP pprof
// listener (via the GORTEX_DAEMON_PPROF_ADDR env var). Empty
// string means pprof is not enabled on this daemon.
PProfAddr string `json:"pprof_addr,omitempty"`
// Ready is false while the daemon is still loading the snapshot and
// resolving references in the background. It flips true once references
// are resolved and the graph is queryable — which can be well before
// EnrichmentComplete. The socket is reachable even when Ready=false.
Ready bool `json:"ready"`
WarmupSeconds int64 `json:"warmup_seconds"`
// EnrichmentComplete is false while semantic enrichment and the
// graph-wide derivation passes are still running in the background.
// Ready can be true (references resolved and queryable) while this is
// still false. EnrichSeconds records the full warmup duration.
EnrichmentComplete bool `json:"enrichment_complete"`
EnrichSeconds int64 `json:"enrich_seconds,omitempty"`
// Enrichment reports live semantic-enrichment progress (repos
// finished vs total, and the in-flight pass) so a client sees a
// number instead of a mute "enrichment in progress". Nil when no
// semantic manager is wired or it has never run a pass.
Enrichment *EnrichmentProgress `json:"enrichment,omitempty"`
// Workspaces aggregates TrackedRepos by workspace slug. Empty
// when no repo declares one (every repo defaults to its own
// workspace; the table form is more compact in that case).
Workspaces []WorkspaceSummary `json:"workspaces,omitempty"`
// MCPSessions lists every connected proxy client (Claude Code,
// Cursor, Codex, etc.). Empty when the daemon hasn't yet been
// asked to track sessions.
MCPSessions []MCPSessionStatus `json:"mcp_sessions,omitempty"`
// ConfiguredServers reflects `~/.gortex/servers.toml` when
// present. Empty when running single-server (the file is missing
// or empty); in that case the daemon serves locally and the
// multi-server router is disabled.
ConfiguredServers []ConfiguredServerStatus `json:"configured_servers,omitempty"`
// LocalServerSlug is the slug this daemon recognises as itself
// when matching against ConfiguredServers — set from servers.toml
// `default` or the first entry. Empty when no servers.toml.
LocalServerSlug string `json:"local_server_slug,omitempty"`
// ToolPreset / ToolPresetMode report the active MCP tool-surface preset
// and mode; LearnedTools is the per-workspace learned-promotion count
// (deferred tools promoted through use, persisted across restarts). Empty
// / zero on a control-only daemon.
ToolPreset string `json:"tool_preset,omitempty"`
ToolPresetMode string `json:"tool_preset_mode,omitempty"`
LearnedTools int `json:"learned_tools,omitempty"`
// LSPRouter reports the daemon's LSP-router state — every
// registered spec, whether its binary resolves on PATH, and
// every alive (spec, workspace) subprocess. Empty when no LSP
// router is wired (`semantic.enabled: false` in `.gortex.yaml`).
LSPRouter *LSPRouterStatus `json:"lsp_router,omitempty"`
}
// LSPRouterStatus reflects one daemon's LSP-router state for the
// daemon-status TUI / `gortex daemon status` JSON.
type LSPRouterStatus struct {
// DefaultWorkspace is the rootURI used when callers don't supply
// a workspace (single-repo daemons).
DefaultWorkspace string `json:"default_workspace,omitempty"`
// EnabledSpecs lists every spec the user opted into via
// `.gortex.yaml`, with a flag indicating whether its command
// resolved on PATH at boot. Pure metadata — no spawn implied.
EnabledSpecs []LSPSpecStatus `json:"enabled_specs"`
// ActiveProviders lists every (spec, workspace) pair currently
// owning an alive LSP subprocess. May be empty even when several
// specs are enabled — the router lazy-spawns on first request.
ActiveProviders []LSPActiveProvider `json:"active_providers,omitempty"`
}
// LSPSpecStatus is one row in LSPRouterStatus.EnabledSpecs.
type LSPSpecStatus struct {
Name string `json:"name"`
Available bool `json:"available"` // command on PATH right now
Languages string `json:"languages"` // comma-separated for readability
}
// LSPActiveProvider is one alive subprocess.
type LSPActiveProvider struct {
Spec string `json:"spec"`
Workspace string `json:"workspace"`
LastUsed string `json:"last_used"` // RFC3339
}
// EnrichmentProgress summarizes the semantic-enrichment manager's
// per-(repo, provider) status list into the counts a status line
// needs: how many repos have finished, and which pass (if any) is
// running right now.
type EnrichmentProgress struct {
// Running is true while at least one (repo, provider) pass is in
// the "running" state.
Running bool `json:"running"`
// ReposTotal is the number of distinct repos the manager has ever
// recorded a pass for. ReposDone is the subset of those where
// every recorded provider has reached a terminal state (completed,
// partial, abandoned, or failed).
ReposTotal int `json:"repos_total"`
ReposDone int `json:"repos_done"`
// Current is the in-flight pass, when Running is true.
Current *EnrichmentCurrent `json:"current,omitempty"`
}
// EnrichmentCurrent is the one (repo, provider) enrichment pass
// currently running, with how long it has been running and (when
// known) its deadline.
type EnrichmentCurrent struct {
Repo string `json:"repo"`
Provider string `json:"provider"`
ElapsedSeconds float64 `json:"elapsed_seconds"`
DeadlineSeconds float64 `json:"deadline_seconds,omitempty"`
}
// RuntimeStats captures Go runtime.MemStats fields users care about
// when diagnosing daemon memory: live vs reserve, GC pressure, and
// goroutine count. All byte fields are raw (not human-formatted).
type RuntimeStats struct {
Alloc uint64 `json:"alloc"` // live heap allocations
Sys uint64 `json:"sys"` // total bytes from OS
HeapInuse uint64 `json:"heap_inuse"` // bytes in in-use spans
HeapIdle uint64 `json:"heap_idle"` // bytes in idle spans (released or reserve)
HeapReleased uint64 `json:"heap_released"` // bytes returned to OS
StackInuse uint64 `json:"stack_inuse"` // goroutine stacks
NumGC uint32 `json:"num_gc"` // completed GC cycles
NumGoroutine int `json:"num_goroutine"` // live goroutines
}
// SearchBackendStats identifies which search backend is currently
// serving queries, so users can read the `search_b` column in the
// repo breakdown with the right mental model. Bleve with the default
// gtreap KV store costs ~32 KiB per document; BM25 costs ~2 KiB.
type SearchBackendStats struct {
Name string `json:"name"` // "bm25" | "bleve-memory" | "bleve-disk" | "sqlite-fts5"
DocCount int `json:"doc_count"` // indexed documents across all repos
Bytes uint64 `json:"bytes"` // approximate heap footprint
DiskPath string `json:"disk_path,omitempty"` // set only when Name == "bleve-disk"
DiskBytes uint64 `json:"disk_bytes,omitempty"` // current on-disk size for "bleve-disk"
// DiskResident marks a backend (e.g. "sqlite-fts5") that has no
// meaningful heap footprint of its own — its index lives inside the
// graph store's own file — and no cheap byte count is available
// either. Rendering must not print a fabricated "heap=0 B" for it.
DiskResident bool `json:"disk_resident,omitempty"`
}
// SearchSymbolsParams is the payload for ControlSearchSymbols.
// Repo (optional) limits results to one repo prefix; Limit caps the result
// count (zero defaults to a small built-in cap so unbounded calls can't
// stall the hook).
type SearchSymbolsParams struct {
Query string `json:"query"`
Limit int `json:"limit,omitempty"`
Repo string `json:"repo,omitempty"`
}
// SymbolHit is one entry in SearchSymbolsResult.Hits.
type SymbolHit struct {
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
FilePath string `json:"file_path"`
Line int `json:"line,omitempty"`
}
// SearchSymbolsResult is the payload returned under Result for a
// successful ControlSearchSymbols call.
type SearchSymbolsResult struct {
Hits []SymbolHit `json:"hits"`
}
// EnrichChurnParams is the payload for ControlEnrichChurn.
//
// Path scopes the enrichment to a single tracked repo (matched by
// prefix, abs path, or "" for "every tracked repo"). Branch overrides
// the default-branch resolution — pass "origin/main" / "main" / a tag
// / a SHA. Empty Branch means the daemon picks the default branch
// from each repo's working tree.
type EnrichChurnParams struct {
Path string `json:"path,omitempty"`
Branch string `json:"branch,omitempty"`
}
// EnrichChurnResult is the payload returned under Result for a
// successful ControlEnrichChurn call. Counts are summed across every
// repo that participated (typically one).
type EnrichChurnResult struct {
Files int `json:"files"`
Symbols int `json:"symbols"`
Branch string `json:"branch"`
HeadSHA string `json:"head_sha"`
DurationMS int64 `json:"duration_ms"`
}
// EnrichReleasesParams is the payload for ControlEnrichReleases.
//
// Path scopes the enrichment to a single tracked repo (prefix or
// absolute root, "" for "every tracked repo"). Branch restricts the
// considered tags to those reachable from that branch; empty Branch
// means "every tag in the repo" — matches the legacy `analyze
// kind=releases` behaviour.
type EnrichReleasesParams struct {
Path string `json:"path,omitempty"`
Branch string `json:"branch,omitempty"`
}
// EnrichReleasesResult is the payload returned under Result for a
// successful ControlEnrichReleases call. Files is the count of file
// nodes stamped with meta.added_in across every repo that
// participated.
type EnrichReleasesResult struct {
Files int `json:"files"`
Branch string `json:"branch,omitempty"`
DurationMS int64 `json:"duration_ms"`
}
// EnrichBlameParams is the payload for ControlEnrichBlame.
//
// Path scopes the enrichment to a single tracked repo (matched by
// prefix, abs path, or "" for "every tracked repo").
type EnrichBlameParams struct {
Path string `json:"path,omitempty"`
}
// EnrichBlameResult is the payload returned under Result for a
// successful ControlEnrichBlame call. Nodes is the count of symbol /
// file nodes stamped with meta.last_authored across every repo that
// participated.
type EnrichBlameResult struct {
Nodes int `json:"nodes"`
DurationMS int64 `json:"duration_ms"`
}
// EnrichCoverageSegment mirrors coverage.Segment on the wire so the
// CLI can parse the cover profile against its own filesystem (the
// profile path is relative to the caller, not the daemon) and hand the
// parsed segments to the daemon. Field shape matches coverage.Segment
// exactly.
type EnrichCoverageSegment struct {
File string `json:"file"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
NumStmt int `json:"num_stmt"`
Count int `json:"count"`
}
// EnrichCoverageParams is the payload for ControlEnrichCoverage.
//
// Path scopes the enrichment to a single tracked repo (matched by
// prefix, abs path, or "" for "every tracked repo"). Segments are the
// pre-parsed cover-profile entries; the CLI parses the profile so the
// daemon never has to read the caller's filesystem.
type EnrichCoverageParams struct {
Path string `json:"path,omitempty"`
Segments []EnrichCoverageSegment `json:"segments"`
}
// EnrichCoverageResult is the payload returned under Result for a
// successful ControlEnrichCoverage call. Symbols is the count of nodes
// stamped with meta.coverage_pct across every repo that participated;
// Segments echoes how many profile segments were supplied.
type EnrichCoverageResult struct {
Symbols int `json:"symbols"`
Segments int `json:"segments"`
DurationMS int64 `json:"duration_ms"`
}
// EnrichCochangeParams is the payload for ControlEnrichCochange.
//
// Path scopes the enrichment to a single tracked repo (matched by
// prefix, abs path, or "" for "every tracked repo").
type EnrichCochangeParams struct {
Path string `json:"path,omitempty"`
}
// EnrichCochangeResult is the payload returned under Result for a
// successful ControlEnrichCochange call. Edges is the count of
// co_change edges added across every repo that participated.
type EnrichCochangeResult struct {
Edges int `json:"edges"`
DurationMS int64 `json:"duration_ms"`
}
// TrackedRepoStatus is one row in StatusResponse.TrackedRepos.
type TrackedRepoStatus struct {
Prefix string `json:"prefix"`
Path string `json:"path"`
Name string `json:"name,omitempty"`
// Project is the GlobalConfig active-project slug — a named
// grouping of repos in `~/.gortex/config.yaml::projects`.
// Distinct from `WorkspaceProject` below, which is the project
// slug from `.gortex.yaml::project`. Kept here for backwards
// compatibility with older daemon clients that read the field.
Project string `json:"project,omitempty"`
// Workspace is the workspace slug stamped onto every node emitted
// from this repo. Falls back to Prefix when no
// `.gortex.yaml::workspace` is declared. Two repos that share a
// Workspace pair their contracts as one logical service.
Workspace string `json:"workspace,omitempty"`
// WorkspaceProject is the project slug — the soft sub-boundary
// inside Workspace. Falls back to Prefix when no
// `.gortex.yaml::project` is declared.
WorkspaceProject string `json:"workspace_project,omitempty"`
Ref string `json:"ref,omitempty"`
Files int `json:"files"`
Nodes int `json:"nodes"`
Edges int `json:"edges"`
LastIndex int64 `json:"last_index_unix"`
Memory MemoryBreakdown `json:"memory"`
}
// WorkspaceSummary aggregates per-workspace stats so `gortex daemon
// status` can render a "workspaces" block above the per-repo table.
// Reflects the workspace hard boundary: counts roll up across every
// repo declaring the same workspace slug.
type WorkspaceSummary struct {
Slug string `json:"slug"`
Repos []string `json:"repos"`
Projects []string `json:"projects"`
Files int `json:"files"`
Nodes int `json:"nodes"`
Edges int `json:"edges"`
}
// MCPSessionStatus is one row in the sessions list. Reports the
// per-client state the daemon tracks: connection time, current cwd
// (used by the router for cwd-based workspace resolution), and the
// remote agent identifier when the client supplied one in MCP's
// `clientInfo`.
type MCPSessionStatus struct {
ID string `json:"id"`
Cwd string `json:"cwd,omitempty"`
ClientName string `json:"client_name,omitempty"`
ClientVersion string `json:"client_version,omitempty"`
ConnectedSecs int64 `json:"connected_secs"`
}
// ConfiguredServerStatus is one row in the servers list, mirroring a
// `~/.gortex/servers.toml::[[server]]` entry plus a "this is us"
// flag set when Slug equals the daemon's local slug.
type ConfiguredServerStatus struct {
Slug string `json:"slug"`
URL string `json:"url"`
Default bool `json:"default,omitempty"`
Local bool `json:"local,omitempty"`
Workspaces []string `json:"workspaces,omitempty"`
HasAuth bool `json:"has_auth,omitempty"`
}
// MemoryBreakdown is a per-repo memory estimate split across the
// data structures that dominate the daemon's footprint. All values
// are approximate — exact accounting would require walking Go's
// heap, which is too expensive for a status call. See the individual
// estimators (graph.RepoMemoryEstimate, search.BleveBackend.SizeBytes,
// search.VectorBackend.SizeBytes) for methodology.
type MemoryBreakdown struct {
NodesBytes uint64 `json:"nodes_bytes"`
EdgesBytes uint64 `json:"edges_bytes"`
SearchBytes uint64 `json:"search_bytes"`
VectorsBytes uint64 `json:"vectors_bytes"`
// DiskBytes is populated only when the Bleve backend is running in
// disk mode (GORTEX_BLEVE_DISK_DIR set). Each repo gets a
// node-proportional share of the on-disk index size. Zero in
// memory-only mode.
DiskBytes uint64 `json:"disk_bytes,omitempty"`
TotalBytes uint64 `json:"total_bytes"`
}
// WriteJSONLine writes v as one JSON object followed by a newline. The
// daemon protocol is newline-delimited JSON (NDJSON) so we can scan it
// cheaply with bufio.Scanner.
func WriteJSONLine(w io.Writer, v any) error {
buf, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
buf = append(buf, '\n')
_, err = w.Write(buf)
return err
}