Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

752 lines
26 KiB
Go

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
}