Files
zzet--gortex/cmd/gortex/daemon.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

1626 lines
61 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"time"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/jedib0t/go-pretty/v6/text"
"github.com/spf13/cobra"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/daemon"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
"github.com/zzet/gortex/internal/llm/conversationlog"
"github.com/zzet/gortex/internal/platform"
"github.com/zzet/gortex/internal/progress"
"github.com/zzet/gortex/internal/server"
"github.com/zzet/gortex/internal/server/hub"
"github.com/zzet/gortex/internal/tui"
)
var (
daemonDetach bool
daemonTail int
daemonEmbeddings bool
// daemonEmbeddingsChanged records whether `--embeddings` was given
// explicitly on `gortex daemon start`. buildDaemonState reads it
// (the function has no *cobra.Command of its own) to decide whether
// the flag overrides the `embedding:` config block. Set once in
// runDaemonStart before buildDaemonState runs.
daemonEmbeddingsChanged bool
// daemonEmbeddingsURL / daemonEmbeddingsModel mirror `gortex mcp`'s
// --embeddings-url / --embeddings-model so the daemon can be started with an
// explicit OpenAI-compatible (or Ollama) embedding API. A non-empty URL forces
// the api provider in ResolveEmbedder, overriding the embedding: config block.
daemonEmbeddingsURL string
daemonEmbeddingsModel string
daemonStatusWatch bool
daemonStatusInterval time.Duration
daemonHTTPAddr string
daemonHTTPAuthToken string
daemonHTTPCORSOrigin string
daemonHTTPConversationAllow []string
daemonBackend string
daemonBackendPath string
daemonBackendBufferPoolMB uint64
daemonTools string
daemonToolsMode string
)
var daemonCmd = &cobra.Command{
Use: "daemon",
Short: "Manage the long-living Gortex daemon",
Long: `The daemon holds the graph for all tracked repositories and serves every
MCP client (Claude Code, Cursor, Kiro, ...) plus the CLI from one shared
index.
If no daemon is running, ` + "`gortex mcp`" + ` still works standalone — the daemon
is additive, not required.`,
}
var daemonStartCmd = &cobra.Command{
Use: "start",
Short: "Start the daemon",
RunE: runDaemonStart,
}
var daemonStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the daemon gracefully (waits for final snapshot)",
RunE: runDaemonStop,
}
var daemonRestartCmd = &cobra.Command{
Use: "restart",
Short: "Stop and start the daemon (preserves tracked repos)",
RunE: runDaemonRestart,
}
var daemonReloadCmd = &cobra.Command{
Use: "reload",
Short: "Re-read config and pick up new or removed repos without restart",
RunE: runDaemonReload,
}
var daemonStatusCmd = &cobra.Command{
Use: "status",
Short: "Show daemon PID, uptime, tracked repos, memory, sessions",
RunE: runDaemonStatus,
}
var daemonLogsCmd = &cobra.Command{
Use: "logs",
Short: "Tail the daemon log file",
RunE: runDaemonLogs,
}
func init() {
daemonStartCmd.Flags().BoolVar(&daemonDetach, "detach", false,
"fork to background after starting (logs to the daemon log file — see `gortex daemon logs`)")
daemonStartCmd.Flags().BoolVar(&daemonEmbeddings, "embeddings", false,
"load a semantic embedding provider (opt-in — adds ~87 MB model download on first use and ~60 ms/symbol warmup)")
daemonStartCmd.Flags().StringVar(&daemonEmbeddingsURL, "embeddings-url", "",
"OpenAI-compatible (or Ollama) embedding API base URL (e.g. https://api.openai.com/v1). A non-empty URL forces the api provider, overriding the embedding: config. Key via $GORTEX_EMBEDDINGS_API_KEY or $OPENAI_API_KEY (openai.com only).")
daemonStartCmd.Flags().StringVar(&daemonEmbeddingsModel, "embeddings-model", "",
"embedding model for --embeddings-url (default: auto-detect — text-embedding-3-small for OpenAI, nomic-embed-text for Ollama)")
daemonStartCmd.Flags().StringVar(&daemonHTTPAddr, "http-addr", "",
"also expose the MCP 2026 Streamable HTTP transport on this TCP address (e.g. 127.0.0.1:7411); empty disables")
daemonStartCmd.Flags().StringVar(&daemonHTTPAuthToken, "http-auth-token", "",
"bearer token required on every Streamable HTTP request (default: read $GORTEX_DAEMON_HTTP_TOKEN; empty allows unauthenticated localhost binds)")
daemonStartCmd.Flags().StringVar(&daemonHTTPCORSOrigin, "cors-origin", "*",
"allowed CORS origin for the HTTP surface (use '*' for any); applies to both /mcp and /v1 when --http-addr is set")
daemonStartCmd.Flags().StringSliceVar(&daemonHTTPConversationAllow, "conversation-host", nil,
"extra Host values (beyond loopback) the conversation-log inspector accepts without a token; repeatable")
daemonStartCmd.Flags().StringVar(&daemonBackend, "backend", "sqlite",
"storage backend: sqlite (default — pure-Go embedded SQL, persists to --backend-path so warm restarts skip re-indexing) | memory (in-process, no persistence — fastest per-op but pays the full cold-warmup cost on every restart)")
daemonStartCmd.Flags().StringVar(&daemonBackendPath, "backend-path", "",
"path to the on-disk backend's store file (its parent directory is created if absent). Defaults to ~/.gortex/store/store.sqlite; ignored when --backend is memory")
daemonStartCmd.Flags().Uint64Var(&daemonBackendBufferPoolMB, "backend-buffer-pool-mb", 0,
"advisory page-cache cap (MiB) for on-disk backends. 0 reads $GORTEX_DAEMON_BUFFER_POOL_MB or lets the backend choose its own default; backends that manage their own cache (e.g. sqlite) ignore it")
daemonStartCmd.Flags().StringVar(&daemonTools, "tools", "",
"restrict the published MCP tool surface to a preset: core (default)|full|readonly|edit|nav (optionally with ,+tool / ,-tool deltas). GORTEX_TOOLS overrides this")
daemonStartCmd.Flags().StringVar(&daemonToolsMode, "tools-mode", "",
"how a --tools preset hides tools: hide (remove from tools/list + block calls) or defer (keep reachable via tools_search). Default hide")
daemonLogsCmd.Flags().IntVarP(&daemonTail, "tail", "n", 50,
"show only the last N log lines")
daemonStatusCmd.Flags().BoolVarP(&daemonStatusWatch, "watch", "w", false,
"continuously refresh the status until interrupted (alt-screen buffer)")
daemonStatusCmd.Flags().DurationVar(&daemonStatusInterval, "interval", 2*time.Second,
"refresh interval in --watch mode (clamped to >=200ms)")
daemonCmd.AddCommand(daemonStartCmd)
daemonCmd.AddCommand(daemonStopCmd)
daemonCmd.AddCommand(daemonRestartCmd)
daemonCmd.AddCommand(daemonReloadCmd)
daemonCmd.AddCommand(daemonStatusCmd)
daemonCmd.AddCommand(daemonLogsCmd)
rootCmd.AddCommand(daemonCmd)
}
// runDaemonStart starts the daemon in foreground (default) or detached
// (when --detach is passed). Detach does a self-exec: re-runs this binary
// with GORTEX_DAEMON_CHILD=1 set, which the inner exec picks up and runs
// the actual serve loop.
func runDaemonStart(cmd *cobra.Command, _ []string) error {
// An explicit start (user or supervisor) supersedes a prior `daemon stop` —
// clear the stay-down mark so autostart works again. The autostart-spawned
// child (GORTEX_DAEMON_CHILD=1) must NOT clear it: that would let an
// in-flight autostart erase a stop-intent the user wrote in the meantime.
if os.Getenv("GORTEX_DAEMON_CHILD") != "1" {
daemon.ClearStopIntent()
}
if isDaemonRunning() {
return fmt.Errorf("daemon already running (socket: %s)", daemon.SocketPath())
}
// IsRunning only probes the socket. A daemon that is mid-shutdown — or
// one whose socket wedged — still owns the PID file and, crucially, still
// holds the store's on-disk lock. Starting over the top of it makes the
// backend open fail with an opaque "failed to open database" lock
// conflict, so refuse early with the PID and an actionable next step. The
// detached child reaches here too, but it hasn't written its own PID file
// yet (that happens in the serve loop), so this can't false-positive on
// the daemon we're in the middle of starting.
if pid, ok := daemon.RunningPID(); ok {
return fmt.Errorf("daemon already running (pid %d) — stop it with `gortex daemon stop`, or use `gortex daemon restart`", pid)
}
if daemonDetach && os.Getenv("GORTEX_DAEMON_CHILD") != "1" {
return spawnDetachedDaemon()
}
logger := newLogger()
// Raise the per-process file-descriptor cap as early as possible.
// fsnotify holds one FD per watched directory on Linux and one FD
// per directory plus every file inside it on macOS, so a multi-repo
// install easily blows past the inherited soft cap (256 on macOS,
// 1024 on most Linuxes) and surfaces as "accept: too many open
// files" once the daemon is hot.
if fdl, err := daemon.RaiseFDLimit(); err != nil {
logger.Warn("daemon: could not raise file-descriptor limit", zap.Error(err))
} else {
logger.Info("daemon: file-descriptor cap",
zap.Uint64("soft", fdl.Soft), zap.Uint64("hard", fdl.Hard))
}
srv := daemon.New(daemon.SocketPath(), canonicalVersion(), logger)
// Record whether `--embeddings` was set explicitly so
// buildDaemonState can let it override the `embedding:` config
// block. With `--detach` the re-exec'd child sees no flags, so an
// explicit opt-in there must travel via GORTEX_EMBEDDINGS instead.
daemonEmbeddingsChanged = cmd.Flags().Changed("embeddings")
// Fast path: snapshot load + indexer + MCP server wiring. The
// per-repo TrackRepoCtx loop and MultiWatcher init are deferred to
// warmupDaemonState below so the socket opens immediately instead
// of waiting 3060s for contract re-extraction across every tracked
// repo.
state, err := buildDaemonState(logger)
if err != nil {
return fmt.Errorf("build daemon state: %w", err)
}
// Install the standing soft memory limit now — logging and config are
// up and no warmup / indexing has allocated yet, so the cold-index
// window's temporary override restores to this value rather than to
// "no limit" (see applyStandingMemoryLimit).
var daemonMemLimit string
if gc := state.configManager.Global(); gc != nil {
daemonMemLimit = gc.Daemon.MemoryLimit
}
applyStandingMemoryLimit(logger, daemonMemLimit)
controller := &realController{
graph: state.graph,
indexer: state.indexer,
multiIndexer: state.multiIndexer,
configManager: state.configManager,
logger: logger,
}
if state.mcpServer != nil {
srv := state.mcpServer
controller.toolSurface = func() (string, string, int) {
preset, mode := srv.ActivePreset()
return preset, mode, srv.LearnedToolCount()
}
}
controller.onShutdown = func() error {
// Stop watchers first so no late events race the snapshot
// write — we want the snapshot to reflect a quiescent graph,
// not one being mutated by an in-flight re-index.
controller.mu.Lock()
mw := controller.multiWatcher
controller.mu.Unlock()
if mw != nil {
_ = mw.Stop()
}
if mg, ok := state.graph.(*graph.Graph); ok {
// Memory backend — snapshot the full in-memory graph;
// the next warmup replays nodes/edges from the gob+gzip
// dump because there's no other persistence layer.
saveSnapshot(mg, collectSnapshotRepos(state.multiIndexer), collectSnapshotContracts(state.multiIndexer), collectSnapshotVector(state.multiIndexer), version, logger)
}
// Persistent backends (sqlite) no longer write a metadata
// snapshot: per-file mtimes live in the FileMtime sidecar
// table, contract records ride on KindContract.Meta, and the
// vector index is persisted by the backend itself. Warm
// restart reads everything it needs from the on-disk store —
// no gob+gzip round-trip required.
// Run the shared stack's teardown chain — flushes the savings
// store and closes the backend handle (checkpointing the sqlite
// WAL) so the daemon shuts down cleanly.
if state.shared != nil {
_ = state.shared.Close()
} else if state.mcpServer != nil {
_ = state.mcpServer.FlushSavings()
}
return nil
}
srv.Controller = controller
// Surface warmup state on the handshake ack: a proxy / CLI that connects
// during the (minutes-long) warmup should know the graph is still filling
// instead of guessing. controller.IsReady() is authoritative for the bool;
// the readiness broadcaster carries the fine-grained phase name.
srv.Ready = func() (bool, string) {
ready := controller.IsReady()
phase := "warming"
if ready {
phase = "ready"
}
if state.mcpServer != nil {
if p, _ := state.mcpServer.ReadinessPhase(); p != "" {
phase = p
}
}
return ready, phase
}
disp := newMCPDispatcher(state.mcpServer, state.multiIndexer, logger)
// The local executor + the dispatcher's SetRouter are handed to the
// controller so ControlProxy can build/publish/tear-down the router
// live (gortex proxy on/off/add/remove) without a daemon restart.
localExec := newLocalToolExecutor(state.mcpServer, logger)
controller.localExecute = localExec
controller.publishRouter = disp.SetRouter
// Wire the multi-server router into the daemon dispatcher when
// servers.toml exists. Local-only
// daemons (no servers.toml) leave router=nil and dispatch flows
// straight to the in-process MCP server unchanged.
if scfg, scfgErr := daemon.LoadServersConfig(""); scfgErr == nil && scfg != nil && len(scfg.Server) > 0 {
rosters := daemon.NewWorkspaceRosterCache(60 * time.Second)
// Local identity is a reserved sentinel, never DefaultServer().Slug:
// a remote marked default=true must still be proxied to, not
// treated as the daemon's own graph.
router := daemon.NewRouter(daemon.RouterConfig{
Servers: scfg,
Rosters: rosters,
LocalSlug: daemon.LocalServerSentinel,
LocalExecute: localExec,
Logger: logger,
Federation: resolveFederationConfig(),
})
disp.SetRouter(router)
controller.liveRouter = router
logger.Info("daemon: multi-server router wired",
zap.Int("servers", len(scfg.Server)), zap.String("local_slug", daemon.LocalServerSentinel))
// Cross-daemon proxy-edge minting: when federation.edges is on,
// install the evidence prober on the indexer's resolver (so
// cross-repo resolution mints proxy edges) and keep the hydrator
// for the read path. No-op when the flag is off.
if hyd := daemon.WireRemoteStitch(router, state.multiIndexer, state.graph, resolveFederationEdgesConfig(), logger); hyd != nil {
state.proxyHydrator = hyd
if state.mcpServer != nil {
state.mcpServer.SetProxyHydrator(hyd.Hydrate)
}
logger.Info("daemon: federation proxy edges enabled (proxy minting + hydration)")
}
} else if scfgErr != nil {
logger.Warn("daemon: servers.toml load error (running single-server)", zap.Error(scfgErr))
}
// Bridge the session proxy-toggle MCP tools to the daemon's
// per-session overrides + the live router roster. The router
// accessor is dynamic so a ControlProxy swap is reflected.
if state.mcpServer != nil {
state.mcpServer.SetRemoteOverrideSink(&sessionRemoteOverrideSink{
sessions: srv.Sessions(),
router: disp.Router,
})
}
srv.MCPDispatcher = disp
// Event hub feeding the /v1 REST surface's graph-change SSE stream
// (/v1/events). Created lazily when the HTTP surface is enabled below
// and fed from the MultiWatcher once warmup attaches it — without this
// the daemon's /v1/events would only ever emit the "watch mode not
// active" frame even while the daemon is actively re-indexing changed
// files, leaving the REST surface short of the former `gortex server`.
var v1EventHub *hub.Hub
// Optional MCP 2026 Streamable HTTP transport. Off by default
// (--http-addr unset) so a fresh `gortex daemon start` keeps
// the unix-socket-only behaviour every existing client already
// expects. When set, the daemon mounts /mcp on the supplied
// TCP address using the in-process streamable.Transport;
// per-request session state is replayed out of an in-memory
// store so multiple workers / reverse-proxies could later share
// it. The auth token is mandatory for non-localhost binds —
// exposing an unauthenticated MCP server on an external
// interface is a footgun, not a feature.
if daemonHTTPAddr != "" {
token := daemonHTTPAuthToken
if token == "" {
token = os.Getenv("GORTEX_DAEMON_HTTP_TOKEN")
}
if err := httpTokenRequirementError(daemonHTTPAddr, token); err != nil {
return err
}
// Resolve the expected token per request so $GORTEX_DAEMON_HTTP_TOKEN
// can be rotated without restarting the daemon. The flag, when set,
// is fixed for the process lifetime and wins; otherwise the env var
// is re-read on every request.
tokenFn := func() string {
if daemonHTTPAuthToken != "" {
return daemonHTTPAuthToken
}
return os.Getenv("GORTEX_DAEMON_HTTP_TOKEN")
}
// Router was already wired into the dispatcher above; reuse
// it here so the streamable transport sees the same proxy
// fan-out for cross-workspace tool calls.
var router *daemon.Router
if r := disp.Router(); r != nil {
router = r
}
streamH := buildDaemonStreamableHandler(disp, srv.Sessions(), router, logger, tokenFn)
// Mount the /v1 REST surface (the former `gortex server`) on the
// same listener so a single daemon process serves both the MCP
// Streamable transport and the REST API that gortex-cloud and the
// web UI consume. The daemon already owns every dependency the
// handler needs — the MCP server, graph, config manager, overlay
// manager, and federation router — so this is pure composition.
v1 := server.NewHandler(state.mcpServer.MCPServer(), state.graph, version, logger)
if state.configManager != nil {
v1.SetConfigManager(state.configManager)
}
if id, idErr := resolveServerID(platform.DataDir()); idErr == nil {
v1.SetServerID(id)
}
if state.overlays != nil {
v1.SetOverlayManager(state.overlays)
}
if router != nil {
v1.SetRouter(router)
}
// Wire a graph-change event hub so /v1/events streams real events.
// The hub is fed from the MultiWatcher once warmup attaches it
// below; until then it has no source and /v1/events keepalives.
v1EventHub = hub.New()
v1.SetEventHub(v1EventHub)
// Conversation-log inspector. The /v1/conversations* routes read
// the opt-in sink's JSONL (raw LLM I/O), so they carry a
// route-scoped DNS-rebind guard that cooperates with the existing
// auth token: loopback / allowlisted hosts OR a valid token pass.
// The directory resolves through the same env helper the sink
// writer uses, so reader and writer always agree.
v1.SetConversationDir(conversationlog.DirFromEnv())
v1.SetConversationGuard(daemonHTTPConversationAllow, tokenFn)
srv.HTTPHandler = composeDaemonHTTPHandler(streamH, v1, tokenFn, daemonHTTPCORSOrigin)
srv.HTTPAddr = daemonHTTPAddr
logger.Info("daemon: HTTP surface configured (/mcp + /v1)",
zap.String("addr", daemonHTTPAddr),
zap.Bool("authenticated", token != ""),
zap.String("cors_origin", daemonHTTPCORSOrigin))
}
// Opt-in pprof endpoint. No-op unless GORTEX_DAEMON_PPROF_ADDR is
// set — keeps profiling off by default so the daemon doesn't hand
// its heap to anything on localhost.
startPProfIfEnabled(logger)
// Wire the daemon-health snapshot fn into the MCP server's
// healthBroadcaster. Captures the live controller, daemon
// server (for session count), and multi indexer so every periodic
// tick reflects current state. Stopped in the deferred shutdown
// below so the ticker goroutine doesn't outlive the process.
daemonStart := time.Now()
if state.mcpServer != nil {
srvCapture := srv
stateCapture := state
controllerCapture := controller
state.mcpServer.AttachHealthSnapshot(func() map[string]any {
out := map[string]any{
"uptime_seconds": int64(time.Since(daemonStart).Seconds()),
"ready": controllerCapture.IsReady(),
"enriched": controllerCapture.IsEnriched(),
"warmup_seconds": int64(0),
}
if st, statusErr := controllerCapture.Status(context.Background()); statusErr == nil {
out["warmup_seconds"] = st.WarmupSeconds
out["tracked_repos"] = len(st.TrackedRepos)
out["alloc_bytes"] = st.Runtime.Alloc
out["sys_bytes"] = st.Runtime.Sys
out["heap_inuse_bytes"] = st.Runtime.HeapInuse
out["num_goroutine"] = st.Runtime.NumGoroutine
out["num_gc"] = st.Runtime.NumGC
if st.LSPRouter != nil {
out["lsp_alive"] = len(st.LSPRouter.ActiveProviders)
out["lsp_specs_registered"] = len(st.LSPRouter.EnabledSpecs)
}
}
if sessions := srvCapture.Sessions(); sessions != nil {
out["sessions"] = sessions.Count()
}
if stateCapture.graph != nil {
out["graph_nodes"] = stateCapture.graph.NodeCount()
out["graph_edges"] = stateCapture.graph.EdgeCount()
if r, ok := stateCapture.graph.(graph.DBStatReporter); ok {
dbBytes, walBytes := r.DBStats()
if dbBytes > 0 || walBytes > 0 {
out["db_bytes"] = dbBytes
out["wal_bytes"] = walBytes
if dbBytes > 0 {
out["wal_db_ratio"] = float64(walBytes) / float64(dbBytes)
}
}
}
}
return out
})
}
defer func() {
if state.mcpServer != nil {
state.mcpServer.StopHealthBroadcaster()
}
}()
// Initial workspace_readiness phase — the snapshot has been
// loaded but warmup hasn't started yet.
publishReadinessPhase(state, "snapshot_loaded", false, map[string]any{
"snapshot_repos": len(state.snapshotRepos),
})
// Periodic snapshots — 10 minute interval, gated on warmup-complete.
// On a crash we lose at most one interval's worth of work, which is
// acceptable given snapshot writes are atomic (tmp → rename) and can
// never leave a truncated file on disk.
//
// Gating: a snapshot walks the whole graph (AllNodes + AllEdges) and
// holds shard RLocks for the duration. While the daemon is still
// warming up the parser worker pool is concurrently writing those
// shards via AddBatch, so an unsynchronised snapshot tick steals
// graph-lock budget from the work that needs to finish first and
// pulls millions of node/edge pointers into a live allocation set
// the GC then has to clean up. Skipping snapshots until ready cleared
// a stall observed in profile #5 where saveSnapshotTo was the only
// runnable goroutine on a daemon mid-warmup.
// Periodic snapshots fire only for the memory backend — that's
// the path that has no other persistence layer for the graph
// itself. Persistent backends (sqlite) rely on the backend's own
// durability (graph + FileMtimes + contracts + vectors all live
// on disk) so the gob+gzip snapshot is dead weight in that mode.
stopSnapshotter := func() {}
if mg, ok := state.graph.(*graph.Graph); ok {
// Gate on IsEnriched, not IsReady: ready now flips once references
// resolve (well before enrichment finishes), but a snapshot tick mid-
// enrichment still steals shard-lock budget from the enrichment passes
// writing those shards — exactly the stall this gate prevents.
stopSnapshotter = startPeriodicSnapshots(mg, state.multiIndexer, version, 10*time.Minute, controller.IsEnriched, logger)
}
defer stopSnapshotter()
// Periodic reconciliation — the "janitor". Walks each tracked repo
// and runs IncrementalReindex to evict files deleted offline and
// re-index files whose mtime changed. Insurance against gaps in
// fsnotify coverage (inotify watch limits, NFS mounts, kernel
// event-queue overflow). Default interval 1 h; override via
// GORTEX_RECONCILE_INTERVAL (a Go duration string, e.g. "15m").
// Set to "0" to disable.
stopJanitor := startReconcileJanitor(state.multiIndexer, reconcileInterval(), logger)
defer stopJanitor()
if err := srv.Listen(); err != nil {
return err
}
fmt.Fprintf(cmd.ErrOrStderr(),
"[gortex daemon] listening on %s (pid %d)\n",
daemon.SocketPath(), os.Getpid())
// Warmup runs in the background: re-index tracked repos, extract
// contracts, attach file watchers. The daemon is already reachable
// on the socket at this point, so clients can connect and start
// issuing queries while this work continues. Queries against
// not-yet-re-indexed repos still hit the snapshot data loaded in
// buildDaemonState — they just won't reflect files that changed
// since the snapshot was written until warmup gets to that repo.
go func() {
start := time.Now()
logger.Info("daemon: warmup starting")
// markReady fires once references are resolved and the graph is
// queryable — ahead of the slow enrichment pass — so clients can
// start issuing find_usages / get_callers immediately. Enrichment
// continues in this goroutine afterward and finishes at MarkEnriched.
var queryableElapsed time.Duration
markReady := func() {
elapsed := time.Since(start)
queryableElapsed = elapsed
controller.MarkReady(elapsed)
logger.Info("daemon: graph queryable", zap.Duration("warmup", elapsed))
publishReadinessPhase(state, "ready", true, map[string]any{
"queryable": true,
"warmup_seconds": int64(elapsed.Seconds()),
"warmup_ms": elapsed.Milliseconds(),
})
}
mw, warmup := warmupDaemonState(state, logger, markReady)
controller.AttachWatcher(mw)
// Wire the daemon's MultiWatcher into the per-server history
// surface so `get_recent_changes` and `get_symbol_history` see
// real events under the daemon. Without this the tools always
// reported "watch mode is not active" even though MultiWatcher
// was actively re-indexing changed files.
if state.mcpServer != nil && mw != nil {
state.mcpServer.SetWatcher(mw)
// Push a one-time degraded notice on the readiness channel when a
// watcher exhausts inotify watches / file descriptors, so a
// subscribed agent learns the index may be frozen without polling.
srv := state.mcpServer
mw.OnDegraded(func(reason string) {
srv.PublishReadiness("degraded", true, map[string]any{"watch_degraded": reason})
})
}
// Drive the /v1/events SSE stream from the MultiWatcher. The hub is
// the only consumer of mw.Events() (SetWatcher reads History(), not
// the channel), so this can't starve any other reader. No-op when
// the HTTP surface is disabled (v1EventHub stays nil).
if v1EventHub != nil && mw != nil {
go v1EventHub.Run(mw.Events())
}
// Community detection and process discovery only run when a
// repo is tracked or indexed via MCP — a daemon coming up off
// a snapshot never triggers them. Fire once here so
// get_communities / get_processes / dashboards reflect the
// loaded graph instead of returning "run index_repository
// first" against a fully populated state.
if state.mcpServer != nil {
state.mcpServer.RunAnalysis()
// Co-change pre-warm: fire the git-history mine in the
// background so the first user-visible
// find_co_changing_symbols / search-rerank call sees a
// populated cache. On a persistent backend the mine is
// dominated by the AllNodes + per-pair AddEdge disk-persist
// step that mineCoChange already defers into its own
// goroutine — but even the git log itself can take 1030s
// on a large history, and we want that off every request
// path.
state.mcpServer.PrewarmCoChange()
}
elapsed := time.Since(start)
controller.MarkEnriched(elapsed)
logger.Info("daemon: enrichment complete", zap.Duration("warmup", elapsed))
publishReadinessPhase(state, "enrichment_complete", true, map[string]any{
"enriched": true,
"warmup_seconds": int64(elapsed.Seconds()),
"warmup_ms": elapsed.Milliseconds(),
})
logWarmupSummary(logger, warmup, queryableElapsed, elapsed)
// Warmup is the daemon's single largest allocation burst (parse +
// resolve + the end_batch graph passes, then the whole-graph
// analysis above). This is the last point reached in every warmup
// shape — every early return inside warmupDaemonState still lands
// here — so return the burst's heap high-water to the OS now rather
// than letting the peak pin the idle footprint. RunAnalysis above
// may already have released, but the enrichment tail allocates
// further, so a final release at the true end still pays.
releaseMemoryToOS(logger, "warmup_complete")
}()
return srv.Serve()
}
// startPeriodicSnapshots kicks off a goroutine that writes a snapshot on
// every tick. Returns a stop function the caller runs at shutdown. The
// final snapshot on shutdown is handled by onShutdown — this loop only
// covers the "crash resilience" case (interval loss vs full re-index).
// reconcileInterval returns the janitor tick interval, defaulting to 1
// hour. GORTEX_RECONCILE_INTERVAL overrides; "0" or "off" disables the
// janitor entirely (returns 0, which startReconcileJanitor treats as
// a no-op). Malformed values fall back to the default with a warning
// handled by the caller via the zero-return sentinel behaviour.
func reconcileInterval() time.Duration {
raw := os.Getenv("GORTEX_RECONCILE_INTERVAL")
if raw == "" {
return time.Hour
}
if raw == "0" || raw == "off" {
return 0
}
d, err := time.ParseDuration(raw)
if err != nil || d < 0 {
return time.Hour
}
return d
}
// startReconcileJanitor launches a background goroutine that, on every
// interval tick, garbage-collects the index of any linked git worktree
// whose root directory has vanished from disk and then calls
// MultiIndexer.ReconcileAll. interval=0 is a no-op; the returned stop
// function can be called unconditionally.
//
// The worktree GC runs *before* ReconcileAll on purpose: a removed
// worktree's root no longer exists, so ReconcileAll's IncrementalReindex
// would only error on the missing path without evicting anything.
// Pruning the vanished worktrees first keeps the reconcile sweep
// working on live repos and stops a deleted worktree's snapshot slot
// and graph nodes from leaking forever.
func startReconcileJanitor(mi *indexer.MultiIndexer, interval time.Duration, logger *zap.Logger) func() {
if mi == nil || interval <= 0 {
logger.Info("daemon: reconcile janitor disabled")
return func() {}
}
stop := make(chan struct{})
go func() {
t := time.NewTicker(interval)
defer t.Stop()
logger.Info("daemon: reconcile janitor running", zap.Duration("interval", interval))
for {
select {
case <-t.C:
gced := mi.GCVanishedWorktrees()
if len(gced) > 0 {
logger.Info("janitor: pruned vanished worktrees",
zap.Int("count", len(gced)))
}
results := mi.ReconcileAll()
// Return the tick's heap to the OS only when it actually did
// work — a repo reindexed stale/deleted files, or a worktree
// was pruned. ReconcileAll fills a result for every repo, so
// the honest "did work" signal is the per-repo stale/deleted
// counts, not the map size. A quiescent tick skips the
// release: FreeOSMemory is a full GC, and paying it hourly
// for a no-op sweep is the periodic cost the release policy
// exists to avoid.
reconciled := 0
for _, r := range results {
if r != nil {
reconciled += r.StaleFileCount + r.DeletedFileCount
}
}
if reconciled > 0 || len(gced) > 0 {
releaseMemoryToOS(logger, "reconcile_janitor")
}
case <-stop:
return
}
}
}()
return func() { close(stop) }
}
func startPeriodicSnapshots(g *graph.Graph, mi *indexer.MultiIndexer, version string, interval time.Duration, isReady func() bool, logger *zap.Logger) func() {
stop := make(chan struct{})
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-t.C:
// Skip while the daemon is still warming up — the
// graph walk inside saveSnapshot would fight the
// parser worker pool for shard locks and pin a
// transient allocation set the GC then has to drain.
// The next tick after warmup completes catches up.
if isReady != nil && !isReady() {
logger.Debug("snapshot: skipped tick — daemon still warming up")
continue
}
saveSnapshot(g, collectSnapshotRepos(mi), collectSnapshotContracts(mi), collectSnapshotVector(mi), version, logger)
case <-stop:
return
}
}
}()
return func() { close(stop) }
}
// spawnDetachedDaemon re-invokes the binary with GORTEX_DAEMON_CHILD=1
// set, the log redirected to the daemon log file, and the child
// parented to init. Parent exits as soon as the child has the socket up.
//
// On a TTY the parent shows a mesh-spinner banner and a styled "ready" card
// once the socket is live. On a non-TTY (CI scripts, automation) we keep the
// historical one-line "[gortex daemon] detached (pid X, log: Y)" message so
// existing parsers don't break.
func spawnDetachedDaemon() error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("resolve executable: %w", err)
}
if err := daemon.EnsureParentDir(daemon.LogFilePath()); err != nil {
return err
}
logFile, err := os.OpenFile(daemon.LogFilePath(),
os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("open log file: %w", err)
}
child := exec.Command(exe, "daemon", "start")
childEnv := append(os.Environ(), "GORTEX_DAEMON_CHILD=1")
// --detach re-execs `daemon start` with no flags, so the tool-preset
// selection must travel to the child via env (the GORTEX_TOOLS
// override path), mirroring how --embeddings propagates. Appended
// last so an explicit flag wins over any inherited GORTEX_TOOLS.
if daemonTools != "" {
childEnv = append(childEnv, "GORTEX_TOOLS="+daemonTools)
}
if daemonToolsMode != "" {
childEnv = append(childEnv, "GORTEX_TOOLS_MODE="+daemonToolsMode)
}
child.Env = childEnv
child.Stdout = logFile
child.Stderr = logFile
child.Stdin = nil
// Detach the child from the parent's controlling terminal /
// console so Ctrl-C on the parent doesn't kill the daemon.
child.SysProcAttr = platform.DetachSysProcAttr()
if err := child.Start(); err != nil {
_ = logFile.Close()
return fmt.Errorf("spawn daemon: %w", err)
}
// Don't block on the child — it's detached and inherits the log
// file handle. Reap it in a background goroutine so a crash during
// startup surfaces on `exited` instead of stalling the poll loop.
exited := make(chan error, 1)
go func() { exited <- child.Wait() }()
emitDaemonStartBanner(os.Stderr)
sp := newDaemonSpawnSpinner(os.Stderr)
if sp != nil {
sp.Start("Waiting for daemon socket")
}
// Wait until the socket is live or a timeout hits, so we fail fast
// if the child died on startup. The socket opens after buildDaemonState
// decodes the snapshot; on a multi-hundred-MB snapshot that decode
// can take 1020 s, so 5 s used to time out a perfectly healthy
// daemon mid-load. 60 s comfortably covers ~1 GiB snapshots while
// still failing fast on a child that crashed outright (those die
// in well under a second).
start := time.Now()
deadline := start.Add(60 * time.Second)
for time.Now().Before(deadline) {
if daemon.IsRunning() {
elapsed := time.Since(start).Truncate(10 * time.Millisecond)
if sp != nil {
sp.Set("", fmt.Sprintf("socket up · %s", elapsed))
sp.Done()
emitDaemonStartSummary(os.Stderr, child.Process.Pid, elapsed)
} else {
fmt.Fprintf(os.Stderr, "[gortex daemon] detached (pid %d, log: %s)\n",
child.Process.Pid, daemon.LogFilePath())
}
return nil
}
// Bail out early if the child has already exited — no point
// waiting another 59 seconds for a corpse.
select {
case werr := <-exited:
failMsg := fmt.Errorf("daemon exited during startup (%v); check %s",
werr, daemon.LogFilePath())
if sp != nil {
sp.Fail(failMsg)
}
return failMsg
default:
}
if sp != nil {
sp.Set("", fmt.Sprintf("snapshot decoding · %s", time.Since(start).Truncate(100*time.Millisecond)))
}
time.Sleep(50 * time.Millisecond)
}
timeoutErr := fmt.Errorf("daemon did not come up within 60s; check %s", daemon.LogFilePath())
if sp != nil {
sp.Fail(timeoutErr)
}
return timeoutErr
}
// newDaemonSpawnSpinner returns a spinner bound to w when it's a TTY (and the
// global --no-progress flag isn't set). Returns nil otherwise, so callers can
// branch on the spinner's presence to choose between the framed-card vs.
// one-line output paths.
func newDaemonSpawnSpinner(w io.Writer) *progress.Spinner {
if noProgress || !progress.IsTTY(w) {
return nil
}
return progress.NewSpinner(w)
}
// emitDaemonStartBanner prints the gortex mesh banner + subtitle for the
// detach flow. Only fires on a TTY — non-TTY callers stay quiet so script
// stderr remains parseable.
func emitDaemonStartBanner(w io.Writer) {
if !progress.IsTTY(w) || noProgress || daemonRestartActive {
return
}
banner := tui.Banner{
Title: "gortex daemon start",
Subtitle: "Spawning daemon in the background.",
}.Render()
fmt.Fprintln(w)
fmt.Fprintln(w, banner)
fmt.Fprintln(w)
}
// emitDaemonStartSummary prints the post-spawn card showing pid, socket, log
// path, elapsed time, and useful next-step hints. Only fires on a TTY.
func emitDaemonStartSummary(w io.Writer, pid int, elapsed time.Duration) {
if !progress.IsTTY(w) || noProgress {
return
}
stats := []string{
progress.Stat(fmt.Sprintf("%d", pid), "pid", progress.StatGood),
progress.Stat(elapsed.String(), "boot", progress.StatGood),
}
fmt.Fprintln(w)
fmt.Fprintln(w, " "+progress.StyleOK.Render("✓")+" "+progress.StyleStrong.Render("daemon ready"))
fmt.Fprintln(w, " "+progress.StatStrip(stats...))
fmt.Fprintln(w, " "+progress.Row("socket", daemon.SocketPath(), 8))
fmt.Fprintln(w, " "+progress.Row("log", daemon.LogFilePath(), 8))
fmt.Fprintln(w)
fmt.Fprintln(w, " "+progress.Heading("next"))
fmt.Fprintln(w, " "+progress.NumberedStep(1, "track a repo: gortex track <path>"))
fmt.Fprintln(w, " "+progress.NumberedStep(2, "watch status: gortex daemon status --watch"))
fmt.Fprintln(w, " "+progress.NumberedStep(3, "shut down: gortex daemon stop"))
fmt.Fprintln(w)
}
func runDaemonStop(cmd *cobra.Command, _ []string) error {
w := cmd.ErrOrStderr()
// Record the user's "stay down" intent so the autostart path (a live
// `gortex mcp` proxy relaunched by an editor) doesn't immediately respawn
// the daemon we're about to stop. A `daemon restart` re-clears it via the
// following start, so only a standalone stop is sticky.
if !daemonRestartActive {
if err := daemon.MarkStopIntent(); err != nil {
fmt.Fprintf(w, "[gortex daemon] warning: could not record stop intent (%v); daemon may auto-respawn\n", err)
}
// If an OS supervisor (systemd --user / launchd) owns the daemon, stop
// it THROUGH the supervisor — a socket-level stop just kills the worker
// and the supervisor restarts it. `daemon restart` skips this and
// bounces via the supervisor instead.
if serviceActive() {
return serviceStop(w)
}
}
if !daemon.IsRunning() {
// The socket is gone, but a process may still be alive and holding
// the store lock — a daemon mid-shutdown, or one whose socket wedged.
// killByPID terminates it AND blocks until it has actually exited,
// which is what `daemon restart` relies on to not race the lock.
if _, ok := daemon.RunningPID(); ok {
return killByPID()
}
emitDaemonStopAlreadyDown(w)
return nil
}
// Capture uptime + socket *before* shutdown so we can show them in the
// post-stop summary (the socket file vanishes on clean shutdown).
socket := daemon.SocketPath()
uptime := daemonUptimeBeforeStop()
// Capture the PID too. ControlShutdown only *acks* — the daemon then
// flushes and closes the store (releasing its on-disk lock) and exits
// asynchronously (see server.go: the handler Shutdown()s ~100ms later in
// a goroutine). We must block until that process is gone, or a following
// `daemon start` races the still-held lock and dies with the opaque
// "failed to open database with status 1".
pid, havePID := daemon.RunningPID()
c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeControl, ClientName: "cli"})
if err != nil {
// Daemon said it was alive but won't talk — probably a stale PID file
// the daemon hasn't cleaned up. Fall back to killing by PID.
return killByPID()
}
resp, err := c.Control(daemon.ControlShutdown, nil)
_ = c.Close()
if err != nil {
return err
}
if !resp.OK {
return fmt.Errorf("shutdown rejected: %s %s", resp.ErrorCode, resp.ErrorMsg)
}
if havePID {
waitForDaemonExit(pid)
}
emitDaemonStopSummary(w, socket, uptime)
return nil
}
// waitForDaemonExit blocks until the daemon process pid has exited — and thus
// released the store's on-disk lock — force-killing it if a graceful shutdown
// stalls. This is what makes `daemon stop` honest: when it returns, the store
// is free for the next process, which is the foundation `daemon restart`
// stands on. Polls cheaply; the common case (a clean flush) clears in well
// under a second.
func waitForDaemonExit(pid int) {
deadline := time.Now().Add(15 * time.Second)
for time.Now().Before(deadline) {
if !platform.ProcessAlive(pid) {
return
}
time.Sleep(50 * time.Millisecond)
}
// Graceful shutdown stalled (e.g. a wedged cgo call). Don't leave a
// half-exited daemon clutching the lock — force it, then clean up the
// socket/PID so the next start isn't tripped by stale files.
fmt.Fprintln(os.Stderr, "[gortex daemon] graceful shutdown timed out — force-killing")
_ = platform.KillProcess(pid)
for i := 0; i < 60 && platform.ProcessAlive(pid); i++ {
time.Sleep(50 * time.Millisecond)
}
_ = os.Remove(daemon.PIDFilePath())
_ = os.Remove(daemon.SocketPath())
}
// daemonUptimeBeforeStop best-effort-fetches the daemon's reported uptime via
// a Status control before shutdown so the summary card can show how long the
// process ran. Returns 0 on any error — we'd rather degrade the card than
// fail the stop.
func daemonUptimeBeforeStop() time.Duration {
c, err := daemonControlClient()
if err != nil {
return 0
}
defer c.Close()
resp, err := c.Control(daemon.ControlStatus, nil)
if err != nil || !resp.OK {
return 0
}
var st daemon.StatusResponse
if jerr := json.Unmarshal(resp.Result, &st); jerr != nil {
return 0
}
return time.Duration(st.UptimeSeconds) * time.Second
}
// emitDaemonStopAlreadyDown prints the "not running" message: a one-liner on
// non-TTY for script compat, a styled hint card on TTY.
func emitDaemonStopAlreadyDown(w io.Writer) {
if !progress.IsTTY(w) || noProgress {
fmt.Fprintln(w, "[gortex daemon] not running")
return
}
fmt.Fprintln(w)
fmt.Fprintln(w, " "+progress.StyleHint.Render("◌ daemon was not running — nothing to stop"))
fmt.Fprintln(w, " "+progress.Caption("start with `gortex daemon start --detach`"))
fmt.Fprintln(w)
}
// emitDaemonStopSummary prints the post-shutdown banner + result card mirroring
// daemon start's surface: uptime + socket path so the user can confirm the
// right daemon went down.
func emitDaemonStopSummary(w io.Writer, socket string, uptime time.Duration) {
if !progress.IsTTY(w) || noProgress {
fmt.Fprintln(w, "[gortex daemon] stopped")
return
}
if !daemonRestartActive {
banner := tui.Banner{
Title: "gortex daemon stop",
Subtitle: "Daemon shut down cleanly.",
}.Render()
fmt.Fprintln(w)
fmt.Fprintln(w, banner)
}
stats := []string{progress.Stat("clean shutdown", "", progress.StatGood)}
if uptime > 0 {
stats = append(stats, progress.Stat(uptime.Truncate(time.Second).String(), "uptime", progress.StatNeutral))
}
fmt.Fprintln(w, " "+progress.StyleOK.Render("✓")+" "+progress.StyleStrong.Render("stopped"))
fmt.Fprintln(w, " "+progress.StatStrip(stats...))
if socket != "" {
fmt.Fprintln(w, " "+progress.Row("socket", socket+" (removed)", 8))
}
fmt.Fprintln(w)
}
// daemonRestartActive flips on for the duration of runDaemonRestart so the
// inner stop / start emit functions skip their own banners — restart shows the
// logo once at the top and then lists the stop + start cards underneath.
var daemonRestartActive bool
func runDaemonRestart(cmd *cobra.Command, args []string) error {
daemonRestartActive = true
defer func() { daemonRestartActive = false }()
emitDaemonRestartBanner(cmd.ErrOrStderr())
// When an OS supervisor owns the daemon, bounce it THROUGH the supervisor so
// the supervisor keeps ownership; a manual stop+start would orphan the new
// daemon from the unit (the unit reads inactive while a hand-started process
// holds the socket).
if serviceActive() {
return serviceRestart(cmd.ErrOrStderr())
}
// Stop is idempotent when not running and now blocks until the old
// process has fully exited — releasing the store's on-disk lock — before
// returning. That's what lets the start below reuse the store without
// racing the lock. The old code polled `daemon.IsRunning()` here, which
// watched the wrong resource: the socket is torn down ~100ms after the
// shutdown ack, long before the process exits and the lock clears, so the
// poll fell through early and the restart died on "failed to open
// database with status 1".
if err := runDaemonStop(cmd, args); err != nil {
return err
}
daemonDetach = true
return runDaemonStart(cmd, args)
}
// emitDaemonRestartBanner prints the unified header for `gortex daemon
// restart` so the user sees the mesh logo once instead of twice.
func emitDaemonRestartBanner(w io.Writer) {
if !progress.IsTTY(w) || noProgress {
return
}
banner := tui.Banner{
Title: "gortex daemon restart",
Subtitle: "Cycling daemon: stop then start.",
}.Render()
fmt.Fprintln(w)
fmt.Fprintln(w, banner)
fmt.Fprintln(w)
}
func runDaemonReload(_ *cobra.Command, _ []string) error {
c, err := daemonControlClient()
if err != nil {
return err
}
defer c.Close()
resp, err := c.Control(daemon.ControlReload, nil)
if err != nil {
return err
}
if !resp.OK {
return fmt.Errorf("reload rejected: %s %s", resp.ErrorCode, resp.ErrorMsg)
}
fmt.Fprintln(os.Stderr, "[gortex daemon] reloaded")
return nil
}
func runDaemonStatus(cmd *cobra.Command, _ []string) error {
if daemonStatusWatch {
return runDaemonStatusWatch(cmd)
}
st, err := fetchDaemonStatusForCLI()
if err != nil {
return err
}
w := cmd.OutOrStdout()
renderDaemonHeader(w, st)
renderDaemonWorkspaces(w, st)
renderDaemonRepos(w, st)
renderDaemonSessions(w, st)
renderDaemonServers(w, st)
return nil
}
// fetchDaemonStatusForCLI dials the control socket once and returns a parsed
// StatusResponse. Shared by the one-shot and watch paths.
func fetchDaemonStatusForCLI() (daemon.StatusResponse, error) {
var st daemon.StatusResponse
c, err := daemonControlClient()
if err != nil {
return st, err
}
defer c.Close()
resp, err := c.Control(daemon.ControlStatus, nil)
if err != nil {
return st, err
}
if !resp.OK {
return st, fmt.Errorf("status rejected: %s %s", resp.ErrorCode, resp.ErrorMsg)
}
if err := json.Unmarshal(resp.Result, &st); err != nil {
return st, fmt.Errorf("parse status: %w", err)
}
return st, nil
}
// renderDaemonHeader writes the fixed-schema key/value facts about the
// daemon process as a borderless two-column table.
func renderDaemonHeader(w io.Writer, st daemon.StatusResponse) {
t := table.NewWriter()
t.SetOutputMirror(w)
t.SetStyle(table.StyleLight)
t.Style().Options.DrawBorder = false
t.Style().Options.SeparateColumns = false
t.Style().Options.SeparateHeader = false
t.Style().Options.SeparateRows = false
t.SetColumnConfigs([]table.ColumnConfig{
{Number: 1, Align: text.AlignLeft, WidthMax: 12},
{Number: 2, Align: text.AlignLeft},
})
t.AppendRow(table.Row{"daemon", st.Version})
t.AppendRow(table.Row{"pid", st.PID})
t.AppendRow(table.Row{"socket", st.SocketPath})
t.AppendRow(table.Row{"uptime", formatDuration(time.Duration(st.UptimeSeconds) * time.Second)})
switch {
case st.Ready && st.EnrichmentComplete:
t.AppendRow(table.Row{
"state",
fmt.Sprintf("ready (warmup %s)", formatDuration(time.Duration(st.EnrichSeconds)*time.Second)),
})
case st.Ready:
t.AppendRow(table.Row{
"state",
fmt.Sprintf("ready — queryable (warmup %s);%s",
formatDuration(time.Duration(st.WarmupSeconds)*time.Second),
formatEnrichmentProgress(st.Enrichment)),
})
default:
t.AppendRow(table.Row{"state", "warming up (socket reachable, resolving references)"})
}
t.AppendRow(table.Row{"sessions", st.Sessions})
if st.MemoryBytes > 0 {
t.AppendRow(table.Row{"memory", formatBytes(st.MemoryBytes)})
}
if sb := st.SearchBackend; sb.Name != "" {
switch {
case sb.DiskPath != "":
t.AppendRow(table.Row{"search", fmt.Sprintf(
"%s docs=%d heap=%s disk=%s path=%s",
sb.Name, sb.DocCount, formatBytes(sb.Bytes),
formatBytes(sb.DiskBytes), sb.DiskPath)})
case sb.DiskResident:
// No heap footprint to report — the index lives inside the
// graph store's own file, not a separate in-memory
// structure. Printing "heap=0 B" here would read as "this
// backend costs nothing", which is false.
t.AppendRow(table.Row{"search", fmt.Sprintf(
"%s docs=%d disk-resident (indexed in the graph store)",
sb.Name, sb.DocCount)})
default:
t.AppendRow(table.Row{"search", fmt.Sprintf(
"%s docs=%d heap=%s", sb.Name, sb.DocCount, formatBytes(sb.Bytes))})
}
}
rt := st.Runtime
if rt.Sys > 0 {
t.AppendRow(table.Row{"runtime", fmt.Sprintf(
"alloc=%s sys=%s heap_inuse=%s heap_idle=%s heap_released=%s stacks=%s gc=%d goroutines=%d",
formatBytes(rt.Alloc),
formatBytes(rt.Sys),
formatBytes(rt.HeapInuse),
formatBytes(rt.HeapIdle),
formatBytes(rt.HeapReleased),
formatBytes(rt.StackInuse),
rt.NumGC,
rt.NumGoroutine,
)})
}
if st.PProfAddr != "" {
t.AppendRow(table.Row{"pprof", fmt.Sprintf(
"http://%s/debug/pprof/ (example: go tool pprof -http=: http://%s/debug/pprof/heap)",
st.PProfAddr, st.PProfAddr)})
}
t.Render()
}
// formatEnrichmentProgress renders the semantic-enrichment progress
// suffix appended to the "state" row while Ready but not yet
// EnrichmentComplete. Falls back to the old mute message when no
// progress summary is available (no semantic manager wired, or an
// older daemon build reporting a nil Enrichment) — the row still says
// something instead of going blank.
func formatEnrichmentProgress(e *daemon.EnrichmentProgress) string {
if e == nil {
return " enrichment in progress"
}
if e.Current == nil {
return fmt.Sprintf(" enriching %d/%d repos", e.ReposDone, e.ReposTotal)
}
elapsed := formatDuration(time.Duration(e.Current.ElapsedSeconds * float64(time.Second)))
if e.Current.DeadlineSeconds > 0 {
deadline := formatDuration(time.Duration(e.Current.DeadlineSeconds * float64(time.Second)))
return fmt.Sprintf(" enriching %d/%d (%s, %s/%s)",
e.ReposDone, e.ReposTotal, e.Current.Repo, elapsed, deadline)
}
return fmt.Sprintf(" enriching %d/%d (%s, %s)",
e.ReposDone, e.ReposTotal, e.Current.Repo, elapsed)
}
// renderDaemonRepos writes the per-repo breakdown as a single table.
// Rows sort by attributed memory descending so the largest consumers
// appear first. An "other" row at the bottom covers the delta between
// process-total memory and the sum of attributed per-repo memory —
// embedder model weights, runtime heap headroom, semantic caches, etc.
func renderDaemonRepos(w io.Writer, st daemon.StatusResponse) {
if len(st.TrackedRepos) == 0 {
fmt.Fprintln(w, "\ntracked repos: (none)")
return
}
rows := make([]daemon.TrackedRepoStatus, len(st.TrackedRepos))
copy(rows, st.TrackedRepos)
sort.Slice(rows, func(i, j int) bool {
return rows[i].Memory.TotalBytes > rows[j].Memory.TotalBytes
})
// The disk_b column only appears when any repo actually has disk
// usage — i.e. Bleve is running in disk mode. Keeping it
// conditional stops the default in-memory output from carrying a
// dead column users would (rightly) ask about.
showDisk := false
for _, r := range rows {
if r.Memory.DiskBytes > 0 {
showDisk = true
break
}
}
fmt.Fprintln(w, "\ntracked repos:")
t := table.NewWriter()
t.SetOutputMirror(w)
t.SetStyle(table.StyleLight)
t.Style().Format.Header = text.FormatDefault
t.Style().Format.Footer = text.FormatDefault
// The workspace column only adds signal when at least one repo
// declares an explicit workspace (i.e. one that doesn't equal the
// repo prefix). Pure-default setups already see the prefix in
// column 1; printing the same string twice is just noise.
showWS := false
for _, r := range rows {
if r.Workspace != "" && r.Workspace != r.Prefix {
showWS = true
break
}
}
header := table.Row{"repo"}
colConfigs := []table.ColumnConfig{{Number: 1, Align: text.AlignLeft}}
if showWS {
header = append(header, "workspace")
colConfigs = append(colConfigs, table.ColumnConfig{Number: len(colConfigs) + 1, Align: text.AlignLeft})
}
header = append(header, "total", "files", "nodes", "edges",
"nodes_b", "edges_b", "search_b", "vectors_b")
for i := 0; i < 8; i++ {
colConfigs = append(colConfigs, table.ColumnConfig{Number: len(colConfigs) + 1, Align: text.AlignRight})
}
if showDisk {
header = append(header, "disk_b")
colConfigs = append(colConfigs, table.ColumnConfig{Number: len(colConfigs) + 1, Align: text.AlignRight})
}
header = append(header, "path")
colConfigs = append(colConfigs, table.ColumnConfig{Number: len(colConfigs) + 1, Align: text.AlignLeft})
t.AppendHeader(header)
t.SetColumnConfigs(colConfigs)
var attributed uint64
for _, r := range rows {
attributed += r.Memory.TotalBytes
row := table.Row{r.Prefix}
if showWS {
ws := r.Workspace
if r.WorkspaceProject != "" && r.WorkspaceProject != ws {
ws = ws + "/" + r.WorkspaceProject
}
row = append(row, ws)
}
row = append(row,
formatBytes(r.Memory.TotalBytes),
r.Files,
r.Nodes,
r.Edges,
formatBytes(r.Memory.NodesBytes),
formatBytes(r.Memory.EdgesBytes),
formatBytes(r.Memory.SearchBytes),
formatBytes(r.Memory.VectorsBytes),
)
if showDisk {
row = append(row, formatBytes(r.Memory.DiskBytes))
}
row = append(row, r.Path)
t.AppendRow(row)
}
if st.MemoryBytes > attributed {
other := st.MemoryBytes - attributed
footer := table.Row{"other"}
if showWS {
footer = append(footer, "")
}
footer = append(footer, formatBytes(other), "", "", "", "", "", "", "")
if showDisk {
footer = append(footer, "")
}
footer = append(footer, "embedder + runtime + caches (not attributed)")
t.AppendFooter(footer)
}
t.Render()
}
// renderDaemonWorkspaces prints the per-workspace rollup above the
// repos table. When every workspace is a default singleton (each
// repo in its own auto-named workspace), it emits a one-line hint
// pointing at `gortex workspace set` instead of a wall-of-text
// table that just duplicates the per-repo view.
func renderDaemonWorkspaces(w io.Writer, st daemon.StatusResponse) {
if len(st.Workspaces) == 0 {
return
}
multiRepo := false
for _, ws := range st.Workspaces {
if len(ws.Repos) > 1 {
multiRepo = true
break
}
}
if !multiRepo {
// Compact form: tell the user the workspace boundary is in
// default mode and how to opt repos into a shared workspace.
// Avoids
// printing a 33-row table where every row says "1 repo".
fmt.Fprintf(w,
"\nworkspaces: %d (one per repo, default — every repo is its own workspace)\n",
len(st.Workspaces))
fmt.Fprintln(w,
" Group repos into a shared workspace with `gortex workspace set <repo> <slug> --global`")
fmt.Fprintln(w,
" or `gortex workspace set-all <slug> --root <path> --global`. See `gortex workspace --help`.")
return
}
fmt.Fprintln(w, "\nworkspaces:")
t := table.NewWriter()
t.SetOutputMirror(w)
t.SetStyle(table.StyleLight)
t.Style().Format.Header = text.FormatDefault
t.AppendHeader(table.Row{"workspace", "repos", "projects", "files", "nodes", "edges"})
t.SetColumnConfigs([]table.ColumnConfig{
{Number: 1, Align: text.AlignLeft},
{Number: 2, Align: text.AlignRight},
{Number: 3, Align: text.AlignLeft},
{Number: 4, Align: text.AlignRight},
{Number: 5, Align: text.AlignRight},
{Number: 6, Align: text.AlignRight},
})
for _, ws := range st.Workspaces {
projects := strings.Join(ws.Projects, ", ")
if len(projects) > 50 {
projects = projects[:47] + "..."
}
t.AppendRow(table.Row{ws.Slug, len(ws.Repos), projects, ws.Files, ws.Nodes, ws.Edges})
}
t.Render()
}
// renderDaemonSessions lists every connected MCP client. Skipped
// when no sessions are registered — single-process stdio embeds
// don't go through the daemon socket so they never show up here.
func renderDaemonSessions(w io.Writer, st daemon.StatusResponse) {
if len(st.MCPSessions) == 0 {
return
}
fmt.Fprintln(w, "\nMCP sessions:")
t := table.NewWriter()
t.SetOutputMirror(w)
t.SetStyle(table.StyleLight)
t.Style().Format.Header = text.FormatDefault
t.AppendHeader(table.Row{"id", "client", "version", "connected", "cwd"})
t.SetColumnConfigs([]table.ColumnConfig{
{Number: 1, Align: text.AlignLeft},
{Number: 2, Align: text.AlignLeft},
{Number: 3, Align: text.AlignLeft},
{Number: 4, Align: text.AlignRight},
{Number: 5, Align: text.AlignLeft},
})
for _, s := range st.MCPSessions {
client := s.ClientName
if client == "" {
client = "unknown"
}
t.AppendRow(table.Row{
s.ID,
client,
s.ClientVersion,
formatDuration(time.Duration(s.ConnectedSecs) * time.Second),
s.Cwd,
})
}
t.Render()
}
// renderDaemonServers shows the `~/.gortex/servers.toml` roster.
// Skipped when no file is present — the daemon is in single-server
// mode and there's nothing to list. The "local" column flags the
// entry the multi-server router treats as this daemon itself; auth
// is reported as "yes/no" (token values stay private to the
// daemon).
func renderDaemonServers(w io.Writer, st daemon.StatusResponse) {
if len(st.ConfiguredServers) == 0 {
return
}
fmt.Fprintln(w, "\nconfigured servers (~/.gortex/servers.toml):")
t := table.NewWriter()
t.SetOutputMirror(w)
t.SetStyle(table.StyleLight)
t.Style().Format.Header = text.FormatDefault
t.AppendHeader(table.Row{"slug", "url", "local", "default", "auth", "workspaces"})
t.SetColumnConfigs([]table.ColumnConfig{
{Number: 1, Align: text.AlignLeft},
{Number: 2, Align: text.AlignLeft},
{Number: 3, Align: text.AlignCenter},
{Number: 4, Align: text.AlignCenter},
{Number: 5, Align: text.AlignCenter},
{Number: 6, Align: text.AlignLeft},
})
yesno := func(b bool) string {
if b {
return "yes"
}
return ""
}
for _, s := range st.ConfiguredServers {
t.AppendRow(table.Row{
s.Slug,
s.URL,
yesno(s.Local),
yesno(s.Default),
yesno(s.HasAuth),
strings.Join(s.Workspaces, ", "),
})
}
t.Render()
}
func runDaemonLogs(cmd *cobra.Command, _ []string) error {
path := daemon.LogFilePath()
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open log %s: %w", path, err)
}
defer f.Close()
lines, err := tailLines(f, daemonTail)
if err != nil {
return err
}
for _, l := range lines {
fmt.Fprintln(cmd.OutOrStdout(), l)
}
return nil
}
// daemonControlClient is the shared "dial + expect running" helper for
// the read-only control subcommands. Returns a clear error instead of
// a misleading ErrDaemonUnavailable.
func daemonControlClient() (*daemon.Client, error) {
c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeControl, ClientName: "cli"})
if err != nil {
return nil, fmt.Errorf("daemon not reachable (%v) — is it running? Try `gortex daemon start`", err)
}
return c, nil
}
// resolveDaemonBufferPoolMB returns the effective buffer-pool cap.
// Precedence: --backend-buffer-pool-mb flag > GORTEX_DAEMON_BUFFER_POOL_MB env > 0
// (which Open then maps to DefaultBufferPoolMB inside the store).
func resolveDaemonBufferPoolMB() uint64 {
if daemonBackendBufferPoolMB != 0 {
return daemonBackendBufferPoolMB
}
if env := strings.TrimSpace(os.Getenv("GORTEX_DAEMON_BUFFER_POOL_MB")); env != "" {
if v, err := strconv.ParseUint(env, 10, 64); err == nil {
return v
}
}
return 0
}
// killByPID is the fallback stop path for stale daemons that have a PID
// file but don't respond on the socket. Asks the process to terminate,
// waits, then force-kills. Silently returns nil if the PID no longer
// exists.
func killByPID() error {
pidBytes, err := os.ReadFile(daemon.PIDFilePath())
if err != nil {
return nil
}
pid, _ := strconv.Atoi(string(pidBytes))
if pid <= 0 {
return nil
}
_ = platform.TerminateProcess(pid)
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if !platform.ProcessAlive(pid) {
// Process gone.
_ = os.Remove(daemon.PIDFilePath())
_ = os.Remove(daemon.SocketPath())
fmt.Fprintln(os.Stderr, "[gortex daemon] stopped")
return nil
}
time.Sleep(100 * time.Millisecond)
}
// Last resort.
_ = platform.KillProcess(pid)
_ = os.Remove(daemon.PIDFilePath())
_ = os.Remove(daemon.SocketPath())
fmt.Fprintln(os.Stderr, "[gortex daemon] stopped (force-killed)")
return nil
}
// tailLines returns the last n lines of f. Used by `daemon logs`. Small
// implementation — log files are capped at a few MB so we can afford a
// full read and slice rather than seeking from the end.
func tailLines(f io.Reader, n int) ([]string, error) {
buf, err := io.ReadAll(f)
if err != nil {
return nil, err
}
// Split on newline without pulling in bufio.Scanner buffer-size gotchas.
var out []string
start := 0
for i, b := range buf {
if b == '\n' {
out = append(out, string(buf[start:i]))
start = i + 1
}
}
if start < len(buf) {
out = append(out, string(buf[start:]))
}
if len(out) > n {
out = out[len(out)-n:]
}
return out, nil
}
func formatDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm%ds", int(d.Minutes()), int(d.Seconds())%60)
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60)
}
return fmt.Sprintf("%dd%dh", int(d.Hours())/24, int(d.Hours())%24)
}
func formatBytes(n uint64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d B", n)
}
div, exp := uint64(unit), 0
for x := n / unit; x >= unit; x /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp])
}
// stubController is a placeholder Controller so `gortex daemon start`
// works end-to-end before the real MultiIndexer integration lands. It