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
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:
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user