chore: import upstream snapshot with attribution
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:33:42 +08:00
commit a06f331eb8
3186 changed files with 689843 additions and 0 deletions
+74
View File
@@ -0,0 +1,74 @@
# `internal/daemon`
Process-local daemon plumbing — Unix socket transport, MCP dispatch
session state, snapshot paths, plus the iteration-1 multi-server
roster and editor-overlay machinery.
## Surface
| File | Role |
|------|------|
| `paths.go` | Default file paths (socket, PID, snapshot, log) |
| `proto.go` | Wire types for the daemon's internal MCP protocol |
| `client.go` | Connect-and-talk client used by the proxy CLI |
| `servers.go` | `~/.gortex/servers.toml` loader + `ServerClient` (HTTP/unix) + `WorkspaceRosterCache` + `RouteForCwd` |
| `overlay.go` | `OverlayManager` — register / push / delete editor buffer overrides |
| `router.go` | `Router` — hybrid-read query router |
## Multi-server routing
`~/.gortex/servers.toml` declares the set of Gortex servers reachable
from this daemon. The schema:
```toml
[[server]]
slug = "main"
url = "unix:///run/gortex/main.sock"
default = true
[[server]]
slug = "tuck"
url = "https://tuck.gortex.example/v1"
auth_token_env = "GORTEX_TOKEN_TUCK"
workspaces = ["tuck"]
```
`url` accepts `http(s)://...` (TCP) or `unix:///path/to.sock`
(Unix-domain socket). Auth tokens come from `auth_token` (literal,
discouraged) or `auth_token_env` (env-var name resolved per-call so
rotation lands without a daemon restart).
`Router.RouteToolCall` walks the priority chain on every MCP tool
invocation:
1. Caller-supplied scope override (e.g. `workspace: "tuck"` on the
tool args).
2. Walking up from cwd to find a `.gortex.yaml::workspace` declaration.
3. Workspace roster — pre-declared `workspaces = [...]` lists in
`servers.toml`, then the cached `GET /v1/workspaces/<ws>/repos`
roster from each configured server.
4. The `default = true` entry, if any.
Resolved server == `LocalSlug` → run in-process via `LocalExecute`.
Resolved server != `LocalSlug` → proxy via `ServerClient.ProxyTool`
(POST `/v1/tools/<name>` with bearer auth).
## Editor overlays
`OverlayManager` holds per-session in-flight file overrides so MCP
clients can ask "what would `find_usages` look like with my unsaved
buffer?" The HTTP front door at `/v1/overlay/sessions/...` is wired
in `internal/server`. `BaseSHA` drift detection refuses to merge a
stale overlay so wrong-line-number errors don't surface as graph bugs.
## Caveat: pre-workspace-slug snapshots
Old snapshots written by daemons that predate the workspace-slug
schema carry no `WorkspaceID`/`ProjectID` fields on nodes (gob
decodes additive fields as zero). The daemon's warmup path
(`MultiIndexer.BackfillWorkspaceSlugs` invoked from
`cmd/gortex/daemon_state.go::warmupDaemonState`) re-stamps them from
the per-repo `.gortex.yaml`. Without that pass, the matcher's
`EffectiveWorkspace` falls back to `RepoPrefix` and explicit
shared-workspace setups silently lose identity until every file is
touched.
+43
View File
@@ -0,0 +1,43 @@
package daemon
import (
"os"
"path/filepath"
"strings"
)
// ParseAutostart reports whether daemon auto-start is enabled. Default ON.
// Disabled only by an explicit GORTEX_AUTOSTART in {0,false,off,no}
// (case-insensitive). GORTEX_AUTOSTART is the only knob — there is no
// synonym.
func ParseAutostart() bool {
if v, ok := os.LookupEnv("GORTEX_AUTOSTART"); ok {
switch strings.ToLower(strings.TrimSpace(v)) {
case "0", "false", "off", "no":
return false
}
return true // any other value (incl. 1/true/on) => on
}
return true // unset => default on
}
// SpawnLockPath returns the advisory-lock file guarding daemon auto-start
// so concurrent `gortex mcp` launches on one machine spawn exactly one
// daemon. Co-located with the socket/PID/log under the per-user state dir.
func SpawnLockPath() string {
if dir, ok := stateDir(); ok {
return filepath.Join(dir, "daemon.spawn.lock")
}
return filepath.Join(os.TempDir(), "gortex-daemon.spawn.lock")
}
// SpawnFailMarkerPath returns the short-lived sentinel a lock holder
// stamps when a spawn fails, so contending callers skip their own spawn
// attempt within the cooldown window instead of each re-paying the spawn
// wait on a broken spawn.
func SpawnFailMarkerPath() string {
if dir, ok := stateDir(); ok {
return filepath.Join(dir, "daemon.spawn.fail")
}
return filepath.Join(os.TempDir(), "gortex-daemon.spawn.fail")
}
+57
View File
@@ -0,0 +1,57 @@
package daemon
import (
"os"
"path/filepath"
"testing"
)
func TestParseAutostart(t *testing.T) {
cases := map[string]bool{
"0": false, "false": false, "off": false, "no": false, "OFF": false, " no ": false,
"1": true, "true": true, "on": true, "anything": true, "": true,
}
for v, want := range cases {
t.Setenv("GORTEX_AUTOSTART", v)
if got := ParseAutostart(); got != want {
t.Errorf("GORTEX_AUTOSTART=%q => %v, want %v", v, got, want)
}
}
}
func TestParseAutostart_UnsetDefaultsOn(t *testing.T) {
if v, ok := os.LookupEnv("GORTEX_AUTOSTART"); ok {
_ = os.Unsetenv("GORTEX_AUTOSTART")
t.Cleanup(func() { _ = os.Setenv("GORTEX_AUTOSTART", v) })
}
if !ParseAutostart() {
t.Error("unset GORTEX_AUTOSTART must default on")
}
}
func TestParseAutostart_NoSynonym(t *testing.T) {
// GORTEX_NO_AUTOSTART is not consulted — only GORTEX_AUTOSTART is.
t.Setenv("GORTEX_NO_AUTOSTART", "1")
if v, ok := os.LookupEnv("GORTEX_AUTOSTART"); ok {
_ = os.Unsetenv("GORTEX_AUTOSTART")
t.Cleanup(func() { _ = os.Setenv("GORTEX_AUTOSTART", v) })
}
if !ParseAutostart() {
t.Error("GORTEX_NO_AUTOSTART must not disable autostart")
}
}
func TestSpawnLockPath_UnderStateDir(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
lock := SpawnLockPath()
fail := SpawnFailMarkerPath()
if filepath.Base(lock) != "daemon.spawn.lock" {
t.Errorf("lock basename = %q", filepath.Base(lock))
}
if filepath.Base(fail) != "daemon.spawn.fail" {
t.Errorf("fail-marker basename = %q", filepath.Base(fail))
}
if filepath.Dir(lock) != filepath.Dir(fail) {
t.Error("lock and fail-marker should be siblings")
}
}
+209
View File
@@ -0,0 +1,209 @@
package daemon
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"syscall"
"time"
)
// Client is one end of a daemon connection. Used by:
// - `gortex mcp --proxy` — stdio MCP → daemon relay.
// - `gortex daemon status / reload / track / untrack / stop` — control RPC.
//
// A Client is not safe for concurrent use — each caller owns its own.
type Client struct {
Conn net.Conn
reader *bufio.Reader
Ack HandshakeAck
}
// Dial connects to the daemon on the default socket path and completes a
// handshake. Returns (nil, err) if the socket doesn't exist, the daemon
// isn't reachable, or the handshake is rejected.
//
// Callers in a fallback path (proxy mode, CLI commands that can work
// standalone) should treat ErrDaemonUnavailable distinctly from other
// errors and continue as if the daemon isn't there.
func Dial(h Handshake) (*Client, error) {
return DialTo(SocketPath(), h)
}
// DialTo is Dial with an explicit socket path. Useful for tests and for
// clients that want to talk to a daemon on a non-default socket.
func DialTo(socketPath string, h Handshake) (*Client, error) {
d := &net.Dialer{Timeout: 500 * time.Millisecond}
conn, err := d.Dial("unix", socketPath)
if err != nil {
if isNoDaemonErr(err) {
return nil, fmt.Errorf("%w: %v", ErrDaemonUnavailable, err)
}
return nil, err
}
if h.Version == 0 {
h.Version = ProtocolVersion
}
if h.PID == 0 {
h.PID = os.Getpid()
}
if err := WriteJSONLine(conn, h); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("send handshake: %w", err)
}
reader := bufio.NewReader(conn)
ackLine, err := reader.ReadBytes('\n')
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("read handshake ack: %w", err)
}
var ack HandshakeAck
if err := json.Unmarshal(ackLine, &ack); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("parse handshake ack: %w", err)
}
if !ack.OK {
_ = conn.Close()
// A protocol-version rejection is recoverable: wrap the sentinel so the
// proxy can fall back to the embedded server instead of failing.
if ack.ErrorCode == ErrProtocolMismatch {
return nil, fmt.Errorf("%w: %s", ErrProtocolVersionMismatch, ack.ErrorMsg)
}
return nil, fmt.Errorf("daemon rejected handshake [%s]: %s", ack.ErrorCode, ack.ErrorMsg)
}
return &Client{Conn: conn, reader: reader, Ack: ack}, nil
}
// Close tears down the connection.
func (c *Client) Close() error {
if c == nil || c.Conn == nil {
return nil
}
return c.Conn.Close()
}
// Control sends one control request and returns the paired response.
// Fails if the Client was opened in ModeMCP.
func (c *Client) Control(kind string, params any) (ControlResponse, error) {
var raw json.RawMessage
if params != nil {
var err error
raw, err = json.Marshal(params)
if err != nil {
return ControlResponse{}, fmt.Errorf("marshal params: %w", err)
}
}
req := ControlRequest{Kind: kind, Params: raw}
if err := WriteJSONLine(c.Conn, req); err != nil {
return ControlResponse{}, fmt.Errorf("send control request: %w", err)
}
line, err := c.reader.ReadBytes('\n')
if err != nil {
return ControlResponse{}, fmt.Errorf("read control response: %w", err)
}
var resp ControlResponse
if err := json.Unmarshal(line, &resp); err != nil {
return ControlResponse{}, fmt.Errorf("parse control response: %w", err)
}
return resp, nil
}
// WriteMCPFrame writes a raw MCP JSON-RPC frame to the daemon. Caller is
// responsible for ensuring the frame is valid JSON-RPC; the daemon
// passes it through to the session's MCPDispatcher.
func (c *Client) WriteMCPFrame(frame []byte) error {
if _, err := c.Conn.Write(frame); err != nil {
return err
}
// Ensure newline-delimited framing.
if n := len(frame); n == 0 || frame[n-1] != '\n' {
if _, err := c.Conn.Write([]byte{'\n'}); err != nil {
return err
}
}
return nil
}
// ReadMCPFrame reads one MCP JSON-RPC frame from the daemon. Returns
// io.EOF when the daemon closes the connection.
func (c *Client) ReadMCPFrame() ([]byte, error) {
line, err := c.reader.ReadBytes('\n')
if err != nil {
return nil, err
}
return line, nil
}
// ErrDaemonUnavailable is the sentinel Dial returns when the daemon
// isn't reachable. Callers in fallback paths should errors.Is() against
// this to distinguish "daemon just isn't running" from real errors
// (permissions, handshake rejection, etc.) where silent fallback would
// mask a bug.
var ErrDaemonUnavailable = errors.New("daemon unavailable")
// ErrProtocolVersionMismatch is returned by Dial when the daemon answers the
// handshake with a protocol_mismatch rejection — the running daemon speaks a
// different wire version than this binary (a stale daemon after an upgrade).
// Like ErrDaemonUnavailable it is recoverable: the caller should fall back to
// the embedded in-process server rather than fail, so an in-flight editor
// session keeps working across a version skew.
var ErrProtocolVersionMismatch = errors.New("daemon protocol version mismatch")
// ShouldFallBackToEmbedded reports whether a Dial error is one the MCP proxy
// can recover from by running the embedded in-process server instead: the
// daemon isn't running, or it is running a mismatched protocol version. Any
// other error (permissions, a genuinely broken socket) is a real failure the
// caller must surface.
func ShouldFallBackToEmbedded(err error) bool {
return errors.Is(err, ErrDaemonUnavailable) || errors.Is(err, ErrProtocolVersionMismatch)
}
// isNoDaemonErr returns true for the subset of dial errors that mean
// "the daemon probably isn't running" as opposed to "your system is
// broken." We treat connection-refused, no-such-file, and timeout as
// "unavailable" because spinning up the daemon or falling back is the
// right answer to all three.
func isNoDaemonErr(err error) bool {
var se syscall.Errno
if errors.As(err, &se) {
switch se {
case syscall.ECONNREFUSED, syscall.ENOENT:
return true
}
}
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
return true
}
// net.OpError wrapping "no such file or directory" is how Go reports
// a missing socket path on some platforms.
var op *net.OpError
if errors.As(err, &op) {
if errors.Is(op.Err, syscall.ECONNREFUSED) || errors.Is(op.Err, syscall.ENOENT) {
return true
}
}
return false
}
// IsRunning returns true when a daemon is reachable on the default socket.
// A thin convenience for CLI paths that want to branch on availability
// without constructing a full handshake.
func IsRunning() bool {
return IsRunningAt(SocketPath())
}
// IsRunningAt is IsRunning with an explicit socket path.
func IsRunningAt(socketPath string) bool {
d := &net.Dialer{Timeout: 200 * time.Millisecond}
conn, err := d.Dial("unix", socketPath)
if err != nil {
return false
}
_ = conn.Close()
return true
}
+28
View File
@@ -0,0 +1,28 @@
package daemon
import (
"errors"
"fmt"
"testing"
)
// TestProtocolFallbackEmbedded covers the recoverable-error classification the
// MCP proxy uses to decide between dialing the daemon and running the embedded
// server: a missing daemon OR a protocol-version mismatch both fall back; any
// other error is a real failure.
func TestProtocolFallbackEmbedded(t *testing.T) {
if !ShouldFallBackToEmbedded(ErrDaemonUnavailable) {
t.Error("ErrDaemonUnavailable must trigger embedded fallback")
}
// Dial wraps a protocol_mismatch ack into ErrProtocolVersionMismatch.
wrapped := fmt.Errorf("%w: server v1 vs client v2", ErrProtocolVersionMismatch)
if !ShouldFallBackToEmbedded(wrapped) {
t.Error("a wrapped ErrProtocolVersionMismatch must trigger embedded fallback")
}
if ShouldFallBackToEmbedded(errors.New("permission denied")) {
t.Error("an unrelated error must NOT trigger fallback (would mask a real bug)")
}
if ShouldFallBackToEmbedded(nil) {
t.Error("nil must not trigger fallback")
}
}
+57
View File
@@ -0,0 +1,57 @@
//go:build !windows
package daemon
import (
"errors"
"syscall"
)
// isEMFILE reports whether err is "too many open files" on the calling
// process — the typical accept(2) failure mode when the daemon has
// exhausted its file-descriptor cap.
func isEMFILE(err error) bool {
return errors.Is(err, syscall.EMFILE) || errors.Is(err, syscall.ENFILE)
}
// FDLimit is the soft and hard RLIMIT_NOFILE caps after RaiseFDLimit ran.
type FDLimit struct {
Soft uint64
Hard uint64
}
// RaiseFDLimit raises the soft RLIMIT_NOFILE to the hard cap, falling
// back to a high finite ceiling when the OS rejects RLIM_INFINITY. The
// daemon's file watcher holds one descriptor per watched directory on
// Linux (inotify) and additionally one per file inside each watched
// directory on macOS (kqueue), so the inherited soft cap — 1024 on
// most Linuxes, 256 on macOS — is far below what a multi-repo
// installation needs. Callers should log the resulting cap so users
// can correlate "too many open files" against the observed ceiling.
func RaiseFDLimit() (FDLimit, error) {
var lim syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &lim); err != nil {
return FDLimit{}, err
}
if lim.Cur >= lim.Max {
return FDLimit{Soft: uint64(lim.Cur), Hard: uint64(lim.Max)}, nil
}
want := lim
want.Cur = lim.Max
if err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &want); err == nil {
return FDLimit{Soft: uint64(want.Cur), Hard: uint64(want.Max)}, nil
}
// macOS rejects Cur=RLIM_INFINITY even when Max claims it. Try
// progressively lower finite ceilings so a single Setrlimit failure
// doesn't leave the daemon stuck on the inherited soft cap.
for _, ceiling := range []uint64{1 << 20, 1 << 16, 1 << 14, 1 << 13} {
if uint64(lim.Cur) >= ceiling {
continue
}
want.Cur = ceiling
if err := syscall.Setrlimit(syscall.RLIMIT_NOFILE, &want); err == nil {
return FDLimit{Soft: uint64(want.Cur), Hard: uint64(want.Max)}, nil
}
}
return FDLimit{Soft: uint64(lim.Cur), Hard: uint64(lim.Max)}, nil
}
+56
View File
@@ -0,0 +1,56 @@
//go:build !windows
package daemon
import (
"errors"
"syscall"
"testing"
)
func TestRaiseFDLimit_RaisesSoftCapAndDoesNotShrink(t *testing.T) {
var before syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &before); err != nil {
t.Fatalf("Getrlimit: %v", err)
}
got, err := RaiseFDLimit()
if err != nil {
t.Fatalf("RaiseFDLimit: %v", err)
}
if got.Soft < uint64(before.Cur) {
t.Fatalf("soft cap shrank: before=%d after=%d", before.Cur, got.Soft)
}
var after syscall.Rlimit
if err := syscall.Getrlimit(syscall.RLIMIT_NOFILE, &after); err != nil {
t.Fatalf("Getrlimit (after): %v", err)
}
if uint64(after.Cur) != got.Soft {
t.Fatalf("returned soft (%d) does not match observed soft (%d)", got.Soft, after.Cur)
}
}
func TestIsEMFILE(t *testing.T) {
if !isEMFILE(syscall.EMFILE) {
t.Fatal("EMFILE should be detected")
}
if !isEMFILE(syscall.ENFILE) {
t.Fatal("ENFILE should be detected")
}
wrapped := &fdErr{err: syscall.EMFILE}
if !isEMFILE(wrapped) {
t.Fatal("wrapped EMFILE should be detected via errors.Is")
}
if isEMFILE(syscall.EACCES) {
t.Fatal("EACCES is not an FD-exhaustion error")
}
if isEMFILE(errors.New("boom")) {
t.Fatal("plain error must not match")
}
}
type fdErr struct{ err error }
func (e *fdErr) Error() string { return e.err.Error() }
func (e *fdErr) Unwrap() error { return e.err }
+25
View File
@@ -0,0 +1,25 @@
//go:build windows
package daemon
// isEMFILE reports whether err is a file-descriptor-exhaustion error.
// Windows has no RLIMIT_NOFILE and its socket layer surfaces exhaustion
// differently, so the accept loop never takes the EMFILE branch there —
// this is a constant false.
func isEMFILE(error) bool {
return false
}
// FDLimit mirrors the Unix struct so callers compile unchanged. On
// Windows both fields stay zero — there is no per-process descriptor
// cap to report.
type FDLimit struct {
Soft uint64
Hard uint64
}
// RaiseFDLimit is a no-op on Windows: the OS imposes no RLIMIT_NOFILE
// equivalent on a user process, so there is nothing to raise.
func RaiseFDLimit() (FDLimit, error) {
return FDLimit{}, nil
}
+637
View File
@@ -0,0 +1,637 @@
package daemon
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
)
// federationReadTools is the allowlist of read traversal tools eligible
// for remote fan-out. Anything not here (and everything in MutatingTools)
// is never federated.
var federationReadTools = map[string]bool{
"find_usages": true,
"get_callers": true,
"get_call_chain": true,
"find_implementations": true,
"get_dependents": true,
"search_symbols": true,
"smart_context": true,
}
// localSchemaMajor is this daemon's graph-schema major version. A remote
// advertising an incompatible major is refused (never federated). Kept in
// sync with the value /v1/health advertises (server.SchemaVersion).
const localSchemaMajor = 1
// FederationConfig carries the tunable knobs (from .gortex.yaml's
// federation: block). Zero values fall back to sane defaults.
type FederationConfig struct {
PerRemoteTimeout time.Duration
Budget time.Duration
BreakerThreshold int
BreakerCooldown time.Duration
HealthTTL time.Duration
NameKeyedFallback bool
}
func (c FederationConfig) withDefaults() FederationConfig {
if c.PerRemoteTimeout <= 0 {
c.PerRemoteTimeout = 2 * time.Second
}
if c.Budget <= 0 {
c.Budget = 3 * time.Second
}
if c.BreakerThreshold <= 0 {
c.BreakerThreshold = 3
}
if c.BreakerCooldown <= 0 {
c.BreakerCooldown = 30 * time.Second
}
if c.HealthTTL <= 0 {
c.HealthTTL = 30 * time.Second
}
return c
}
// Federator fans an allowlisted read tool out to enabled remotes after
// the local result is in hand, and merges the responses with provenance.
// It NEVER mutates a stored *graph.Node — it works on serialized bytes,
// so json.Unmarshal already yields detached copies; provenance lives only
// in the response-side origins map, never on a node.
type Federator struct {
cfg FederationConfig
clientFor func(ServerEntry) (*ServerClient, error)
breaker *circuitBreaker
health *healthCache
logger *zap.Logger
}
// NewFederator builds a Federator. clientFor reuses the router's client
// cache so connections are shared.
func NewFederator(cfg FederationConfig, clientFor func(ServerEntry) (*ServerClient, error), logger *zap.Logger) *Federator {
cfg = cfg.withDefaults()
if logger == nil {
logger = zap.NewNop()
}
return &Federator{
cfg: cfg,
clientFor: clientFor,
breaker: newCircuitBreaker(cfg.BreakerThreshold, cfg.BreakerCooldown),
health: newHealthCache(cfg.HealthTTL),
logger: logger,
}
}
// FederationMeta is the response-side provenance block (JSON-only in v1).
type FederationMeta struct {
RemotesQueried []string `json:"remotes_queried"`
RemotesFailed []RemoteFailure `json:"remotes_failed,omitempty"`
Degraded bool `json:"degraded"`
NamespaceRewrites []string `json:"namespace_rewrites,omitempty"`
Origins map[string]string `json:"origins,omitempty"`
Note string `json:"note,omitempty"`
}
// RemoteFailure records why a remote was not merged.
type RemoteFailure struct {
Slug string `json:"slug"`
Reason string `json:"reason"`
}
type remoteResult struct {
slug string
toolJSON []byte
}
// Augment runs AFTER the local tool result is in hand. It fans the same
// tool+args out to each enabled remote under a bounded deadline, merges
// the responses by per-tool shape, and returns the merged bytes carrying
// a sibling federation{} block + origins map. It NEVER blocks past the
// budget and never lets one remote's failure drop another's results or
// the local result.
func (f *Federator) Augment(ctx context.Context, tool string, body, localResult []byte, remotes []ServerEntry) []byte {
// Gate: only an allowlisted read tool with at least one enabled
// remote is federated. With no enabled remotes the local result is
// returned byte-for-byte — a pure-local install (or an all-disabled
// roster) is unaffected; the federation{} block + origins map are
// the additive superset that appears only when there is something to
// federate.
if !federationReadTools[tool] || MutatingTools[tool] || len(remotes) == 0 {
return localResult
}
localTool, wrapped := unwrapToolJSON(localResult)
results, meta := f.fanOut(ctx, tool, body, remotes)
merged, origins := f.merge(tool, localTool, results)
meta.Origins = origins
// Opt-in name-keyed fallback (OFF by default): a bare-name
// search on each remote, rendered in a SEPARATE name_hits section
// tagged text_matched — never merged into the primary id-keyed
// results (name hits have different native ids that ID-dedup cannot
// collapse). Rarity/length-gated so stdlib/builtin or too-short
// names don't surface plausible-but-wrong cross-repo hits.
if f.cfg.NameKeyedFallback && idKeyedTools[tool] {
if name := bareNameFromBody(body); nameEligible(name) {
if hits := f.nameKeyedFan(ctx, name, remotes); len(hits) > 0 {
merged = injectField(merged, "name_hits", hits)
}
}
}
if len(meta.RemotesFailed) > 0 && meta.Note == "" {
meta.Note = fmt.Sprintf("%d remote(s) did not answer; results are local%s.",
len(meta.RemotesFailed), remoteOnlyOrPartial(meta))
}
mergedWithMeta := attachFederation(merged, meta)
if !wrapped {
return mergedWithMeta
}
return rewrapToolJSON(localResult, mergedWithMeta)
}
func remoteOnlyOrPartial(meta FederationMeta) string {
if len(meta.RemotesQueried) > len(meta.RemotesFailed) {
return " plus the remotes that answered"
}
return ""
}
// fanOut queries each enabled remote concurrently under a per-remote
// deadline and a global budget. A plain WaitGroup (not errgroup) is used
// so one remote's error never cancels the others.
func (f *Federator) fanOut(ctx context.Context, tool string, body []byte, remotes []ServerEntry) ([]remoteResult, FederationMeta) {
meta := FederationMeta{RemotesQueried: []string{}}
if len(remotes) == 0 {
return nil, meta
}
budgetCtx, cancel := context.WithTimeout(ctx, f.cfg.Budget)
defer cancel()
var (
mu sync.Mutex
results []remoteResult
wg sync.WaitGroup
)
fail := func(slug, reason string) {
mu.Lock()
meta.RemotesFailed = append(meta.RemotesFailed, RemoteFailure{Slug: slug, Reason: reason})
meta.Degraded = true
mu.Unlock()
// Name the failing remote in the logs, not only the JSON block,
// so a degraded fan-out is visible to operators.
f.logger.Warn("federation: remote degraded",
zap.String("tool", tool),
zap.String("target_slug", slug),
zap.String("reason", reason))
}
audit := auditInfoFrom(ctx)
for _, rem := range remotes {
rem := rem
mu.Lock()
meta.RemotesQueried = append(meta.RemotesQueried, rem.Slug)
mu.Unlock()
// Audit every remote-routed fan-out call (cross-daemon access
// record), carrying the same {session_id, cwd, tool, target_slug}
// tuple as the single-remote proxy-routing audit line.
f.logger.Info("federation: remote-routed call",
zap.String("tool", tool),
zap.String("target_slug", rem.Slug),
zap.String("cwd", audit.Cwd),
zap.String("session_id", audit.SessionID),
zap.String("via", "fan-out"))
if f.breaker.isOpen(rem.Slug) {
fail(rem.Slug, "circuit_open")
continue
}
cli, err := f.clientFor(rem)
if err != nil {
fail(rem.Slug, "client_error")
continue
}
wg.Add(1)
go func() {
defer wg.Done()
// Capability + readiness negotiation (cached), inside the
// goroutine so remotes negotiate concurrently. An
// incompatible major schema is refused; a still-warming
// remote is bucketed rather than counted as empty success.
if h, herr := f.health.get(budgetCtx, cli, f.cfg.PerRemoteTimeout); herr == nil {
if h.SchemaVersion != 0 && h.SchemaVersion != localSchemaMajor {
fail(rem.Slug, "incompatible_schema")
return
}
if !h.Indexed {
fail(rem.Slug, "warming")
return
}
}
rctx, rcancel := context.WithTimeout(budgetCtx, f.cfg.PerRemoteTimeout)
defer rcancel()
out, status, err := cli.ProxyToolCtx(rctx, tool, body)
if err != nil {
f.breaker.fail(rem.Slug)
fail(rem.Slug, "unreachable")
return
}
if status >= 400 {
f.breaker.fail(rem.Slug)
fail(rem.Slug, fmt.Sprintf("status_%d", status))
return
}
f.breaker.success(rem.Slug)
toolJSON, _ := unwrapToolJSON(out)
mu.Lock()
results = append(results, remoteResult{slug: rem.Slug, toolJSON: toolJSON})
mu.Unlock()
}()
}
wg.Wait()
return results, meta
}
// merge dispatches to the per-tool adapter and returns the merged tool
// JSON plus the origins map.
func (f *Federator) merge(tool string, local []byte, remotes []remoteResult) ([]byte, map[string]string) {
switch tool {
case "find_usages", "get_callers", "get_call_chain", "get_dependents":
return mergeSubGraph(local, remotes)
case "search_symbols":
return mergeKeyedList(local, remotes, "results")
case "find_implementations":
return mergeKeyedList(local, remotes, "implementations")
case "smart_context":
return mergeSmartContext(local, remotes)
default:
return local, map[string]string{}
}
}
// mergeSubGraph merges query.SubGraph responses: nodes deduped by string
// ID (local wins), edges by (From,To,Kind). Origins keys each node ID to
// "local" or "remote:<slug>".
func mergeSubGraph(local []byte, remotes []remoteResult) ([]byte, map[string]string) {
origins := map[string]string{}
var sg query.SubGraph
if err := json.Unmarshal(local, &sg); err != nil {
return local, origins
}
seen := make(map[string]bool, len(sg.Nodes))
for _, n := range sg.Nodes {
if n != nil {
seen[n.ID] = true
origins[n.ID] = "local"
}
}
edgeSeen := make(map[string]bool, len(sg.Edges))
for _, e := range sg.Edges {
if e != nil {
edgeSeen[edgeKey(e)] = true
}
}
for _, rr := range remotes {
var rsg query.SubGraph
if err := json.Unmarshal(rr.toolJSON, &rsg); err != nil {
continue
}
for _, n := range rsg.Nodes {
if n == nil || seen[n.ID] {
continue // local wins on collision
}
seen[n.ID] = true
origins[n.ID] = "remote:" + rr.slug
sg.Nodes = append(sg.Nodes, n)
}
for _, e := range rsg.Edges {
if e == nil {
continue
}
k := edgeKey(e)
if edgeSeen[k] {
continue
}
edgeSeen[k] = true
sg.Edges = append(sg.Edges, e)
}
}
sg.TotalNodes = len(sg.Nodes)
sg.TotalEdges = len(sg.Edges)
out, err := json.Marshal(sg)
if err != nil {
return local, origins
}
return out, origins
}
func edgeKey(e *graph.Edge) string {
return e.From + "\x00" + e.To + "\x00" + string(e.Kind)
}
// idKeyedTools are the SubGraph traversals whose primary query keys on a
// node id; only these get the optional bare-name fallback fan.
var idKeyedTools = map[string]bool{
"find_usages": true,
"get_callers": true,
"get_call_chain": true,
}
// commonNames are too-frequent identifiers a bare-name fan would
// mis-match across repos; the fallback skips them even when enabled.
var commonNames = map[string]bool{
"len": true, "set": true, "get": true, "new": true, "string": true,
"error": true, "close": true, "read": true, "write": true, "run": true,
"stop": true, "start": true, "init": true, "name": true, "value": true,
"key": true, "data": true, "result": true, "next": true, "size": true,
}
// bareNameFromBody extracts the symbol name from the {"arguments":{"id":...}}
// body, stripping the repo/file prefix and the :: separator.
func bareNameFromBody(body []byte) string {
var b struct {
Arguments struct {
ID string `json:"id"`
} `json:"arguments"`
}
if err := json.Unmarshal(body, &b); err != nil {
return ""
}
id := b.Arguments.ID
if i := strings.LastIndex(id, "::"); i >= 0 {
id = id[i+2:]
}
return id
}
// nameEligible gates the bare-name fallback on rarity/length so a common
// or short identifier never fans out.
func nameEligible(name string) bool {
if len(name) < 4 {
return false
}
return !commonNames[strings.ToLower(name)]
}
// nameKeyedFan issues a per-remote search_symbols query for the bare
// name, capping each remote's contribution and tagging every hit with
// its origin + the text_matched confidence tier.
func (f *Federator) nameKeyedFan(ctx context.Context, name string, remotes []ServerEntry) []any {
const perRemoteCap = 5
body, _ := json.Marshal(map[string]any{
"arguments": map[string]any{"query": name, "limit": perRemoteCap, "format": "json"},
})
budgetCtx, cancel := context.WithTimeout(ctx, f.cfg.Budget)
defer cancel()
var (
mu sync.Mutex
hits []any
wg sync.WaitGroup
)
for _, rem := range remotes {
rem := rem
if f.breaker.isOpen(rem.Slug) {
continue
}
cli, err := f.clientFor(rem)
if err != nil {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
rctx, rcancel := context.WithTimeout(budgetCtx, f.cfg.PerRemoteTimeout)
defer rcancel()
out, status, err := cli.ProxyToolCtx(rctx, "search_symbols", body)
if err != nil || status >= 400 {
return
}
tj, _ := unwrapToolJSON(out)
var rp map[string]any
if json.Unmarshal(tj, &rp) != nil {
return
}
results, _ := rp["results"].([]any)
mu.Lock()
for i, r := range results {
if i >= perRemoteCap {
break
}
if m, ok := r.(map[string]any); ok {
m["origin"] = "remote:" + rem.Slug
m["confidence"] = "text_matched"
hits = append(hits, m)
}
}
mu.Unlock()
}()
}
wg.Wait()
return hits
}
// injectField adds a top-level key to a JSON object payload.
func injectField(b []byte, key string, value any) []byte {
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
return b
}
m[key] = value
out, err := json.Marshal(m)
if err != nil {
return b
}
return out
}
// mergeKeyedList merges a {<key>:[{id,...}], total} payload (search_symbols
// results / find_implementations implementations): concat, dedup by native
// id (local wins), sum total.
func mergeKeyedList(local []byte, remotes []remoteResult, key string) ([]byte, map[string]string) {
origins := map[string]string{}
var payload map[string]any
if err := json.Unmarshal(local, &payload); err != nil {
return local, origins
}
items, _ := payload[key].([]any)
seen := map[string]bool{}
for _, it := range items {
if m, ok := it.(map[string]any); ok {
if id, ok := m["id"].(string); ok && id != "" {
seen[id] = true
origins[id] = "local"
}
}
}
total := toInt(payload["total"])
for _, rr := range remotes {
var rp map[string]any
if err := json.Unmarshal(rr.toolJSON, &rp); err != nil {
continue
}
ritems, _ := rp[key].([]any)
for _, it := range ritems {
m, ok := it.(map[string]any)
if !ok {
continue
}
id, _ := m["id"].(string)
if id != "" && seen[id] {
continue
}
if id != "" {
seen[id] = true
origins[id] = "remote:" + rr.slug
}
items = append(items, m)
}
total += toInt(rp["total"])
}
payload[key] = items
payload["total"] = total
out, err := json.Marshal(payload)
if err != nil {
return local, origins
}
return out, origins
}
// mergeSmartContext keeps the local manifest authoritative and folds each
// remote's contribution into a SEPARATE remote_context section under its
// slug. The edit_plan is NEVER federated (edits are local-only).
func mergeSmartContext(local []byte, remotes []remoteResult) ([]byte, map[string]string) {
origins := map[string]string{}
var payload map[string]any
if err := json.Unmarshal(local, &payload); err != nil {
return local, origins
}
var sections []any
for _, rr := range remotes {
var rp map[string]any
if err := json.Unmarshal(rr.toolJSON, &rp); err != nil {
continue
}
section := map[string]any{"slug": rr.slug}
// Carry the remote's relevant symbols / manifest, never its
// edit_plan.
for _, k := range []string{"relevant_symbols", "context_manifest", "symbols"} {
if v, ok := rp[k]; ok {
section[k] = v
}
}
sections = append(sections, section)
}
if len(sections) > 0 {
payload["remote_context"] = sections
}
delete(payload, "")
out, err := json.Marshal(payload)
if err != nil {
return local, origins
}
return out, origins
}
func toInt(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case json.Number:
i, _ := n.Int64()
return int(i)
}
return 0
}
// attachFederation adds the federation{} block + origins map as siblings
// on a JSON-object tool response. A non-object payload is returned
// unchanged (no place to attach).
func attachFederation(toolJSON []byte, meta FederationMeta) []byte {
var m map[string]any
if err := json.Unmarshal(toolJSON, &m); err != nil {
return toolJSON
}
fed := map[string]any{
"remotes_queried": meta.RemotesQueried,
"degraded": meta.Degraded,
}
if len(meta.RemotesFailed) > 0 {
fed["remotes_failed"] = meta.RemotesFailed
}
if len(meta.NamespaceRewrites) > 0 {
fed["namespace_rewrites"] = meta.NamespaceRewrites
}
if meta.Note != "" {
fed["note"] = meta.Note
}
m["federation"] = fed
if len(meta.Origins) > 0 {
m["origins"] = meta.Origins
}
out, err := json.Marshal(m)
if err != nil {
return toolJSON
}
return out
}
// unwrapToolJSON extracts the tool's JSON payload from the MCP result
// envelope ({content:[{type:text,text:<json>}]}). When the bytes are not
// that envelope, they are returned as-is with wrapped=false.
func unwrapToolJSON(b []byte) (toolJSON []byte, wrapped bool) {
var env struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(b, &env); err != nil || len(env.Content) == 0 {
return b, false
}
if env.Content[0].Type != "text" || env.Content[0].Text == "" {
return b, false
}
return []byte(env.Content[0].Text), true
}
// rewrapToolJSON replaces the text payload of an MCP result envelope with
// newToolJSON, preserving the envelope's other fields (e.g. is_error).
func rewrapToolJSON(envelope, newToolJSON []byte) []byte {
var m map[string]any
if err := json.Unmarshal(envelope, &m); err != nil {
return newToolJSON
}
content, ok := m["content"].([]any)
if !ok || len(content) == 0 {
return newToolJSON
}
first, ok := content[0].(map[string]any)
if !ok {
return newToolJSON
}
first["text"] = string(newToolJSON)
content[0] = first
m["content"] = content
out, err := json.Marshal(m)
if err != nil {
return newToolJSON
}
return out
}
+31
View File
@@ -0,0 +1,31 @@
package daemon
import "context"
// auditInfo carries the request-scoped fields the federation fan-out adds
// to its audit lines so a federated remote call records the same
// {session_id, cwd, tool, target_slug} tuple as the single-remote proxy
// route. It rides the context because the Federator's Augment/fanOut take
// the request ctx but not the router's RouteContext.
type auditInfo struct {
Cwd string
SessionID string
}
type auditInfoKey struct{}
// withAuditInfo attaches the caller's cwd + session id to ctx for the
// fan-out audit. A no-op when both are empty (control/HTTP paths).
func withAuditInfo(ctx context.Context, cwd, sessionID string) context.Context {
if cwd == "" && sessionID == "" {
return ctx
}
return context.WithValue(ctx, auditInfoKey{}, auditInfo{Cwd: cwd, SessionID: sessionID})
}
// auditInfoFrom returns the audit fields carried on ctx, or the zero
// value when none were attached.
func auditInfoFrom(ctx context.Context) auditInfo {
v, _ := ctx.Value(auditInfoKey{}).(auditInfo)
return v
}
+84
View File
@@ -0,0 +1,84 @@
package daemon
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
// TestFanOut_AuditsAndLogsFailures asserts the read-only fan-out emits a
// structured audit line per remote-routed call AND names a failing remote
// in the logs (not only in the JSON federation block).
func TestFanOut_AuditsAndLogsFailures(t *testing.T) {
core, logs := observer.New(zap.InfoLevel)
good := fakeRemote(t, fakeRemoteOpts{indexed: true, toolJSON: `{"nodes":[],"edges":[]}`})
// A remote that negotiates fine but 500s on the tool call -> degraded.
badMux := http.NewServeMux()
badMux.HandleFunc("/v1/health", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok", "indexed": true,
"schema_version": localSchemaMajor, "api_version": 1,
})
})
badMux.HandleFunc("/v1/tools/", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
bad := httptest.NewServer(badMux)
t.Cleanup(bad.Close)
fed := NewFederator(FederationConfig{
PerRemoteTimeout: 250 * time.Millisecond,
Budget: 2 * time.Second,
HealthTTL: time.Millisecond,
}, func(e ServerEntry) (*ServerClient, error) { return NewServerClient(e) }, zap.New(core))
local := envelope(`{"nodes":[],"edges":[],"total_nodes":0,"total_edges":0}`)
ctx := withAuditInfo(context.Background(), "/repo", "sess-9")
fed.Augment(ctx, "find_usages", []byte(`{}`), local,
[]ServerEntry{{Slug: "good", URL: good.URL}, {Slug: "bad", URL: bad.URL}})
// Audit: one remote-routed call line per remote carrying the full
// {session_id, cwd, tool, target_slug} tuple, tagged via=fan-out.
audit := logs.FilterMessage("federation: remote-routed call").All()
if len(audit) != 2 {
t.Fatalf("want 2 fan-out audit lines, got %d", len(audit))
}
seen := map[string]bool{}
for _, e := range audit {
f := e.ContextMap()
if f["via"] != "fan-out" || f["tool"] != "find_usages" {
t.Errorf("audit line missing via/tool: %v", f)
}
if f["cwd"] != "/repo" || f["session_id"] != "sess-9" {
t.Errorf("audit line missing cwd/session_id tuple: %v", f)
}
seen[f["target_slug"].(string)] = true
}
if !seen["good"] || !seen["bad"] {
t.Errorf("both remotes must be audited; got %v", seen)
}
// Degraded: the failing remote is named in the logs with a reason.
degraded := logs.FilterMessage("federation: remote degraded").All()
if len(degraded) == 0 {
t.Fatal("a degraded fan-out must emit a warning naming the remote")
}
found := false
for _, e := range degraded {
f := e.ContextMap()
if f["target_slug"] == "bad" && f["reason"] != "" {
found = true
}
}
if !found {
t.Errorf("the failing remote 'bad' must be named with a reason; got %d degraded lines", len(degraded))
}
}
+107
View File
@@ -0,0 +1,107 @@
package daemon
import (
"context"
"sync"
"time"
)
// circuitBreaker skips a remote that has failed K times in a row until a
// cooldown elapses, so a chronically-dead remote stops costing a per-call
// timeout. Half-opens after the cooldown (one trial call) by resetting.
type circuitBreaker struct {
mu sync.Mutex
threshold int
cooldown time.Duration
state map[string]*breakerState
}
type breakerState struct {
failures int
openUntil time.Time
}
func newCircuitBreaker(threshold int, cooldown time.Duration) *circuitBreaker {
return &circuitBreaker{
threshold: threshold,
cooldown: cooldown,
state: map[string]*breakerState{},
}
}
func (b *circuitBreaker) isOpen(slug string) bool {
b.mu.Lock()
defer b.mu.Unlock()
st := b.state[slug]
if st == nil || st.openUntil.IsZero() {
return false
}
if time.Now().After(st.openUntil) {
// Cooldown elapsed — half-open: let the next call through.
st.openUntil = time.Time{}
st.failures = 0
return false
}
return true
}
func (b *circuitBreaker) fail(slug string) {
b.mu.Lock()
defer b.mu.Unlock()
st := b.state[slug]
if st == nil {
st = &breakerState{}
b.state[slug] = st
}
st.failures++
if st.failures >= b.threshold {
st.openUntil = time.Now().Add(b.cooldown)
}
}
func (b *circuitBreaker) success(slug string) {
b.mu.Lock()
defer b.mu.Unlock()
if st := b.state[slug]; st != nil {
st.failures = 0
st.openUntil = time.Time{}
}
}
// healthCache memoises each remote's /v1/health advertisement for a short
// TTL so the per-call capability + readiness negotiation costs at most one
// extra round-trip per remote per window.
type healthCache struct {
mu sync.Mutex
ttl time.Duration
entries map[string]healthEntry
}
type healthEntry struct {
h RemoteHealth
at time.Time
err error
}
func newHealthCache(ttl time.Duration) *healthCache {
return &healthCache{ttl: ttl, entries: map[string]healthEntry{}}
}
func (c *healthCache) get(ctx context.Context, cli *ServerClient, timeout time.Duration) (RemoteHealth, error) {
slug := cli.Entry.Slug
c.mu.Lock()
if e, ok := c.entries[slug]; ok && time.Since(e.at) < c.ttl {
c.mu.Unlock()
return e.h, e.err
}
c.mu.Unlock()
hctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
h, err := cli.FetchHealth(hctx)
c.mu.Lock()
c.entries[slug] = healthEntry{h: h, at: time.Now(), err: err}
c.mu.Unlock()
return h, err
}
@@ -0,0 +1,106 @@
package daemon
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
// failingToolRemote serves a healthy /v1/health (indexed, compatible
// schema) but answers every /v1/tools/<name> call with HTTP 500, and
// records how many times its tool endpoint was actually hit. That lets a
// test drive the circuit breaker to the open state and then prove the
// next fan-out makes NO outbound tool request to it.
func failingToolRemote(t *testing.T) (*httptest.Server, *int32) {
t.Helper()
var toolCalls int32
mux := http.NewServeMux()
mux.HandleFunc("/v1/health", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok", "indexed": true, "schema_version": localSchemaMajor,
"api_version": 1, "read_only": true,
})
})
mux.HandleFunc("/v1/tools/", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&toolCalls, 1)
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"boom"}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, &toolCalls
}
// breakerFederator builds a Federator with a small, deterministic breaker
// threshold and a long cooldown so the open state does not half-open
// mid-test.
func breakerFederator(threshold int) *Federator {
return NewFederator(FederationConfig{
PerRemoteTimeout: 250 * time.Millisecond,
Budget: 2 * time.Second,
BreakerThreshold: threshold,
BreakerCooldown: 30 * time.Second,
HealthTTL: time.Millisecond,
}, func(e ServerEntry) (*ServerClient, error) { return NewServerClient(e) }, nil)
}
// TestFederator_CircuitBreaker asserts that after K consecutive failures
// to a remote, that remote is circuit-broken and SKIPPED on the next
// fan-out with no outbound tool request observed, and that it is bucketed
// in remotes_failed with reason "circuit_open".
func TestFederator_CircuitBreaker(t *testing.T) {
const threshold = 3
remote, toolCalls := failingToolRemote(t)
fed := breakerFederator(threshold)
local := envelope(`{"nodes":[],"edges":[],"total_nodes":0,"total_edges":0}`)
roster := []ServerEntry{{Slug: "flaky", URL: remote.URL}}
body := []byte(`{}`)
// Drive exactly K failing fan-outs. Each one issues one tool call that
// returns 500, recording one breaker failure. After the K-th, the
// breaker for this remote should be open.
for i := 0; i < threshold; i++ {
out := fed.Augment(context.Background(), "find_usages", body, local, roster)
m := decodeFederated(t, out)
fed2, _ := m["federation"].(map[string]any)
if fed2["degraded"] != true {
t.Fatalf("call %d: a failing remote must mark the response degraded", i+1)
}
}
calledBeforeBreak := atomic.LoadInt32(toolCalls)
if calledBeforeBreak != threshold {
t.Fatalf("expected exactly %d outbound tool calls while the breaker was closed, got %d",
threshold, calledBeforeBreak)
}
// Next call: the breaker is open, so fanOut must short-circuit this
// remote BEFORE any client is built or any HTTP request is made.
out := fed.Augment(context.Background(), "find_usages", body, local, roster)
if got := atomic.LoadInt32(toolCalls); got != calledBeforeBreak {
t.Fatalf("an open circuit must make NO further outbound tool request: tool hit %d times, want it to stay at %d",
got, calledBeforeBreak)
}
m := decodeFederated(t, out)
fed3, _ := m["federation"].(map[string]any)
if fed3["degraded"] != true {
t.Error("a circuit-broken remote must keep the response degraded")
}
failed, _ := fed3["remotes_failed"].([]any)
if len(failed) != 1 {
t.Fatalf("the broken remote must be bucketed in remotes_failed, got %v", fed3["remotes_failed"])
}
first, _ := failed[0].(map[string]any)
if first["reason"] != "circuit_open" {
t.Errorf("a circuit-broken remote must be reported with reason circuit_open, got %v", failed[0])
}
if first["slug"] != "flaky" {
t.Errorf("the broken remote slug must be reported, got %v", first["slug"])
}
}
+498
View File
@@ -0,0 +1,498 @@
package daemon
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/config"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/indexer"
"github.com/zzet/gortex/internal/resolver"
)
// WireRemoteStitch activates cross-daemon proxy-edge minting end-to-end
// when the edges feature is enabled and the router carries a federator: it
// installs the evidence prober on the MultiIndexer (so cross-repo
// resolution mints proxy edges) and returns a ProxyHydrator for the read
// path. Returns nil (a no-op) when the feature is off, the router has no
// federator, or there is no MultiIndexer — leaving the daemon on the
// read-only fan-out only.
func WireRemoteStitch(router *Router, mi *indexer.MultiIndexer, g graph.Store, cfg config.FederationEdgesConfig, logger *zap.Logger) *ProxyHydrator {
if !cfg.IsEnabled() || router == nil || router.federator == nil || mi == nil || g == nil {
return nil
}
remotes := func() []ServerEntry { return router.EffectiveEnabledRemotes(nil) }
timeout := router.federator.cfg.PerRemoteTimeout
prober := NewProxyEdgeProber(router.federator, remotes, timeout, logger)
mi.SetRemoteStitch(prober, cfg.MaxNodes())
hydrator := NewProxyHydrator(g, router.federator.clientFor, remotes,
cfg.TTL(), cfg.Depth(), cfg.MaxNodes(), timeout, logger)
// Subscribe to each subgraph-capable remote's event stream so a graph
// change there evicts our cached proxies for it (daemon-lifetime).
fed := router.federator
hydrator.SubscribeRemoteEvents(context.Background(), func(ctx context.Context, cli *ServerClient) bool {
h, err := fed.health.get(ctx, cli, timeout)
return err == nil && h.HasCapability("subgraph")
})
return hydrator
}
// ProxyEdgeProber implements resolver.RemoteDeclarationProber for the
// proxy-edge mint path: it asks each enabled remote that advertises the
// `subgraph` capability whether it owns a declaration of `name`, via the
// existing find_declaration tool over POST /v1/tools/find_declaration.
// It reuses the Federator's shared client cache, health cache, and
// circuit breaker, so it inherits the bounded-deadline + breaker
// protection of the read-only fan-out.
type ProxyEdgeProber struct {
fed *Federator
remotes func() []ServerEntry // enabled-remote snapshot
timeout time.Duration
logger *zap.Logger
}
// NewProxyEdgeProber wires the prober to the Federator's plumbing and an
// enabled-remote snapshot. Constructed by the daemon entry point only
// when federation.edges.enabled.
func NewProxyEdgeProber(fed *Federator, remotes func() []ServerEntry, timeout time.Duration, logger *zap.Logger) *ProxyEdgeProber {
if logger == nil {
logger = zap.NewNop()
}
if timeout <= 0 {
timeout = 2 * time.Second
}
return &ProxyEdgeProber{fed: fed, remotes: remotes, timeout: timeout, logger: logger}
}
// ProbeDeclaration asks each subgraph-capable enabled remote whether it
// owns a declaration of name, returning the first positive hit (cheapest,
// deterministic by roster order). importHint is already the positive
// evidence the resolver required to call us at all; the remote
// confirmation is the second half.
func (p *ProxyEdgeProber) ProbeDeclaration(ctx context.Context, name, importHint string) (resolver.RemoteDecl, bool) {
if p == nil || p.fed == nil || name == "" || importHint == "" {
return resolver.RemoteDecl{}, false
}
body, _ := json.Marshal(map[string]any{"use_site": name})
for _, rem := range p.remotes() {
if p.fed.breaker.isOpen(rem.Slug) {
continue
}
cli, err := p.fed.clientFor(rem)
if err != nil {
continue
}
// Only probe remotes that advertise the subgraph capability;
// otherwise proxy-edge minting is skipped for this remote and
// the read path stays on the read-only fan-out.
h, herr := p.fed.health.get(ctx, cli, p.timeout)
if herr != nil || !h.HasCapability("subgraph") {
continue
}
rctx, cancel := context.WithTimeout(ctx, p.timeout)
out, status, err := cli.ProxyToolCtx(rctx, "find_declaration", body)
cancel()
if err != nil || status != http.StatusOK {
p.fed.breaker.fail(rem.Slug)
continue
}
if decl, ok := parseRemoteDecl(out, rem.Slug, name); ok {
return decl, true
}
}
return resolver.RemoteDecl{}, false
}
// parseRemoteDecl unwraps a find_declaration tool result and returns the
// first declaration whose Name matches name (a real declaration of the
// symbol, not a coincidental use site), mapped to a resolver.RemoteDecl.
func parseRemoteDecl(out []byte, slug, name string) (resolver.RemoteDecl, bool) {
toolJSON, _ := unwrapToolJSON(out)
var payload struct {
Declarations []struct {
Declaration *graph.Node `json:"declaration"`
} `json:"declarations"`
}
if err := json.Unmarshal(toolJSON, &payload); err != nil {
return resolver.RemoteDecl{}, false
}
for _, g := range payload.Declarations {
d := g.Declaration
if d == nil || d.Name != name {
continue
}
return resolver.RemoteDecl{
Slug: slug,
RemoteID: d.ID,
Kind: d.Kind,
RepoPrefix: d.RepoPrefix,
WorkspaceID: d.WorkspaceID,
File: d.FilePath,
Line: d.StartLine,
}, true
}
return resolver.RemoteDecl{}, false
}
// remoteSubGraph mirrors server.SubGraphResponse on the wire (a local
// copy avoids a daemon -> server import).
type remoteSubGraph struct {
Root *graph.Node `json:"root"`
Nodes []*graph.Node `json:"nodes"`
Edges []*graph.Edge `json:"edges"`
Stats struct {
FetchedAt time.Time `json:"fetched_at"`
Truncated bool `json:"truncated"`
} `json:"stats"`
}
// GetSubGraph fetches a node's FULL neighbour ring from a remote's
// GET /v1/subgraph (proxy-edge hydration). depth defaults to 1.
func (c *ServerClient) GetSubGraph(ctx context.Context, id string, depth int) (*remoteSubGraph, error) {
base, err := url.JoinPath(c.BaseURL, "v1", "subgraph")
if err != nil {
return nil, fmt.Errorf("join subgraph URL: %w", err)
}
q := url.Values{}
q.Set("id", id)
if depth > 0 {
q.Set("depth", strconv.Itoa(depth))
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"?"+q.Encode(), nil)
if err != nil {
return nil, fmt.Errorf("build subgraph request: %w", err)
}
if tok := c.resolveAuthToken(); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch subgraph from %q: %w", c.Entry.Slug, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("subgraph from %q: status %d", c.Entry.Slug, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read subgraph response: %w", err)
}
var out remoteSubGraph
if err := json.Unmarshal(body, &out); err != nil {
return nil, fmt.Errorf("decode subgraph from %q: %w", c.Entry.Slug, err)
}
return &out, nil
}
// ProxyHydrator lazily fills a proxy node's neighbour ring from the
// owning remote's /v1/subgraph. It lives in the daemon read path (not in
// graph.Graph, which has no HTTP knowledge); --oneshot and pure-local
// installs never construct one.
type ProxyHydrator struct {
graph graph.Store
clientFor func(ServerEntry) (*ServerClient, error)
remotes func() []ServerEntry
ttl time.Duration
depth int
budget int
timeout time.Duration
logger *zap.Logger
}
// NewProxyHydrator builds a hydrator. Constructed by the daemon entry
// point only when federation.edges.enabled.
func NewProxyHydrator(g graph.Store, clientFor func(ServerEntry) (*ServerClient, error), remotes func() []ServerEntry, ttl time.Duration, depth, budget int, timeout time.Duration, logger *zap.Logger) *ProxyHydrator {
if logger == nil {
logger = zap.NewNop()
}
if depth <= 0 {
depth = 1
}
if timeout <= 0 {
timeout = 2 * time.Second
}
return &ProxyHydrator{
graph: g, clientFor: clientFor, remotes: remotes,
ttl: ttl, depth: depth, budget: budget, timeout: timeout, logger: logger,
}
}
// Hydrate pulls one neighbour ring for a proxy node over /v1/subgraph,
// mints any newly-referenced proxy nodes (origin-namespaced), adds the
// edges with honest provenance, refreshes FetchedAt, and returns the
// number of edges added. No-op when the ring is fresh (within ttl) and
// already populated. Bounded by ctx and the proxy budget.
func (h *ProxyHydrator) Hydrate(ctx context.Context, proxyID string) (int, error) {
if h == nil || h.graph == nil {
return 0, nil
}
n := h.graph.GetNode(proxyID)
if n == nil || !graph.IsProxyNode(n) {
return 0, nil
}
if !n.FetchedAt.IsZero() && time.Since(n.FetchedAt) < h.ttl &&
len(h.graph.GetOutEdges(proxyID)) > 0 {
return 0, nil
}
slug := graph.ProxyOriginSlug(proxyID)
remoteID := graph.ProxyRemoteID(proxyID)
rem, ok := h.remoteForSlug(slug)
if !ok {
return 0, nil
}
cli, err := h.clientFor(rem)
if err != nil {
return 0, err
}
rctx, cancel := context.WithTimeout(ctx, h.timeout)
sg, err := cli.GetSubGraph(rctx, remoteID, h.depth)
cancel()
if err != nil {
return 0, err
}
// Mint a proxy node for each neighbour (origin-namespaced).
for _, rn := range sg.Nodes {
if rn == nil || rn.ID == "" || rn.ID == remoteID {
continue
}
pid := graph.ProxyNodeID(slug, rn.ID)
if h.graph.GetNode(pid) != nil {
continue
}
if h.budgetExceeded() {
h.logger.Warn("federation: proxy budget exceeded during hydration",
zap.String("slug", slug))
break
}
h.graph.AddNode(&graph.Node{
ID: pid, Kind: rn.Kind, Name: rn.Name,
FilePath: rn.FilePath, StartLine: rn.StartLine,
RepoPrefix: rn.RepoPrefix, WorkspaceID: rn.WorkspaceID,
Origin: "remote:" + slug, Stub: true, FetchedAt: time.Now(),
})
}
// Add the ring's edges, rewriting remote ids to proxy ids (the root
// maps back to the existing proxy id). Skip an edge whose endpoint we
// did not pull (it would dangle).
added := 0
for _, re := range sg.Edges {
if re == nil {
continue
}
from := h.proxyize(slug, re.From, remoteID, proxyID)
to := h.proxyize(slug, re.To, remoteID, proxyID)
if h.graph.GetNode(from) == nil || h.graph.GetNode(to) == nil {
continue
}
h.graph.AddEdge(&graph.Edge{
From: from, To: to, Kind: re.Kind,
Origin: graph.OriginTextMatched, CrossRepo: true,
})
added++
}
// Refresh the root proxy's FetchedAt (AddNode upserts).
refreshed := *n
refreshed.FetchedAt = time.Now()
h.graph.AddNode(&refreshed)
return added, nil
}
// EvictRemote marks every proxy node owned by slug stale (resets
// FetchedAt) so the next access re-hydrates against fresh remote data.
// Called on a graph_invalidated frame from that remote. The
// graph.Store has no node-removal primitive that targets the origin
// namespace cleanly, so staleness is expressed as a forced re-hydrate
// rather than a hard delete — same observable outcome (fresh data on the
// next read). Returns the number of proxy nodes invalidated.
func (h *ProxyHydrator) EvictRemote(slug string) int {
if h == nil || h.graph == nil || slug == "" {
return 0
}
count := 0
for _, n := range h.graph.AllNodes() {
if !graph.IsProxyNode(n) || graph.ProxyOriginSlug(n.ID) != slug {
continue
}
stale := *n
stale.FetchedAt = time.Time{}
h.graph.AddNode(&stale)
count++
}
return count
}
func (h *ProxyHydrator) proxyize(slug, remoteNodeID, rootRemoteID, rootProxyID string) string {
if remoteNodeID == rootRemoteID {
return rootProxyID
}
return graph.ProxyNodeID(slug, remoteNodeID)
}
func (h *ProxyHydrator) remoteForSlug(slug string) (ServerEntry, bool) {
for _, r := range h.remotes() {
if r.Slug == slug {
return r, true
}
}
return ServerEntry{}, false
}
func (h *ProxyHydrator) budgetExceeded() bool {
if h.budget <= 0 {
return false
}
count := 0
for _, n := range h.graph.AllNodes() {
if graph.IsProxyNode(n) {
count++
if count >= h.budget {
return true
}
}
}
return false
}
// StreamEvents connects to the remote's GET /v1/events SSE stream and
// calls onEvent for every graph-change frame, until ctx is cancelled or
// the stream ends. SSE is long-lived, so it uses a request without the
// client's 60s timeout (cancellation rides on ctx). Returns the
// connection/read error so the caller can back off and reconnect.
func (c *ServerClient) StreamEvents(ctx context.Context, onEvent func()) error {
u, err := url.JoinPath(c.BaseURL, "v1", "events")
if err != nil {
return fmt.Errorf("join events URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return fmt.Errorf("build events request: %w", err)
}
if tok := c.resolveAuthToken(); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
req.Header.Set("Accept", "text/event-stream")
// Reuse the client's transport (so unix-socket remotes still work) but
// drop the request timeout — an SSE stream must outlive 60s.
sse := &http.Client{Transport: c.httpClient.Transport}
resp, err := sse.Do(req)
if err != nil {
return fmt.Errorf("subscribe events on %q: %w", c.Entry.Slug, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("events from %q: status %d", c.Entry.Slug, resp.StatusCode)
}
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
// Each graph-change SSE frame opens with `event: graph_change`.
if strings.HasPrefix(sc.Text(), "event: graph_change") {
onEvent()
}
}
return sc.Err()
}
// SubscribeRemoteEvents starts background per-remote /v1/events
// subscriptions: whenever a subgraph-capable enabled remote's graph
// changes, this daemon's cached proxy nodes for that remote are evicted
// (marked stale) so the next access re-hydrates against fresh data. Runs
// until ctx is cancelled; reconnects with backoff on a dropped stream and
// reconciles the subscription set as the enabled-remote roster changes.
// capable reports whether a remote advertises the subgraph capability.
func (h *ProxyHydrator) SubscribeRemoteEvents(ctx context.Context, capable func(context.Context, *ServerClient) bool) {
if h == nil || h.remotes == nil {
return
}
go func() {
subs := map[string]context.CancelFunc{}
reconcile := func() {
want := map[string]ServerEntry{}
for _, r := range h.remotes() {
want[r.Slug] = r
}
for slug, cancel := range subs {
if _, ok := want[slug]; !ok {
cancel()
delete(subs, slug)
}
}
for slug, rem := range want {
if _, ok := subs[slug]; ok {
continue
}
cli, err := h.clientFor(rem)
if err != nil {
continue
}
if capable != nil && !capable(ctx, cli) {
continue
}
sctx, cancel := context.WithCancel(ctx)
subs[slug] = cancel
go h.runRemoteSubscription(sctx, rem)
}
}
reconcile()
t := time.NewTicker(30 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
for _, cancel := range subs {
cancel()
}
return
case <-t.C:
reconcile()
}
}
}()
}
// runRemoteSubscription holds one remote's event subscription open,
// evicting that remote's proxies on every change, and reconnects after a
// short backoff when the stream drops.
func (h *ProxyHydrator) runRemoteSubscription(ctx context.Context, rem ServerEntry) {
for {
if ctx.Err() != nil {
return
}
if cli, err := h.clientFor(rem); err == nil {
_ = cli.StreamEvents(ctx, func() {
if n := h.EvictRemote(rem.Slug); n > 0 {
h.logger.Info("federation: evicted cached proxies on remote change",
zap.String("slug", rem.Slug), zap.Int("count", n))
}
})
}
select {
case <-ctx.Done():
return
case <-time.After(2 * time.Second):
}
}
}
+175
View File
@@ -0,0 +1,175 @@
package daemon
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/zzet/gortex/internal/graph"
)
func declRemote(t *testing.T, declJSON string, caps []string) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v1/health", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok", "indexed": true,
"schema_version": localSchemaMajor, "api_version": 1, "read_only": true,
"capabilities": caps,
})
})
mux.HandleFunc("/v1/tools/find_declaration", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(envelope(declJSON))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func proberFor(remotes []ServerEntry) *ProxyEdgeProber {
fed := NewFederator(FederationConfig{
PerRemoteTimeout: 250 * time.Millisecond,
Budget: 2 * time.Second,
HealthTTL: time.Millisecond,
}, func(e ServerEntry) (*ServerClient, error) { return NewServerClient(e) }, nil)
return NewProxyEdgeProber(fed, func() []ServerEntry { return remotes }, 250*time.Millisecond, nil)
}
const helperDeclJSON = `{"declarations":[{"declaration":{"id":"rb/lib/c.go::Helper","kind":"function","name":"Helper","file_path":"rb/lib/c.go","start_line":12,"repo_prefix":"rb","workspace_id":"wsB"},"use_sites":[]}]}`
func TestProbeDeclaration_Hit(t *testing.T) {
remote := declRemote(t, helperDeclJSON, []string{"events", "subgraph"})
p := proberFor([]ServerEntry{{Slug: "remoteB", URL: remote.URL}})
decl, ok := p.ProbeDeclaration(context.Background(), "Helper", "extmod")
if !ok {
t.Fatal("expected a positive declaration hit")
}
if decl.Slug != "remoteB" || decl.RemoteID != "rb/lib/c.go::Helper" {
t.Errorf("wrong decl identity: %+v", decl)
}
if decl.Kind != graph.KindFunction || decl.RepoPrefix != "rb" || decl.Line != 12 {
t.Errorf("decl fields wrong: %+v", decl)
}
}
func TestProbeDeclaration_NoSubgraphCap_Skipped(t *testing.T) {
// The remote advertises no `subgraph` capability, so proxy-edge minting is
// skipped for it even though it would have the declaration.
remote := declRemote(t, helperDeclJSON, []string{"events"})
p := proberFor([]ServerEntry{{Slug: "remoteB", URL: remote.URL}})
if _, ok := p.ProbeDeclaration(context.Background(), "Helper", "extmod"); ok {
t.Error("a remote without the subgraph cap must not yield a hit")
}
}
func TestProbeDeclaration_EmptyHint_NoProbe(t *testing.T) {
remote := declRemote(t, helperDeclJSON, []string{"events", "subgraph"})
p := proberFor([]ServerEntry{{Slug: "remoteB", URL: remote.URL}})
if _, ok := p.ProbeDeclaration(context.Background(), "Helper", ""); ok {
t.Error("an empty import hint must short-circuit before probing")
}
}
func TestProbeDeclaration_NameMismatch_NoHit(t *testing.T) {
// Remote returns a declaration named something else — not a hit.
other := `{"declarations":[{"declaration":{"id":"rb/lib/c.go::Other","kind":"function","name":"Other"}}]}`
remote := declRemote(t, other, []string{"events", "subgraph"})
p := proberFor([]ServerEntry{{Slug: "remoteB", URL: remote.URL}})
if _, ok := p.ProbeDeclaration(context.Background(), "Helper", "extmod"); ok {
t.Error("a declaration whose name does not match must not be a hit")
}
}
func subgraphRemote(t *testing.T, body string) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v1/subgraph", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(body))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func hydratorFor(g graph.Store, remotes []ServerEntry) *ProxyHydrator {
return NewProxyHydrator(g,
func(e ServerEntry) (*ServerClient, error) { return NewServerClient(e) },
func() []ServerEntry { return remotes },
5*time.Minute, 1, 5000, 250*time.Millisecond, nil)
}
const helperSubgraphJSON = `{"root":{"id":"rb/lib/c.go::Helper","kind":"function","name":"Helper"},` +
`"nodes":[{"id":"rb/lib/d.go::Sub","kind":"function","name":"Sub"}],` +
`"edges":[{"from":"rb/lib/c.go::Helper","to":"rb/lib/d.go::Sub","kind":"calls"}],` +
`"stats":{"schema_version":1,"truncated":false}}`
func TestHydrate_PullsRing(t *testing.T) {
remote := subgraphRemote(t, helperSubgraphJSON)
g := graph.New()
proxyID := graph.ProxyNodeID("remoteB", "rb/lib/c.go::Helper")
g.AddNode(&graph.Node{ID: proxyID, Kind: graph.KindFunction, Name: "Helper", Origin: "remote:remoteB", Stub: true})
h := hydratorFor(g, []ServerEntry{{Slug: "remoteB", URL: remote.URL}})
added, err := h.Hydrate(context.Background(), proxyID)
if err != nil {
t.Fatalf("hydrate: %v", err)
}
if added != 1 {
t.Errorf("edges added = %d, want 1", added)
}
subPID := graph.ProxyNodeID("remoteB", "rb/lib/d.go::Sub")
if n := g.GetNode(subPID); n == nil || !graph.IsProxyNode(n) {
t.Errorf("neighbour proxy should be minted; got %+v", n)
}
outs := g.GetOutEdges(proxyID)
if len(outs) != 1 || outs[0].To != subPID {
t.Errorf("ring edge wrong: %+v", outs)
}
if g.GetNode(proxyID).FetchedAt.IsZero() {
t.Error("root proxy FetchedAt should be refreshed after hydration")
}
}
func TestHydrate_FreshRing_NoOp(t *testing.T) {
remote := subgraphRemote(t, helperSubgraphJSON)
g := graph.New()
proxyID := graph.ProxyNodeID("remoteB", "rb/lib/c.go::Helper")
g.AddNode(&graph.Node{ID: proxyID, Kind: graph.KindFunction, Name: "Helper", Origin: "remote:remoteB", Stub: true, FetchedAt: time.Now()})
g.AddEdge(&graph.Edge{From: proxyID, To: "rb/lib/d.go::Existing", Kind: graph.EdgeCalls})
h := hydratorFor(g, []ServerEntry{{Slug: "remoteB", URL: remote.URL}})
added, err := h.Hydrate(context.Background(), proxyID)
if err != nil {
t.Fatalf("hydrate: %v", err)
}
if added != 0 {
t.Errorf("a fresh, populated ring must be a no-op; added=%d", added)
}
}
func TestEvictRemote_ForcesRehydrate(t *testing.T) {
g := graph.New()
proxyID := graph.ProxyNodeID("remoteB", "rb/lib/c.go::Helper")
g.AddNode(&graph.Node{ID: proxyID, Kind: graph.KindFunction, Name: "Helper", Origin: "remote:remoteB", Stub: true, FetchedAt: time.Now()})
// A proxy owned by a different remote must be untouched.
otherID := graph.ProxyNodeID("remoteC", "rc/x.go::Z")
g.AddNode(&graph.Node{ID: otherID, Kind: graph.KindFunction, Name: "Z", Origin: "remote:remoteC", Stub: true, FetchedAt: time.Now()})
h := hydratorFor(g, nil)
if n := h.EvictRemote("remoteB"); n != 1 {
t.Errorf("evicted = %d, want 1", n)
}
if !g.GetNode(proxyID).FetchedAt.IsZero() {
t.Error("evicted proxy must be marked stale (FetchedAt zeroed)")
}
if g.GetNode(otherID).FetchedAt.IsZero() {
t.Error("a different remote's proxy must be untouched by eviction")
}
}
+56
View File
@@ -0,0 +1,56 @@
package daemon
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/zzet/gortex/internal/graph"
)
// TestStreamEvents_EvictsCachedProxies asserts a remote's graph_change SSE
// frame triggers eviction of this daemon's cached proxy nodes for that
// remote (marking them stale so the next access re-hydrates).
func TestStreamEvents_EvictsCachedProxies(t *testing.T) {
g := graph.New()
proxyID := graph.ProxyNodeID("remoteB", "rb/c.go::Helper")
g.AddNode(&graph.Node{
ID: proxyID, Kind: graph.KindFunction, Name: "Helper",
Origin: "remote:remoteB", Stub: true, FetchedAt: time.Now(),
})
// Fake remote: /v1/events emits one graph_change frame, then closes.
mux := http.NewServeMux()
mux.HandleFunc("/v1/events", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
fmt.Fprint(w, "event: graph_change\nid: 1\ndata: {}\n\n")
if fl, ok := w.(http.Flusher); ok {
fl.Flush()
}
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
h := hydratorFor(g, []ServerEntry{{Slug: "remoteB", URL: srv.URL}})
cli, err := NewServerClient(ServerEntry{Slug: "remoteB", URL: srv.URL})
if err != nil {
t.Fatal(err)
}
evicted := 0
if err := cli.StreamEvents(context.Background(), func() {
evicted += h.EvictRemote("remoteB")
}); err != nil {
t.Fatalf("StreamEvents: %v", err)
}
if evicted == 0 {
t.Fatal("a graph_change frame must trigger eviction")
}
if !g.GetNode(proxyID).FetchedAt.IsZero() {
t.Error("the evicted proxy must be marked stale (FetchedAt zeroed) so it re-hydrates")
}
}
@@ -0,0 +1,102 @@
package daemon
import (
"context"
"encoding/json"
"testing"
"github.com/zzet/gortex/internal/graph"
"github.com/zzet/gortex/internal/query"
)
// TestFederator_MergeOriginsMap asserts the federated merge attaches a
// sibling origins map that tags which remote each merged node came from
// (and tags the local nodes "local").
func TestFederator_MergeOriginsMap(t *testing.T) {
remote := fakeRemote(t, fakeRemoteOpts{indexed: true, toolJSON: `{"nodes":[{"id":"r/x.go::RemoteOnly"},{"id":"shared::Sym"}],"edges":[],"total_nodes":2,"total_edges":0}`})
local := envelope(`{"nodes":[{"id":"shared::Sym"},{"id":"l/a.go::LocalOnly"}],"edges":[],"total_nodes":2,"total_edges":0}`)
out := testFederator().Augment(context.Background(), "find_usages", []byte(`{}`),
local, []ServerEntry{{Slug: "r2", URL: remote.URL}})
m := decodeFederated(t, out)
origins, ok := m["origins"].(map[string]any)
if !ok {
t.Fatalf("a federated merge must attach a sibling origins map, got %v", m["origins"])
}
// Every merged node id must be tagged with its provenance.
want := map[string]string{
"l/a.go::LocalOnly": "local",
"shared::Sym": "local", // local wins on collision
"r/x.go::RemoteOnly": "remote:r2",
}
for id, src := range want {
if origins[id] != src {
t.Errorf("origins[%q] = %v, want %q", id, origins[id], src)
}
}
if len(origins) != len(want) {
t.Errorf("origins map should tag exactly the %d merged nodes, got %d (%v)", len(want), len(origins), origins)
}
}
// TestFederator_DeepCopiesNodes asserts the merge works on detached
// copies of remote node data: mutating a node in one merged response must
// never bleed into a subsequent merge of the same remote, proving no
// stored *graph.Node pointer is aliased across responses.
func TestFederator_DeepCopiesNodes(t *testing.T) {
const remoteBody = `{"nodes":[{"id":"r/x.go::Caller","name":"OriginalName"}],"edges":[],"total_nodes":1,"total_edges":0}`
remote := fakeRemote(t, fakeRemoteOpts{indexed: true, toolJSON: remoteBody})
local := envelope(`{"nodes":[],"edges":[],"total_nodes":0,"total_edges":0}`)
fed := testFederator()
roster := []ServerEntry{{Slug: "r2", URL: remote.URL}}
// First fan-out: pull the remote node into a merged SubGraph and mutate
// it in place.
out1 := fed.Augment(context.Background(), "find_usages", []byte(`{}`), local, roster)
tool1, _ := unwrapToolJSON(out1)
var sg1 query.SubGraph
if err := json.Unmarshal(tool1, &sg1); err != nil {
t.Fatalf("decode first merged subgraph: %v", err)
}
caller1 := findNamedNode(&sg1, "r/x.go::Caller")
if caller1 == nil {
t.Fatal("first merge must carry the remote node")
}
if caller1.Name != "OriginalName" {
t.Fatalf("remote node name should round-trip, got %q", caller1.Name)
}
// Mutate the per-response node. If the federator aliased a shared,
// stored pointer this would corrupt the source of every later merge.
caller1.Name = "MUTATED"
// Second fan-out of the same remote: the node must still carry the
// original name, untouched by the first response's mutation.
out2 := fed.Augment(context.Background(), "find_usages", []byte(`{}`), local, roster)
tool2, _ := unwrapToolJSON(out2)
var sg2 query.SubGraph
if err := json.Unmarshal(tool2, &sg2); err != nil {
t.Fatalf("decode second merged subgraph: %v", err)
}
caller2 := findNamedNode(&sg2, "r/x.go::Caller")
if caller2 == nil {
t.Fatal("second merge must carry the remote node")
}
if caller2.Name != "OriginalName" {
t.Errorf("mutating a merged node must not bleed into a later response (deep copy): got %q, want %q",
caller2.Name, "OriginalName")
}
// And the two responses must not share the same node pointer.
if caller1 == caller2 {
t.Error("each federated response must hand back its own node, not a shared pointer")
}
}
func findNamedNode(sg *query.SubGraph, id string) *graph.Node {
for _, n := range sg.Nodes {
if n != nil && n.ID == id {
return n
}
}
return nil
}
+436
View File
@@ -0,0 +1,436 @@
package daemon
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
// recordingSearchRemote serves find_usages (empty SubGraph) and a
// search_symbols endpoint that records how many times it was hit, so a
// test can prove the name-keyed fallback did or did not fire.
func recordingSearchRemote(t *testing.T, searchJSON string) (*httptest.Server, *int32) {
t.Helper()
var searchCalls int32
mux := http.NewServeMux()
mux.HandleFunc("/v1/health", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok", "indexed": true, "schema_version": localSchemaMajor,
"api_version": 1, "read_only": true,
})
})
mux.HandleFunc("/v1/tools/search_symbols", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&searchCalls, 1)
_, _ = w.Write(envelope(searchJSON))
})
mux.HandleFunc("/v1/tools/", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(envelope(`{"nodes":[],"edges":[],"total_nodes":0,"total_edges":0}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, &searchCalls
}
func nameKeyedFederator(on bool) *Federator {
return NewFederator(FederationConfig{
PerRemoteTimeout: 250 * time.Millisecond,
Budget: 2 * time.Second,
HealthTTL: time.Millisecond,
NameKeyedFallback: on,
}, func(e ServerEntry) (*ServerClient, error) { return NewServerClient(e) }, nil)
}
// TestFederator_NameKeyedFallback_OffByDefault asserts that with the
// fallback off, no bare-name search is issued.
func TestFederator_NameKeyedFallback_OffByDefault(t *testing.T) {
remote, searchCalls := recordingSearchRemote(t, `{"results":[{"id":"r/a::ComputeChecksum"}],"total":1}`)
local := envelope(`{"nodes":[],"edges":[],"total_nodes":0,"total_edges":0}`)
out := nameKeyedFederator(false).Augment(context.Background(), "find_usages",
[]byte(`{"arguments":{"id":"l/a.go::ComputeChecksum"}}`), local,
[]ServerEntry{{Slug: "r2", URL: remote.URL}})
if atomic.LoadInt32(searchCalls) != 0 {
t.Fatalf("name-keyed fallback must not fire when off (search hit %d times)", *searchCalls)
}
m := decodeFederated(t, out)
if _, ok := m["name_hits"]; ok {
t.Error("no name_hits section when the fallback is off")
}
}
// TestFederator_NameKeyedFallback_OptInSeparateSection asserts that with
// the fallback on and an eligible name, remote hits land in a separate
// name_hits section tagged text_matched; a common/short name is skipped.
func TestFederator_NameKeyedFallback_OptInSeparateSection(t *testing.T) {
remote, searchCalls := recordingSearchRemote(t, `{"results":[{"id":"r/a::ComputeChecksum","name":"ComputeChecksum"}],"total":1}`)
fed := nameKeyedFederator(true)
local := envelope(`{"nodes":[],"edges":[],"total_nodes":0,"total_edges":0}`)
// Eligible name => name_hits present, tagged text_matched.
out := fed.Augment(context.Background(), "find_usages",
[]byte(`{"arguments":{"id":"l/a.go::ComputeChecksum"}}`), local,
[]ServerEntry{{Slug: "r2", URL: remote.URL}})
if atomic.LoadInt32(searchCalls) == 0 {
t.Fatal("an eligible name must trigger the bare-name search")
}
m := decodeFederated(t, out)
hits, ok := m["name_hits"].([]any)
if !ok || len(hits) != 1 {
t.Fatalf("name_hits should carry the remote hit, got %v", m["name_hits"])
}
if first, _ := hits[0].(map[string]any); first["confidence"] != "text_matched" || first["origin"] != "remote:r2" {
t.Errorf("name hit must be tagged text_matched + remote origin, got %v", hits[0])
}
// The primary results are NOT polluted with name hits (SubGraph nodes stay empty).
if nodes, _ := m["nodes"].([]any); len(nodes) != 0 {
t.Error("name hits must stay in their own section, not the primary results")
}
// A short/common name is gated out even when on.
atomic.StoreInt32(searchCalls, 0)
out2 := fed.Augment(context.Background(), "find_usages",
[]byte(`{"arguments":{"id":"l/a.go::len"}}`), local,
[]ServerEntry{{Slug: "r2", URL: remote.URL}})
if atomic.LoadInt32(searchCalls) != 0 {
t.Error("a common/short name must be gated out of the fallback")
}
if _, ok := decodeFederated(t, out2)["name_hits"]; ok {
t.Error("a gated name must produce no name_hits section")
}
}
// TestAudit_RemoteRoutedCallLogged asserts a remote-routed call emits a
// structured audit line carrying {session_id, cwd, tool, target_slug}.
func TestAudit_RemoteRoutedCallLogged(t *testing.T) {
core, logs := observer.New(zap.InfoLevel)
remote := fakeRemote(t, fakeRemoteOpts{indexed: true, toolJSON: `{"nodes":[],"edges":[]}`})
cfg := &ServersConfig{Server: []ServerEntry{{Slug: "r2", URL: remote.URL, Default: true}}}
router := NewRouter(RouterConfig{
Servers: cfg,
Rosters: NewWorkspaceRosterCache(time.Minute),
CwdResolver: func(string) (string, bool) { return "", false },
LocalSlug: LocalServerSentinel,
LocalExecute: func(context.Context, string, []byte) ([]byte, int, error) {
return []byte(`{}`), 200, nil
},
Logger: zap.New(core),
})
_, _, err := router.RouteToolCall(context.Background(), "find_usages", []byte(`{}`), RouteContext{
Cwd: "/repo",
SessionID: "sess-1",
EnabledRemotes: []ServerEntry{{Slug: "r2", URL: remote.URL}},
})
if err != nil {
t.Fatal(err)
}
entries := logs.FilterMessage("federation: remote-routed call").All()
if len(entries) != 1 {
t.Fatalf("want exactly one audit line, got %d", len(entries))
}
fields := entries[0].ContextMap()
for k, want := range map[string]string{"tool": "find_usages", "target_slug": "r2", "cwd": "/repo", "session_id": "sess-1"} {
if fields[k] != want {
t.Errorf("audit field %q = %v, want %q", k, fields[k], want)
}
}
}
// envelope wraps a tool JSON payload in the MCP result envelope the local
// executor and /v1/tools both emit.
func envelope(toolJSON string) []byte {
b, _ := json.Marshal(map[string]any{
"content": []map[string]any{{"type": "text", "text": toolJSON}},
})
return b
}
type fakeRemoteOpts struct {
indexed bool
schema int
toolJSON string
toolSleep time.Duration
healthErr bool
}
func fakeRemote(t *testing.T, o fakeRemoteOpts) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/v1/health", func(w http.ResponseWriter, r *http.Request) {
if o.healthErr {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
schema := o.schema
if schema == 0 {
schema = localSchemaMajor
}
_ = json.NewEncoder(w).Encode(map[string]any{
"status": "ok", "indexed": o.indexed, "nodes": 1, "edges": 0,
"version": "test", "schema_version": schema, "api_version": 1,
"read_only": true, "capabilities": []string{"events"},
})
})
mux.HandleFunc("/v1/tools/", func(w http.ResponseWriter, r *http.Request) {
if o.toolSleep > 0 {
time.Sleep(o.toolSleep)
}
_, _ = w.Write(envelope(o.toolJSON))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func testFederator() *Federator {
return NewFederator(FederationConfig{
PerRemoteTimeout: 250 * time.Millisecond,
Budget: 2 * time.Second,
BreakerThreshold: 2,
BreakerCooldown: 500 * time.Millisecond,
HealthTTL: time.Millisecond,
}, func(e ServerEntry) (*ServerClient, error) { return NewServerClient(e) }, nil)
}
func decodeFederated(t *testing.T, out []byte) map[string]any {
t.Helper()
tool, _ := unwrapToolJSON(out)
var m map[string]any
if err := json.Unmarshal(tool, &m); err != nil {
t.Fatalf("decode merged tool json: %v (%s)", err, tool)
}
return m
}
// TestFederator_MergeSubGraphOrigins merges a local + remote SubGraph and
// asserts the origins map tags each node, local wins on collision, and
// edges dedupe.
func TestFederator_MergeSubGraphOrigins(t *testing.T) {
remote := fakeRemote(t, fakeRemoteOpts{indexed: true, toolJSON: `{"nodes":[{"id":"r/x.go::Caller"},{"id":"shared::Sym"}],"edges":[{"from":"r/x.go::Caller","to":"shared::Sym","kind":"calls"}],"total_nodes":2,"total_edges":1}`})
local := envelope(`{"nodes":[{"id":"shared::Sym"}],"edges":[],"total_nodes":1,"total_edges":0}`)
out := testFederator().Augment(context.Background(), "find_usages", []byte(`{}`),
local, []ServerEntry{{Slug: "r2", URL: remote.URL}})
m := decodeFederated(t, out)
origins, _ := m["origins"].(map[string]any)
if origins["shared::Sym"] != "local" {
t.Errorf("collision node must stay local-origin, got %v", origins["shared::Sym"])
}
if origins["r/x.go::Caller"] != "remote:r2" {
t.Errorf("remote-only node must be tagged remote:r2, got %v", origins["r/x.go::Caller"])
}
nodes, _ := m["nodes"].([]any)
if len(nodes) != 2 {
t.Fatalf("merged nodes: want 2 (local wins on the shared id), got %d", len(nodes))
}
fed, _ := m["federation"].(map[string]any)
q, _ := fed["remotes_queried"].([]any)
if len(q) != 1 {
t.Errorf("remotes_queried should list r2, got %v", fed["remotes_queried"])
}
if fed["degraded"] != false {
t.Errorf("a healthy remote must not be degraded")
}
}
// TestFederator_LocalOnlyUnchanged asserts no enabled remotes leaves the
// local response byte-for-byte unchanged in the pure-local path.
func TestFederator_LocalOnlyUnchanged(t *testing.T) {
local := envelope(`{"nodes":[{"id":"shared::Sym"}],"edges":[],"total_nodes":1,"total_edges":0}`)
out := testFederator().Augment(context.Background(), "find_usages", []byte(`{}`), local, nil)
if string(out) != string(local) {
t.Fatalf("local-only response must be unchanged:\n got %s\nwant %s", out, local)
}
}
// TestFederator_DeadRemoteDegrades asserts a never-answering remote
// degrades the response within the deadline and is bucketed in
// remotes_failed, with the local result still present.
func TestFederator_DeadRemoteDegrades(t *testing.T) {
dead := fakeRemote(t, fakeRemoteOpts{indexed: true, toolSleep: 5 * time.Second, toolJSON: `{}`})
local := envelope(`{"nodes":[{"id":"shared::Sym"}],"edges":[],"total_nodes":1,"total_edges":0}`)
start := time.Now()
out := testFederator().Augment(context.Background(), "find_usages", []byte(`{}`),
local, []ServerEntry{{Slug: "slow", URL: dead.URL}})
if time.Since(start) > 2*time.Second {
t.Fatalf("a dead remote must not block past the budget")
}
m := decodeFederated(t, out)
fed, _ := m["federation"].(map[string]any)
if fed["degraded"] != true {
t.Errorf("a dead remote must set degraded:true")
}
failed, _ := fed["remotes_failed"].([]any)
if len(failed) != 1 {
t.Fatalf("the dead remote must be in remotes_failed, got %v", fed["remotes_failed"])
}
// Local node still present.
if nodes, _ := m["nodes"].([]any); len(nodes) != 1 {
t.Errorf("the local result must survive a remote failure, got %d nodes", len(nodes))
}
}
// TestFederator_QueriedVsFailed asserts both lists are present with one OK
// and one dead remote.
func TestFederator_QueriedVsFailed(t *testing.T) {
ok := fakeRemote(t, fakeRemoteOpts{indexed: true, toolJSON: `{"nodes":[{"id":"r/a::N"}],"edges":[],"total_nodes":1,"total_edges":0}`})
dead := fakeRemote(t, fakeRemoteOpts{indexed: true, toolSleep: 5 * time.Second, toolJSON: `{}`})
local := envelope(`{"nodes":[],"edges":[],"total_nodes":0,"total_edges":0}`)
out := testFederator().Augment(context.Background(), "find_usages", []byte(`{}`), local,
[]ServerEntry{{Slug: "ok", URL: ok.URL}, {Slug: "dead", URL: dead.URL}})
m := decodeFederated(t, out)
fed, _ := m["federation"].(map[string]any)
if q, _ := fed["remotes_queried"].([]any); len(q) != 2 {
t.Errorf("remotes_queried should list both, got %v", fed["remotes_queried"])
}
if f, _ := fed["remotes_failed"].([]any); len(f) != 1 {
t.Errorf("remotes_failed should list the dead one, got %v", fed["remotes_failed"])
}
if fed["note"] == nil {
t.Error("a human-readable note must be emitted when a remote fails")
}
}
// TestFederator_WarmingBucketed asserts a reachable-but-warming remote is
// bucketed, not counted as an empty success.
func TestFederator_WarmingBucketed(t *testing.T) {
warming := fakeRemote(t, fakeRemoteOpts{indexed: false, toolJSON: `{}`})
local := envelope(`{"nodes":[],"edges":[],"total_nodes":0,"total_edges":0}`)
out := testFederator().Augment(context.Background(), "find_usages", []byte(`{}`),
local, []ServerEntry{{Slug: "warm", URL: warming.URL}})
m := decodeFederated(t, out)
fed, _ := m["federation"].(map[string]any)
failed, _ := fed["remotes_failed"].([]any)
if len(failed) != 1 {
t.Fatalf("a warming remote must be in remotes_failed, got %v", fed["remotes_failed"])
}
if first, _ := failed[0].(map[string]any); first["reason"] != "warming" {
t.Errorf("reason should be warming, got %v", failed[0])
}
}
// TestFederator_IncompatibleSchemaRefused asserts a remote on an
// incompatible major schema is refused.
func TestFederator_IncompatibleSchemaRefused(t *testing.T) {
incompatible := fakeRemote(t, fakeRemoteOpts{indexed: true, schema: 99, toolJSON: `{"nodes":[{"id":"r/a::N"}]}`})
local := envelope(`{"nodes":[],"edges":[],"total_nodes":0,"total_edges":0}`)
out := testFederator().Augment(context.Background(), "find_usages", []byte(`{}`),
local, []ServerEntry{{Slug: "newer", URL: incompatible.URL}})
m := decodeFederated(t, out)
fed, _ := m["federation"].(map[string]any)
failed, _ := fed["remotes_failed"].([]any)
if len(failed) != 1 {
t.Fatalf("incompatible schema must be refused, got %v", fed["remotes_failed"])
}
if first, _ := failed[0].(map[string]any); first["reason"] != "incompatible_schema" {
t.Errorf("reason should be incompatible_schema, got %v", failed[0])
}
}
// TestFederator_PerToolShapes asserts the ranked-list (search_symbols)
// and impl-list (find_implementations) adapters merge by their own key.
func TestFederator_PerToolShapes(t *testing.T) {
t.Run("search_symbols", func(t *testing.T) {
remote := fakeRemote(t, fakeRemoteOpts{indexed: true, toolJSON: `{"results":[{"id":"r/a::N","name":"N"}],"total":1}`})
local := envelope(`{"results":[{"id":"l/a::M","name":"M"}],"total":1}`)
out := testFederator().Augment(context.Background(), "search_symbols", []byte(`{}`),
local, []ServerEntry{{Slug: "r2", URL: remote.URL}})
m := decodeFederated(t, out)
results, _ := m["results"].([]any)
if len(results) != 2 {
t.Fatalf("ranked list should concat to 2, got %d", len(results))
}
if toInt(m["total"]) != 2 {
t.Errorf("totals should sum to 2, got %v", m["total"])
}
origins, _ := m["origins"].(map[string]any)
if origins["r/a::N"] != "remote:r2" || origins["l/a::M"] != "local" {
t.Errorf("origins wrong: %v", origins)
}
})
t.Run("find_implementations", func(t *testing.T) {
remote := fakeRemote(t, fakeRemoteOpts{indexed: true, toolJSON: `{"implementations":[{"id":"r/a::Impl"}],"total":1}`})
local := envelope(`{"implementations":[{"id":"l/a::Impl"}],"total":1}`)
out := testFederator().Augment(context.Background(), "find_implementations", []byte(`{}`),
local, []ServerEntry{{Slug: "r2", URL: remote.URL}})
m := decodeFederated(t, out)
if impls, _ := m["implementations"].([]any); len(impls) != 2 {
t.Fatalf("impl list should union to 2, got %d", len(impls))
}
})
}
// TestFederation_MultiRemoteConcurrent asserts the fan-out queries two
// remotes concurrently (both handlers entered before either returns) and
// never bulk-ingests /v1/graph.
func TestFederation_MultiRemoteConcurrent(t *testing.T) {
entered := make(chan struct{}, 2)
release := make(chan struct{})
mk := func(slug string) *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc("/v1/health", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"status": "ok", "indexed": true, "schema_version": localSchemaMajor, "api_version": 1})
})
mux.HandleFunc("/v1/graph", func(w http.ResponseWriter, r *http.Request) {
t.Error("federation must never bulk-ingest /v1/graph")
})
mux.HandleFunc("/v1/tools/", func(w http.ResponseWriter, r *http.Request) {
entered <- struct{}{}
<-release
_, _ = w.Write(envelope(`{"nodes":[{"id":"` + slug + `::N"}],"edges":[],"total_nodes":1,"total_edges":0}`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
r1, r2 := mk("r1"), mk("r2")
fed := NewFederator(FederationConfig{PerRemoteTimeout: 5 * time.Second, Budget: 10 * time.Second, HealthTTL: time.Millisecond},
func(e ServerEntry) (*ServerClient, error) { return NewServerClient(e) }, nil)
local := envelope(`{"nodes":[],"edges":[],"total_nodes":0,"total_edges":0}`)
done := make(chan []byte, 1)
go func() {
done <- fed.Augment(context.Background(), "find_usages", []byte(`{}`), local,
[]ServerEntry{{Slug: "r1", URL: r1.URL}, {Slug: "r2", URL: r2.URL}})
}()
// If the fan-out were serial, only one handler would enter while the
// other blocks — this would deadlock. Reading both proves concurrency.
timeout := time.After(8 * time.Second)
for i := 0; i < 2; i++ {
select {
case <-entered:
case <-timeout:
t.Fatal("remotes were queried serially, not concurrently")
}
}
close(release)
m := decodeFederated(t, <-done)
origins, _ := m["origins"].(map[string]any)
if origins["r1::N"] != "remote:r1" || origins["r2::N"] != "remote:r2" {
t.Errorf("both remotes' nodes should be tagged by origin, got %v", origins)
}
}
// TestFederator_WriteToolNeverFederated asserts a mutating tool is never
// fanned out even if (somehow) it reaches Augment.
func TestFederator_WriteToolNeverFederated(t *testing.T) {
remote := fakeRemote(t, fakeRemoteOpts{indexed: true, toolJSON: `{}`})
local := envelope(`{"ok":true}`)
for _, tool := range []string{"edit_file", "batch_edit"} {
out := testFederator().Augment(context.Background(), tool, []byte(`{}`),
local, []ServerEntry{{Slug: "r2", URL: remote.URL}})
if string(out) != string(local) {
t.Fatalf("%s must never be federated; response changed", tool)
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package daemon
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
// startWarmingTestServer brings up a daemon on a temp socket with the given
// optional Ready probe and returns the live socket path.
func startWarmingTestServer(t *testing.T, ready func() (bool, string)) string {
t.Helper()
dir, err := os.MkdirTemp("/tmp", "gx-warm")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(dir) })
socket := filepath.Join(dir, "s")
t.Setenv("GORTEX_DAEMON_SOCKET", socket)
t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(dir, "p"))
srv := New(socket, "test", zap.NewNop())
srv.Controller = &fakeController{}
srv.Ready = ready
require.NoError(t, srv.Listen())
go func() { _ = srv.Serve() }()
t.Cleanup(func() { _ = srv.Shutdown() })
require.Eventually(t, func() bool { return IsRunningAt(socket) },
2*time.Second, 10*time.Millisecond)
return socket
}
// TestHandshake_StampsWarmingState proves the daemon reports its warmup state
// on the handshake ack so a connecting proxy / CLI can tell a still-filling
// graph from a ready one instead of guessing (and dead-ending on empty).
func TestHandshake_StampsWarmingState(t *testing.T) {
t.Run("warming", func(t *testing.T) {
socket := startWarmingTestServer(t, func() (bool, string) {
return false, "global_resolve"
})
client, err := DialTo(socket, Handshake{Mode: ModeControl, ClientName: "cli"})
require.NoError(t, err)
defer client.Close()
assert.True(t, client.Ack.OK)
assert.True(t, client.Ack.Warming, "ack should flag a still-warming daemon")
assert.Equal(t, "global_resolve", client.Ack.WarmupPhase)
})
t.Run("ready", func(t *testing.T) {
socket := startWarmingTestServer(t, func() (bool, string) {
return true, "ready"
})
client, err := DialTo(socket, Handshake{Mode: ModeControl, ClientName: "cli"})
require.NoError(t, err)
defer client.Close()
assert.True(t, client.Ack.OK)
assert.False(t, client.Ack.Warming, "ack should not flag a ready daemon as warming")
assert.Equal(t, "ready", client.Ack.WarmupPhase)
})
t.Run("no probe assumes ready", func(t *testing.T) {
socket := startWarmingTestServer(t, nil)
client, err := DialTo(socket, Handshake{Mode: ModeControl, ClientName: "cli"})
require.NoError(t, err)
defer client.Close()
assert.True(t, client.Ack.OK)
assert.False(t, client.Ack.Warming, "a nil Ready probe means assume ready")
assert.Empty(t, client.Ack.WarmupPhase)
})
}
+86
View File
@@ -0,0 +1,86 @@
package daemon
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
)
// RemoteHealth is the daemon-side view of a remote's GET /v1/health
// advertisement. It mirrors the server's HealthResponse JSON but keeps
// ReadOnly as a *bool so the consumer can distinguish "advertised
// read_only:false" (writable) from "field absent" (an older remote that
// does not advertise) — the latter is treated as read-only (fail-safe).
//
// It lives here, not as a shared type, because internal/daemon must not
// import internal/server (the import direction is server -> daemon).
type RemoteHealth struct {
Status string `json:"status"`
Indexed bool `json:"indexed"`
Nodes int `json:"nodes"`
Edges int `json:"edges"`
Version string `json:"version"`
UptimeSeconds float64 `json:"uptime_seconds"`
SchemaVersion int `json:"schema_version"`
APIVersion int `json:"api_version"`
ReadOnly *bool `json:"read_only"`
Capabilities []string `json:"capabilities,omitempty"`
}
// EffectiveReadOnly reports whether the remote must be treated as
// read-only. An unadvertised read_only (nil) is read-only by fail-safe
// default, so an older remote that predates the field is never assumed
// writable.
func (h RemoteHealth) EffectiveReadOnly() bool {
return h.ReadOnly == nil || *h.ReadOnly
}
// HasCapability reports whether the remote advertised a named federation
// capability (e.g. "subgraph", "events").
func (h RemoteHealth) HasCapability(name string) bool {
for _, c := range h.Capabilities {
if c == name {
return true
}
}
return false
}
// FetchHealth reads the remote's GET /v1/health under the caller's ctx
// (so a slow remote is bounded by the same per-remote deadline the read
// fan-out uses). The bearer token is sent per call, like every other
// remote hop.
func (c *ServerClient) FetchHealth(ctx context.Context) (RemoteHealth, error) {
var out RemoteHealth
u, err := url.JoinPath(c.BaseURL, "v1", "health")
if err != nil {
return out, fmt.Errorf("join health URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return out, fmt.Errorf("build health request: %w", err)
}
if tok := c.resolveAuthToken(); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return out, fmt.Errorf("fetch health from %q: %w", c.Entry.Slug, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return out, fmt.Errorf("health from %q: status %d", c.Entry.Slug, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return out, fmt.Errorf("read health response: %w", err)
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("decode health from %q: %w", c.Entry.Slug, err)
}
return out, nil
}
+90
View File
@@ -0,0 +1,90 @@
package daemon
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// TestFetchHealth_ParsesAdvertisedFields asserts FetchHealth decodes the
// full advertisement, including a presence-aware read_only and the
// capability set.
func TestFetchHealth_ParsesAdvertisedFields(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"status":"ok","indexed":true,"nodes":10,"edges":20,` +
`"version":"1.0","schema_version":1,"api_version":1,"read_only":false,` +
`"capabilities":["events","subgraph"]}`))
}))
defer srv.Close()
cli, err := NewServerClient(ServerEntry{Slug: "r2", URL: srv.URL})
if err != nil {
t.Fatal(err)
}
h, err := cli.FetchHealth(context.Background())
if err != nil {
t.Fatalf("FetchHealth: %v", err)
}
if !h.Indexed {
t.Error("indexed should be true")
}
if h.ReadOnly == nil || *h.ReadOnly {
t.Errorf("read_only advertised false should decode to non-nil false, got %v", h.ReadOnly)
}
if h.EffectiveReadOnly() {
t.Error("a remote advertising read_only:false is writable")
}
if !h.HasCapability("subgraph") {
t.Error("subgraph capability should be detected")
}
if h.SchemaVersion != 1 || h.APIVersion != 1 {
t.Errorf("schema/api version: got %d/%d", h.SchemaVersion, h.APIVersion)
}
}
// TestFetchHealth_MissingReadOnlyTreatedRO asserts the fail-safe: a
// remote whose health payload omits read_only entirely (an older build)
// is treated as read-only, never assumed writable.
func TestFetchHealth_MissingReadOnlyTreatedRO(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Legacy payload: no read_only, no capabilities.
_, _ = w.Write([]byte(`{"status":"ok","indexed":true,"nodes":1,"edges":0,"version":"0.9"}`))
}))
defer srv.Close()
cli, err := NewServerClient(ServerEntry{Slug: "old", URL: srv.URL})
if err != nil {
t.Fatal(err)
}
h, err := cli.FetchHealth(context.Background())
if err != nil {
t.Fatalf("FetchHealth: %v", err)
}
if h.ReadOnly != nil {
t.Fatalf("absent read_only should decode to nil, got %v", *h.ReadOnly)
}
if !h.EffectiveReadOnly() {
t.Error("an unadvertised read_only must be treated as read-only (fail-safe)")
}
if h.HasCapability("subgraph") {
t.Error("a remote advertising no capabilities cannot have subgraph")
}
}
// TestFetchHealth_NonOKStatus asserts an unhealthy remote yields an error
// rather than a zero-value RemoteHealth that would look writable.
func TestFetchHealth_NonOKStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer srv.Close()
cli, err := NewServerClient(ServerEntry{Slug: "down", URL: srv.URL})
if err != nil {
t.Fatal(err)
}
if _, err := cli.FetchHealth(context.Background()); err == nil {
t.Fatal("a 503 health must surface an error")
}
}
+188
View File
@@ -0,0 +1,188 @@
package daemon
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
// echoDispatcher returns a canned JSON-RPC response whenever a frame
// comes in. Used to prove that (a) the daemon delivers frames to the
// dispatcher with the right session context, and (b) the dispatcher's
// reply round-trips back to the client over the socket. Swapping in a
// real *mcp.Server-backed dispatcher is the job of package main, not
// the daemon package itself — that's where the tool registry lives.
type echoDispatcher struct {
seen chan echoFrame
}
type echoFrame struct {
sessionID string
cwd string
frame string
}
func (e *echoDispatcher) Dispatch(_ context.Context, sess *Session, frame []byte) ([]byte, error) {
e.seen <- echoFrame{sessionID: sess.ID, cwd: sess.CWD, frame: string(frame)}
// Synthesize a well-formed JSON-RPC response so the proxy side can
// parse it without extra helpers.
resp := map[string]any{
"jsonrpc": "2.0",
"id": 1,
"result": map[string]any{"session_id": sess.ID, "echoed": string(frame)},
}
return json.Marshal(resp)
}
// TestDaemon_MCPRoundTrip verifies the full happy path: proxy dials in
// MCP mode, sends a frame, daemon delivers it to the dispatcher with
// session context, response flows back to the proxy.
func TestDaemon_MCPRoundTrip(t *testing.T) {
disp := &echoDispatcher{seen: make(chan echoFrame, 4)}
dir, err := os.MkdirTemp("/tmp", "gx")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(dir) }()
socket := filepath.Join(dir, "s")
t.Setenv("GORTEX_DAEMON_SOCKET", socket)
t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(dir, "p"))
srv := New(socket, "test", zap.NewNop())
srv.MCPDispatcher = disp
srv.Controller = &fakeController{}
require.NoError(t, srv.Listen())
go func() { _ = srv.Serve() }()
defer func() { _ = srv.Shutdown() }()
require.Eventually(t, func() bool { return IsRunningAt(socket) },
2*time.Second, 10*time.Millisecond)
client, err := DialTo(socket, Handshake{
Mode: ModeMCP,
CWD: "/tmp/fake-repo",
ClientName: "claude-code",
})
require.NoError(t, err)
defer client.Close()
require.NotEmpty(t, client.Ack.SessionID)
// Send a frame; proxy expects to read the paired response.
rpc := `{"jsonrpc":"2.0","id":1,"method":"graph_stats","params":{}}`
require.NoError(t, client.WriteMCPFrame([]byte(rpc)))
// Dispatcher saw the frame with the right session context.
select {
case f := <-disp.seen:
assert.Equal(t, client.Ack.SessionID, f.sessionID)
assert.Equal(t, "/tmp/fake-repo", f.cwd)
assert.Contains(t, f.frame, `"method":"graph_stats"`)
case <-time.After(2 * time.Second):
t.Fatal("dispatcher never received the frame")
}
// Response made it back through the socket. Read one line.
replyBytes, err := client.ReadMCPFrame()
require.NoError(t, err)
var reply map[string]any
require.NoError(t, json.Unmarshal(replyBytes, &reply))
result, ok := reply["result"].(map[string]any)
require.True(t, ok, "result must be an object: %v", reply)
assert.Equal(t, client.Ack.SessionID, result["session_id"])
}
// TestDaemon_MCPNoDispatcher verifies the daemon rejects MCP traffic
// cleanly when no dispatcher is attached — caller sees a JSON-RPC
// error frame rather than a hang or a broken connection.
func TestDaemon_MCPNoDispatcher(t *testing.T) {
dir, err := os.MkdirTemp("/tmp", "gx")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(dir) }()
socket := filepath.Join(dir, "s")
t.Setenv("GORTEX_DAEMON_SOCKET", socket)
t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(dir, "p"))
srv := New(socket, "test", zap.NewNop())
srv.Controller = &fakeController{}
// Intentionally: no MCPDispatcher.
require.NoError(t, srv.Listen())
go func() { _ = srv.Serve() }()
defer func() { _ = srv.Shutdown() }()
require.Eventually(t, func() bool { return IsRunningAt(socket) },
2*time.Second, 10*time.Millisecond)
client, err := DialTo(socket, Handshake{Mode: ModeMCP, CWD: "/tmp/x"})
require.NoError(t, err)
defer client.Close()
replyBytes, err := client.ReadMCPFrame()
require.NoError(t, err)
var reply map[string]any
require.NoError(t, json.Unmarshal(replyBytes, &reply))
errObj, ok := reply["error"].(map[string]any)
require.True(t, ok, "expected JSON-RPC error, got: %v", reply)
msg, _ := errObj["message"].(string)
assert.Contains(t, msg, "control-only")
}
// assertNoPanic keeps imports tidy for the fmt package when tests grow.
var _ = fmt.Sprintf
// hookDispatcher satisfies both MCPDispatcher and SessionEndedHook so we
// can prove the daemon fires the disconnect callback when a proxy closes.
type hookDispatcher struct {
ended chan string // receives the session ID that ended
}
func (h *hookDispatcher) Dispatch(_ context.Context, _ *Session, _ []byte) ([]byte, error) {
return nil, nil
}
func (h *hookDispatcher) SessionEnded(sess *Session) {
h.ended <- sess.ID
}
// TestDaemon_SessionEndedHook_FiresOnDisconnect pins the contract that
// dispatchers implementing SessionEndedHook get notified when a proxy
// closes its connection. Without this, per-session state allocated in
// the dispatcher would leak for the daemon's lifetime.
func TestDaemon_SessionEndedHook_FiresOnDisconnect(t *testing.T) {
dir, err := os.MkdirTemp("/tmp", "gx")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(dir) }()
socket := filepath.Join(dir, "s")
t.Setenv("GORTEX_DAEMON_SOCKET", socket)
t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(dir, "p"))
hook := &hookDispatcher{ended: make(chan string, 2)}
srv := New(socket, "test", zap.NewNop())
srv.MCPDispatcher = hook
srv.Controller = &fakeController{}
require.NoError(t, srv.Listen())
go func() { _ = srv.Serve() }()
defer func() { _ = srv.Shutdown() }()
require.Eventually(t, func() bool { return IsRunningAt(socket) },
2*time.Second, 10*time.Millisecond)
client, err := DialTo(socket, Handshake{Mode: ModeMCP, CWD: "/tmp/x"})
require.NoError(t, err)
sessionID := client.Ack.SessionID
require.NoError(t, client.Close())
select {
case got := <-hook.ended:
assert.Equal(t, sessionID, got,
"SessionEnded must receive the ID of the disconnected session")
case <-time.After(2 * time.Second):
t.Fatal("SessionEnded hook never fired after client close")
}
}
+53
View File
@@ -0,0 +1,53 @@
package daemon
import "sort"
// MutatingTools is the single source of truth for MCP tools that mutate
// state — files on disk, the graph store, or the project config. It is
// the verified superset of the three previously-disagreeing lists: the
// planning-mode editing set, the cloud proxy's write denylist, and the
// editing entries scattered through the eager-publish set.
//
// It is consulted by BOTH the planning-mode gate (which hides and
// hard-blocks these tools for a read-only session) and the federation
// write-gate (which refuses to route any of these to a remote in v1).
// A tool listed here is NEVER federated.
//
// Keep this list a superset: the parity test asserts every member of
// the legacy lists is present, so the consolidation can never silently
// shrink the deny-set.
var MutatingTools = map[string]bool{
// File / symbol editors.
"edit_file": true,
"edit_symbol": true,
"write_file": true,
"rename_symbol": true,
"batch_edit": true,
"move_symbol": true,
"inline_symbol": true,
"safe_delete_symbol": true,
"scaffold": true,
// Repo / project / scope mutators.
"index_repository": true,
"reindex_repository": true,
"track_repository": true,
"untrack_repository": true,
"set_active_project": true,
"delete_scope": true,
"save_scope": true,
}
// IsMutating reports whether a tool name mutates state.
func IsMutating(name string) bool { return MutatingTools[name] }
// SortedMutatingTools returns the canonical mutating-tool names in
// stable order — used where a deterministic list is surfaced to a
// client (e.g. set_planning_mode's response).
func SortedMutatingTools() []string {
out := make([]string, 0, len(MutatingTools))
for n := range MutatingTools {
out = append(out, n)
}
sort.Strings(out)
return out
}
+69
View File
@@ -0,0 +1,69 @@
package daemon
import "testing"
// legacyEditingToolNames mirrors the planning-mode editing set that used
// to live in internal/mcp (tools_mode.go::editingToolNames) before the
// consolidation. Duplicated here as a fixture so the parity test fails
// closed if the canonical set ever drops one of them.
var legacyEditingToolNames = []string{
"edit_file", "edit_symbol", "write_file", "rename_symbol",
}
// cloudMutatingDenied mirrors gortex-cloud internal/proxy.MutatingDenied
// (a separate repo, not buildable from here). Duplicated as a fixture so
// the canonical set can never silently shrink below the cloud denylist.
var cloudMutatingDenied = []string{
"edit_symbol", "batch_edit", "rename_symbol", "scaffold",
"index_repository", "track_repository", "untrack_repository",
"set_active_project", "edit_file", "write_file",
}
// TestMutatingTools_Superset asserts the canonical MutatingTools set is a
// superset of both legacy write-tool lists — the single source of truth
// the planning-mode gate and the federation write-gate both consult.
func TestMutatingTools_Superset(t *testing.T) {
for _, name := range legacyEditingToolNames {
if !MutatingTools[name] {
t.Errorf("MutatingTools is missing legacy editing tool %q", name)
}
if !IsMutating(name) {
t.Errorf("IsMutating(%q) should be true", name)
}
}
for _, name := range cloudMutatingDenied {
if !MutatingTools[name] {
t.Errorf("MutatingTools is missing cloud-denied tool %q", name)
}
}
}
// TestMutatingTools_ReadToolsExcluded guards against over-broad blocking:
// pure read traversal tools must never be classified as mutating, or the
// planning-mode gate and write-gate would block reads.
func TestMutatingTools_ReadToolsExcluded(t *testing.T) {
reads := []string{
"find_usages", "get_callers", "get_call_chain", "find_implementations",
"get_dependents", "search_symbols", "smart_context", "get_symbol_source",
"read_file", "graph_stats",
}
for _, name := range reads {
if IsMutating(name) {
t.Errorf("read tool %q must not be classified as mutating", name)
}
}
}
// TestSortedMutatingTools_Stable asserts the surfaced list is sorted and
// complete.
func TestSortedMutatingTools_Stable(t *testing.T) {
sorted := SortedMutatingTools()
if len(sorted) != len(MutatingTools) {
t.Fatalf("sorted list length %d != set size %d", len(sorted), len(MutatingTools))
}
for i := 1; i < len(sorted); i++ {
if sorted[i-1] >= sorted[i] {
t.Fatalf("not strictly sorted at %d: %q >= %q", i, sorted[i-1], sorted[i])
}
}
}
File diff suppressed because it is too large Load Diff
+251
View File
@@ -0,0 +1,251 @@
package daemon
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// TestOverlayManager_RegisterAndFiles is the happy-path round trip:
// register a session, push two overlays, list them via SnapshotFor.
// The snapshot must be path-sorted (the apply pass relies on stable
// ordering for reproducible drift errors) and must not alias the
// manager's internal map.
func TestOverlayManager_RegisterAndFiles(t *testing.T) {
m := NewOverlayManager(time.Minute)
id := m.Register("ws")
require.NotEmpty(t, id)
require.NoError(t, m.Push(id, OverlayFile{Path: "b.go", Content: "package b"}, nil))
require.NoError(t, m.Push(id, OverlayFile{Path: "a.go", Content: "package a"}, nil))
ws, files, err := m.SnapshotFor(id)
require.NoError(t, err)
require.Equal(t, "ws", ws)
require.Len(t, files, 2)
require.Equal(t, "a.go", files[0].Path, "snapshot must be path-sorted")
require.Equal(t, "b.go", files[1].Path)
// Snapshot must not alias the internal map.
files[0].Content = "mutated"
_, again, _ := m.SnapshotFor(id)
require.Equal(t, "package a", again[0].Content, "SnapshotFor must return a deep copy")
}
// TestOverlayManager_RegisterWithID_Idempotent verifies the MCP-side
// register flow: calling RegisterWithID twice for the same (sessionID,
// workspaceID) tuple is a no-op, but a workspace mismatch is rejected
// with ErrSessionExists. Without this contract the MCP overlay_register
// tool would have to teach every editor extension to track register
// state across reconnects.
func TestOverlayManager_RegisterWithID_Idempotent(t *testing.T) {
m := NewOverlayManager(time.Minute)
require.NoError(t, m.RegisterWithID("sess-1", "ws-a"))
require.NoError(t, m.RegisterWithID("sess-1", "ws-a"), "idempotent re-register must succeed")
err := m.RegisterWithID("sess-1", "ws-b")
require.ErrorIs(t, err, ErrSessionExists, "workspace mismatch must surface ErrSessionExists")
// Empty session ID is a programming error.
require.Error(t, m.RegisterWithID("", "ws-a"))
}
// TestOverlayManager_HasAndFileCount covers the dispatcher's fast-path
// gating: tools/call middleware bails before any apply work when
// Has==false or FileCount==0.
func TestOverlayManager_HasAndFileCount(t *testing.T) {
m := NewOverlayManager(time.Minute)
require.False(t, m.Has("unknown"))
require.Zero(t, m.FileCount("unknown"))
id := m.Register("ws")
require.True(t, m.Has(id))
require.Zero(t, m.FileCount(id), "freshly registered session has no files")
require.NoError(t, m.Push(id, OverlayFile{Path: "x.go", Content: "x"}, nil))
require.Equal(t, 1, m.FileCount(id))
m.Drop(id)
require.False(t, m.Has(id))
require.Zero(t, m.FileCount(id))
}
// TestOverlayManager_DriftCheck verifies that Push surfaces a drift
// error when the supplied BaseSHA disagrees with the on-disk SHA
// reported by the callback. Without drift detection two clients
// pushing stale overlays against the same path would silently corrupt
// each other's query results.
func TestOverlayManager_DriftCheck(t *testing.T) {
m := NewOverlayManager(time.Minute)
id := m.Register("ws")
// BaseSHA matches: push succeeds.
require.NoError(t, m.Push(id,
OverlayFile{Path: "x.go", Content: "x", BaseSHA: "abc"},
func(path, sha string) bool { return sha == "abc" },
))
// BaseSHA mismatches: ErrOverlayDrift.
err := m.Push(id,
OverlayFile{Path: "x.go", Content: "x", BaseSHA: "stale"},
func(path, sha string) bool { return sha == "abc" },
)
require.True(t, errors.Is(err, ErrOverlayDrift))
}
// TestOverlayManager_SweepIdleHonoursTTL ensures that sessions older
// than IdleTTL are reaped. A crashed editor extension leaving overlays
// in the daemon would otherwise pin memory until restart.
func TestOverlayManager_SweepIdleHonoursTTL(t *testing.T) {
m := NewOverlayManager(20 * time.Millisecond)
id := m.Register("ws")
require.True(t, m.Has(id))
time.Sleep(40 * time.Millisecond)
dropped := m.SweepIdle()
require.Equal(t, 1, dropped, "session past idleTTL must be reaped")
require.False(t, m.Has(id))
}
func TestOverlayManager_StartJanitorSweepsAndStops(t *testing.T) {
m := NewOverlayManager(20 * time.Millisecond)
id := m.Register("ws")
require.True(t, m.Has(id))
swept := make(chan int, 1)
stop := m.StartJanitor(10*time.Millisecond, func(dropped int) {
select {
case swept <- dropped:
default:
}
})
defer stop()
select {
case dropped := <-swept:
require.Equal(t, 1, dropped, "janitor must reap the idle session")
case <-time.After(2 * time.Second):
t.Fatal("janitor never swept the idle session")
}
require.False(t, m.Has(id))
// stop is idempotent and terminates the goroutine.
stop()
stop()
}
func TestOverlayManager_StartJanitorDisabledTTL(t *testing.T) {
m := NewOverlayManager(0)
stop := m.StartJanitor(time.Millisecond, func(int) {
t.Error("janitor must not run when expiry is disabled")
})
stop()
stop()
}
// TestOverlayManager_SnapshotForBumpsLastUsed proves the load-bearing
// "reads count as activity" guarantee: a session that only queries
// (no further Push) keeps its lease alive as long as the view-build
// path keeps calling SnapshotFor. Without this a long sequence of
// tool calls without intervening pushes would let the TTL trip the
// session in active use.
func TestOverlayManager_SnapshotForBumpsLastUsed(t *testing.T) {
m := NewOverlayManager(60 * time.Millisecond)
id := m.Register("ws")
require.NoError(t, m.Push(id, OverlayFile{Path: "x.go", Content: "x"}, nil))
// Two TTL/3 sleeps with a SnapshotFor in between — total span >
// TTL — but the read bumps LastUsed so the session survives.
time.Sleep(30 * time.Millisecond)
_, _, err := m.SnapshotFor(id)
require.NoError(t, err, "SnapshotFor must bump LastUsed")
time.Sleep(30 * time.Millisecond)
require.True(t, m.Has(id), "session must survive when reads bump LastUsed")
// Now truly idle: skip the read, sleep past TTL, sweep — should
// be reaped.
time.Sleep(80 * time.Millisecond)
dropped := m.SweepIdle()
require.Equal(t, 1, dropped)
require.False(t, m.Has(id))
}
// TestOverlayManager_TouchExtendsLease verifies the keepalive
// primitive: Touch refreshes LastUsed without altering files.
func TestOverlayManager_TouchExtendsLease(t *testing.T) {
m := NewOverlayManager(40 * time.Millisecond)
id := m.Register("ws")
require.NoError(t, m.Push(id, OverlayFile{Path: "y.go", Content: "y"}, nil))
time.Sleep(25 * time.Millisecond)
require.NoError(t, m.Touch(id))
time.Sleep(25 * time.Millisecond)
require.True(t, m.Has(id), "Touch must keep the session alive past the original TTL window")
// Touch on unknown session reports a structured error.
require.ErrorIs(t, m.Touch("nope"), ErrSessionNotFound)
}
// TestOverlayManager_StatusForDoesNotBumpLastUsed makes sure the
// liveness-query path is read-only: polling overlay_list every
// second must NOT extend the lease (otherwise a misconfigured
// editor could keep a dropped session alive forever just by
// polling).
func TestOverlayManager_StatusForDoesNotBumpLastUsed(t *testing.T) {
m := NewOverlayManager(40 * time.Millisecond)
id := m.Register("ws")
require.NoError(t, m.Push(id, OverlayFile{Path: "z.go", Content: "z"}, nil))
// Poll status every 10ms; total elapsed > TTL. Without
// LastUsed-bump on StatusFor, the sweeper still reaps.
for i := 0; i < 6; i++ {
_, err := m.StatusFor(id)
require.NoError(t, err)
time.Sleep(10 * time.Millisecond)
}
dropped := m.SweepIdle()
require.Equal(t, 1, dropped, "StatusFor must NOT bump LastUsed; sweep should reap the idle session")
require.False(t, m.Has(id))
}
// TestOverlayManager_StatusForReportsExpiryMetadata: overlay_list
// surfaces this metadata so editor extensions can schedule
// keepalive proactively.
func TestOverlayManager_StatusForReportsExpiryMetadata(t *testing.T) {
m := NewOverlayManager(2 * time.Minute)
id := m.Register("ws-alpha")
st, err := m.StatusFor(id)
require.NoError(t, err)
require.Equal(t, "ws-alpha", st.WorkspaceID)
require.False(t, st.Created.IsZero())
require.False(t, st.LastUsed.IsZero())
require.InDelta(t, 0, st.IdleSeconds, 1.0, "newly-registered session has near-zero idle time")
require.InDelta(t, 120, st.IdleTTLSeconds, 0.5)
require.False(t, st.ExpiresAt.IsZero())
require.True(t, st.ExpiresAt.After(time.Now()), "expires_at must be in the future")
}
// TestOverlayIdleTTLFromEnv covers the three branches of the
// configuration resolution: explicit override > env var > default.
// t.Setenv auto-restores the original value on test completion, so
// the test never leaks env state into sibling tests in the package.
func TestOverlayIdleTTLFromEnv(t *testing.T) {
// Explicit non-zero override wins over env.
t.Setenv("GORTEX_OVERLAY_IDLE_TTL", "1h")
require.Equal(t, 7*time.Minute, OverlayIdleTTLFromEnv(7*time.Minute))
// Env var (no override).
t.Setenv("GORTEX_OVERLAY_IDLE_TTL", "45m")
require.Equal(t, 45*time.Minute, OverlayIdleTTLFromEnv(0))
// Garbage env: fall through to default (don't fail startup).
t.Setenv("GORTEX_OVERLAY_IDLE_TTL", "garbage")
require.Equal(t, DefaultOverlayIdleTTL, OverlayIdleTTLFromEnv(0))
// Empty env (t.Setenv to "" is the documented way to model "unset"
// in a test-scoped way): default.
t.Setenv("GORTEX_OVERLAY_IDLE_TTL", "")
require.Equal(t, DefaultOverlayIdleTTL, OverlayIdleTTLFromEnv(0))
}
+197
View File
@@ -0,0 +1,197 @@
package daemon
import (
"fmt"
"hash/fnv"
"os"
"path/filepath"
"runtime"
"github.com/zzet/gortex/internal/platform"
)
// stateDir returns the directory the daemon keeps its runtime state in
// (socket, PID file, logs, snapshot) and whether it could be resolved.
//
// An absolute $XDG_CACHE_HOME is honoured on every platform. When it is
// unset the location stays at the historical default so an existing
// daemon state directory is not orphaned:
//
// - Windows: %USERPROFILE%\.gortex\cache (via os.UserCacheDir).
// - macOS / Linux: $HOME/.gortex/cache.
//
// The boolean is false when the home / cache directory can't be
// resolved at all, in which case callers fall back to the temp dir.
func stateDir() (string, bool) {
if runtime.GOOS == "windows" {
if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) {
if _, err := os.UserCacheDir(); err != nil {
return "", false
}
}
return platform.OSCacheDir(), true
}
if v := os.Getenv("XDG_CACHE_HOME"); v == "" || !filepath.IsAbs(v) {
if _, err := os.UserHomeDir(); err != nil {
return "", false
}
}
return platform.CacheDir(), true
}
// SocketPath returns the socket path the daemon listens on. The socket
// is an AF_UNIX socket on every supported OS — Windows has supported
// AF_UNIX since Windows 10 1803, so the same transport works there.
//
// Order of preference:
// 1. $GORTEX_DAEMON_SOCKET — explicit override (tests, custom deployments).
// 2. $XDG_RUNTIME_DIR/gortex.sock — Linux standard for user runtime files.
// This path is cleaned automatically on logout and has sensible perms.
// 3. The per-user state dir — $HOME/.gortex/cache on macOS/Linux,
// %USERPROFILE%\.gortex\cache on Windows.
//
// AF_UNIX socket paths have a length limit (~104 bytes on macOS, 108 on Linux
// and Windows). An auto-computed path that would exceed it — a deeply-nested
// home directory, a long $XDG_RUNTIME_DIR — is replaced by a short, stable
// temp-dir fallback (clampSocketPath) so the listener binds instead of failing.
// An explicit $GORTEX_DAEMON_SOCKET override is honoured verbatim: the user
// chose that path and gets a loud failure if it's too long, never a silent
// redirect.
func SocketPath() string {
if override := os.Getenv("GORTEX_DAEMON_SOCKET"); override != "" {
return override
}
return clampSocketPath(autoSocketPath())
}
// autoSocketPath computes the daemon's default socket path from the runtime /
// state directories, before any length clamping.
func autoSocketPath() string {
if rt := os.Getenv("XDG_RUNTIME_DIR"); rt != "" && runtime.GOOS == "linux" {
return filepath.Join(rt, "gortex.sock")
}
if dir, ok := stateDir(); ok {
return filepath.Join(dir, "daemon.sock")
}
// Fall back to the temp dir as a last resort; the daemon must start
// somewhere.
return filepath.Join(os.TempDir(), "gortex.sock")
}
// socketAddrMax is the AF_UNIX sun_path limit for the current OS: 104 bytes on
// macOS/BSD, 108 on Linux and Windows. A path whose length reaches this fails
// the bind, so we clamp strictly below it.
func socketAddrMax() int {
if runtime.GOOS == "darwin" {
return 104
}
return 108
}
// clampSocketPath returns p unchanged when it is short enough to bind, else a
// short temp-dir fallback derived from a stable hash of p — so two daemons that
// would have used different over-long paths still get distinct sockets.
func clampSocketPath(p string) string {
if len(p) < socketAddrMax() {
return p
}
h := fnv.New32a()
_, _ = h.Write([]byte(p))
return filepath.Join(os.TempDir(), fmt.Sprintf("gx-%08x.sock", h.Sum32()))
}
// PIDFilePath returns the path of the daemon PID file. The daemon writes
// this on startup and removes it on graceful shutdown. Staleness detection
// (for crashed daemons that never removed their PID) is a process-liveness
// probe — see platform.ProcessAlive.
func PIDFilePath() string {
if override := os.Getenv("GORTEX_DAEMON_PIDFILE"); override != "" {
return override
}
if dir, ok := stateDir(); ok {
return filepath.Join(dir, "daemon.pid")
}
return filepath.Join(os.TempDir(), "gortex-daemon.pid")
}
// LogFilePath returns the path the daemon writes logs to when running in
// --detach mode. In foreground mode stderr is used instead.
func LogFilePath() string {
if override := os.Getenv("GORTEX_DAEMON_LOGFILE"); override != "" {
return override
}
if dir, ok := stateDir(); ok {
return filepath.Join(dir, "daemon.log")
}
return filepath.Join(os.TempDir(), "gortex-daemon.log")
}
// SnapshotPath returns the legacy backend-agnostic snapshot path —
// `daemon.gob.gz` under the state dir. Kept for callers that haven't
// moved to backend-tagged storage yet (the legacy cloud indexer
// worker). The daemon itself routes through
// BackendSnapshotPath so a memory ↔ disk-backend switch can't read the
// other backend's snapshot — see that function's doc.
func SnapshotPath() string {
if override := os.Getenv("GORTEX_DAEMON_SNAPSHOT"); override != "" {
return override
}
if dir, ok := stateDir(); ok {
return filepath.Join(dir, "daemon.gob.gz")
}
return filepath.Join(os.TempDir(), "gortex-daemon.gob.gz")
}
// BackendSnapshotPath returns a backend-tagged snapshot path so the
// memory and disk backends use distinct files. The memory backend
// snapshot is a full gob+gzip of the in-memory graph; the disk
// backend snapshot is metadata-only (FileMtimes, contracts, vector
// index) because the graph itself lives in the on-disk store. Loading
// the memory backend's snapshot into a disk-backed daemon (or vice
// versa) silently produced wrong state — empty graph after disk→memory
// switch, decode-and-discard nodes after memory→disk — so a fresh
// daemon now picks the right file by backend tag.
//
// Empty backend tag falls back to SnapshotPath() so embedded callers
// that don't know the backend (the cloud indexer worker) keep working.
//
// GORTEX_DAEMON_SNAPSHOT overrides every backend tag — the override
// is an explicit "use exactly this path" signal.
func BackendSnapshotPath(backend string) string {
if override := os.Getenv("GORTEX_DAEMON_SNAPSHOT"); override != "" {
return override
}
tag := normalizeBackendTag(backend)
if tag == "" {
return SnapshotPath()
}
filename := "daemon-" + tag + ".gob.gz"
if dir, ok := stateDir(); ok {
return filepath.Join(dir, filename)
}
return filepath.Join(os.TempDir(), "gortex-"+filename)
}
// normalizeBackendTag canonicalizes a backend identifier into the
// short tag used in the snapshot filename — "memory" / "sqlite" /
// etc. Empty / unknown input returns the empty string so the caller
// can fall back to the legacy unsuffixed path.
func normalizeBackendTag(backend string) string {
switch backend {
case "memory", "mem", "in-memory":
return "memory"
case "sqlite", "sqlite3":
return "sqlite"
default:
return ""
}
}
// EnsureParentDir creates the parent directory of path with permissions
// 0o700 (user only). Daemon state files live under the user's cache dir
// and should not be world-readable. The mode is advisory on Windows,
// where filesystem ACLs already scope %USERPROFILE% to the user.
func EnsureParentDir(path string) error {
dir := filepath.Dir(path)
return os.MkdirAll(dir, 0o700)
}
+52
View File
@@ -0,0 +1,52 @@
package daemon
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestSocketPathClampFallback proves the AF_UNIX length clamp: a short path
// passes through, an over-long one is replaced by a short, stable, distinct
// temp-dir socket.
func TestSocketPathClampFallback(t *testing.T) {
short := filepath.Join(os.TempDir(), "gortex.sock")
if got := clampSocketPath(short); got != short {
t.Errorf("a short path must pass through unchanged; got %q", got)
}
long := "/" + strings.Repeat("a", 200) + "/daemon.sock"
got := clampSocketPath(long)
if len(got) >= socketAddrMax() {
t.Errorf("clamped path still too long (%d ≥ %d): %q", len(got), socketAddrMax(), got)
}
if !strings.HasSuffix(got, ".sock") {
t.Errorf("the fallback must be a .sock path; got %q", got)
}
if clampSocketPath(long) != got {
t.Error("the clamp must be deterministic (same input → same socket)")
}
long2 := "/" + strings.Repeat("b", 200) + "/daemon.sock"
if clampSocketPath(long2) == got {
t.Error("different over-long paths must map to distinct sockets")
}
}
// TestIdleTimeoutFromEnvParse covers the opt-in idle-timeout parsing: only a
// valid positive duration enables auto-exit; everything else stays disabled.
func TestIdleTimeoutFromEnvParse(t *testing.T) {
if parseIdleTimeout("") != 0 {
t.Error("empty must disable the idle timeout")
}
if parseIdleTimeout("garbage") != 0 {
t.Error("an unparseable value must disable the idle timeout")
}
if parseIdleTimeout("-5m") != 0 {
t.Error("a non-positive duration must disable the idle timeout")
}
if got := parseIdleTimeout("30m"); got != 30*time.Minute {
t.Errorf("parseIdleTimeout(30m) = %v, want 30m", got)
}
}
+67
View File
@@ -0,0 +1,67 @@
package daemon
import (
"os"
"path/filepath"
"strconv"
"testing"
)
// TestRunningPID covers the four states RunningPID must distinguish: no PID
// file, a live owner, a stale owner (process gone), and a corrupt file. The
// stale case is the load-bearing one — misreading a crashed daemon's leftover
// PID file as "running" would block every subsequent start.
func TestRunningPID(t *testing.T) {
pidPath := filepath.Join(t.TempDir(), "daemon.pid")
t.Setenv("GORTEX_DAEMON_PIDFILE", pidPath)
t.Run("no pid file", func(t *testing.T) {
if pid, ok := RunningPID(); ok {
t.Fatalf("want (0,false), got (%d,%v)", pid, ok)
}
})
t.Run("live owner", func(t *testing.T) {
writePID(t, pidPath, os.Getpid())
pid, ok := RunningPID()
if !ok || pid != os.Getpid() {
t.Fatalf("want (%d,true), got (%d,%v)", os.Getpid(), pid, ok)
}
})
t.Run("live owner with trailing newline", func(t *testing.T) {
// A pidfile written by `echo`/a process manager ends in "\n". The
// guard must still detect the live owner — otherwise a restart
// silently races the store lock again.
if err := os.WriteFile(pidPath, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if pid, ok := RunningPID(); !ok || pid != os.Getpid() {
t.Fatalf("want (%d,true), got (%d,%v)", os.Getpid(), pid, ok)
}
})
t.Run("stale owner", func(t *testing.T) {
// A PID well above any platform's pid_max — guaranteed not live.
writePID(t, pidPath, 1<<30)
if pid, ok := RunningPID(); ok {
t.Fatalf("stale pid must read as not running, got (%d,%v)", pid, ok)
}
})
t.Run("corrupt file", func(t *testing.T) {
if err := os.WriteFile(pidPath, []byte("not-a-pid"), 0o600); err != nil {
t.Fatal(err)
}
if pid, ok := RunningPID(); ok {
t.Fatalf("corrupt pid file must read as not running, got (%d,%v)", pid, ok)
}
})
}
func writePID(t *testing.T, path string, pid int) {
t.Helper()
if err := os.WriteFile(path, []byte(strconv.Itoa(pid)), 0o600); err != nil {
t.Fatal(err)
}
}
+556
View File
@@ -0,0 +1,556 @@
// 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
}
+71
View File
@@ -0,0 +1,71 @@
package daemon
import "context"
// RouteInputs is what a transport extracts from its inbound tools/call
// frame for routing: the tool name, the {"arguments":...} body
// RouteToolCall forwards verbatim, the session cwd, and an explicit
// workspace scope override (either may be empty).
type RouteInputs struct {
ToolName string
Body []byte
Cwd string
Scope string
}
// Outcome tells the caller how to frame a routed tool call. When Proxied
// is true the route resolved to a remote (or a gate refusal) and
// Out/Status carry the response bytes to frame for the transport; when
// Proxied is false the caller runs its own in-process local dispatch.
type Outcome struct {
Proxied bool
Out []byte
Status int
Err error // routing error (ErrRouteUnresolved etc.); caller may log
}
// ProxyDecision is the single peek→route→outcome helper shared by all
// three transports (the daemon AF_UNIX dispatcher, the /v1 HTTP handler,
// and the Streamable-HTTP transport). It owns the routing decision —
// computing the per-call effective enabled-set once and invoking
// RouteToolCall — while transport-specific frame parsing and RESPONSE
// FRAMING stay at each call site. The router is read through a dynamic
// accessor so a live ControlProxy swap is reflected.
type ProxyDecision struct {
router func() *Router
}
// NewProxyDecision builds a decision helper over a dynamic router
// accessor (typically an atomic.Pointer[Router].Load).
func NewProxyDecision(router func() *Router) *ProxyDecision {
return &ProxyDecision{router: router}
}
// Decide computes the effective enabled-set once (from the published
// roster overlaid with the session's overrides), threads it through
// RouteContext, runs RouteToolCall, and classifies the result. A nil
// router or an unresolved/local route yields Proxied=false (the caller
// dispatches locally); any resolved remote response or gate refusal
// yields Proxied=true with the bytes + status to frame.
func (d *ProxyDecision) Decide(ctx context.Context, in RouteInputs, sess *Session) Outcome {
r := d.router()
if r == nil {
return Outcome{}
}
sid := ""
if sess != nil {
sid = sess.ID
}
out, status, err := r.RouteToolCall(ctx, in.ToolName, in.Body, RouteContext{
Cwd: in.Cwd,
ScopeOverride: in.Scope,
SessionID: sid,
EnabledRemotes: r.EffectiveEnabledRemotes(sess),
})
if err != nil || status == 0 {
// ErrRouteUnresolved or a routing failure — fall through to the
// caller's local dispatch path (the same body works there).
return Outcome{Err: err}
}
return Outcome{Proxied: true, Out: out, Status: status}
}
+440
View File
@@ -0,0 +1,440 @@
package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"sync"
"sync/atomic"
"go.uber.org/zap"
)
// LocalServerSentinel is the Router's reserved identity for "this
// daemon's own in-process graph". It can never be a user slug:
// ServersConfig.Validate rejects any [[server]] claiming it, and the
// workspace resolver rejects any workspace slug equal to it (or
// containing ~ or /). Local identity is NO LONGER derived from
// DefaultServer().Slug — so a remote marked default=true is still
// proxied to, never mistaken for local.
const LocalServerSentinel = "@local"
// Router is the "hybrid-read query router". It takes a tool
// invocation plus an optional scope override, decides
// whether the request should run locally or be proxied to a remote
// server, and returns the result bytes the daemon hands back to its
// MCP client.
//
// The router is created once at daemon construction with three
// dependencies:
//
// - cfg: the parsed `~/.gortex/servers.toml`. nil when the daemon
// was started without a multi-server config; in that case
// RouteToolCall always uses the local executor.
// - rosters: the WorkspaceRosterCache. Populated lazily as
// workspaces are first looked up; survives across requests so we
// don't roundtrip on every query.
// - localSlug: the slug this daemon hosts itself. When the
// resolved server matches localSlug, the router calls localExec
// instead of proxying — this is the "hybrid" part. Empty disables
// the local-fast path; useful for tests and for daemons that only
// proxy.
//
// Server clients are created lazily on first use and cached; HTTP
// keep-alive in net/http reuses connections across calls.
// routerState is the swappable roster + cache the Router serves. It is
// held behind an atomic.Pointer so ControlProxy can rebuild it from a
// changed servers.toml and publish it without a restart and without a
// lock on the query hot path: an in-flight call loads the pointer once
// and keeps that snapshot even if a concurrent reload swaps in a fresh
// one (no torn router, no mid-call roster flip).
type routerState struct {
cfg *ServersConfig
rosters *WorkspaceRosterCache
}
type Router struct {
state atomic.Pointer[routerState]
resolveCwd CwdResolver
localSlug string
logger *zap.Logger
clientsMu sync.Mutex
clients map[string]*ServerClient
localExecute LocalExecutor
// federator augments a LOCAL read result with enabled remotes'
// results via the read-only fan-out. nil disables federation entirely.
federator *Federator
}
// LocalExecutor runs a tool against the local server. The daemon
// supplies one wrapping its in-process MCP server. Returning bytes
// (not a decoded struct) lets this stay independent of the mcp-go
// types and matches what ProxyTool yields, so the caller treats both
// paths identically.
type LocalExecutor func(ctx context.Context, toolName string, body []byte) ([]byte, int, error)
// RouterConfig bundles construction inputs.
type RouterConfig struct {
Servers *ServersConfig
Rosters *WorkspaceRosterCache
CwdResolver CwdResolver
LocalSlug string
LocalExecute LocalExecutor
Logger *zap.Logger
// Federation tunes the read-only fan-out. When the daemon builds a
// router it also builds a Federator over these knobs; a zero value
// uses the defaults. --oneshot passes no router and so no Federator.
Federation FederationConfig
}
// NewRouter constructs a Router with the given dependencies. nil
// Logger is replaced with a no-op to keep call sites tidy. nil
// CwdResolver defaults to DefaultCwdResolver.
func NewRouter(rc RouterConfig) *Router {
r := &Router{
resolveCwd: rc.CwdResolver,
localSlug: rc.LocalSlug,
logger: rc.Logger,
clients: make(map[string]*ServerClient),
localExecute: rc.LocalExecute,
}
r.state.Store(&routerState{cfg: rc.Servers, rosters: rc.Rosters})
if r.logger == nil {
r.logger = zap.NewNop()
}
if r.resolveCwd == nil {
r.resolveCwd = DefaultCwdResolver
}
r.federator = NewFederator(rc.Federation, r.clientFor, r.logger)
return r
}
// RouteContext is the per-call routing input. cwd flows from the
// MCP client (typically the user's pwd at the time of the
// invocation); scopeOverride is what the tool args carried (e.g.
// `workspace: "tuck"`). Either may be empty.
type RouteContext struct {
Cwd string
ScopeOverride string
// SessionID identifies the calling MCP session for the cross-daemon
// audit log (empty for the HTTP / sessionless paths).
SessionID string
// EnabledRemotes is the per-call snapshot of the effective
// enabled-set: the dialable roster entries that remain enabled
// after session overrides are applied over the global Enabled
// state, captured once per call by the dispatch site. The remote
// hop gates on it — a resolved remote not present here is refused
// with a structured "remote disabled" envelope (fail-closed),
// covering BOTH the explicit single-remote route and the
// federation fan-out. nil means "not pre-computed": the router
// falls back to its own global-only enabled-set so the gate is
// never accidentally bypassed.
EnabledRemotes []ServerEntry
}
// ErrRouteUnresolved is returned when no server can be picked for a
// request. The caller may surface this as a user-visible "no
// workspace declared and no default server set" error.
var ErrRouteUnresolved = errors.New("no server resolves for this request")
// RouteToolCall implements the priority chain:
//
// 1. If the resolved server slug is empty, the daemon has no remote
// servers configured: fall back to localExecute.
// 2. If the resolved slug equals localSlug, run locally.
// 3. Otherwise proxy to the remote server.
//
// On a cache miss with a non-empty workspace slug, the router also
// triggers a one-shot FetchWorkspaceRoster call so subsequent
// requests for the same workspace skip the discovery roundtrip.
//
// The body bytes passed through are whatever the caller wants to
// send to the tool — typically the JSON the MCP client posted to
// /v1/tools/<name>. The router does NOT re-marshal them.
func (r *Router) RouteToolCall(ctx context.Context, toolName string, body []byte, route RouteContext) ([]byte, int, error) {
st := r.state.Load()
if st == nil || st.cfg == nil {
// No multi-server config: only local makes sense.
return r.callLocal(ctx, toolName, body)
}
lookup := RouteForCwd(st.cfg, st.rosters, r.resolveCwd, route.Cwd, route.ScopeOverride)
r.logger.Debug("router: resolve",
zap.String("tool", toolName),
zap.String("workspace", lookup.Workspace),
zap.String("source", lookup.Source))
if lookup.Server == nil {
// We have a workspace but no server for it: try a roster
// fetch across every configured server so the next call
// resolves. If none claim it, fall through to local —
// better than failing outright when localSlug is the only
// runtime option.
if lookup.Workspace != "" && st.rosters != nil {
r.discoverRoster(lookup.Workspace)
st = r.state.Load()
lookup = RouteForCwd(st.cfg, st.rosters, r.resolveCwd, route.Cwd, route.ScopeOverride)
}
if lookup.Server == nil {
if r.localExecute != nil {
return r.callLocalFederated(ctx, toolName, body, route)
}
return nil, 0, fmt.Errorf("%w: workspace=%q", ErrRouteUnresolved, lookup.Workspace)
}
}
if lookup.Server.Slug == r.localSlug {
// Local fast path: the resolved slug is the reserved local
// sentinel (the daemon's own in-process graph). A roster row can
// never carry the sentinel, so a remote marked default=true now
// proxies correctly instead of being mistaken for local. The
// "no server resolves" case is already handled above by the
// lookup.Server == nil fall-through.
return r.callLocalFederated(ctx, toolName, body, route)
}
// Remote hop. Two gates fire here, BEFORE any client is built or
// any bearer token leaves the process, for BOTH explicit
// single-remote routing and (later) federation fan-out:
// 1. enabled-set gate — a disabled remote is never queried.
// 2. write-gate — a mutating tool never routes to any remote.
slug := lookup.Server.Slug
// Audit every remote-routed call (cross-daemon access record),
// emitted before the gates so a refusal is auditable too.
r.logger.Info("federation: remote-routed call",
zap.String("tool", toolName),
zap.String("target_slug", slug),
zap.String("cwd", route.Cwd),
zap.String("session_id", route.SessionID))
enabled := route.EnabledRemotes
if enabled == nil {
// Caller didn't pre-compute; fall back to the router's own
// global enabled-set so the gate is never silently bypassed.
enabled = r.EffectiveEnabledRemotes(nil)
}
if !remoteEnabledIn(enabled, slug) {
return remoteDisabledRefusal(slug)
}
if IsMutating(toolName) {
return remoteReadOnlyRefusal(toolName, slug)
}
cli, err := r.clientFor(*lookup.Server)
if err != nil {
return nil, 0, err
}
return cli.ProxyToolCtx(ctx, toolName, body)
}
// remoteEnabledIn reports whether slug is in the per-call enabled-set.
func remoteEnabledIn(enabled []ServerEntry, slug string) bool {
for i := range enabled {
if enabled[i].Slug == slug {
return true
}
}
return false
}
// remoteDisabledRefusal is the structured envelope returned when a route
// resolves to a remote the effective enabled-set excludes. Status 403 so
// the dispatch site frames it as a response, not a local fall-through;
// distinct error_code so a client tells "disabled" from "unreachable".
func remoteDisabledRefusal(slug string) ([]byte, int, error) {
b, _ := json.Marshal(map[string]any{
"error": "remote_disabled",
"error_code": "remote_disabled",
"slug": slug,
"message": fmt.Sprintf("remote %q is disabled; run `gortex proxy on %s` to enable it", slug, slug),
})
return b, http.StatusForbidden, nil
}
// remoteReadOnlyRefusal is the structured envelope returned when a
// mutating tool resolves to a remote. In v1 no write ever routes to a
// remote, regardless of flags. Fires before any outbound HTTP.
func remoteReadOnlyRefusal(tool, slug string) ([]byte, int, error) {
b, _ := json.Marshal(map[string]any{
"error": "remote_read_only",
"error_code": "remote_read_only",
"tool": tool,
"target_slug": slug,
"message": fmt.Sprintf("%q is a write tool and remote %q is read-only — writes never route to a remote", tool, slug),
})
return b, http.StatusForbidden, nil
}
// callLocal invokes the local executor or returns an error if the
// router was constructed without one. The caller's ctx is forwarded
// verbatim so per-session values attached upstream (notably
// `mcp.WithSessionID`) reach the tool handler — discarding ctx here
// would let local-fast-path tool calls run under context.Background
// and lose every per-session signal (client name → default wire
// format, in-flight cancellation, deadlines, etc.).
func (r *Router) callLocal(ctx context.Context, toolName string, body []byte) ([]byte, int, error) {
if r.localExecute == nil {
return nil, 0, fmt.Errorf("%w: no local executor wired", ErrRouteUnresolved)
}
return r.localExecute(ctx, toolName, body)
}
// callLocalFederated runs the local executor and, for an allowlisted
// read tool, augments the result with the enabled remotes' results via
// the read-only fan-out — the post-step that lives on the LOCAL path
// only. The verbatim remote-route path is never federated (no fan-out
// recursion).
func (r *Router) callLocalFederated(ctx context.Context, toolName string, body []byte, route RouteContext) ([]byte, int, error) {
out, status, err := r.callLocal(ctx, toolName, body)
if err != nil || r.federator == nil {
return out, status, err
}
// Carry the caller's cwd + session id so the fan-out audit records the
// same access tuple as the single-remote proxy route.
ctx = withAuditInfo(ctx, route.Cwd, route.SessionID)
return r.federator.Augment(ctx, toolName, body, out, route.EnabledRemotes), status, nil
}
// clientFor returns the ServerClient for the given entry, building
// it on first use. The cache key is the slug; entries that share a
// slug are not allowed by ServersConfig.Validate so this is safe.
func (r *Router) clientFor(entry ServerEntry) (*ServerClient, error) {
r.clientsMu.Lock()
defer r.clientsMu.Unlock()
if c, ok := r.clients[entry.Slug]; ok {
return c, nil
}
c, err := NewServerClient(entry)
if err != nil {
return nil, err
}
r.clients[entry.Slug] = c
return c, nil
}
// discoverRoster walks every configured server and asks who hosts
// `workspace`. Caches the answer so the next routing call hits the
// cache. Errors are logged but not surfaced — the caller's lookup
// will simply not find a server and the fallback path takes over.
func (r *Router) discoverRoster(workspace string) {
st := r.state.Load()
if st == nil || st.cfg == nil || st.rosters == nil {
return
}
for _, slug := range st.cfg.AllSlugs() {
entry := st.cfg.FindBySlug(slug)
if entry == nil {
continue
}
cli, err := r.clientFor(*entry)
if err != nil {
r.logger.Warn("router: build client failed",
zap.String("slug", slug),
zap.Error(err))
continue
}
repos, err := cli.FetchWorkspaceRoster(workspace)
if err != nil {
if errors.Is(err, ErrWorkspaceNotFound) {
st.rosters.SetNotFound(slug, workspace)
continue
}
r.logger.Debug("router: roster fetch failed",
zap.String("slug", slug),
zap.String("workspace", workspace),
zap.Error(err))
continue
}
st.rosters.Set(slug, workspace, repos)
return
}
}
// CurrentConfig returns the roster the router is currently serving (the
// last-published servers.toml). The returned pointer is a read-only
// snapshot — callers must not mutate it. nil when the router holds no
// roster.
func (r *Router) CurrentConfig() *ServersConfig {
if r == nil {
return nil
}
st := r.state.Load()
if st == nil {
return nil
}
return st.cfg
}
// ReloadConfig atomically swaps in a freshly-parsed roster + a fresh
// roster cache, invalidates the old cache, and drops the cached server
// clients so a removed or re-pointed remote is never reused. Applied
// live by ControlProxy with no restart and no lock on the query hot
// path; an in-flight RouteToolCall keeps the snapshot it already loaded.
func (r *Router) ReloadConfig(cfg *ServersConfig, rosters *WorkspaceRosterCache) {
if r == nil {
return
}
old := r.state.Swap(&routerState{cfg: cfg, rosters: rosters})
if old != nil && old.rosters != nil {
old.rosters.Invalidate()
}
r.clientsMu.Lock()
r.clients = make(map[string]*ServerClient)
r.clientsMu.Unlock()
}
// EffectiveEnabledRemotes computes the per-call enabled-set: the
// dialable roster entries that remain enabled after a session's
// overrides are applied over the global Enabled state. Fail-closed —
// only enabled entries are returned, and a session override naming a
// slug no longer in the roster is ignored (the builder iterates the
// published roster only). Reads off the published roster; never
// re-parses servers.toml.
func (r *Router) EffectiveEnabledRemotes(sess *Session) []ServerEntry {
cfg := r.CurrentConfig()
if cfg == nil {
return nil
}
var ov map[string]bool
if sess != nil {
ov = sess.RemoteOverrides()
}
out := make([]ServerEntry, 0, len(cfg.Server))
for _, s := range cfg.Server {
on := s.IsEnabled()
if v, set := ov[s.Slug]; set {
on = v
}
if on {
out = append(out, s)
}
}
return out
}
// EncodeJSON is a small helper for callers that want to marshal a
// scope override + tool args into the body bytes RouteToolCall
// expects. Round-trippable on the proxy side because the local
// server's POST /v1/tools/<name> handler is the same Mux route the
// MCP client originally hit.
func EncodeJSON(v any) ([]byte, error) {
if v == nil {
return []byte("{}"), nil
}
return json.Marshal(v)
}
// LookupForCwd exposes RouteForCwd against the router's own
// servers.toml + roster cache + cwd resolver. Callers (notably the
// daemon's MCP dispatcher) use this to decide whether a session's
// cwd has any chance of being routable — locally OR remotely —
// before applying their own "is this cwd tracked?" gate. Returns a
// zero LookupResult when the router has no servers.toml.
func (r *Router) LookupForCwd(cwd, scopeOverride string) LookupResult {
if r == nil {
return LookupResult{}
}
st := r.state.Load()
if st == nil || st.cfg == nil {
return LookupResult{}
}
return RouteForCwd(st.cfg, st.rosters, r.resolveCwd, cwd, scopeOverride)
}
+105
View File
@@ -0,0 +1,105 @@
package daemon
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
// TestRouter_DefaultRemoteIsProxiedNotLocal is the core localSlug
// foot-gun fix: a remote marked default=true must be proxied, never
// mistaken for the daemon's own graph. Local identity is the reserved
// sentinel, which no roster entry can carry.
func TestRouter_DefaultRemoteIsProxiedNotLocal(t *testing.T) {
var localCalled atomic.Bool
remoteHit := make(chan struct{}, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case remoteHit <- struct{}{}:
default:
}
_, _ = w.Write([]byte(`{"from":"remote"}`))
}))
defer srv.Close()
cfg := &ServersConfig{Server: []ServerEntry{{Slug: "r2", URL: srv.URL, Default: true}}}
router := NewRouter(RouterConfig{
Servers: cfg,
Rosters: NewWorkspaceRosterCache(time.Minute),
CwdResolver: func(string) (string, bool) { return "", false },
LocalSlug: LocalServerSentinel,
LocalExecute: func(context.Context, string, []byte) ([]byte, int, error) {
localCalled.Store(true)
return []byte(`{"from":"local"}`), http.StatusOK, nil
},
})
out, status, err := router.RouteToolCall(context.Background(), "find_usages", []byte(`{}`), RouteContext{})
if err != nil {
t.Fatalf("RouteToolCall: %v", err)
}
if status != http.StatusOK {
t.Fatalf("status = %d", status)
}
if string(out) != `{"from":"remote"}` {
t.Fatalf("expected the remote result, got %s", out)
}
if localCalled.Load() {
t.Fatal("a default=true remote must be proxied, never executed locally")
}
select {
case <-remoteHit:
default:
t.Fatal("the remote endpoint was never hit")
}
}
// TestRouter_LocalOnlyCallsLocal asserts a daemon with no roster
// (cfg==nil) always runs locally.
func TestRouter_LocalOnlyCallsLocal(t *testing.T) {
var localCalled atomic.Bool
router := NewRouter(RouterConfig{
LocalSlug: LocalServerSentinel,
LocalExecute: func(context.Context, string, []byte) ([]byte, int, error) {
localCalled.Store(true)
return []byte(`{"from":"local"}`), http.StatusOK, nil
},
})
if _, _, err := router.RouteToolCall(context.Background(), "find_usages", []byte(`{}`), RouteContext{}); err != nil {
t.Fatalf("RouteToolCall: %v", err)
}
if !localCalled.Load() {
t.Fatal("a local-only daemon must call the local executor")
}
}
// TestValidate_RejectsLocalSentinelSlug asserts a roster cannot claim
// the reserved local sentinel as a server slug.
func TestValidate_RejectsLocalSentinelSlug(t *testing.T) {
cfg := &ServersConfig{Server: []ServerEntry{{Slug: LocalServerSentinel, URL: "https://x:4747"}}}
if err := cfg.Validate(); err == nil {
t.Fatal("Validate must reject a [[server]] claiming the local sentinel slug")
}
}
// TestScanWorkspaceField_RejectsSentinelAndPathChars asserts the
// workspace-resolver peek degrades a sentinel-colliding or
// path-containing workspace slug to "no workspace", closing the
// foot-gun from the workspace side.
func TestScanWorkspaceField_RejectsSentinelAndPathChars(t *testing.T) {
cases := []struct{ in, want string }{
{"workspace: @local\n", ""},
{"workspace: a/b\n", ""},
{"workspace: ~home\n", ""},
{"workspace: tuck\n", "tuck"},
{"workspace: \"quoted\"\n", "quoted"},
}
for _, c := range cases {
if got := scanWorkspaceField([]byte(c.in)); got != c.want {
t.Errorf("scanWorkspaceField(%q) = %q, want %q", c.in, got, c.want)
}
}
}
+202
View File
@@ -0,0 +1,202 @@
package daemon
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
func boolp(b bool) *bool { return &b }
// recordingRemote returns an httptest server plus a flag that flips when
// it is hit, so a test can assert a gate fired BEFORE any outbound call.
func recordingRemote(t *testing.T) (*httptest.Server, *bool) {
t.Helper()
hit := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hit = true
_, _ = w.Write([]byte(`{"from":"remote"}`))
}))
t.Cleanup(srv.Close)
return srv, &hit
}
// TestRouter_DisabledRemoteRefused asserts an explicit route to a
// disabled remote returns the structured remote_disabled refusal and
// never sends a request to the remote.
func TestRouter_DisabledRemoteRefused(t *testing.T) {
srv, hit := recordingRemote(t)
cfg := &ServersConfig{Server: []ServerEntry{{Slug: "r2", URL: srv.URL, Default: true, Enabled: boolp(false)}}}
router := NewRouter(RouterConfig{
Servers: cfg,
Rosters: NewWorkspaceRosterCache(time.Minute),
CwdResolver: func(string) (string, bool) { return "", false },
LocalSlug: LocalServerSentinel,
LocalExecute: func(context.Context, string, []byte) ([]byte, int, error) {
t.Error("local executor must not run for an explicit disabled-remote route")
return nil, 0, nil
},
})
out, status, err := router.RouteToolCall(context.Background(), "find_usages", []byte(`{}`), RouteContext{})
if err != nil {
t.Fatalf("RouteToolCall: %v", err)
}
if status != http.StatusForbidden {
t.Fatalf("disabled remote should refuse with 403, got %d", status)
}
var env map[string]any
_ = json.Unmarshal(out, &env)
if env["error_code"] != "remote_disabled" {
t.Fatalf("expected remote_disabled, got %v", env["error_code"])
}
if *hit {
t.Fatal("the disabled remote must never be contacted")
}
}
// TestRouter_WriteGateRefusesRemote asserts a mutating tool routed to an
// enabled remote is refused before any outbound HTTP.
func TestRouter_WriteGateRefusesRemote(t *testing.T) {
srv, hit := recordingRemote(t)
cfg := &ServersConfig{Server: []ServerEntry{{Slug: "r2", URL: srv.URL, Default: true}}}
router := NewRouter(RouterConfig{
Servers: cfg,
Rosters: NewWorkspaceRosterCache(time.Minute),
CwdResolver: func(string) (string, bool) { return "", false },
LocalSlug: LocalServerSentinel,
LocalExecute: func(context.Context, string, []byte) ([]byte, int, error) {
return []byte(`{}`), 200, nil
},
})
for _, tool := range []string{"edit_file", "batch_edit", "rename_symbol", "track_repository"} {
out, status, err := router.RouteToolCall(context.Background(), tool, []byte(`{}`), RouteContext{})
if err != nil {
t.Fatalf("%s: %v", tool, err)
}
if status != http.StatusForbidden {
t.Fatalf("%s to a remote should refuse with 403, got %d", tool, status)
}
var env map[string]any
_ = json.Unmarshal(out, &env)
if env["error_code"] != "remote_read_only" {
t.Fatalf("%s: expected remote_read_only, got %v", tool, env["error_code"])
}
}
if *hit {
t.Fatal("a write tool must never reach a remote")
}
}
// TestRouter_LocalWriteAllowed asserts the write-gate is remote-only: a
// mutating tool that resolves locally runs normally.
func TestRouter_LocalWriteAllowed(t *testing.T) {
ran := false
// No roster => everything resolves local.
router := NewRouter(RouterConfig{
Servers: &ServersConfig{},
LocalExecute: func(context.Context, string, []byte) ([]byte, int, error) {
ran = true
return []byte(`{"ok":true}`), 200, nil
},
})
if _, _, err := router.RouteToolCall(context.Background(), "edit_file", []byte(`{}`), RouteContext{}); err != nil {
t.Fatalf("local write: %v", err)
}
if !ran {
t.Fatal("a local write must run (the gate is remote-only)")
}
}
// TestRouter_EffectiveEnabledRemotes covers the precedence rules: global
// Enabled, session override (both directions), fail-closed for absent,
// and ignore a session override for a slug not in the roster.
func TestRouter_EffectiveEnabledRemotes(t *testing.T) {
cfg := &ServersConfig{Server: []ServerEntry{
{Slug: "r2", URL: "https://r2:4747"}, // global on (nil)
{Slug: "r3", URL: "https://r3:4747", Enabled: boolp(false)}, // global off
}}
router := NewRouter(RouterConfig{Servers: cfg, LocalSlug: LocalServerSentinel})
slugs := func(es []ServerEntry) map[string]bool {
m := map[string]bool{}
for _, e := range es {
m[e.Slug] = true
}
return m
}
// No session: r2 on, r3 off.
g := slugs(router.EffectiveEnabledRemotes(nil))
if !g["r2"] || g["r3"] {
t.Fatalf("global: want {r2}, got %v", g)
}
// Session disables r2 (beats global on) and enables r3 (beats global off).
sess := &Session{ID: "a"}
sess.SetRemoteOverride("r2", false)
sess.SetRemoteOverride("r3", true)
sess.SetRemoteOverride("ghost", true) // not in roster — must be ignored
s := slugs(router.EffectiveEnabledRemotes(sess))
if s["r2"] {
t.Error("session off must beat global on")
}
if !s["r3"] {
t.Error("session on must beat global off")
}
if s["ghost"] {
t.Error("a session override for a slug not in the roster must be ignored")
}
}
// TestRouter_ReloadConfig_ConcurrentNoRace hammers ReloadConfig against
// concurrent routing + reads; the race detector must stay clean and no
// call may panic on a torn router.
func TestRouter_ReloadConfig_ConcurrentNoRace(t *testing.T) {
router := NewRouter(RouterConfig{
Servers: &ServersConfig{},
Rosters: NewWorkspaceRosterCache(time.Minute),
LocalExecute: func(context.Context, string, []byte) ([]byte, int, error) {
return []byte(`{}`), 200, nil
},
})
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 200; j++ {
switch (i + j) % 3 {
case 0:
cfg := &ServersConfig{Server: []ServerEntry{{Slug: "r2", URL: "https://r2:4747"}}}
router.ReloadConfig(cfg, NewWorkspaceRosterCache(time.Minute))
case 1:
_, _, _ = router.RouteToolCall(context.Background(), "find_usages", []byte(`{}`), RouteContext{})
default:
_ = router.CurrentConfig()
_ = router.EffectiveEnabledRemotes(nil)
}
}
}(i)
}
wg.Wait()
}
// TestRouter_ReloadConfig_ReflectsNewRoster asserts a swap is observed by
// the next call.
func TestRouter_ReloadConfig_ReflectsNewRoster(t *testing.T) {
router := NewRouter(RouterConfig{Servers: &ServersConfig{}, LocalSlug: LocalServerSentinel})
if got := len(router.CurrentConfig().Server); got != 0 {
t.Fatalf("initial roster should be empty, got %d", got)
}
router.ReloadConfig(&ServersConfig{Server: []ServerEntry{{Slug: "r2", URL: "https://r2:4747"}}}, NewWorkspaceRosterCache(time.Minute))
if got := len(router.CurrentConfig().Server); got != 1 {
t.Fatalf("after reload roster should hold 1, got %d", got)
}
if e := router.EffectiveEnabledRemotes(nil); len(e) != 1 || e[0].Slug != "r2" {
t.Fatalf("enabled set should reflect the reloaded roster, got %v", e)
}
}
+206
View File
@@ -0,0 +1,206 @@
package daemon
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"go.uber.org/zap"
)
// TestRouter_LocalFastPath: when the resolved server slug equals the
// router's localSlug, RouteToolCall must invoke the local executor
// instead of dialing out. This is the common case for a single-host
// developer setup with one declared default server.
func TestRouter_LocalFastPath(t *testing.T) {
cfg := &ServersConfig{
Server: []ServerEntry{
{Slug: "local", URL: "unix:///tmp/local.sock", Default: true},
},
}
calledLocal := false
r := NewRouter(RouterConfig{
Servers: cfg,
Rosters: NewWorkspaceRosterCache(time.Minute),
LocalSlug: "local",
LocalExecute: func(_ context.Context, name string, _ []byte) ([]byte, int, error) {
calledLocal = true
if name != "search_symbols" {
t.Fatalf("expected search_symbols, got %q", name)
}
return []byte(`{"ok":true}`), 200, nil
},
Logger: zap.NewNop(),
})
out, status, err := r.RouteToolCall(context.Background(), "search_symbols", []byte(`{}`), RouteContext{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !calledLocal {
t.Fatal("local executor was not called")
}
if status != 200 {
t.Fatalf("status = %d, want 200", status)
}
if string(out) != `{"ok":true}` {
t.Fatalf("body = %q", out)
}
}
// TestRouter_LocalFastPath_CtxPropagates: callLocal must forward the
// caller's ctx verbatim. A previous regression replaced ctx with
// context.Background() inside callLocal, which silently dropped every
// per-session value attached upstream — most visibly the
// `mcp.WithSessionID` value the daemon dispatcher uses to route a
// tool call to the right per-session state. With ctx lost, the
// session-default wire-format negotiation (claude-code → gcx) saw an
// empty session ID and fell through to JSON. This test pins the
// invariant: whatever ctx the caller passes, the local executor sees.
func TestRouter_LocalFastPath_CtxPropagates(t *testing.T) {
type ctxKey struct{}
cfg := &ServersConfig{
Server: []ServerEntry{
{Slug: "local", URL: "unix:///tmp/local.sock", Default: true},
},
}
var seen string
r := NewRouter(RouterConfig{
Servers: cfg,
Rosters: NewWorkspaceRosterCache(time.Minute),
LocalSlug: "local",
LocalExecute: func(ctx context.Context, _ string, _ []byte) ([]byte, int, error) {
if v, ok := ctx.Value(ctxKey{}).(string); ok {
seen = v
}
return []byte(`{}`), 200, nil
},
Logger: zap.NewNop(),
})
want := "session-marker"
ctx := context.WithValue(context.Background(), ctxKey{}, want)
if _, _, err := r.RouteToolCall(ctx, "search_symbols", []byte(`{}`), RouteContext{}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if seen != want {
t.Fatalf("local executor saw ctx value %q, want %q — ctx was discarded", seen, want)
}
}
// TestRouter_ProxyToRemote: when the scope override targets a slug
// that is NOT the localSlug, the router must proxy to the upstream
// server's POST /v1/tools/<name>. The upstream is a httptest server
// returning a synthetic body; the router's response must contain it
// verbatim.
func TestRouter_ProxyToRemote(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Fatalf("expected POST, got %s", r.Method)
}
if !strings.HasSuffix(r.URL.Path, "/v1/tools/search_symbols") {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"ok":true,"server":"remote"}`)
}))
defer upstream.Close()
cfg := &ServersConfig{
Server: []ServerEntry{
{Slug: "remote", URL: upstream.URL, Workspaces: []string{"tuck"}},
{Slug: "local", URL: "unix:///tmp/local.sock", Default: true},
},
}
r := NewRouter(RouterConfig{
Servers: cfg,
Rosters: NewWorkspaceRosterCache(time.Minute),
LocalSlug: "local",
LocalExecute: func(_ context.Context, _ string, _ []byte) ([]byte, int, error) {
t.Fatal("local executor should not have been called for remote scope")
return nil, 0, nil
},
Logger: zap.NewNop(),
})
out, status, err := r.RouteToolCall(context.Background(), "search_symbols", []byte(`{}`), RouteContext{
ScopeOverride: "tuck",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != 200 {
t.Fatalf("status = %d, want 200", status)
}
var body map[string]any
if err := json.Unmarshal(out, &body); err != nil {
t.Fatalf("decode body: %v", err)
}
if body["server"] != "remote" {
t.Fatalf("body did not come from upstream: %v", body)
}
}
// TestRouter_CwdResolution: a session whose cwd's `.gortex.yaml`
// declares `workspace: tuck` must route to the server whose
// `workspaces` list claims tuck.
func TestRouter_CwdResolution(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"ok":true}`)
}))
defer upstream.Close()
cwd := t.TempDir()
if err := os.WriteFile(filepath.Join(cwd, ".gortex.yaml"), []byte("workspace: tuck\n"), 0o644); err != nil {
t.Fatalf("write yaml: %v", err)
}
cfg := &ServersConfig{
Server: []ServerEntry{
{Slug: "remote", URL: upstream.URL, Workspaces: []string{"tuck"}},
{Slug: "local", URL: "unix:///tmp/local.sock", Default: true},
},
}
r := NewRouter(RouterConfig{
Servers: cfg,
Rosters: NewWorkspaceRosterCache(time.Minute),
LocalSlug: "local",
LocalExecute: func(_ context.Context, _ string, _ []byte) ([]byte, int, error) {
t.Fatal("local executor should not have been called for cwd-resolved scope")
return nil, 0, nil
},
Logger: zap.NewNop(),
})
_, status, err := r.RouteToolCall(context.Background(), "search_symbols", []byte(`{}`), RouteContext{Cwd: cwd})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if status != 200 {
t.Fatalf("status = %d, want 200", status)
}
}
// TestRouter_NoConfig_LocalOnly: without a ServersConfig the router
// must always run locally. Validates the "single-server daemon"
// fallback path.
func TestRouter_NoConfig_LocalOnly(t *testing.T) {
called := false
r := NewRouter(RouterConfig{
LocalExecute: func(_ context.Context, _ string, _ []byte) ([]byte, int, error) {
called = true
return []byte(`{"ok":true}`), 200, nil
},
})
_, _, err := r.RouteToolCall(context.Background(), "x", []byte(`{}`), RouteContext{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !called {
t.Fatal("local executor was not called")
}
}
+849
View File
@@ -0,0 +1,849 @@
package daemon
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"os/signal"
"runtime"
"strconv"
"strings"
"sync"
"time"
"go.uber.org/zap"
"github.com/zzet/gortex/internal/platform"
)
// Server is the long-living Gortex daemon. It owns the Unix socket
// listener, the session registry, and the control-surface dispatcher.
// MCP traffic is plumbed through a ToolDispatcher that's injected at
// construction time — the daemon package deliberately doesn't depend
// on internal/mcp to keep the direction of imports clean.
type Server struct {
SocketPath string
Version string
Logger *zap.Logger
// Dispatcher handles MCP mode traffic (JSON-RPC 2.0) after handshake.
// A nil Dispatcher means this daemon is control-only — useful for
// tests and for early integration before the MCP passthrough lands.
MCPDispatcher MCPDispatcher
// Controller handles control-mode RPCs (track/untrack/reload/status/shutdown).
Controller Controller
// Ready, when set, reports whether the daemon has finished warmup and the
// current warmup phase. The result is surfaced on every successful
// handshake ack (HandshakeAck.Warming / WarmupPhase) so a connecting proxy
// or CLI knows the graph is still filling rather than guessing — a session
// that connects mid-warmup keeps working and self-heals as the graph fills.
// Optional: a nil probe means "assume ready" (control-only test servers).
Ready func() (ready bool, phase string)
// HTTPHandler, when non-nil, is mounted on a TCP listener at
// HTTPAddr alongside the unix-socket dispatcher. This is how the
// MCP 2026 Streamable HTTP transport reaches the daemon —
// internal/mcp/streamable.Transport plugs in here. Nil disables
// the HTTP face entirely; the unix-socket transport keeps
// working unchanged. HTTPAddr accepts standard net.Listen
// addresses; "127.0.0.1:7411" is the recommended default for a
// single-user dev box.
HTTPHandler http.Handler
HTTPAddr string
sessions *SessionRegistry
listener net.Listener
httpListener net.Listener
httpServer *http.Server
started time.Time
shutdown chan struct{}
doneOnce sync.Once
conns map[net.Conn]struct{}
connsMu sync.Mutex
}
// MCPDispatcher is implemented by whichever layer runs the MCP tool
// handlers. The daemon hands off one JSON-RPC frame at a time (raw bytes,
// newline-delimited) and the dispatcher returns the response bytes to
// write back. Session gives the dispatcher the per-client context it
// needs (scope, session-level state). Return an empty slice to suppress
// the response (notifications with no reply).
type MCPDispatcher interface {
Dispatch(ctx context.Context, sess *Session, frame []byte) ([]byte, error)
}
// SessionEndedHook is an optional extension that MCPDispatcher
// implementations can satisfy to get a disconnect callback. The daemon
// invokes it in the per-connection goroutine's defer, giving
// implementations a chance to release per-session state (e.g., the
// `*mcp.Server.sessions` map entry) so idle memory doesn't grow with
// total session-count-ever.
//
// Implementations must be fast and non-blocking — this fires during
// connection teardown.
type SessionEndedHook interface {
SessionEnded(sess *Session)
}
// Controller implements the daemon's control surface. Separated from
// MCPDispatcher so the two can evolve independently and so control-only
// tests don't need a full MCP stack.
type Controller interface {
Track(ctx context.Context, params TrackParams) (json.RawMessage, error)
Untrack(ctx context.Context, params UntrackParams) (json.RawMessage, error)
Reload(ctx context.Context) (json.RawMessage, error)
// ReloadServers re-reads servers.toml and atomically swaps the
// daemon's multi-server Router (building or tearing it down as the
// roster requires), then invalidates the roster cache — applying
// `gortex proxy on/off/add/remove` to a running daemon without a
// restart. Distinct from Reload, which reconciles tracked repos.
ReloadServers(ctx context.Context) (json.RawMessage, error)
Status(ctx context.Context) (StatusResponse, error)
// SearchSymbols is the cheap probe path used by external clients
// (Claude Code's Grep-redirect hook) that need a single short answer
// without setting up a full MCP session.
SearchSymbols(ctx context.Context, params SearchSymbolsParams) (SearchSymbolsResult, error)
// EnrichChurn runs the per-symbol / per-file churn enricher against
// the daemon's in-process graph. Exposed over the control surface so
// CLI invocations (and the post-commit / post-merge git hook) can
// trigger it without taking the on-disk store's write lock the daemon owns.
EnrichChurn(ctx context.Context, params EnrichChurnParams) (EnrichChurnResult, error)
// EnrichReleases runs the per-file release enricher against the
// daemon's in-process graph. Same routing rationale as
// EnrichChurn — keeps the on-disk store's write lock with the daemon.
EnrichReleases(ctx context.Context, params EnrichReleasesParams) (EnrichReleasesResult, error)
// EnrichBlame runs the git-blame authorship enricher against the
// daemon's in-process graph. Same routing rationale as EnrichChurn.
EnrichBlame(ctx context.Context, params EnrichBlameParams) (EnrichBlameResult, error)
// EnrichCoverage projects pre-parsed Go cover-profile segments onto
// the daemon's in-process graph. The CLI parses the profile so the
// daemon never reads the caller's filesystem.
EnrichCoverage(ctx context.Context, params EnrichCoverageParams) (EnrichCoverageResult, error)
// EnrichCochange mines co-change edges against the daemon's
// in-process graph. Same routing rationale as EnrichChurn.
EnrichCochange(ctx context.Context, params EnrichCochangeParams) (EnrichCochangeResult, error)
// Shutdown is invoked via the control surface and should return
// quickly; the daemon's actual shutdown work happens after the
// response is written.
Shutdown(ctx context.Context) error
}
// New builds a Server but does not start listening.
func New(socketPath, version string, logger *zap.Logger) *Server {
if logger == nil {
logger = zap.NewNop()
}
return &Server{
SocketPath: socketPath,
Version: version,
Logger: logger,
sessions: NewSessionRegistry(),
shutdown: make(chan struct{}),
conns: make(map[net.Conn]struct{}),
}
}
// Listen creates the socket, writes the PID file, and installs the
// shutdown-signal handlers for graceful shutdown. The socket permissions
// are 0o600 on Unix — the daemon is user-local and nothing else on the
// machine should reach it; on Windows, %USERPROFILE% ACLs scope it to
// the user instead.
func (s *Server) Listen() error {
if err := EnsureParentDir(s.SocketPath); err != nil {
return fmt.Errorf("ensure socket dir: %w", err)
}
// Remove stale socket file from a crashed previous run. If the daemon
// is actually running, the PID check below will catch it and abort.
_ = os.Remove(s.SocketPath)
if err := s.writePIDFile(); err != nil {
return fmt.Errorf("pid file: %w", err)
}
lc := &net.ListenConfig{}
l, err := lc.Listen(context.Background(), "unix", s.SocketPath)
if err != nil {
_ = os.Remove(PIDFilePath())
return fmt.Errorf("listen: %w", err)
}
// chmod the socket to user-only on Unix. Windows has no POSIX mode
// bits — the socket inherits the ACLs of %USERPROFILE%, which is
// already user-scoped — so skip it there.
if runtime.GOOS != "windows" {
if err := os.Chmod(s.SocketPath, 0o600); err != nil {
_ = l.Close()
return fmt.Errorf("chmod socket: %w", err)
}
}
s.listener = l
s.started = time.Now()
// Optional HTTP listener for the MCP 2026 Streamable transport.
// We bring it up alongside the unix-socket listener so both
// transports share the same shutdown / lifecycle plumbing. A
// listen failure here is fatal — running the unix-socket
// transport silently while HTTP is down would mask the operator
// misconfiguration that pointed clients at a port that never
// answered.
if s.HTTPHandler != nil && s.HTTPAddr != "" {
httpLn, herr := net.Listen("tcp", s.HTTPAddr)
if herr != nil {
_ = l.Close()
_ = os.Remove(PIDFilePath())
return fmt.Errorf("listen http: %w", herr)
}
s.httpListener = httpLn
s.httpServer = &http.Server{
Handler: s.HTTPHandler,
ReadHeaderTimeout: 10 * time.Second,
}
}
// Install signal handlers once the listener is live.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, platform.ShutdownSignals()...)
go func() {
<-sigCh
s.Logger.Info("daemon: received signal, shutting down")
_ = s.Shutdown()
}()
return nil
}
// Serve runs the accept loop. Blocks until Shutdown is called or the
// listener returns an unrecoverable error. When an HTTP listener was
// brought up by Listen it runs concurrently in its own goroutine; an
// HTTP-side failure pushes onto the same shutdown channel so the
// unix-socket loop tears down too.
func (s *Server) Serve() error {
if s.listener == nil {
return errors.New("daemon: Listen must be called before Serve")
}
if s.httpListener != nil && s.httpServer != nil {
go func() {
if err := s.httpServer.Serve(s.httpListener); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.Logger.Warn("daemon: http serve exited", zap.Error(err))
}
}()
s.Logger.Info("daemon: http listener active",
zap.String("addr", s.httpListener.Addr().String()))
}
s.Logger.Info("daemon: serving", zap.String("socket", s.SocketPath))
// Background hygiene: reap sessions whose client process died without a
// clean disconnect, and (opt-in) auto-exit after an idle window.
go s.runMaintenance()
var emfileBackoff time.Duration
for {
conn, err := s.listener.Accept()
if err != nil {
// listener closed during Shutdown — normal exit.
select {
case <-s.shutdown:
return nil
default:
}
if errors.Is(err, net.ErrClosed) {
return nil
}
// EMFILE means the process is out of file descriptors.
// Without backoff the loop spins, pinning a CPU and making
// the FD pressure even worse. The exponential ramp gives
// in-flight handlers time to release descriptors.
if isEMFILE(err) {
if emfileBackoff == 0 {
emfileBackoff = 5 * time.Millisecond
} else if emfileBackoff < time.Second {
emfileBackoff *= 2
}
s.Logger.Warn("daemon: accept failed, FD-starved — backing off",
zap.Error(err), zap.Duration("sleep", emfileBackoff))
select {
case <-time.After(emfileBackoff):
case <-s.shutdown:
return nil
}
continue
}
emfileBackoff = 0
s.Logger.Warn("daemon: accept failed", zap.Error(err))
continue
}
emfileBackoff = 0
s.trackConn(conn)
go s.handle(conn)
}
}
// deadPeerSweepInterval is how often runMaintenance reaps dead-peer sessions.
// A var so tests can shorten it.
var deadPeerSweepInterval = 30 * time.Second
// runMaintenance is the daemon's background hygiene loop: every
// deadPeerSweepInterval it sweeps sessions whose originating client process has
// died (platform.ProcessAlive), and — when GORTEX_DAEMON_IDLE_TIMEOUT is set —
// it shuts the daemon down after that long with no live sessions. Exits on the
// shutdown signal.
func (s *Server) runMaintenance() {
idle := IdleTimeoutFromEnv()
tick := deadPeerSweepInterval
if idle > 0 && idle/4 < tick {
tick = idle / 4 // sample often enough to honour a short idle window
}
if tick <= 0 {
tick = deadPeerSweepInterval
}
t := time.NewTicker(tick)
defer t.Stop()
var idleSince time.Time
for {
select {
case <-s.shutdown:
return
case <-t.C:
for _, sd := range s.sessions.SweepDead(platform.ProcessAlive) {
s.Logger.Info("daemon: swept dead session",
zap.String("session_id", sd.ID), zap.Int("client_pid", sd.ClientPID))
if sd.Conn != nil {
s.untrackConn(sd.Conn)
}
}
if idle <= 0 {
continue
}
if s.sessions.Count() > 0 {
idleSince = time.Time{}
continue
}
if idleSince.IsZero() {
idleSince = time.Now()
continue
}
if time.Since(idleSince) >= idle {
s.Logger.Info("daemon: idle timeout reached, shutting down",
zap.Duration("idle_timeout", idle))
_ = s.Shutdown()
return
}
}
}
}
// IdleTimeoutFromEnv reads the opt-in GORTEX_DAEMON_IDLE_TIMEOUT — a Go
// duration (e.g. "30m", "2h"). Returns 0 (disabled) when unset, empty, or
// unparseable, so the daemon only ever auto-exits when the user asked it to.
func IdleTimeoutFromEnv() time.Duration {
return parseIdleTimeout(os.Getenv("GORTEX_DAEMON_IDLE_TIMEOUT"))
}
func parseIdleTimeout(v string) time.Duration {
v = strings.TrimSpace(v)
if v == "" {
return 0
}
d, err := time.ParseDuration(v)
if err != nil || d <= 0 {
return 0
}
return d
}
// handle runs the per-connection lifecycle: handshake → dispatch loop →
// cleanup. Every exit path must remove the session and close the conn.
func (s *Server) handle(conn net.Conn) {
defer func() {
_ = conn.Close()
s.untrackConn(conn)
if sess := s.sessions.Remove(conn); sess != nil {
// Fire the optional disconnect hook so implementations can
// release per-session resources keyed by this ID.
if hook, ok := s.MCPDispatcher.(SessionEndedHook); ok && hook != nil {
hook.SessionEnded(sess)
}
s.Logger.Debug("daemon: session closed",
zap.String("session_id", sess.ID),
zap.String("client", sess.ClientName))
}
}()
reader := bufio.NewReader(conn)
sess, err := s.handshake(conn, reader)
if err != nil {
if errors.Is(err, io.EOF) {
// Liveness probe (daemon.IsRunningAt and friends): the peer
// dialed the socket and closed it without sending a handshake
// frame, so the first read returns a clean EOF. This is an
// expected "is the socket accepting?" knock, not a fault —
// keep it at Debug. A partially-written frame yields
// io.ErrUnexpectedEOF instead and still warns below.
s.Logger.Debug("daemon: connection closed before handshake", zap.Error(err))
} else {
s.Logger.Warn("daemon: handshake failed", zap.Error(err))
}
return
}
switch sess.Mode {
case ModeMCP:
s.serveMCP(conn, reader, sess)
case ModeControl:
s.serveControl(conn, reader, sess)
default:
s.Logger.Warn("daemon: unknown mode after handshake",
zap.String("mode", string(sess.Mode)))
}
}
// handshake reads one handshake frame, validates it, and replies with an
// ack. A rejected handshake writes an error ack then closes the connection.
func (s *Server) handshake(conn net.Conn, reader *bufio.Reader) (*Session, error) {
line, err := reader.ReadBytes('\n')
if err != nil {
return nil, fmt.Errorf("read handshake: %w", err)
}
var h Handshake
if err := json.Unmarshal(line, &h); err != nil {
_ = WriteJSONLine(conn, HandshakeAck{
ErrorCode: ErrInternal,
ErrorMsg: "invalid handshake json: " + err.Error(),
})
return nil, fmt.Errorf("parse handshake: %w", err)
}
if h.Version != ProtocolVersion {
_ = WriteJSONLine(conn, HandshakeAck{
ErrorCode: ErrProtocolMismatch,
ErrorMsg: fmt.Sprintf("daemon expects protocol %d, client sent %d",
ProtocolVersion, h.Version),
})
return nil, fmt.Errorf("protocol mismatch: %d vs %d", ProtocolVersion, h.Version)
}
if h.Mode != ModeMCP && h.Mode != ModeControl {
_ = WriteJSONLine(conn, HandshakeAck{
ErrorCode: ErrUnsupportedMode,
ErrorMsg: "mode must be 'mcp' or 'control'",
})
return nil, fmt.Errorf("unsupported mode: %q", h.Mode)
}
sess := s.sessions.Register(conn, h)
ack := HandshakeAck{
OK: true,
SessionID: sess.ID,
DaemonVersion: s.Version,
}
// Stamp warmup state so the client can tell a still-warming daemon from a
// ready one. The session is established either way — Warming is advisory.
if s.Ready != nil {
ready, phase := s.Ready()
ack.Warming = !ready
ack.WarmupPhase = phase
}
if err := WriteJSONLine(conn, ack); err != nil {
_ = s.sessions.Remove(conn)
return nil, fmt.Errorf("write ack: %w", err)
}
s.Logger.Debug("daemon: session established",
zap.String("session_id", sess.ID),
zap.String("mode", string(sess.Mode)),
zap.String("cwd", sess.CWD),
zap.String("client", sess.ClientName))
return sess, nil
}
// serveMCP pumps MCP JSON-RPC frames. Each line on the wire is a single
// message. The Dispatcher gets the raw frame + session context and
// returns the raw reply to write back. Nil reply = no response (the
// client sent a notification).
func (s *Server) serveMCP(conn net.Conn, reader *bufio.Reader, sess *Session) {
if s.MCPDispatcher == nil {
_ = WriteJSONLine(conn, map[string]any{
"jsonrpc": "2.0",
"error": map[string]any{
"code": -32000,
"message": "daemon started without MCP dispatcher; control-only mode",
},
"id": nil,
})
return
}
for {
line, err := reader.ReadBytes('\n')
if err != nil {
if !errors.Is(err, io.EOF) {
s.Logger.Debug("daemon: mcp read closed",
zap.String("session_id", sess.ID), zap.Error(err))
}
return
}
// Scanner-style: trim trailing newline but keep the payload as-is
// so the dispatcher sees valid JSON.
if n := len(line); n > 0 && line[n-1] == '\n' {
line = line[:n-1]
}
if len(line) == 0 {
continue
}
ctx := context.Background()
reply, err := s.MCPDispatcher.Dispatch(ctx, sess, line)
if err != nil {
s.Logger.Warn("daemon: dispatch error",
zap.String("session_id", sess.ID), zap.Error(err))
continue
}
if len(reply) == 0 {
continue
}
// The dispatcher returns a full JSON-RPC frame; re-append newline.
if _, werr := conn.Write(append(reply, '\n')); werr != nil {
s.Logger.Debug("daemon: mcp write failed",
zap.String("session_id", sess.ID), zap.Error(werr))
return
}
}
}
// serveControl drains ControlRequest messages, invokes the Controller,
// and writes paired ControlResponse messages.
func (s *Server) serveControl(conn net.Conn, reader *bufio.Reader, sess *Session) {
if s.Controller == nil {
_ = WriteJSONLine(conn, ControlResponse{
ErrorCode: ErrInternal,
ErrorMsg: "daemon started without controller",
})
return
}
for {
line, err := reader.ReadBytes('\n')
if err != nil {
return
}
var req ControlRequest
if err := json.Unmarshal(line, &req); err != nil {
_ = WriteJSONLine(conn, ControlResponse{
ErrorCode: ErrInternal,
ErrorMsg: "malformed request: " + err.Error(),
})
continue
}
resp := s.handleControl(sess, req)
if err := WriteJSONLine(conn, resp); err != nil {
return
}
if req.Kind == ControlShutdown && resp.OK {
// Give the client one more moment to flush the ack before the
// listener goes away, then stop.
go func() {
time.Sleep(100 * time.Millisecond)
_ = s.Shutdown()
}()
return
}
}
}
func (s *Server) handleControl(_ *Session, req ControlRequest) ControlResponse {
ctx := context.Background()
switch req.Kind {
case ControlTrack:
var p TrackParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.Track(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
return ControlResponse{OK: true, Result: result}
case ControlUntrack:
var p UntrackParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.Untrack(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
return ControlResponse{OK: true, Result: result}
case ControlProxy:
result, err := s.Controller.ReloadServers(ctx)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
return ControlResponse{OK: true, Result: result}
case ControlReload:
result, err := s.Controller.Reload(ctx)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
return ControlResponse{OK: true, Result: result}
case ControlStatus:
st, err := s.Controller.Status(ctx)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
// Daemon-level fields the controller doesn't know about.
st.Version = s.Version
st.PID = os.Getpid()
st.UptimeSeconds = int64(time.Since(s.started).Seconds())
st.SocketPath = s.SocketPath
st.Sessions = s.sessions.Count()
// Per-session detail (cwd, client name, connect time) for the
// status command's "sessions" block. The controller can't see
// these — sessions live on the daemon server, not the
// MultiIndexer — so we attach them here. Sorted newest-first
// so the list reads as "what's connected right now".
if all := s.sessions.All(); len(all) > 0 {
now := time.Now()
rows := make([]MCPSessionStatus, 0, len(all))
for _, sess := range all {
if sess == nil {
continue
}
name, version := sess.SnapshotClientInfo()
row := MCPSessionStatus{
ID: sess.ID,
Cwd: sess.CWD,
ClientName: name,
ClientVersion: version,
}
if !sess.StartedAt.IsZero() {
row.ConnectedSecs = int64(now.Sub(sess.StartedAt).Seconds())
}
rows = append(rows, row)
}
st.MCPSessions = rows
}
buf, _ := json.Marshal(st)
return ControlResponse{OK: true, Result: buf}
case ControlSearchSymbols:
var p SearchSymbolsParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.SearchSymbols(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
buf, err := json.Marshal(result)
if err != nil {
return controlErr(ErrInternal, "marshal search result: "+err.Error())
}
return ControlResponse{OK: true, Result: buf}
case ControlShutdown:
if err := s.Controller.Shutdown(ctx); err != nil {
return controlErr(ErrInternal, err.Error())
}
return ControlResponse{OK: true}
case ControlEnrichChurn:
var p EnrichChurnParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.EnrichChurn(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
buf, err := json.Marshal(result)
if err != nil {
return controlErr(ErrInternal, "marshal enrich_churn result: "+err.Error())
}
return ControlResponse{OK: true, Result: buf}
case ControlEnrichReleases:
var p EnrichReleasesParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.EnrichReleases(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
buf, err := json.Marshal(result)
if err != nil {
return controlErr(ErrInternal, "marshal enrich_releases result: "+err.Error())
}
return ControlResponse{OK: true, Result: buf}
case ControlEnrichBlame:
var p EnrichBlameParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.EnrichBlame(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
buf, err := json.Marshal(result)
if err != nil {
return controlErr(ErrInternal, "marshal enrich_blame result: "+err.Error())
}
return ControlResponse{OK: true, Result: buf}
case ControlEnrichCoverage:
var p EnrichCoverageParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.EnrichCoverage(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
buf, err := json.Marshal(result)
if err != nil {
return controlErr(ErrInternal, "marshal enrich_coverage result: "+err.Error())
}
return ControlResponse{OK: true, Result: buf}
case ControlEnrichCochange:
var p EnrichCochangeParams
if err := unmarshalParams(req.Params, &p); err != nil {
return controlErr(ErrInternal, err.Error())
}
result, err := s.Controller.EnrichCochange(ctx, p)
if err != nil {
return controlErr(ErrInternal, err.Error())
}
buf, err := json.Marshal(result)
if err != nil {
return controlErr(ErrInternal, "marshal enrich_cochange result: "+err.Error())
}
return ControlResponse{OK: true, Result: buf}
}
return controlErr(ErrInternal, "unknown control kind: "+req.Kind)
}
// Shutdown stops the accept loop, closes outstanding connections, and
// removes the socket and PID files. Safe to call multiple times.
func (s *Server) Shutdown() error {
var first error
s.doneOnce.Do(func() {
close(s.shutdown)
if s.listener != nil {
first = s.listener.Close()
}
// Tear down the HTTP listener with a short grace window so
// in-flight Streamable responses can finish flushing. We
// don't propagate the http error unless the unix-socket
// listener succeeded — the operator already sees a
// unix-socket close error in the same path.
if s.httpServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
if herr := s.httpServer.Shutdown(ctx); herr != nil && first == nil {
first = herr
}
cancel()
}
// Close all live conns so per-conn goroutines exit their read loops.
s.connsMu.Lock()
for c := range s.conns {
_ = c.Close()
}
s.connsMu.Unlock()
_ = os.Remove(s.SocketPath)
_ = os.Remove(PIDFilePath())
})
return first
}
// writePIDFile fails if a live daemon is already running, so starting
// twice is a loud "already running" error rather than a silent overwrite.
func (s *Server) writePIDFile() error {
path := PIDFilePath()
if err := EnsureParentDir(path); err != nil {
return err
}
if existing, err := os.ReadFile(path); err == nil {
if pid, _ := strconv.Atoi(string(existing)); pid > 0 {
if platform.ProcessAlive(pid) {
return fmt.Errorf("daemon already running (pid %d)", pid)
}
// Stale pid file — old daemon crashed without cleanup.
_ = os.Remove(path)
}
}
return os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())), 0o600)
}
// RunningPID reports the PID of a live daemon recorded in the PID file, or
// (0, false) when none is. Unlike IsRunning — which only probes the control
// socket — this still reports a daemon that is *mid-shutdown*: the
// ControlShutdown handler tears the listener down ~100ms after acking, but
// the process stays alive while it flushes and closes the store, and it
// holds the store's on-disk lock until it exits. That window is exactly what
// turned a quick restart into a "failed to open database" lock conflict, so
// callers that must not start a second daemon over the top of a dying one —
// or that need to wait for it to exit — consult this, not the socket.
//
// A PID file whose process is dead is stale (the owner crashed without
// cleanup) and reported as not-running, mirroring writePIDFile's own
// staleness handling.
func RunningPID() (int, bool) {
b, err := os.ReadFile(PIDFilePath())
if err != nil {
return 0, false
}
// TrimSpace so a PID file written with a trailing newline — by a shell
// `echo`, a process manager, or a hand edit — still parses. The daemon
// writes it without one, but tolerating both is free and the silent
// failure mode (guard never fires, restart races the lock again) is
// exactly the bug this helper exists to prevent.
pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
if err != nil || pid <= 0 {
return 0, false
}
if !platform.ProcessAlive(pid) {
return 0, false
}
return pid, true
}
func (s *Server) trackConn(c net.Conn) {
s.connsMu.Lock()
s.conns[c] = struct{}{}
s.connsMu.Unlock()
}
func (s *Server) untrackConn(c net.Conn) {
s.connsMu.Lock()
delete(s.conns, c)
s.connsMu.Unlock()
}
// Sessions exposes the registry for inspection (status command, tests).
func (s *Server) Sessions() *SessionRegistry { return s.sessions }
// StartedAt returns the time Listen() completed — used for uptime math.
func (s *Server) StartedAt() time.Time { return s.started }
// unmarshalParams decodes RawMessage into a typed struct, treating empty
// or null params as an empty struct (zero value) so callers don't need
// to special-case missing params.
func unmarshalParams(raw json.RawMessage, v any) error {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
return json.Unmarshal(raw, v)
}
func controlErr(code, msg string) ControlResponse {
return ControlResponse{ErrorCode: code, ErrorMsg: msg}
}
+402
View File
@@ -0,0 +1,402 @@
package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
// osStat is an alias so fileExists() reads linearly without import rearrangement.
var osStat = os.Stat
// fakeController is a Controller stub that records calls and returns
// canned responses — lets the daemon lifecycle tests run without wiring
// in the real MultiIndexer.
type fakeController struct {
mu sync.Mutex
trackCalls []TrackParams
untrackCalls []UntrackParams
reloadCalls int
reloadServersCalls int
statusCalls int
shutdownCalls int
shutdownErr error
searchCalls []SearchSymbolsParams
searchHits []SymbolHit
searchErr error
enrichChurnCalls []EnrichChurnParams
enrichReleasesCalls []EnrichReleasesParams
enrichBlameCalls []EnrichBlameParams
enrichCoverageCalls []EnrichCoverageParams
enrichCochangeCalls []EnrichCochangeParams
}
func (f *fakeController) Track(_ context.Context, p TrackParams) (json.RawMessage, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.trackCalls = append(f.trackCalls, p)
return json.RawMessage(fmt.Sprintf(`{"tracked":%q}`, p.Path)), nil
}
func (f *fakeController) Untrack(_ context.Context, p UntrackParams) (json.RawMessage, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.untrackCalls = append(f.untrackCalls, p)
return json.RawMessage(`{"untracked":true}`), nil
}
func (f *fakeController) Reload(_ context.Context) (json.RawMessage, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.reloadCalls++
return json.RawMessage(`{"reloaded":true}`), nil
}
func (f *fakeController) ReloadServers(_ context.Context) (json.RawMessage, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.reloadServersCalls++
return json.RawMessage(`{"servers":0,"router_wired":false}`), nil
}
func (f *fakeController) Status(_ context.Context) (StatusResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.statusCalls++
return StatusResponse{
TrackedRepos: []TrackedRepoStatus{
{Prefix: "myrepo", Path: "/tmp/myrepo", Files: 42, Nodes: 100, Edges: 200},
},
}, nil
}
func (f *fakeController) Shutdown(_ context.Context) error {
f.mu.Lock()
defer f.mu.Unlock()
f.shutdownCalls++
return f.shutdownErr
}
func (f *fakeController) SearchSymbols(_ context.Context, p SearchSymbolsParams) (SearchSymbolsResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.searchCalls = append(f.searchCalls, p)
if f.searchErr != nil {
return SearchSymbolsResult{}, f.searchErr
}
return SearchSymbolsResult{Hits: f.searchHits}, nil
}
func (f *fakeController) EnrichChurn(_ context.Context, p EnrichChurnParams) (EnrichChurnResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.enrichChurnCalls = append(f.enrichChurnCalls, p)
return EnrichChurnResult{Files: 1, Symbols: 2, Branch: p.Branch}, nil
}
func (f *fakeController) EnrichReleases(_ context.Context, p EnrichReleasesParams) (EnrichReleasesResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.enrichReleasesCalls = append(f.enrichReleasesCalls, p)
return EnrichReleasesResult{Files: 3, Branch: p.Branch}, nil
}
func (f *fakeController) EnrichBlame(_ context.Context, p EnrichBlameParams) (EnrichBlameResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.enrichBlameCalls = append(f.enrichBlameCalls, p)
return EnrichBlameResult{Nodes: 5}, nil
}
func (f *fakeController) EnrichCoverage(_ context.Context, p EnrichCoverageParams) (EnrichCoverageResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.enrichCoverageCalls = append(f.enrichCoverageCalls, p)
return EnrichCoverageResult{Symbols: 7, Segments: len(p.Segments)}, nil
}
func (f *fakeController) EnrichCochange(_ context.Context, p EnrichCochangeParams) (EnrichCochangeResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.enrichCochangeCalls = append(f.enrichCochangeCalls, p)
return EnrichCochangeResult{Edges: 11}, nil
}
// newDaemon spins up a Server on a short socket path + Fake controller.
// macOS limits Unix socket paths to ~104 chars (sizeof(sun_path)), and
// Go's t.TempDir() path can exceed that for long test names, so we mint
// our own short directory under /tmp/gx-<random>.
func newDaemon(t *testing.T, ctrl Controller) (*Server, string) {
t.Helper()
dir, err := os.MkdirTemp("/tmp", "gx")
require.NoError(t, err, "short tmp dir for unix socket")
t.Cleanup(func() { _ = os.RemoveAll(dir) })
socket := filepath.Join(dir, "s")
t.Setenv("GORTEX_DAEMON_SOCKET", socket)
t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(dir, "p"))
srv := New(socket, "test-0.0.0", zap.NewNop())
srv.Controller = ctrl
require.NoError(t, srv.Listen())
go func() { _ = srv.Serve() }()
// Wait until the socket is actually accepting connections.
require.Eventually(t, func() bool {
return IsRunningAt(socket)
}, 2*time.Second, 10*time.Millisecond, "daemon socket never came up")
t.Cleanup(func() { _ = srv.Shutdown() })
return srv, socket
}
func TestDaemon_ControlStatus(t *testing.T) {
ctrl := &fakeController{}
_, socket := newDaemon(t, ctrl)
c, err := DialTo(socket, Handshake{Mode: ModeControl, ClientName: "cli"})
require.NoError(t, err)
defer c.Close()
require.NotEmpty(t, c.Ack.SessionID, "ack must carry session id")
require.Equal(t, "test-0.0.0", c.Ack.DaemonVersion)
resp, err := c.Control(ControlStatus, nil)
require.NoError(t, err)
require.True(t, resp.OK, "status: %+v", resp)
var st StatusResponse
require.NoError(t, json.Unmarshal(resp.Result, &st))
assert.Equal(t, "test-0.0.0", st.Version)
assert.NotZero(t, st.PID)
assert.Equal(t, socket, st.SocketPath)
require.Len(t, st.TrackedRepos, 1)
assert.Equal(t, "myrepo", st.TrackedRepos[0].Prefix)
}
func TestDaemon_ControlTrackUntrack(t *testing.T) {
ctrl := &fakeController{}
_, socket := newDaemon(t, ctrl)
c, err := DialTo(socket, Handshake{Mode: ModeControl, ClientName: "cli"})
require.NoError(t, err)
defer c.Close()
trackResp, err := c.Control(ControlTrack, TrackParams{Path: "/tmp/myapp", Name: "myapp"})
require.NoError(t, err)
require.True(t, trackResp.OK)
assert.Contains(t, string(trackResp.Result), "/tmp/myapp")
untrackResp, err := c.Control(ControlUntrack, UntrackParams{PathOrPrefix: "myapp"})
require.NoError(t, err)
require.True(t, untrackResp.OK)
ctrl.mu.Lock()
defer ctrl.mu.Unlock()
require.Len(t, ctrl.trackCalls, 1)
assert.Equal(t, "/tmp/myapp", ctrl.trackCalls[0].Path)
require.Len(t, ctrl.untrackCalls, 1)
assert.Equal(t, "myapp", ctrl.untrackCalls[0].PathOrPrefix)
}
// TestDaemon_ControlEnrichDispatch exercises the control dispatch for
// every enrich verb — confirming each routes to the matching Controller
// method, round-trips its Params, and decodes the typed Result. This is
// the contract the `gortex enrich` CLI relies on when it forwards to a
// running daemon.
func TestDaemon_ControlEnrichDispatch(t *testing.T) {
ctrl := &fakeController{}
_, socket := newDaemon(t, ctrl)
c, err := DialTo(socket, Handshake{Mode: ModeControl, ClientName: "cli"})
require.NoError(t, err)
defer c.Close()
// churn
churnResp, err := c.Control(ControlEnrichChurn, EnrichChurnParams{Path: "/r", Branch: "main"})
require.NoError(t, err)
require.True(t, churnResp.OK, "churn: %+v", churnResp)
var churnOut EnrichChurnResult
require.NoError(t, json.Unmarshal(churnResp.Result, &churnOut))
assert.Equal(t, 1, churnOut.Files)
assert.Equal(t, 2, churnOut.Symbols)
assert.Equal(t, "main", churnOut.Branch)
// releases
relResp, err := c.Control(ControlEnrichReleases, EnrichReleasesParams{Path: "/r", Branch: "main"})
require.NoError(t, err)
require.True(t, relResp.OK, "releases: %+v", relResp)
var relOut EnrichReleasesResult
require.NoError(t, json.Unmarshal(relResp.Result, &relOut))
assert.Equal(t, 3, relOut.Files)
// blame
blameResp, err := c.Control(ControlEnrichBlame, EnrichBlameParams{Path: "/r"})
require.NoError(t, err)
require.True(t, blameResp.OK, "blame: %+v", blameResp)
var blameOut EnrichBlameResult
require.NoError(t, json.Unmarshal(blameResp.Result, &blameOut))
assert.Equal(t, 5, blameOut.Nodes)
// coverage
covResp, err := c.Control(ControlEnrichCoverage, EnrichCoverageParams{
Path: "/r",
Segments: []EnrichCoverageSegment{
{File: "a.go", StartLine: 1, EndLine: 3, NumStmt: 2, Count: 1},
{File: "a.go", StartLine: 4, EndLine: 6, NumStmt: 1, Count: 0},
},
})
require.NoError(t, err)
require.True(t, covResp.OK, "coverage: %+v", covResp)
var covOut EnrichCoverageResult
require.NoError(t, json.Unmarshal(covResp.Result, &covOut))
assert.Equal(t, 7, covOut.Symbols)
assert.Equal(t, 2, covOut.Segments)
// cochange
coResp, err := c.Control(ControlEnrichCochange, EnrichCochangeParams{Path: "/r"})
require.NoError(t, err)
require.True(t, coResp.OK, "cochange: %+v", coResp)
var coOut EnrichCochangeResult
require.NoError(t, json.Unmarshal(coResp.Result, &coOut))
assert.Equal(t, 11, coOut.Edges)
ctrl.mu.Lock()
defer ctrl.mu.Unlock()
require.Len(t, ctrl.enrichChurnCalls, 1)
assert.Equal(t, "/r", ctrl.enrichChurnCalls[0].Path)
require.Len(t, ctrl.enrichReleasesCalls, 1)
require.Len(t, ctrl.enrichBlameCalls, 1)
assert.Equal(t, "/r", ctrl.enrichBlameCalls[0].Path)
require.Len(t, ctrl.enrichCoverageCalls, 1)
assert.Len(t, ctrl.enrichCoverageCalls[0].Segments, 2)
require.Len(t, ctrl.enrichCochangeCalls, 1)
}
func TestDaemon_ProtocolMismatchRejected(t *testing.T) {
_, socket := newDaemon(t, &fakeController{})
// Bump the version so the daemon rejects us.
_, err := DialTo(socket, Handshake{Version: ProtocolVersion + 1, Mode: ModeControl})
require.Error(t, err)
assert.Contains(t, err.Error(), "protocol")
}
func TestDial_NoDaemon_ReturnsErrUnavailable(t *testing.T) {
dir, err := os.MkdirTemp("/tmp", "gx")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(dir) }()
missing := filepath.Join(dir, "missing")
_, err = DialTo(missing, Handshake{Mode: ModeControl})
require.Error(t, err)
assert.True(t, errors.Is(err, ErrDaemonUnavailable),
"Dial against missing socket must wrap ErrDaemonUnavailable; got %v", err)
}
func TestIsRunningAt(t *testing.T) {
dir, err := os.MkdirTemp("/tmp", "gx")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(dir) }()
missing := filepath.Join(dir, "nope")
assert.False(t, IsRunningAt(missing))
_, socket := newDaemon(t, &fakeController{})
assert.True(t, IsRunningAt(socket))
}
func TestDaemon_ConcurrentSessions(t *testing.T) {
// Multiple clients handshake simultaneously. Each must get a unique
// session ID and the daemon's session count must reflect them.
srv, socket := newDaemon(t, &fakeController{})
const N = 8
clients := make([]*Client, N)
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func(i int) {
defer wg.Done()
c, err := DialTo(socket, Handshake{Mode: ModeControl, ClientName: fmt.Sprintf("c%d", i)})
if err != nil {
t.Errorf("dial %d: %v", i, err)
return
}
clients[i] = c
}(i)
}
wg.Wait()
seen := make(map[string]bool)
for _, c := range clients {
require.NotNil(t, c)
require.NotEmpty(t, c.Ack.SessionID)
assert.False(t, seen[c.Ack.SessionID], "session_id collision: %s", c.Ack.SessionID)
seen[c.Ack.SessionID] = true
}
require.Eventually(t, func() bool {
return srv.Sessions().Count() == N
}, 2*time.Second, 10*time.Millisecond,
"daemon should see all %d sessions", N)
for _, c := range clients {
_ = c.Close()
}
require.Eventually(t, func() bool {
return srv.Sessions().Count() == 0
}, 2*time.Second, 10*time.Millisecond,
"sessions should drain after clients close")
}
func TestDaemon_ShutdownRemovesSocketAndPIDFile(t *testing.T) {
srv, socket := newDaemon(t, &fakeController{})
pidFile := PIDFilePath()
require.True(t, fileExists(socket))
require.True(t, fileExists(pidFile))
require.NoError(t, srv.Shutdown())
require.Eventually(t, func() bool {
return !fileExists(socket) && !fileExists(pidFile)
}, 2*time.Second, 10*time.Millisecond)
}
func TestDaemon_ShutdownViaControl(t *testing.T) {
ctrl := &fakeController{}
srv, socket := newDaemon(t, ctrl)
c, err := DialTo(socket, Handshake{Mode: ModeControl})
require.NoError(t, err)
resp, err := c.Control(ControlShutdown, nil)
require.NoError(t, err)
require.True(t, resp.OK)
_ = c.Close()
require.Eventually(t, func() bool { return !IsRunningAt(socket) },
2*time.Second, 10*time.Millisecond)
assert.Equal(t, 1, ctrl.shutdownCalls, "Controller.Shutdown must be invoked")
// Calling Shutdown again must be safe (idempotent).
require.NoError(t, srv.Shutdown())
}
// --- helpers ---
func fileExists(path string) bool {
_, err := osStat(path)
return err == nil
}
+751
View File
@@ -0,0 +1,751 @@
package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
toml "github.com/pelletier/go-toml/v2"
"github.com/zzet/gortex/internal/platform"
)
// ServerEntry describes one Gortex server reachable from the daemon.
// Lifted out of `~/.gortex/servers.toml`.
//
// The "slug" is the local identity used in CLI output, log lines, and
// daemon-side routing. URL accepts both TCP (`http://host:port`,
// `https://...`) and Unix-domain socket forms (`unix:///path/to.sock`).
//
// Auth: prefer AuthTokenEnv (an env-var name the daemon resolves at
// connect time) over AuthToken (a literal value). Putting raw
// secrets in `servers.toml` is allowed for parity with how
// `~/.gortex/config.yaml` already gets written by `gortex
// track`, but the env-var form is the recommended path.
//
// Workspaces is the optional pre-declared roster: when set, the
// daemon trusts this list without making the `GET
// /v1/workspaces/<ws>/repos` roundtrip on first use. Empty means
// "discover at runtime"; `WorkspaceRosterCache` falls back to
// querying the server for the roster on demand.
type ServerEntry struct {
Slug string `toml:"slug"`
URL string `toml:"url"`
AuthToken string `toml:"auth_token,omitempty"`
AuthTokenEnv string `toml:"auth_token_env,omitempty"`
Workspaces []string `toml:"workspaces,omitempty"`
// Default flips a single ServerEntry to the "use me when no
// workspace context disambiguates" pick. Conflict (multiple
// entries marked Default=true) is rejected at load time.
Default bool `toml:"default,omitempty"`
// Enabled gates whether the daemon federates/proxies to this
// remote. Pointer-bool so absent-in-TOML (nil) is distinguished
// from an explicit false: absent ⇒ enabled (default-on, so a
// pre-existing roster that has no `enabled` key keeps working).
// `gortex proxy off <slug>` writes `enabled = false`; `on` deletes
// the key (back to default-on) — it never writes `enabled = true`
// so a hand-edited roster without the key stays clean.
Enabled *bool `toml:"enabled,omitempty"`
// ReadOnly marks the remote as accepting only read tools; the
// federation write-gate refuses to route a mutating tool to it.
// In v1 every remote is effectively read-only regardless; this
// field is the persisted intent and the surface for
// `--read-only`. An unadvertised capability is treated as
// read-only at runtime (fail-safe).
ReadOnly bool `toml:"read_only,omitempty"`
// Namespace is the origin-namespaced node-ID keying prefix used
// when minting proxy nodes for this remote. Keying field only —
// reserved here so the struct is edited exactly once.
Namespace string `toml:"namespace,omitempty"`
}
// IsEnabled reports the effective enabled state of the remote: a nil
// Enabled (absent from TOML) means enabled, preserving backward
// compatibility with rosters written before the key existed.
func (e ServerEntry) IsEnabled() bool { return e.Enabled == nil || *e.Enabled }
// ServersConfig is the on-disk schema for `~/.gortex/servers.toml`.
type ServersConfig struct {
Server []ServerEntry `toml:"server"`
}
// ServersConfigPath returns the path the daemon reads / writes for
// the multi-server roster. Order of preference mirrors SocketPath:
//
// 1. $GORTEX_DAEMON_SERVERS — explicit override (tests, custom
// deployments).
// 2. $HOME/.gortex/servers.toml — the canonical user-level file.
// It lives in the unified `~/.gortex/` tree alongside the global
// `config.yaml`, the same place tracking scripts and `gortex
// daemon` already write to. An absolute $XDG_CONFIG_HOME relocates
// this to <XDG_CONFIG_HOME>/gortex/servers.toml.
// 3. $TEMPDIR/gortex-servers.toml — last-resort fallback so the
// daemon can still come up in an environment with no $HOME.
func ServersConfigPath() string {
if override := os.Getenv("GORTEX_DAEMON_SERVERS"); override != "" {
return override
}
if _, err := os.UserHomeDir(); err != nil {
if v := os.Getenv("XDG_CONFIG_HOME"); v == "" || !filepath.IsAbs(v) {
return filepath.Join(os.TempDir(), "gortex-servers.toml")
}
}
return filepath.Join(platform.ConfigDir(), "servers.toml")
}
// LoadServersConfig reads and validates ~/.gortex/servers.toml. A
// missing file is not an error — the daemon may run with zero
// configured servers (e.g. before a user sets one up). Empty/zero
// ServersConfig is returned in that case.
//
// Validation rejects:
// - duplicate slugs
// - empty URLs
// - URLs that aren't `http(s)://...` or `unix://...`
// - more than one ServerEntry marked Default=true
func LoadServersConfig(path string) (*ServersConfig, error) {
if path == "" {
path = ServersConfigPath()
}
cfg := &ServersConfig{}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return cfg, nil
}
return nil, fmt.Errorf("read servers config %s: %w", path, err)
}
if err := toml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("parse servers config %s: %w", path, err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("validate servers config %s: %w", path, err)
}
return cfg, nil
}
// Save writes the config back to `path` (or ServersConfigPath() when
// empty) atomically: marshal to TOML, write to a sibling temp file,
// fsync, rename into place. The parent directory is created with 0700
// and the file with 0600 — matching the daemon's existing convention
// for files that can hold auth tokens.
//
// Validate is run first; an invalid config is never persisted.
func (c *ServersConfig) Save(path string) error {
if c == nil {
return fmt.Errorf("nil ServersConfig")
}
if err := c.Validate(); err != nil {
return err
}
if path == "" {
path = ServersConfigPath()
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return fmt.Errorf("create dir %s: %w", dir, err)
}
data, err := toml.Marshal(c)
if err != nil {
return fmt.Errorf("marshal servers config: %w", err)
}
tmp, err := os.CreateTemp(dir, "servers-*.toml.tmp")
if err != nil {
return fmt.Errorf("create temp file in %s: %w", dir, err)
}
tmpPath := tmp.Name()
cleanup := func() { _ = os.Remove(tmpPath) }
if _, err := tmp.Write(data); err != nil {
_ = tmp.Close()
cleanup()
return fmt.Errorf("write temp file %s: %w", tmpPath, err)
}
if err := tmp.Sync(); err != nil {
_ = tmp.Close()
cleanup()
return fmt.Errorf("fsync %s: %w", tmpPath, err)
}
if err := tmp.Close(); err != nil {
cleanup()
return fmt.Errorf("close %s: %w", tmpPath, err)
}
if err := os.Chmod(tmpPath, 0o600); err != nil {
cleanup()
return fmt.Errorf("chmod %s: %w", tmpPath, err)
}
if err := os.Rename(tmpPath, path); err != nil {
cleanup()
return fmt.Errorf("rename %s -> %s: %w", tmpPath, path, err)
}
return nil
}
// AddServer appends an entry, rejecting on duplicate slug or any other
// invariant violation (so callers can rely on Validate succeeding
// after a successful AddServer). Setting Default=true on an entry
// when another is already default is rejected here rather than
// auto-flipping the existing one — the explicit error keeps the
// "exactly one default" rule visible to the user.
func (c *ServersConfig) AddServer(entry ServerEntry) error {
if c == nil {
return fmt.Errorf("nil ServersConfig")
}
if entry.Slug == "" {
return fmt.Errorf("slug is required")
}
if c.FindBySlug(entry.Slug) != nil {
return fmt.Errorf("server %q already exists", entry.Slug)
}
c.Server = append(c.Server, entry)
if err := c.Validate(); err != nil {
// Roll back so the in-memory config matches what would have
// been persisted.
c.Server = c.Server[:len(c.Server)-1]
return err
}
return nil
}
// RemoveServer drops the entry with the given slug. Returns false
// (no error) when the slug isn't found — matches the typical CLI
// idempotency expectation. Returns an error only on a nil receiver.
func (c *ServersConfig) RemoveServer(slug string) (bool, error) {
if c == nil {
return false, fmt.Errorf("nil ServersConfig")
}
for i := range c.Server {
if c.Server[i].Slug == slug {
c.Server = append(c.Server[:i], c.Server[i+1:]...)
return true, nil
}
}
return false, nil
}
// Validate enforces the ServersConfig invariants.
func (c *ServersConfig) Validate() error {
if c == nil {
return nil
}
seenSlug := make(map[string]bool, len(c.Server))
defaultCount := 0
for i, s := range c.Server {
if s.Slug == "" {
return fmt.Errorf("server[%d]: slug is required", i)
}
if s.Slug == LocalServerSentinel {
return fmt.Errorf("server[%d]: slug %q is reserved for the local daemon's own graph", i, s.Slug)
}
if seenSlug[s.Slug] {
return fmt.Errorf("server[%d]: duplicate slug %q", i, s.Slug)
}
seenSlug[s.Slug] = true
if s.URL == "" {
return fmt.Errorf("server[%q]: url is required", s.Slug)
}
if !isSupportedServerURL(s.URL) {
return fmt.Errorf("server[%q]: url %q must start with http://, https://, or unix://", s.Slug, s.URL)
}
if s.Default {
defaultCount++
if defaultCount > 1 {
return fmt.Errorf("server[%q]: only one entry may set default=true", s.Slug)
}
}
}
return nil
}
// isSupportedServerURL allows http(s) for TCP and unix:// for socket
// transports — the same shape gortex server's --bind flag accepts.
func isSupportedServerURL(u string) bool {
switch {
case strings.HasPrefix(u, "http://"),
strings.HasPrefix(u, "https://"),
strings.HasPrefix(u, "unix://"):
return true
}
return false
}
// FindBySlug returns the ServerEntry with the given slug, or nil.
func (c *ServersConfig) FindBySlug(slug string) *ServerEntry {
if c == nil {
return nil
}
for i := range c.Server {
if c.Server[i].Slug == slug {
return &c.Server[i]
}
}
return nil
}
// DefaultServer returns the entry marked Default=true, falling back
// to the first server in the list when no explicit default is set.
// Returns nil only when the list is empty.
func (c *ServersConfig) DefaultServer() *ServerEntry {
if c == nil || len(c.Server) == 0 {
return nil
}
for i := range c.Server {
if c.Server[i].Default {
return &c.Server[i]
}
}
return &c.Server[0]
}
// SetEnabled flips a remote's enabled state in the roster. enabled=true
// CLEARS the key (back to default-on) rather than writing
// `enabled = true`, so a hand-edited roster without the key stays
// minimal and the default-on contract is the single source of truth;
// enabled=false writes an explicit false. It returns whether the value
// actually changed and errors on an unknown slug.
func (c *ServersConfig) SetEnabled(slug string, enabled bool) (changed bool, err error) {
if c == nil {
return false, fmt.Errorf("no server roster loaded")
}
for i := range c.Server {
if c.Server[i].Slug != slug {
continue
}
was := c.Server[i].IsEnabled()
if enabled {
// Clear the key: nil ⇒ default-on.
c.Server[i].Enabled = nil
} else {
v := false
c.Server[i].Enabled = &v
}
return was != enabled, nil
}
return false, fmt.Errorf("unknown server slug %q", slug)
}
// ServerClient is the daemon-side HTTP client targeting one
// ServerEntry. Holds a transport configured for the entry's URL form
// (TCP vs unix:// chooses Dial vs DialUnix transparently).
//
// The client is goroutine-safe; the daemon shares one per slug
// across multiple worker goroutines and reuses connections via the
// underlying http.Transport.
type ServerClient struct {
Entry ServerEntry
BaseURL string // canonical form passed to http.NewRequest
httpClient *http.Client
}
// NewServerClient builds a ServerClient. For unix:// entries the
// returned client dials the socket on every request; HTTP keep-alive
// works the same as for TCP. The auth token is resolved at request
// time so an env-var rotation lands on the next call.
func NewServerClient(entry ServerEntry) (*ServerClient, error) {
if !isSupportedServerURL(entry.URL) {
return nil, fmt.Errorf("server %q: unsupported url scheme: %s", entry.Slug, entry.URL)
}
cli := &ServerClient{Entry: entry}
if strings.HasPrefix(entry.URL, "unix://") {
// Strip the scheme so http.Request takes a relative path; the
// dialer ignores the host part. Use a synthetic
// http://unix/<path> base so url.Parse downstream stays
// happy.
socket := strings.TrimPrefix(entry.URL, "unix://")
cli.BaseURL = "http://unix"
cli.httpClient = &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", socket)
},
},
}
} else {
// TCP / TLS — Go's default transport handles both http:// and
// https:// URLs; we just plug a sane timeout so a hung server
// doesn't wedge the daemon.
cli.BaseURL = strings.TrimRight(entry.URL, "/")
cli.httpClient = &http.Client{Timeout: 60 * time.Second}
}
return cli, nil
}
// resolveAuthToken returns the bearer token for the next request,
// resolving AuthTokenEnv against os.Getenv each call so rotated
// tokens take effect without a daemon restart.
func (c *ServerClient) resolveAuthToken() string {
if c.Entry.AuthToken != "" {
return c.Entry.AuthToken
}
if c.Entry.AuthTokenEnv != "" {
return os.Getenv(c.Entry.AuthTokenEnv)
}
return ""
}
// ProxyTool forwards a single MCP tool invocation to this server's
// `POST /v1/tools/<name>` endpoint and returns the raw response
// bytes. Used by the daemon's hybrid-read router when a query's
// scope routes to a remote workspace.
//
// The body is passed through verbatim — the caller (typically the
// daemon's RouteToolCall) is responsible for shape, the server
// handler is responsible for parsing. Returning bytes (not a
// decoded struct) keeps this client agnostic to the per-tool
// response shapes.
func (c *ServerClient) ProxyTool(toolName string, body []byte) ([]byte, int, error) {
return c.ProxyToolCtx(context.Background(), toolName, body)
}
// ProxyToolCtx is the context-aware successor to ProxyTool. The
// outbound request is built with http.NewRequestWithContext so a
// caller's deadline / cancellation propagates to the in-flight HTTP
// call, instead of being bounded only by the client's coarse timeout.
// The federation read fan-out derives a short per-remote deadline from
// this ctx so a slow or dead remote can never block the query hot path
// for the full HTTP-client timeout.
func (c *ServerClient) ProxyToolCtx(ctx context.Context, toolName string, body []byte) ([]byte, int, error) {
u, err := url.JoinPath(c.BaseURL, "v1", "tools", toolName)
if err != nil {
return nil, 0, fmt.Errorf("join proxy URL: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, strings.NewReader(string(body)))
if err != nil {
return nil, 0, fmt.Errorf("build proxy request: %w", err)
}
if tok := c.resolveAuthToken(); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, fmt.Errorf("proxy %s/%s: %w", c.Entry.Slug, toolName, err)
}
defer resp.Body.Close()
out, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, fmt.Errorf("read proxy response: %w", err)
}
return out, resp.StatusCode, nil
}
// FetchWorkspaceRoster calls `GET /v1/workspaces/<ws>/repos` on the
// server and returns the list of repo prefixes in that workspace.
// Used by the daemon's lookup logic to know which server owns a
// given workspace.
func (c *ServerClient) FetchWorkspaceRoster(workspace string) ([]string, error) {
u, err := url.JoinPath(c.BaseURL, "v1", "workspaces", workspace, "repos")
if err != nil {
return nil, fmt.Errorf("join roster URL: %w", err)
}
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("build roster request: %w", err)
}
if tok := c.resolveAuthToken(); tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch roster from %q: %w", c.Entry.Slug, err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, ErrWorkspaceNotFound
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server %q roster %s: status %d", c.Entry.Slug, workspace, resp.StatusCode)
}
var payload struct {
Repos []string `json:"repos"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return nil, fmt.Errorf("decode roster: %w", err)
}
return payload.Repos, nil
}
// ErrWorkspaceNotFound is returned by FetchWorkspaceRoster when the
// server doesn't host the requested workspace. Distinct from a
// transport error so the daemon's lookup loop can keep iterating
// servers without surfacing a noisy connectivity warning.
var ErrWorkspaceNotFound = errors.New("workspace not found on server")
// WorkspaceRosterCache caches roster lookups per (slug, workspace).
// TTL is intentionally short — the alpha use case is a developer
// adding/removing repos in one of their tracked workspaces and
// expecting the daemon to notice within a minute or two without a
// restart.
type WorkspaceRosterCache struct {
mu sync.RWMutex
entries map[rosterKey]rosterEntry
ttl time.Duration
}
type rosterKey struct {
slug string
workspace string
}
type rosterEntry struct {
repos []string
expires time.Time
notFound bool
}
// NewWorkspaceRosterCache builds an in-memory cache with the given
// TTL. ttl <= 0 means "always fetch" (no caching).
func NewWorkspaceRosterCache(ttl time.Duration) *WorkspaceRosterCache {
return &WorkspaceRosterCache{
entries: make(map[rosterKey]rosterEntry),
ttl: ttl,
}
}
// Lookup returns the cached roster for (slug, workspace) and whether
// the entry is still fresh. notFound==true means the server told us
// the workspace isn't hosted there; callers should not retry until
// the entry expires.
func (c *WorkspaceRosterCache) Lookup(slug, workspace string) (repos []string, found bool, fresh bool) {
c.mu.RLock()
defer c.mu.RUnlock()
e, ok := c.entries[rosterKey{slug: slug, workspace: workspace}]
if !ok {
return nil, false, false
}
if c.ttl > 0 && time.Now().After(e.expires) {
return nil, false, false
}
return e.repos, !e.notFound, true
}
// Set stores a positive (workspace exists, repos list) result.
func (c *WorkspaceRosterCache) Set(slug, workspace string, repos []string) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[rosterKey{slug: slug, workspace: workspace}] = rosterEntry{
repos: append([]string(nil), repos...),
expires: time.Now().Add(c.ttl),
}
}
// SetNotFound stores a negative result (server doesn't host the
// workspace) so callers don't retry until the TTL expires.
func (c *WorkspaceRosterCache) SetNotFound(slug, workspace string) {
c.mu.Lock()
defer c.mu.Unlock()
c.entries[rosterKey{slug: slug, workspace: workspace}] = rosterEntry{
notFound: true,
expires: time.Now().Add(c.ttl),
}
}
// Invalidate drops every cached entry — used after a `servers.toml`
// reload or when the daemon learns a server is down.
func (c *WorkspaceRosterCache) Invalidate() {
c.mu.Lock()
defer c.mu.Unlock()
c.entries = make(map[rosterKey]rosterEntry)
}
// LookupResult is what RouteForCwd returns: the chosen ServerEntry
// (nil when no server claims the workspace), the workspace slug
// resolved from cwd, and an explanation of which priority fired.
type LookupResult struct {
Server *ServerEntry
Workspace string
// Source is one of:
// "scope-override" — the caller passed an explicit scope (priority 1)
// "config-yaml" — `.gortex.yaml::workspace` walking up from cwd (priority 2)
// "roster" — the daemon found the cwd's repo in a server's roster (priority 3)
// "default" — the servers.toml `default = "<slug>"` entry (priority 4)
Source string
}
// CwdResolver looks up a workspace slug for a current working directory
// by walking up to find a `.gortex.yaml` with a `workspace:` key. Lifted
// out as an interface so tests can inject a stub instead of touching
// the filesystem.
type CwdResolver func(cwd string) (workspace string, ok bool)
// DefaultCwdResolver walks parent directories from `cwd` looking for
// a `.gortex.yaml`. Returns the first non-empty `workspace:` value it
// finds. Stops at filesystem root or after 32 levels (defensive).
//
// The implementation is deliberately tiny — the daemon's hot path
// will hit it on every routed query, so we don't shell out to the
// full config loader for one field. yaml-shaped scan that just looks
// for `workspace: <slug>` at the top level matches the schema.
func DefaultCwdResolver(cwd string) (string, bool) {
dir := cwd
for i := 0; i < 32; i++ {
path := filepath.Join(dir, ".gortex.yaml")
if data, err := os.ReadFile(path); err == nil {
if ws := scanWorkspaceField(data); ws != "" {
return ws, true
}
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return "", false
}
// scanWorkspaceField looks for a top-level `workspace: <slug>` line
// in the file. The slug is whatever follows up to a comment / EOL,
// trimmed of quotes and whitespace. We intentionally do NOT pull in
// the full yaml decoder here — this gets called on every query and
// the .gortex.yaml schema is already fully validated at config load
// time. If a malformed file ends up here, we fall back to "no
// workspace" rather than failing the lookup.
func scanWorkspaceField(data []byte) string {
for _, line := range strings.Split(string(data), "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "workspace:") {
continue
}
v := strings.TrimSpace(strings.TrimPrefix(trimmed, "workspace:"))
// Strip inline comments.
if idx := strings.Index(v, "#"); idx >= 0 {
v = strings.TrimSpace(v[:idx])
}
// Strip quotes.
v = strings.Trim(v, `"' `)
if v == "" {
// Could be a struct shape (e.g. `workspace:\n auto_detect: true`)
// — that's the legacy config.WorkspaceConfig shape, now
// migrated to `multi:` instead. Skip.
continue
}
// A workspace slug may never collide with the reserved local
// sentinel, nor contain ~ or / (which would alias path/home
// expansion and the proxy-node origin segment). A rejected slug
// degrades to "no workspace" so routing falls back to local
// rather than re-opening the localSlug foot-gun from the
// workspace side.
if v == LocalServerSentinel || strings.ContainsAny(v, "~/") {
continue
}
return v
}
return ""
}
// RouteForCwd implements the priority chain:
//
// 1. ScopeOverride (caller-supplied workspace/server slug) wins.
// 2. `.gortex.yaml::workspace` resolved from cwd.
// 3. Roster discovery — the daemon walks every configured server's
// roster and picks the one that owns the cwd's workspace. Cached.
// 4. servers.toml's `default` entry.
//
// `scopeOverride` is the workspace slug the caller passed via the
// query's scope field — empty means "no override". When the
// caller passed a slug, we still resolve which ServerEntry hosts it
// via the same roster lookup the cwd path would use.
//
// The function does NOT make HTTP calls — it consults the supplied
// roster cache and returns an empty Server when the cache doesn't
// know yet. Callers that want roster-fetch behaviour should call
// ServerClient.FetchWorkspaceRoster on cache misses and then
// re-invoke this routine.
func RouteForCwd(
cfg *ServersConfig,
rosters *WorkspaceRosterCache,
resolver CwdResolver,
cwd string,
scopeOverride string,
) LookupResult {
if cfg == nil {
return LookupResult{}
}
resolveServerForWorkspace := func(ws string) *ServerEntry {
// Pre-declared `workspaces = [...]` lists in servers.toml win
// — the user told us authoritatively which server hosts what.
for i := range cfg.Server {
for _, w := range cfg.Server[i].Workspaces {
if w == ws {
return &cfg.Server[i]
}
}
}
// Fall back to the cached roster (populated lazily by the
// caller's prior FetchWorkspaceRoster calls).
if rosters != nil {
for i := range cfg.Server {
if _, exists, fresh := rosters.Lookup(cfg.Server[i].Slug, ws); exists && fresh {
return &cfg.Server[i]
}
}
}
return nil
}
if scopeOverride != "" {
if s := resolveServerForWorkspace(scopeOverride); s != nil {
return LookupResult{Server: s, Workspace: scopeOverride, Source: "scope-override"}
}
// Caller-supplied scope but we don't yet know which server
// hosts it — return the workspace slug so the caller can
// trigger a roster fetch and retry. This is preferable to
// silently falling through to the default server, which
// would mask a typo'd scope.
return LookupResult{Workspace: scopeOverride, Source: "scope-override"}
}
if resolver != nil {
if ws, ok := resolver(cwd); ok {
if s := resolveServerForWorkspace(ws); s != nil {
return LookupResult{Server: s, Workspace: ws, Source: "config-yaml"}
}
// .gortex.yaml says workspace = <ws> but no server claims
// it — same shape as scope-override, return the slug for
// the caller to fetch+retry.
return LookupResult{Workspace: ws, Source: "config-yaml"}
}
}
// Fall through: pick the default server from servers.toml. The
// returned LookupResult.Workspace is empty because we don't know
// what cwd's workspace actually is — the daemon may want to
// surface this as a warning ("running unscoped").
if def := cfg.DefaultServer(); def != nil {
return LookupResult{Server: def, Source: "default"}
}
return LookupResult{}
}
// AllSlugs returns every server slug in declaration order. Useful
// when a caller wants to walk every configured server (e.g. when the
// roster cache is empty and the daemon needs to discover which one
// hosts a workspace via FetchWorkspaceRoster).
func (c *ServersConfig) AllSlugs() []string {
if c == nil {
return nil
}
out := make([]string, len(c.Server))
for i := range c.Server {
out[i] = c.Server[i].Slug
}
return out
}
+166
View File
@@ -0,0 +1,166 @@
package daemon
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestServerEntry_EnabledAbsentMeansEnabled asserts the pointer-bool
// backward-compat contract: a roster written before the `enabled` key
// existed (no key at all) loads as enabled, never silently disabled.
func TestServerEntry_EnabledAbsentMeansEnabled(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "servers.toml")
// No `enabled` key on the entry — the pre-existing-roster shape.
roster := `[[server]]
slug = "r2"
url = "https://r2.example:4747"
`
if err := os.WriteFile(path, []byte(roster), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadServersConfig(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cfg.Server) != 1 {
t.Fatalf("want 1 server, got %d", len(cfg.Server))
}
if cfg.Server[0].Enabled != nil {
t.Fatalf("absent key should unmarshal to nil Enabled, got %v", *cfg.Server[0].Enabled)
}
if !cfg.Server[0].IsEnabled() {
t.Fatal("absent enabled key must be treated as enabled (default-on)")
}
}
// TestServerEntry_EnabledFalsePersists asserts an explicit
// `enabled = false` round-trips and reports disabled.
func TestServerEntry_EnabledFalsePersists(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "servers.toml")
roster := `[[server]]
slug = "r2"
url = "https://r2.example:4747"
enabled = false
`
if err := os.WriteFile(path, []byte(roster), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadServersConfig(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Server[0].Enabled == nil || *cfg.Server[0].Enabled {
t.Fatalf("explicit enabled=false should unmarshal to a non-nil false, got %v", cfg.Server[0].Enabled)
}
if cfg.Server[0].IsEnabled() {
t.Fatal("explicit enabled=false must report disabled")
}
}
// TestSetEnabled_OnDeletesKey asserts `proxy on` semantics: SetEnabled
// to true CLEARS the key (back to default-on) rather than writing
// `enabled = true`, so a re-saved roster stays minimal.
func TestSetEnabled_OnDeletesKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "servers.toml")
roster := `[[server]]
slug = "r2"
url = "https://r2.example:4747"
enabled = false
`
if err := os.WriteFile(path, []byte(roster), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadServersConfig(path)
if err != nil {
t.Fatalf("load: %v", err)
}
changed, err := cfg.SetEnabled("r2", true)
if err != nil {
t.Fatalf("SetEnabled: %v", err)
}
if !changed {
t.Fatal("flipping false->on should report changed")
}
if cfg.Server[0].Enabled != nil {
t.Fatal("SetEnabled(on) must clear the key (nil), not write enabled=true")
}
if err := cfg.Save(path); err != nil {
t.Fatalf("save: %v", err)
}
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), "enabled") {
t.Fatalf("saved roster must not carry an enabled key after proxy-on, got:\n%s", data)
}
// And it reloads as enabled.
reloaded, err := LoadServersConfig(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
if !reloaded.Server[0].IsEnabled() {
t.Fatal("re-saved + reloaded roster must be enabled")
}
}
// TestSetEnabled_OffWritesFalse asserts `proxy off` persists an
// explicit false that survives a daemon restart (Save + reload).
func TestSetEnabled_OffWritesFalse(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "servers.toml")
roster := `[[server]]
slug = "r2"
url = "https://r2.example:4747"
`
if err := os.WriteFile(path, []byte(roster), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := LoadServersConfig(path)
if err != nil {
t.Fatalf("load: %v", err)
}
changed, err := cfg.SetEnabled("r2", false)
if err != nil {
t.Fatalf("SetEnabled: %v", err)
}
if !changed {
t.Fatal("flipping default-on->off should report changed")
}
if err := cfg.Save(path); err != nil {
t.Fatalf("save: %v", err)
}
reloaded, err := LoadServersConfig(path)
if err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Server[0].IsEnabled() {
t.Fatal("proxy off must survive a restart (reload) as disabled")
}
}
// TestSetEnabled_UnknownSlug asserts SetEnabled errors on a slug not in
// the roster rather than silently no-op'ing.
func TestSetEnabled_UnknownSlug(t *testing.T) {
cfg := &ServersConfig{Server: []ServerEntry{{Slug: "r2", URL: "https://r2.example:4747"}}}
if _, err := cfg.SetEnabled("nope", false); err == nil {
t.Fatal("SetEnabled on an unknown slug must error")
}
}
// TestSetEnabled_Idempotent asserts a no-op toggle reports changed=false.
func TestSetEnabled_Idempotent(t *testing.T) {
cfg := &ServersConfig{Server: []ServerEntry{{Slug: "r2", URL: "https://r2.example:4747"}}}
changed, err := cfg.SetEnabled("r2", true) // already default-on
if err != nil {
t.Fatal(err)
}
if changed {
t.Fatal("enabling an already-enabled remote should report no change")
}
}
+69
View File
@@ -0,0 +1,69 @@
package daemon
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// TestProxyToolCtx_Cancellation asserts a per-remote deadline reaches
// the in-flight HTTP request: a remote that never responds in time is
// abandoned when the ctx deadline elapses, well before the client's
// coarse 60s timeout. This is the bound the federation hot path relies
// on.
func TestProxyToolCtx_Cancellation(t *testing.T) {
block := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-block // never responds within the test deadline
}))
defer srv.Close()
defer close(block)
cli, err := NewServerClient(ServerEntry{Slug: "r2", URL: srv.URL})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
start := time.Now()
_, _, err = cli.ProxyToolCtx(ctx, "find_usages", []byte(`{}`))
elapsed := time.Since(start)
if err == nil {
t.Fatal("expected a context error from a never-responding remote")
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected deadline-exceeded, got %v", err)
}
if elapsed > 5*time.Second {
t.Fatalf("cancellation did not reach the wire: took %s (client timeout is 60s)", elapsed)
}
}
// TestProxyTool_ShimUsesBackground asserts the legacy ProxyTool still
// works (a background-context shim over ProxyToolCtx).
func TestProxyTool_ShimUsesBackground(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
cli, err := NewServerClient(ServerEntry{Slug: "r2", URL: srv.URL})
if err != nil {
t.Fatal(err)
}
out, status, err := cli.ProxyTool("find_usages", []byte(`{}`))
if err != nil {
t.Fatalf("ProxyTool: %v", err)
}
if status != http.StatusOK {
t.Fatalf("want 200, got %d", status)
}
if string(out) != `{"ok":true}` {
t.Fatalf("unexpected body: %s", out)
}
}
+129
View File
@@ -0,0 +1,129 @@
package daemon
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestServersConfig_SaveLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nested", "servers.toml")
cfg := &ServersConfig{
Server: []ServerEntry{
{Slug: "main", URL: "http://127.0.0.1:4747", Default: true, Workspaces: []string{"gortex"}},
{Slug: "remote", URL: "https://example.com", AuthTokenEnv: "REMOTE_TOK"},
},
}
if err := cfg.Save(path); err != nil {
t.Fatalf("Save: %v", err)
}
// File created with restrictive perms.
fi, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := fi.Mode().Perm(); perm != 0o600 {
t.Fatalf("file perm = %o, want 0600", perm)
}
got, err := LoadServersConfig(path)
if err != nil {
t.Fatalf("LoadServersConfig: %v", err)
}
if len(got.Server) != 2 {
t.Fatalf("got %d servers, want 2", len(got.Server))
}
if got.Server[0].Slug != "main" || !got.Server[0].Default {
t.Fatalf("first entry: %+v", got.Server[0])
}
if got.Server[1].AuthTokenEnv != "REMOTE_TOK" {
t.Fatalf("auth_token_env not preserved: %+v", got.Server[1])
}
}
func TestServersConfig_AddServer_RejectsDuplicateSlug(t *testing.T) {
cfg := &ServersConfig{
Server: []ServerEntry{{Slug: "main", URL: "http://127.0.0.1:4747"}},
}
err := cfg.AddServer(ServerEntry{Slug: "main", URL: "http://127.0.0.1:5000"})
if err == nil || !strings.Contains(err.Error(), "already exists") {
t.Fatalf("expected duplicate-slug error, got %v", err)
}
if len(cfg.Server) != 1 {
t.Fatalf("config mutated on rejection: %+v", cfg.Server)
}
}
func TestServersConfig_AddServer_RejectsSecondDefault(t *testing.T) {
cfg := &ServersConfig{
Server: []ServerEntry{{Slug: "main", URL: "http://127.0.0.1:4747", Default: true}},
}
err := cfg.AddServer(ServerEntry{Slug: "remote", URL: "https://example.com", Default: true})
if err == nil {
t.Fatal("expected error on second default=true")
}
if len(cfg.Server) != 1 {
t.Fatalf("config not rolled back: %+v", cfg.Server)
}
}
func TestServersConfig_AddServer_RejectsBadURL(t *testing.T) {
cfg := &ServersConfig{}
err := cfg.AddServer(ServerEntry{Slug: "bad", URL: "ftp://nope"})
if err == nil {
t.Fatal("expected error on unsupported URL scheme")
}
if len(cfg.Server) != 0 {
t.Fatalf("config not rolled back: %+v", cfg.Server)
}
}
func TestServersConfig_AddServer_RequiresSlug(t *testing.T) {
cfg := &ServersConfig{}
if err := cfg.AddServer(ServerEntry{URL: "http://x"}); err == nil {
t.Fatal("expected error on empty slug")
}
}
func TestServersConfig_RemoveServer(t *testing.T) {
cfg := &ServersConfig{
Server: []ServerEntry{
{Slug: "main", URL: "http://127.0.0.1:4747"},
{Slug: "remote", URL: "https://example.com"},
},
}
ok, err := cfg.RemoveServer("main")
if err != nil || !ok {
t.Fatalf("RemoveServer(main): ok=%v err=%v", ok, err)
}
if len(cfg.Server) != 1 || cfg.Server[0].Slug != "remote" {
t.Fatalf("after remove: %+v", cfg.Server)
}
ok, err = cfg.RemoveServer("nonexistent")
if err != nil {
t.Fatalf("RemoveServer(missing) returned err: %v", err)
}
if ok {
t.Fatal("RemoveServer(missing) should return false")
}
}
func TestServersConfig_Save_RejectsInvalidConfig(t *testing.T) {
cfg := &ServersConfig{
Server: []ServerEntry{
{Slug: "dup", URL: "http://a"},
{Slug: "dup", URL: "http://b"},
},
}
path := filepath.Join(t.TempDir(), "servers.toml")
if err := cfg.Save(path); err == nil {
t.Fatal("expected validation error on save")
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("file should not have been created on validation failure, stat err = %v", err)
}
}
+326
View File
@@ -0,0 +1,326 @@
package daemon
import (
"crypto/rand"
"encoding/hex"
"net"
"sync"
"time"
)
// Session represents one proxy or CLI connection to the daemon. Per-session
// state (recent activity, symbol history, token stats for this client)
// lives here; shared state (the graph, feedback store, cumulative savings)
// lives on the Server.
//
// A Session is created on a successful handshake and destroyed when its
// socket connection closes. The daemon routes every inbound frame to its
// session by looking up the net.Conn in the session registry.
type Session struct {
ID string
Mode ConnectionMode
CWD string
ClientName string
// ClientVersion is the version reported by the MCP client in its
// `initialize` request (`params.clientInfo.version`). Empty until
// the daemon dispatcher sees that frame; the env-var sniff in
// `cmd/gortex/proxy.go::detectClientName` only fills ClientName.
ClientVersion string
// ClientNameSource records where ClientName came from so the
// MCP-frame snooper can decide whether to overwrite it. "handshake"
// is the env-var fallback the proxy posts at connect time;
// "initialize" is the authoritative MCP-protocol value. Anything
// from "initialize" wins over any "handshake" — including the
// "unknown" string the proxy uses when env-var detection fails.
ClientNameSource string
ClientPID int
DefaultRepo string
ActiveProject string
StartedAt time.Time
// ToolSpec / ToolMode are the client-forwarded tool-surface
// preference (GORTEX_TOOLS / --tools + mode) from the handshake. The
// daemon resolves the effective per-session tool surface from these
// so a client can scope (or widen) its own pipe while the daemon keeps
// serving the full graph to everyone else. Empty = no preference.
ToolSpec string
ToolMode string
// Conn is the underlying socket. Kept for close-on-shutdown and
// logging; handlers should not read from or write to it directly —
// framing is the transport's job.
Conn net.Conn
// Per-session mutable state that will move over from internal/mcp's
// Server during the session-isolation refactor. Left as interface{}
// for now so the types can evolve without churning this file every
// iteration — the refactor will replace this with concrete pointers.
SessionState any
SymHistory any
TokenStats any
// remoteOverrides is the per-session enable/disable layer over the
// global roster: slug -> enabled. An absent slug means "no
// override" (the global Enabled state wins). It is ephemeral by
// construction — freed when the *Session is dropped on disconnect
// via either teardown path (Remove for an AF_UNIX session,
// RemoveByID for a detached /mcp session) — so no explicit cleanup
// is needed. Guarded by mu.
remoteOverrides map[string]bool
// mu protects ClientName / ClientVersion / ClientNameSource and
// remoteOverrides, which can be updated by the dispatcher and the
// proxy-toggle tools mid-session.
mu sync.RWMutex
}
// SetClientInfo updates the session's client metadata from the MCP
// `initialize` frame. Called by the daemon dispatcher when it sees
// the first `initialize` request on this session. Idempotent — a
// second call (e.g. on protocol re-init) just overwrites.
func (s *Session) SetClientInfo(name, version string) {
if s == nil {
return
}
s.mu.Lock()
if name != "" {
s.ClientName = name
s.ClientNameSource = "initialize"
}
if version != "" {
s.ClientVersion = version
}
s.mu.Unlock()
}
// SetRemoteOverride sets a per-session enable/disable override for a
// remote slug. It wins over the remote's global Enabled state for the
// lifetime of this session only.
func (s *Session) SetRemoteOverride(slug string, enabled bool) {
if s == nil {
return
}
s.mu.Lock()
if s.remoteOverrides == nil {
s.remoteOverrides = make(map[string]bool)
}
s.remoteOverrides[slug] = enabled
s.mu.Unlock()
}
// ClearRemoteOverride removes a per-session override so the remote
// reverts to its global Enabled state.
func (s *Session) ClearRemoteOverride(slug string) {
if s == nil {
return
}
s.mu.Lock()
delete(s.remoteOverrides, slug)
s.mu.Unlock()
}
// RemoteOverrides returns a copy of the per-session override map under
// the read lock, so callers can iterate without racing a concurrent
// toggle. nil when no override has been set.
func (s *Session) RemoteOverrides() map[string]bool {
if s == nil {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.remoteOverrides) == 0 {
return nil
}
out := make(map[string]bool, len(s.remoteOverrides))
for k, v := range s.remoteOverrides {
out[k] = v
}
return out
}
// SnapshotClientInfo returns the current client name/version pair
// safely under the session lock. Used by the status path which reads
// while the dispatcher may be writing.
func (s *Session) SnapshotClientInfo() (name, version string) {
if s == nil {
return "", ""
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.ClientName, s.ClientVersion
}
// SessionRegistry tracks active sessions. Safe for concurrent access from
// the accept goroutine and the control-surface handlers.
type SessionRegistry struct {
mu sync.RWMutex
sessions map[string]*Session // session_id → Session
byConn map[net.Conn]*Session
}
func NewSessionRegistry() *SessionRegistry {
return &SessionRegistry{
sessions: make(map[string]*Session),
byConn: make(map[net.Conn]*Session),
}
}
// Register creates and stores a new session for the given connection.
// Called after a successful handshake. Generates the session ID.
func (r *SessionRegistry) Register(conn net.Conn, h Handshake) *Session {
s := &Session{
ID: newSessionID(),
Mode: h.Mode,
CWD: h.CWD,
ClientName: h.ClientName,
ClientNameSource: "handshake",
ClientPID: h.PID,
ToolSpec: h.Tools,
ToolMode: h.ToolsMode,
StartedAt: time.Now(),
Conn: conn,
}
r.mu.Lock()
r.sessions[s.ID] = s
r.byConn[conn] = s
r.mu.Unlock()
return s
}
// RegisterDetached creates a session that isn't bound to a net.Conn —
// used by HTTP-side transports (Streamable HTTP, future SSE/WebSocket
// adapters) where the daemon doesn't own a persistent socket. The
// supplied id becomes the session ID verbatim so the transport's own
// session-id space (e.g. streamable.SessionStore) lines up with the
// daemon's status/metrics surface; pass "" to auto-generate one.
func (r *SessionRegistry) RegisterDetached(id string, h Handshake) *Session {
if id == "" {
id = newSessionID()
}
s := &Session{
ID: id,
Mode: h.Mode,
CWD: h.CWD,
ClientName: h.ClientName,
ClientNameSource: "handshake",
ClientPID: h.PID,
ToolSpec: h.Tools,
ToolMode: h.ToolsMode,
StartedAt: time.Now(),
}
r.mu.Lock()
r.sessions[s.ID] = s
r.mu.Unlock()
return s
}
// RemoveByID deletes a session by id (used by detached sessions which
// have no net.Conn to key off). Idempotent.
func (r *SessionRegistry) RemoveByID(id string) *Session {
if id == "" {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
s := r.sessions[id]
if s == nil {
return nil
}
delete(r.sessions, id)
if s.Conn != nil {
delete(r.byConn, s.Conn)
}
return s
}
// GetByID returns a session by its id, or nil when no session is
// registered under that id. Used by detached-session lookup paths.
func (r *SessionRegistry) GetByID(id string) *Session {
if id == "" {
return nil
}
r.mu.RLock()
defer r.mu.RUnlock()
return r.sessions[id]
}
// Remove deletes the session for a connection. Idempotent — safe to call
// from both the accept-loop's defer and the shutdown path.
func (r *SessionRegistry) Remove(conn net.Conn) *Session {
r.mu.Lock()
defer r.mu.Unlock()
s := r.byConn[conn]
if s == nil {
return nil
}
delete(r.byConn, conn)
delete(r.sessions, s.ID)
return s
}
// Get returns the session for a connection, or nil if the connection hasn't
// completed its handshake yet (or was already removed).
func (r *SessionRegistry) Get(conn net.Conn) *Session {
r.mu.RLock()
defer r.mu.RUnlock()
return r.byConn[conn]
}
// Count returns the number of live sessions — used by the status command
// and for metrics.
func (r *SessionRegistry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.sessions)
}
// SweepDead removes every session whose originating client process (by its
// handshake PID) is no longer alive, closing the session's connection. Sessions
// with no recorded PID (0 — detached/HTTP transports, or a client that reported
// none) are left untouched, since liveness can't be judged from a PID we don't
// have. Returns the removed sessions so the caller can log / adjust metrics.
// isAlive is injected (platform.ProcessAlive in production) so the sweep is
// testable without spawning real processes.
func (r *SessionRegistry) SweepDead(isAlive func(int) bool) []*Session {
r.mu.Lock()
var dead []*Session
for id, s := range r.sessions {
if s.ClientPID <= 0 || isAlive(s.ClientPID) {
continue
}
dead = append(dead, s)
delete(r.sessions, id)
if s.Conn != nil {
delete(r.byConn, s.Conn)
}
}
r.mu.Unlock()
// Close connections outside the lock — Close can block.
for _, s := range dead {
if s.Conn != nil {
_ = s.Conn.Close()
}
}
return dead
}
// All returns a snapshot of every live session. The caller must not
// mutate the returned Session objects; they're shared with the registry.
func (r *SessionRegistry) All() []*Session {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]*Session, 0, len(r.sessions))
for _, s := range r.sessions {
out = append(out, s)
}
return out
}
// newSessionID generates a short URL-safe identifier. 8 bytes of entropy
// gives us 16 hex chars — collision-resistant enough for a per-user
// single-process registry without bloating log lines.
func newSessionID() string {
var b [8]byte
_, _ = rand.Read(b[:])
return "sess_" + hex.EncodeToString(b[:])
}
+70
View File
@@ -0,0 +1,70 @@
package daemon
import "testing"
// TestSession_RemoteOverride_SetGetClear covers the basic lifecycle of a
// per-session remote override.
func TestSession_RemoteOverride_SetGetClear(t *testing.T) {
s := &Session{ID: "a"}
if got := s.RemoteOverrides(); got != nil {
t.Fatalf("fresh session should have no overrides, got %v", got)
}
s.SetRemoteOverride("r2", false)
s.SetRemoteOverride("r3", true)
ov := s.RemoteOverrides()
if ov["r2"] != false || ov["r3"] != true {
t.Fatalf("overrides not recorded: %v", ov)
}
if _, ok := ov["r2"]; !ok {
t.Fatal("r2 override should be present")
}
s.ClearRemoteOverride("r2")
ov = s.RemoteOverrides()
if _, ok := ov["r2"]; ok {
t.Fatal("cleared override should be gone")
}
if ov["r3"] != true {
t.Fatal("clearing r2 must not affect r3")
}
}
// TestSession_RemoteOverrides_PerSessionIsolation asserts an override on
// one session never leaks into another (overrides are ephemeral and
// scoped to a single session).
func TestSession_RemoteOverrides_PerSessionIsolation(t *testing.T) {
a := &Session{ID: "a"}
b := &Session{ID: "b"}
a.SetRemoteOverride("r2", false)
if b.RemoteOverrides() != nil {
t.Fatal("session B must be unaffected by session A's override")
}
}
// TestSession_RemoteOverrides_CopyOnRead asserts the returned map is a
// copy, so a caller iterating it cannot mutate internal session state.
func TestSession_RemoteOverrides_CopyOnRead(t *testing.T) {
s := &Session{ID: "a"}
s.SetRemoteOverride("r2", true)
ov := s.RemoteOverrides()
ov["r2"] = false
ov["injected"] = true
again := s.RemoteOverrides()
if again["r2"] != true {
t.Fatal("mutating the returned copy must not change session state")
}
if _, ok := again["injected"]; ok {
t.Fatal("injected key must not leak into session state")
}
}
// TestSession_RemoteOverrides_NilSafe asserts the accessors are safe on a
// nil *Session (the embedded / shared server has no per-connection
// session).
func TestSession_RemoteOverrides_NilSafe(t *testing.T) {
var s *Session
s.SetRemoteOverride("r2", false) // must not panic
s.ClearRemoteOverride("r2")
if s.RemoteOverrides() != nil {
t.Fatal("nil session should return nil overrides")
}
}
+32
View File
@@ -0,0 +1,32 @@
package daemon
import "testing"
// TestDeadPeerSweepRemovesDeadSession proves the registry reaps sessions whose
// originating client process has died (by handshake PID) while leaving live
// sessions and PID-less (detached/HTTP) sessions untouched.
func TestDeadPeerSweepRemovesDeadSession(t *testing.T) {
r := NewSessionRegistry()
r.RegisterDetached("live", Handshake{PID: 111, Mode: ModeMCP})
r.RegisterDetached("dead", Handshake{PID: 222, Mode: ModeMCP})
r.RegisterDetached("nopid", Handshake{PID: 0, Mode: ModeMCP})
// Only PID 111 is alive.
removed := r.SweepDead(func(pid int) bool { return pid == 111 })
if len(removed) != 1 || removed[0].ID != "dead" {
t.Fatalf("SweepDead removed = %+v, want exactly [dead]", removed)
}
if r.GetByID("dead") != nil {
t.Error("a dead-PID session must be removed from the registry")
}
if r.GetByID("live") == nil {
t.Error("a live-PID session must remain")
}
if r.GetByID("nopid") == nil {
t.Error("a session with no recorded PID (0) must NOT be swept — liveness is unknown")
}
if r.Count() != 2 {
t.Errorf("Count() = %d, want 2 after the sweep", r.Count())
}
}
+58
View File
@@ -0,0 +1,58 @@
package daemon
import (
"os"
"path/filepath"
"strconv"
"time"
)
// Stop-intent marker.
//
// `gortex daemon stop` is a user's explicit "stay down" signal. But the
// daemon is auto-startable: any `gortex mcp` proxy (the process an editor /
// AI agent keeps alive) re-runs the autostart path on its next launch and
// would immediately respawn the daemon the user just stopped. The stop-intent
// marker records that intent so the autostart single-flight (ensureDaemonReady)
// declines to resurrect a deliberately-stopped daemon.
//
// Unlike the spawn-fail marker, this one is sticky — it has no TTL and is
// cleared only by an explicit `gortex daemon start` / `restart`. Suppressing
// autostart does not break `gortex mcp`: a suppressed proxy falls back to the
// embedded in-process server exactly as it does under GORTEX_AUTOSTART=0.
// StopIntentMarkerPath returns the sentinel file recording an explicit
// `daemon stop`. Co-located with the socket / PID / spawn-lock under the
// per-user state dir so it shares the daemon's lifecycle directory.
func StopIntentMarkerPath() string {
if dir, ok := stateDir(); ok {
return filepath.Join(dir, "daemon.stopped")
}
return filepath.Join(os.TempDir(), "gortex-daemon.stopped")
}
// MarkStopIntent records that the user explicitly stopped the daemon, so the
// autostart path will not respawn it until an explicit start clears the mark.
// The marker content is the stamp time (nanos) for debuggability; only its
// presence is load-bearing.
func MarkStopIntent() error {
path := StopIntentMarkerPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
return os.WriteFile(path, []byte(strconv.FormatInt(time.Now().UnixNano(), 10)), 0o600)
}
// ClearStopIntent removes the stop-intent marker, re-enabling autostart. An
// explicit `daemon start` / `restart` is the user (or supervisor) asking for a
// running daemon, which supersedes a prior stop. Absent marker is a no-op.
func ClearStopIntent() {
_ = os.Remove(StopIntentMarkerPath())
}
// StopIntentActive reports whether the user has an outstanding `daemon stop`
// that no subsequent start has cleared.
func StopIntentActive() bool {
_, err := os.Stat(StopIntentMarkerPath())
return err == nil
}
+48
View File
@@ -0,0 +1,48 @@
package daemon
import (
"path/filepath"
"testing"
)
func TestStopIntent_MarkClearRoundtrip(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
if StopIntentActive() {
t.Fatal("no marker should exist on a fresh state dir")
}
if err := MarkStopIntent(); err != nil {
t.Fatalf("MarkStopIntent: %v", err)
}
if !StopIntentActive() {
t.Fatal("marker must be active after MarkStopIntent")
}
// Idempotent: a second mark keeps it active, not an error.
if err := MarkStopIntent(); err != nil {
t.Fatalf("second MarkStopIntent: %v", err)
}
if !StopIntentActive() {
t.Fatal("marker must still be active after a repeat mark")
}
ClearStopIntent()
if StopIntentActive() {
t.Fatal("marker must be gone after ClearStopIntent")
}
// Clearing an absent marker is a no-op, not a panic/error.
ClearStopIntent()
if StopIntentActive() {
t.Fatal("clearing twice must leave the marker absent")
}
}
func TestStopIntentMarkerPath_UnderStateDir(t *testing.T) {
t.Setenv("XDG_CACHE_HOME", t.TempDir())
marker := StopIntentMarkerPath()
if filepath.Base(marker) != "daemon.stopped" {
t.Errorf("marker basename = %q, want daemon.stopped", filepath.Base(marker))
}
// Co-located with the rest of the daemon's runtime state.
if filepath.Dir(marker) != filepath.Dir(SpawnLockPath()) {
t.Error("stop-intent marker should be a sibling of the spawn lock")
}
}
+228
View File
@@ -0,0 +1,228 @@
package daemon
import (
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
// newDaemonNoServe is newDaemon's twin that stops short of calling
// Listen / Serve so the test can mutate fields like HTTPAddr /
// HTTPHandler before the listener comes up.
func newDaemonNoServe(t *testing.T, ctrl Controller) (*Server, string) {
t.Helper()
dir, err := os.MkdirTemp("/tmp", "gx")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(dir) })
socket := filepath.Join(dir, "s")
t.Setenv("GORTEX_DAEMON_SOCKET", socket)
t.Setenv("GORTEX_DAEMON_PIDFILE", filepath.Join(dir, "p"))
srv := New(socket, "test-0.0.0", zap.NewNop())
srv.Controller = ctrl
t.Cleanup(func() { _ = srv.Shutdown() })
return srv, socket
}
// TestDaemon_HTTPListenerServesAttachedHandler proves the daemon
// Server brings up an HTTP listener on HTTPAddr when HTTPHandler is
// set, that the handler reaches inbound requests, and that Shutdown
// tears the listener down cleanly. The streamable transport itself
// is exercised in internal/mcp/streamable; this test only proves the
// daemon-side plumbing.
func TestDaemon_HTTPListenerServesAttachedHandler(t *testing.T) {
ctrl := &fakeController{}
srv, _ := newDaemonNoServe(t, ctrl)
srv.HTTPHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("X-Method", r.Method)
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok")
})
srv.HTTPAddr = "127.0.0.1:0"
require.NoError(t, srv.Listen())
go func() { _ = srv.Serve() }()
require.Eventually(t, func() bool {
return srv.httpListener != nil
}, 2*time.Second, 10*time.Millisecond, "http listener never came up")
addr := srv.httpListener.Addr().String()
resp, err := http.Get("http://" + addr + "/anything")
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Equal(t, "GET", resp.Header.Get("X-Method"))
body, _ := io.ReadAll(resp.Body)
require.Equal(t, "ok", string(body))
require.NoError(t, srv.Shutdown())
require.Eventually(t, func() bool {
_, err := http.Get("http://" + addr + "/anything")
return err != nil
}, 2*time.Second, 10*time.Millisecond, "http listener stayed up after Shutdown")
}
// TestSessionRegistry_RegisterDetached covers the detached-session
// helper the streamable HTTP path relies on.
func TestSessionRegistry_RegisterDetached(t *testing.T) {
r := NewSessionRegistry()
sess := r.RegisterDetached("custom-id-42", Handshake{
Mode: ModeMCP,
CWD: "/tmp/x",
ClientName: "http",
})
require.NotNil(t, sess)
require.Equal(t, "custom-id-42", sess.ID)
require.Equal(t, "http", sess.ClientName)
require.Equal(t, "/tmp/x", sess.CWD)
require.Nil(t, sess.Conn, "detached session must not carry a Conn")
got := r.GetByID("custom-id-42")
require.NotNil(t, got)
require.Equal(t, sess, got)
removed := r.RemoveByID("custom-id-42")
require.NotNil(t, removed)
require.Nil(t, r.GetByID("custom-id-42"))
require.Nil(t, r.RemoveByID("custom-id-42"))
require.Nil(t, r.RemoveByID(""))
}
func TestSessionRegistry_RegisterDetachedAutoGenID(t *testing.T) {
r := NewSessionRegistry()
a := r.RegisterDetached("", Handshake{Mode: ModeMCP})
b := r.RegisterDetached("", Handshake{Mode: ModeMCP})
require.NotEmpty(t, a.ID)
require.NotEmpty(t, b.ID)
require.NotEqual(t, a.ID, b.ID)
require.Equal(t, 2, r.Count())
}
// TestDaemon_HTTPAndUnixCoexist confirms the unix-socket transport
// still works when an HTTP listener is also attached.
func TestDaemon_HTTPAndUnixCoexist(t *testing.T) {
ctrl := &fakeController{}
srv, socket := newDaemonNoServe(t, ctrl)
srv.HTTPHandler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTeapot)
})
srv.HTTPAddr = "127.0.0.1:0"
require.NoError(t, srv.Listen())
go func() { _ = srv.Serve() }()
require.Eventually(t, func() bool {
return srv.httpListener != nil && IsRunningAt(socket)
}, 2*time.Second, 10*time.Millisecond)
httpAddr := srv.httpListener.Addr().String()
resp, err := http.Get("http://" + httpAddr)
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusTeapot, resp.StatusCode)
c, err := DialTo(socket, Handshake{Mode: ModeControl, ClientName: "cli"})
require.NoError(t, err)
require.NoError(t, c.Close())
}
// TestDaemon_HTTPListenerFailureAbortsListen covers the operator
// misconfig case: pointing at a port that's already in use surfaces
// as a fatal Listen error rather than a silent half-up daemon.
func TestDaemon_HTTPListenerFailureAbortsListen(t *testing.T) {
occupied, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = occupied.Close() })
ctrl := &fakeController{}
srv, _ := newDaemonNoServe(t, ctrl)
srv.HTTPHandler = http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})
srv.HTTPAddr = occupied.Addr().String()
err = srv.Listen()
require.Error(t, err, "Listen should fail when http addr is in use")
require.Contains(t, err.Error(), "listen http")
}
// TestDaemon_NoHTTPAddrKeepsHTTPListenerNil — default-off path: no
// HTTPAddr means no port opens.
func TestDaemon_NoHTTPAddrKeepsHTTPListenerNil(t *testing.T) {
ctrl := &fakeController{}
srv, _ := newDaemonNoServe(t, ctrl)
require.NoError(t, srv.Listen())
require.Nil(t, srv.httpListener)
require.NoError(t, srv.Shutdown())
}
// TestDaemon_StreamableHTTPEndToEnd wires a streamable-shaped
// handler onto the daemon's HTTP listener and confirms a JSON-RPC
// roundtrip works over the wire. The real streamable.Transport lives
// in internal/mcp/streamable with its own dedicated tests; this one
// proves the daemon's HTTPHandler / HTTPAddr / SessionRegistry
// integration in one place.
func TestDaemon_StreamableHTTPEndToEnd(t *testing.T) {
ctrl := &fakeController{}
srv, _ := newDaemonNoServe(t, ctrl)
srv.MCPDispatcher = httpEchoDispatcher{}
srv.HTTPHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, _ := io.ReadAll(r.Body)
sess := srv.sessions.RegisterDetached("", Handshake{Mode: ModeMCP})
w.Header().Set("Mcp-Session-Id", sess.ID)
w.Header().Set("Content-Type", "application/json")
reply, _ := srv.MCPDispatcher.Dispatch(r.Context(), sess, body)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(reply)
})
srv.HTTPAddr = "127.0.0.1:0"
require.NoError(t, srv.Listen())
go func() { _ = srv.Serve() }()
require.Eventually(t, func() bool { return srv.httpListener != nil },
2*time.Second, 10*time.Millisecond)
addr := srv.httpListener.Addr().String()
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"echo"}`)
resp, err := http.Post("http://"+addr, "application/json", bytes.NewReader(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusOK, resp.StatusCode)
sid := resp.Header.Get("Mcp-Session-Id")
require.NotEmpty(t, sid)
out, _ := io.ReadAll(resp.Body)
require.Contains(t, string(out), `"jsonrpc":"2.0"`)
require.NotNil(t, srv.sessions.GetByID(sid))
}
// httpEchoDispatcher is the minimal MCPDispatcher used by
// TestDaemon_StreamableHTTPEndToEnd — returns a canned envelope
// naming the session id so the test can prove the dispatch path
// threaded both inputs through.
type httpEchoDispatcher struct{}
func (httpEchoDispatcher) Dispatch(_ context.Context, sess *Session, _ []byte) ([]byte, error) {
out, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"result": map[string]any{
"session_id": sess.ID,
"echoed": "yes",
},
})
return out, nil
}