Skip to main content

context_harness/
workspace.rs

1//! Multi-workspace routing for the MCP/REST server (SPEC-0014, DESIGN-0008).
2//!
3//! A [`WorkspaceRouter`] owns a set of [`WorkspaceRuntime`]s (one per registered
4//! workspace) and dispatches built-in tool calls to the runtime selected by a
5//! request. The router is a *dispatch layer only*: it never merges stores, and
6//! the underlying query functions (`search_documents` / `get_document` /
7//! `get_sources`) keep operating on a single `&Config`.
8//!
9//! Multi-workspace behavior is **additive and opt-in**. The default server runs
10//! in [`ServerMode::Compat`] — represented internally as a router holding one
11//! workspace — and behaves byte-for-byte like the pre-router server. Multi mode
12//! ([`ServerMode::Multi`]) is activated only by the explicit `--workspaces`
13//! flag and is the only mode that emits workspace-labeled responses.
14//!
15//! Phase 1 (this module) routes built-in tools to a selected workspace. The
16//! `workspace = "all"` fan-out (Phase 2) and origin-scoped extensions (Phase 3)
17//! are deferred — `all` currently returns `unsupported_workspace_selector`.
18
19use std::collections::{BTreeMap, HashMap};
20use std::future::Future;
21use std::path::{Path, PathBuf};
22use std::sync::Arc;
23use std::time::Duration;
24
25use anyhow::Context;
26use serde::{Deserialize, Serialize};
27use tokio::sync::Semaphore;
28use tokio::task::JoinSet;
29
30use crate::config::Config;
31
32/// The reserved selector meaning "every enabled workspace".
33pub const ALL_SELECTOR: &str = "all";
34
35/// Default per-workspace deadline for an `all` fan-out search (SPEC-0014 R33).
36pub const DEFAULT_SEARCH_DEADLINE_MS: u64 = 5000;
37/// Maximum number of workspaces searched concurrently during an `all` fan-out.
38pub const FAN_OUT_CONCURRENCY: usize = 4;
39
40/// Whether the server emits the pre-router flat shapes (`Compat`) or the
41/// workspace-labeled shapes (`Multi`). Chosen once at startup by activation
42/// path, never by the number of registered workspaces (SPEC-0014 R14/R15).
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum ServerMode {
45    /// Single-workspace compatibility mode (default). Pre-router behavior.
46    Compat,
47    /// Multi-workspace mode, activated by `--workspaces`.
48    Multi,
49}
50
51/// How a workspace's effective `Config` was resolved (SPEC-0014 R4/R5),
52/// surfaced by the `workspaces` tool so the divergence is visible.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum ResolutionMode {
55    /// Compatibility single-workspace config (the pre-router resolution).
56    Compat,
57    /// Registry entry with `root` only: workspace config + merged global defaults.
58    RootMerge,
59    /// Registry entry with `config = …`: pinned file, no global merge.
60    PinnedConfig,
61}
62
63impl ResolutionMode {
64    /// Stable string for operator-facing output.
65    pub fn as_str(self) -> &'static str {
66        match self {
67            ResolutionMode::Compat => "compat",
68            ResolutionMode::RootMerge => "root-merge",
69            ResolutionMode::PinnedConfig => "pinned-config",
70        }
71    }
72}
73
74/// Two-tier health (SPEC-0014 R60/R61). Cheap validation happens at startup;
75/// the SQLite store is opened lazily by the query functions on first use.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum WorkspaceHealth {
78    /// Passed cheap startup validation (id shape, absolute paths, config parse).
79    Ok,
80    /// Failed validation or initialization; queries are rejected.
81    Unavailable(String),
82}
83
84impl WorkspaceHealth {
85    /// Whether the workspace can currently serve queries.
86    pub fn is_ok(&self) -> bool {
87        matches!(self, WorkspaceHealth::Ok)
88    }
89}
90
91/// In-memory runtime for one workspace: its id, root, resolved config, and health.
92#[derive(Debug)]
93pub struct WorkspaceRuntime {
94    /// Stable, user-facing workspace id (`[A-Za-z0-9][A-Za-z0-9_-]*`).
95    pub id: String,
96    /// Absolute workspace root, if known. `None` for the compat default runtime.
97    pub root: Option<PathBuf>,
98    /// Whether the workspace accepts queries (a disabled workspace is listed
99    /// by discovery but rejects search/get/sources — R7).
100    pub enabled: bool,
101    /// The resolved effective config; its `[db].path` binds the store (R55–R58).
102    pub config: Arc<Config>,
103    /// Cheap-validation health state.
104    pub health: WorkspaceHealth,
105    /// How `config` was resolved (R4/R5), for the `workspaces` tool.
106    pub resolution: ResolutionMode,
107}
108
109impl WorkspaceRuntime {
110    /// The single runtime backing compatibility mode.
111    fn compat(config: Arc<Config>) -> Self {
112        Self {
113            id: "default".to_string(),
114            root: None,
115            enabled: true,
116            config,
117            health: WorkspaceHealth::Ok,
118            resolution: ResolutionMode::Compat,
119        }
120    }
121}
122
123/// Router error codes (SPEC-0014 R64). Returned to REST as the existing JSON
124/// error shape and to MCP as a tool error.
125#[derive(Debug, Clone)]
126pub enum RouterError {
127    /// A request needs an explicit workspace selector (>1 enabled, no default).
128    WorkspaceRequired { enabled: Vec<String> },
129    /// No workspace exists with the requested id.
130    UnknownWorkspace(String),
131    /// The requested workspace exists but is disabled.
132    WorkspaceDisabled(String),
133    /// The requested workspace cannot be loaded or queried.
134    WorkspaceUnavailable { id: String, reason: String },
135    /// A qualified id conflicts with an explicit `workspace` field.
136    WorkspaceIdConflict { field: String, qualified: String },
137    /// A selector such as `all` is not valid for the requested operation.
138    UnsupportedWorkspaceSelector(String),
139    /// A workspace exceeded its per-workspace deadline during an `all` fan-out.
140    WorkspaceTimeout { id: String, deadline_ms: u64 },
141}
142
143impl RouterError {
144    /// The machine-readable error code (SPEC-0014 R64 table).
145    pub fn code(&self) -> &'static str {
146        match self {
147            RouterError::WorkspaceRequired { .. } => "workspace_required",
148            RouterError::UnknownWorkspace(_) => "unknown_workspace",
149            RouterError::WorkspaceDisabled(_) => "workspace_disabled",
150            RouterError::WorkspaceUnavailable { .. } => "workspace_unavailable",
151            RouterError::WorkspaceIdConflict { .. } => "workspace_id_conflict",
152            RouterError::UnsupportedWorkspaceSelector(_) => "unsupported_workspace_selector",
153            RouterError::WorkspaceTimeout { .. } => "workspace_timeout",
154        }
155    }
156}
157
158impl std::fmt::Display for RouterError {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        match self {
161            RouterError::WorkspaceRequired { enabled } if enabled.is_empty() => write!(
162                f,
163                "no enabled workspaces are registered; add one with `ctx workspace add`"
164            ),
165            RouterError::WorkspaceRequired { enabled } => write!(
166                f,
167                "more than one workspace is enabled and no default is set; pass `workspace` \
168                 (one of: {}) or set [defaults].workspace",
169                enabled.join(", ")
170            ),
171            RouterError::UnknownWorkspace(id) => {
172                write!(f, "unknown workspace id: {id}")
173            }
174            RouterError::WorkspaceDisabled(id) => {
175                write!(f, "workspace is disabled: {id}")
176            }
177            RouterError::WorkspaceUnavailable { id, reason } => {
178                write!(f, "workspace unavailable: {id} ({reason})")
179            }
180            RouterError::WorkspaceIdConflict { field, qualified } => write!(
181                f,
182                "workspace field '{field}' conflicts with qualified id prefix '{qualified}'"
183            ),
184            RouterError::UnsupportedWorkspaceSelector(sel) => {
185                write!(f, "selector '{sel}' is not valid for this operation")
186            }
187            RouterError::WorkspaceTimeout { id, deadline_ms } => {
188                write!(f, "workspace timed out after {deadline_ms}ms: {id}")
189            }
190        }
191    }
192}
193
194impl std::error::Error for RouterError {}
195
196/// Routes built-in operations to the selected [`WorkspaceRuntime`].
197pub struct WorkspaceRouter {
198    default_workspace: Option<String>,
199    workspaces: HashMap<String, Arc<WorkspaceRuntime>>,
200    /// Stable iteration order for discovery/listing.
201    order: Vec<String>,
202    mode: ServerMode,
203    /// Per-workspace deadline for `all` fan-out (R33). Defaults to
204    /// `DEFAULT_SEARCH_DEADLINE_MS`; overridden by `with_search_deadline`.
205    search_deadline: Duration,
206}
207
208impl WorkspaceRouter {
209    /// Build the one-workspace router that backs compatibility mode.
210    pub fn single(config: Arc<Config>) -> Self {
211        let runtime = Arc::new(WorkspaceRuntime::compat(config));
212        let id = runtime.id.clone();
213        let mut workspaces = HashMap::new();
214        workspaces.insert(id.clone(), runtime);
215        Self {
216            default_workspace: Some(id.clone()),
217            workspaces,
218            order: vec![id],
219            mode: ServerMode::Compat,
220            search_deadline: Duration::from_millis(DEFAULT_SEARCH_DEADLINE_MS),
221        }
222    }
223
224    /// Build a multi-workspace router from pre-resolved runtimes.
225    ///
226    /// `runtimes` is consumed in the order it should be listed by discovery.
227    /// `default_workspace` is the `[defaults].workspace` id, if any.
228    pub fn multi(runtimes: Vec<WorkspaceRuntime>, default_workspace: Option<String>) -> Self {
229        let mut workspaces = HashMap::new();
230        let mut order = Vec::with_capacity(runtimes.len());
231        for rt in runtimes {
232            order.push(rt.id.clone());
233            workspaces.insert(rt.id.clone(), Arc::new(rt));
234        }
235        Self {
236            default_workspace,
237            workspaces,
238            order,
239            mode: ServerMode::Multi,
240            search_deadline: Duration::from_millis(DEFAULT_SEARCH_DEADLINE_MS),
241        }
242    }
243
244    /// The server mode this router represents.
245    pub fn mode(&self) -> ServerMode {
246        self.mode
247    }
248
249    /// The configured default workspace id, if set.
250    pub fn default_id(&self) -> Option<&str> {
251        self.default_workspace.as_deref()
252    }
253
254    /// Whether `id` is a registered workspace (used for qualified-id parsing, R40).
255    pub fn is_registered(&self, id: &str) -> bool {
256        self.workspaces.contains_key(id)
257    }
258
259    /// All runtimes in stable listing order (for the `workspaces` tool).
260    pub fn list(&self) -> Vec<Arc<WorkspaceRuntime>> {
261        self.order
262            .iter()
263            .filter_map(|id| self.workspaces.get(id).cloned())
264            .collect()
265    }
266
267    /// A config to back convenience-method access in a [`ToolContext`]. Returns
268    /// the default workspace's config, else the first enabled, else the first.
269    /// In multi mode built-in tools resolve per-call and do not rely on this.
270    pub fn default_config(&self) -> Arc<Config> {
271        if let Some(rt) = self
272            .default_workspace
273            .as_ref()
274            .and_then(|d| self.workspaces.get(d))
275        {
276            return rt.config.clone();
277        }
278        self.order
279            .iter()
280            .filter_map(|id| self.workspaces.get(id))
281            .find(|rt| rt.enabled)
282            .or_else(|| self.order.first().and_then(|id| self.workspaces.get(id)))
283            .map(|rt| rt.config.clone())
284            .expect("WorkspaceRouter must contain at least one workspace")
285    }
286
287    /// Ids of all enabled workspaces (used in `workspace_required` messages).
288    fn enabled_ids(&self) -> Vec<String> {
289        self.order
290            .iter()
291            .filter(|id| {
292                self.workspaces
293                    .get(*id)
294                    .map(|rt| rt.enabled)
295                    .unwrap_or(false)
296            })
297            .cloned()
298            .collect()
299    }
300
301    /// Resolve a selector to a single runtime (SPEC-0014 R19–R26).
302    ///
303    /// Precedence: explicit id → `[defaults].workspace` → single enabled →
304    /// otherwise `workspace_required`. The `all` selector is rejected here in
305    /// Phase 1 (`unsupported_workspace_selector`); fan-out is Phase 2.
306    pub fn resolve(&self, selector: Option<&str>) -> Result<Arc<WorkspaceRuntime>, RouterError> {
307        match selector {
308            Some(sel) if sel == ALL_SELECTOR => {
309                Err(RouterError::UnsupportedWorkspaceSelector(sel.to_string()))
310            }
311            Some(id) => self.resolve_id(id),
312            None => {
313                if let Some(default) = self.default_workspace.clone() {
314                    return self.resolve_id(&default);
315                }
316                let enabled = self.enabled_ids();
317                match enabled.as_slice() {
318                    [only] => self.resolve_id(only),
319                    _ => Err(RouterError::WorkspaceRequired { enabled }),
320                }
321            }
322        }
323    }
324
325    /// Resolve a concrete workspace id, applying enabled/health checks.
326    fn resolve_id(&self, id: &str) -> Result<Arc<WorkspaceRuntime>, RouterError> {
327        let runtime = self
328            .workspaces
329            .get(id)
330            .ok_or_else(|| RouterError::UnknownWorkspace(id.to_string()))?;
331        if !runtime.enabled {
332            return Err(RouterError::WorkspaceDisabled(id.to_string()));
333        }
334        match &runtime.health {
335            WorkspaceHealth::Ok => Ok(runtime.clone()),
336            WorkspaceHealth::Unavailable(reason) => Err(RouterError::WorkspaceUnavailable {
337                id: id.to_string(),
338                reason: reason.clone(),
339            }),
340        }
341    }
342
343    /// The configured per-workspace `all`-search deadline (R33).
344    pub fn search_deadline(&self) -> Duration {
345        self.search_deadline
346    }
347
348    /// Override the per-workspace `all`-search deadline. `None` leaves the
349    /// current value (the default) unchanged. Builder-style so existing
350    /// `multi(...)` call sites need no change.
351    pub fn with_search_deadline(mut self, ms: Option<u64>) -> Self {
352        if let Some(ms) = ms {
353            self.search_deadline = Duration::from_millis(ms);
354        }
355        self
356    }
357
358    /// Resolve the `all` selector (SPEC-0014 R21) into the enabled workspaces,
359    /// partitioned into healthy runtimes (to search) and enabled-but-unavailable
360    /// workspaces as pre-built error entries (R26/R37). Disabled workspaces are
361    /// excluded entirely — they are not part of `all` and are not errors (R7).
362    /// Output follows the stable registry order.
363    pub fn resolve_all(&self) -> (Vec<Arc<WorkspaceRuntime>>, Vec<(String, RouterError)>) {
364        let mut healthy = Vec::new();
365        let mut errors = Vec::new();
366        for id in &self.order {
367            let Some(rt) = self.workspaces.get(id) else {
368                continue;
369            };
370            if !rt.enabled {
371                continue;
372            }
373            match &rt.health {
374                WorkspaceHealth::Ok => healthy.push(rt.clone()),
375                WorkspaceHealth::Unavailable(reason) => errors.push((
376                    id.clone(),
377                    RouterError::WorkspaceUnavailable {
378                        id: id.clone(),
379                        reason: reason.clone(),
380                    },
381                )),
382            }
383        }
384        (healthy, errors)
385    }
386
387    /// Split a `get` id into (workspace, raw_id) using qualified-id rules (R40).
388    ///
389    /// An id is qualified only when it contains `:` and the prefix matches a
390    /// **registered** workspace id; otherwise the whole value is a raw id.
391    /// Returns the workspace prefix (if qualified) and the bare document id.
392    pub fn split_qualified_id<'a>(&self, id: &'a str) -> (Option<&'a str>, &'a str) {
393        if let Some((prefix, rest)) = id.split_once(':') {
394            if self.is_registered(prefix) {
395                return (Some(prefix), rest);
396            }
397        }
398        (None, id)
399    }
400}
401
402// ═══════════════════════════════════════════════════════════════════════
403// Workspace registry ($XDG_CONFIG_HOME/ctx/workspaces.toml)
404// ═══════════════════════════════════════════════════════════════════════
405
406/// The user-level registry of known workspaces (SPEC-0014 R1/R2).
407///
408/// Serialized to `$XDG_CONFIG_HOME/ctx/workspaces.toml`. Managed by
409/// `ctx workspace add/list/remove`; hand-editing is supported but the CLI
410/// validates paths at write time.
411#[derive(Debug, Clone, Default, Deserialize, Serialize)]
412pub struct WorkspaceRegistry {
413    #[serde(default, skip_serializing_if = "RegistryDefaults::is_empty")]
414    pub defaults: RegistryDefaults,
415    #[serde(default)]
416    pub workspaces: BTreeMap<String, WorkspaceEntry>,
417}
418
419/// `[defaults]` table of the registry.
420#[derive(Debug, Clone, Default, Deserialize, Serialize)]
421pub struct RegistryDefaults {
422    /// Default workspace used when a request omits `workspace` (R19).
423    #[serde(default, skip_serializing_if = "Option::is_none")]
424    pub workspace: Option<String>,
425    /// Shared server bind address in multi-workspace mode (R16).
426    #[serde(default, skip_serializing_if = "Option::is_none")]
427    pub bind: Option<String>,
428    /// Per-workspace deadline (ms) for `all` fan-out search (SPEC-0014 R33).
429    /// Overrides the built-in 5000 ms default when set.
430    #[serde(default, skip_serializing_if = "Option::is_none")]
431    pub search_deadline_ms: Option<u64>,
432}
433
434impl RegistryDefaults {
435    /// Whether no defaults are set (so the `[defaults]` table can be omitted).
436    fn is_empty(&self) -> bool {
437        self.workspace.is_none() && self.bind.is_none() && self.search_deadline_ms.is_none()
438    }
439}
440
441/// A single `[workspaces.<id>]` entry.
442#[derive(Debug, Clone, Deserialize, Serialize)]
443pub struct WorkspaceEntry {
444    /// Absolute workspace root (R3).
445    pub root: PathBuf,
446    /// Optional pinned config file; when set it is the sole source, no global
447    /// merge (R5). When omitted, the workspace's own `.ctx/config.toml` is
448    /// resolved with global defaults merged in (R4).
449    #[serde(default, skip_serializing_if = "Option::is_none")]
450    pub config: Option<PathBuf>,
451    /// Whether the workspace accepts queries (R6/R7). Defaults to `true`.
452    #[serde(default = "default_enabled")]
453    pub enabled: bool,
454}
455
456fn default_enabled() -> bool {
457    true
458}
459
460/// Whether `id` is a valid workspace id: `[A-Za-z0-9][A-Za-z0-9_-]*` (R-def).
461pub fn is_valid_workspace_id(id: &str) -> bool {
462    let mut chars = id.chars();
463    match chars.next() {
464        Some(c) if c.is_ascii_alphanumeric() => {}
465        _ => return false,
466    }
467    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
468}
469
470/// The default registry location: `$XDG_CONFIG_HOME/ctx/workspaces.toml` (R1).
471pub fn default_registry_path() -> PathBuf {
472    crate::ctx_dirs::config_dir().join("workspaces.toml")
473}
474
475impl WorkspaceRegistry {
476    /// Parse a registry file. Missing files are an error here; callers decide
477    /// whether absence is acceptable (mode is set by `--workspaces`, not file
478    /// presence — R10/R12).
479    pub fn load(path: &Path) -> anyhow::Result<Self> {
480        let content = std::fs::read_to_string(path)
481            .with_context(|| format!("failed to read workspace registry: {}", path.display()))?;
482        toml::from_str(&content)
483            .with_context(|| format!("failed to parse workspace registry: {}", path.display()))
484    }
485
486    /// Serialize the registry back to TOML (used by `ctx workspace add/remove`).
487    pub fn to_toml(&self) -> anyhow::Result<String> {
488        toml::to_string_pretty(self).context("failed to serialize workspace registry")
489    }
490}
491
492/// Build a multi-workspace [`WorkspaceRouter`] from a parsed registry (R60).
493///
494/// Cheap validation (id shape, absolute paths, config parse) happens here; an
495/// invalid or unresolvable workspace is recorded as
496/// [`WorkspaceHealth::Unavailable`] rather than aborting the server (R9/R61).
497/// Opening the SQLite store is deferred to the first query.
498pub fn build_multi_router(registry: &WorkspaceRegistry) -> anyhow::Result<WorkspaceRouter> {
499    if registry.workspaces.is_empty() {
500        anyhow::bail!("workspace registry has no [workspaces.*] entries");
501    }
502    if let Some(default) = &registry.defaults.workspace {
503        if !registry.workspaces.contains_key(default) {
504            anyhow::bail!("[defaults].workspace = \"{default}\" is not a registered workspace");
505        }
506    }
507    let runtimes = registry
508        .workspaces
509        .iter()
510        .map(|(id, entry)| build_runtime(id, entry))
511        .collect();
512    Ok(
513        WorkspaceRouter::multi(runtimes, registry.defaults.workspace.clone())
514            .with_search_deadline(registry.defaults.search_deadline_ms),
515    )
516}
517
518/// Resolve one registry entry into a runtime, capturing validation/config
519/// errors as an unavailable-but-listed runtime.
520fn build_runtime(id: &str, entry: &WorkspaceEntry) -> WorkspaceRuntime {
521    let resolution = if entry.config.is_some() {
522        ResolutionMode::PinnedConfig
523    } else {
524        ResolutionMode::RootMerge
525    };
526
527    let mut problems: Vec<String> = Vec::new();
528    if !is_valid_workspace_id(id) {
529        problems.push(format!("invalid workspace id '{id}'"));
530    }
531    if entry.root.is_relative() {
532        problems.push(format!("root must be absolute: {}", entry.root.display()));
533    }
534    if let Some(cfg) = &entry.config {
535        if cfg.is_relative() {
536            problems.push(format!("config must be absolute: {}", cfg.display()));
537        }
538    }
539
540    let unavailable = |reason: String| WorkspaceRuntime {
541        id: id.to_string(),
542        root: Some(entry.root.clone()),
543        enabled: entry.enabled,
544        config: Arc::new(Config::minimal()),
545        health: WorkspaceHealth::Unavailable(reason),
546        resolution,
547    };
548
549    if !problems.is_empty() {
550        return unavailable(problems.join("; "));
551    }
552
553    let resolved = match &entry.config {
554        Some(cfg) => crate::config::load_config_pinned(cfg, &entry.root),
555        None => crate::config::load_config_for_root(&entry.root),
556    };
557
558    match resolved {
559        Ok(rc) => WorkspaceRuntime {
560            id: id.to_string(),
561            root: Some(entry.root.clone()),
562            enabled: entry.enabled,
563            config: Arc::new(rc.config),
564            health: WorkspaceHealth::Ok,
565            resolution,
566        },
567        Err(e) => unavailable(format!("config error: {e}")),
568    }
569}
570
571// ═══════════════════════════════════════════════════════════════════════
572// `ctx workspace add/list/remove`
573// ═══════════════════════════════════════════════════════════════════════
574
575/// Persist a registry to disk, creating the parent directory if needed.
576fn write_registry(path: &Path, registry: &WorkspaceRegistry) -> anyhow::Result<()> {
577    if let Some(parent) = path.parent() {
578        std::fs::create_dir_all(parent)
579            .with_context(|| format!("failed to create config dir: {}", parent.display()))?;
580    }
581    std::fs::write(path, registry.to_toml()?)
582        .with_context(|| format!("failed to write workspace registry: {}", path.display()))?;
583    Ok(())
584}
585
586/// Load the registry from `path`, or a fresh empty one if the file is absent.
587fn load_or_default(path: &Path) -> anyhow::Result<WorkspaceRegistry> {
588    if path.exists() {
589        WorkspaceRegistry::load(path)
590    } else {
591        Ok(WorkspaceRegistry::default())
592    }
593}
594
595/// `ctx workspace add` — register a workspace, validating paths at write time.
596pub fn cmd_add(
597    path: &Path,
598    id: &str,
599    root: &Path,
600    config: Option<&Path>,
601    enabled: bool,
602) -> anyhow::Result<()> {
603    if !is_valid_workspace_id(id) {
604        anyhow::bail!("invalid workspace id '{id}': must match [A-Za-z0-9][A-Za-z0-9_-]*");
605    }
606    // Canonicalize to an absolute, existing path (validate at write time so the
607    // registry never holds a relative or dangling root — the design's footgun
608    // mitigation).
609    let root_abs = std::fs::canonicalize(root)
610        .with_context(|| format!("workspace root does not exist: {}", root.display()))?;
611    let config_abs = match config {
612        Some(c) => Some(
613            std::fs::canonicalize(c)
614                .with_context(|| format!("config file does not exist: {}", c.display()))?,
615        ),
616        None => None,
617    };
618
619    let mut registry = load_or_default(path)?;
620    if registry.workspaces.contains_key(id) {
621        anyhow::bail!("workspace '{id}' is already registered; remove it first");
622    }
623    registry.workspaces.insert(
624        id.to_string(),
625        WorkspaceEntry {
626            root: root_abs,
627            config: config_abs,
628            enabled,
629        },
630    );
631    write_registry(path, &registry)?;
632    println!(
633        "Added workspace '{id}'{}",
634        if enabled { "" } else { " (disabled)" }
635    );
636    Ok(())
637}
638
639/// `ctx workspace list` — print registered workspaces.
640pub fn cmd_list(path: &Path) -> anyhow::Result<()> {
641    if !path.exists() {
642        println!("No workspaces registered ({} not found)", path.display());
643        return Ok(());
644    }
645    let registry = WorkspaceRegistry::load(path)?;
646    let default = registry.defaults.workspace.as_deref();
647    println!("{:<20} {:<8} {:<8} ROOT", "ID", "ENABLED", "DEFAULT");
648    for (id, entry) in &registry.workspaces {
649        println!(
650            "{:<20} {:<8} {:<8} {}",
651            id,
652            entry.enabled,
653            Some(id.as_str()) == default,
654            entry.root.display()
655        );
656    }
657    Ok(())
658}
659
660/// `ctx workspace remove` — drop a workspace from the registry.
661pub fn cmd_remove(path: &Path, id: &str) -> anyhow::Result<()> {
662    if !path.exists() {
663        anyhow::bail!("no workspace registry at {}", path.display());
664    }
665    let mut registry = WorkspaceRegistry::load(path)?;
666    if registry.workspaces.remove(id).is_none() {
667        anyhow::bail!("workspace '{id}' is not registered");
668    }
669    // Drop a dangling default pointer.
670    if registry.defaults.workspace.as_deref() == Some(id) {
671        registry.defaults.workspace = None;
672    }
673    write_registry(path, &registry)?;
674    println!("Removed workspace '{id}'");
675    Ok(())
676}
677
678/// Outcome of one workspace's fan-out operation.
679#[derive(Debug)]
680pub(crate) enum FanOut<T> {
681    /// The operation completed within the deadline.
682    Ok(T),
683    /// The operation returned an error (message captured for the `errors[]` entry).
684    Failed(String),
685    /// The operation exceeded the per-item deadline.
686    TimedOut,
687}
688
689/// Run `op` for each `(id, input)` under a concurrency cap and a per-item
690/// deadline, returning outcomes paired with their id in the input order.
691///
692/// No input id is ever dropped: a task that panics is reported as `Failed`.
693/// This backs SPEC-0014 R33 (per-workspace deadline) and R37 (failures surfaced,
694/// never silently removed). It is generic over the op so the timeout/concurrency
695/// behavior is unit-tested with injected futures — no real store required.
696///
697/// Input ids must be unique: outcomes are keyed by id, so duplicate ids collide.
698pub(crate) async fn fan_out<I, T, F, Fut>(
699    items: Vec<(String, I)>,
700    deadline: Duration,
701    max_concurrency: usize,
702    op: F,
703) -> Vec<(String, FanOut<T>)>
704where
705    I: Send + 'static,
706    T: Send + 'static,
707    F: Fn(I) -> Fut + Clone + Send + 'static,
708    Fut: Future<Output = anyhow::Result<T>> + Send + 'static,
709{
710    let order: Vec<String> = items.iter().map(|(id, _)| id.clone()).collect();
711    let sem = Arc::new(Semaphore::new(max_concurrency.max(1)));
712    let mut set: JoinSet<(String, FanOut<T>)> = JoinSet::new();
713    for (id, input) in items {
714        let sem = sem.clone();
715        let op = op.clone();
716        set.spawn(async move {
717            let _permit = sem
718                .acquire_owned()
719                .await
720                .expect("fan-out semaphore is never closed");
721            let outcome = match tokio::time::timeout(deadline, op(input)).await {
722                Ok(Ok(value)) => FanOut::Ok(value),
723                Ok(Err(e)) => FanOut::Failed(format!("{e:#}")),
724                Err(_) => FanOut::TimedOut,
725            };
726            (id, outcome)
727        });
728    }
729
730    let mut by_id: HashMap<String, FanOut<T>> = HashMap::new();
731    while let Some(joined) = set.join_next().await {
732        if let Ok((id, outcome)) = joined {
733            by_id.insert(id, outcome);
734        }
735        // A JoinError (panic/cancel) leaves the id missing; it is reconciled
736        // below as `Failed` so the workspace is never dropped (R37).
737    }
738
739    order
740        .into_iter()
741        .map(|id| {
742            let outcome = by_id
743                .remove(&id)
744                .unwrap_or_else(|| FanOut::Failed("workspace task did not complete".to_string()));
745            (id, outcome)
746        })
747        .collect()
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753
754    fn cfg() -> Arc<Config> {
755        // A minimal config is enough; resolution-mode/health are what we test.
756        Arc::new(crate::config::Config::minimal())
757    }
758
759    fn runtime(id: &str, enabled: bool, health: WorkspaceHealth) -> WorkspaceRuntime {
760        WorkspaceRuntime {
761            id: id.to_string(),
762            root: Some(PathBuf::from(format!("/abs/{id}"))),
763            enabled,
764            config: cfg(),
765            health,
766            resolution: ResolutionMode::RootMerge,
767        }
768    }
769
770    #[test]
771    fn single_router_resolves_default() {
772        let r = WorkspaceRouter::single(cfg());
773        assert_eq!(r.mode(), ServerMode::Compat);
774        let rt = r.resolve(None).unwrap();
775        assert_eq!(rt.id, "default");
776    }
777
778    #[test]
779    fn multi_requires_selector_when_ambiguous() {
780        let r = WorkspaceRouter::multi(
781            vec![
782                runtime("a", true, WorkspaceHealth::Ok),
783                runtime("b", true, WorkspaceHealth::Ok),
784            ],
785            None,
786        );
787        let err = r.resolve(None).unwrap_err();
788        assert_eq!(err.code(), "workspace_required");
789    }
790
791    #[test]
792    fn multi_single_enabled_used_without_default() {
793        let r = WorkspaceRouter::multi(
794            vec![
795                runtime("a", true, WorkspaceHealth::Ok),
796                runtime("b", false, WorkspaceHealth::Ok),
797            ],
798            None,
799        );
800        assert_eq!(r.resolve(None).unwrap().id, "a");
801    }
802
803    #[test]
804    fn multi_default_is_used() {
805        let r = WorkspaceRouter::multi(
806            vec![
807                runtime("a", true, WorkspaceHealth::Ok),
808                runtime("b", true, WorkspaceHealth::Ok),
809            ],
810            Some("b".to_string()),
811        );
812        assert_eq!(r.resolve(None).unwrap().id, "b");
813    }
814
815    #[test]
816    fn unknown_disabled_unavailable_errors() {
817        let r = WorkspaceRouter::multi(
818            vec![
819                runtime("a", true, WorkspaceHealth::Ok),
820                runtime("b", false, WorkspaceHealth::Ok),
821                runtime("c", true, WorkspaceHealth::Unavailable("bad config".into())),
822            ],
823            None,
824        );
825        assert_eq!(
826            r.resolve(Some("nope")).unwrap_err().code(),
827            "unknown_workspace"
828        );
829        assert_eq!(
830            r.resolve(Some("b")).unwrap_err().code(),
831            "workspace_disabled"
832        );
833        assert_eq!(
834            r.resolve(Some("c")).unwrap_err().code(),
835            "workspace_unavailable"
836        );
837    }
838
839    #[test]
840    fn all_selector_rejected_in_phase1() {
841        let r = WorkspaceRouter::multi(vec![runtime("a", true, WorkspaceHealth::Ok)], None);
842        assert_eq!(
843            r.resolve(Some("all")).unwrap_err().code(),
844            "unsupported_workspace_selector"
845        );
846    }
847
848    #[test]
849    fn qualified_id_only_for_registered_prefix() {
850        let r = WorkspaceRouter::multi(
851            vec![runtime("context_harness", true, WorkspaceHealth::Ok)],
852            None,
853        );
854        assert_eq!(
855            r.split_qualified_id("context_harness:01J"),
856            (Some("context_harness"), "01J")
857        );
858        // Unregistered prefix -> treated as a raw id.
859        assert_eq!(r.split_qualified_id("foo:bar"), (None, "foo:bar"));
860        assert_eq!(r.split_qualified_id("01Jabc"), (None, "01Jabc"));
861    }
862
863    #[test]
864    fn workspace_id_validation() {
865        assert!(is_valid_workspace_id("context_harness"));
866        assert!(is_valid_workspace_id("stack-app"));
867        assert!(is_valid_workspace_id("a"));
868        assert!(is_valid_workspace_id("ws1"));
869        assert!(!is_valid_workspace_id(""));
870        assert!(!is_valid_workspace_id("_leading"));
871        assert!(!is_valid_workspace_id("-leading"));
872        assert!(!is_valid_workspace_id("has space"));
873        assert!(!is_valid_workspace_id("has:colon"));
874        assert!(!is_valid_workspace_id("has/slash"));
875    }
876
877    /// Write a minimal workspace at `<root>/.ctx/config.toml` with a
878    /// workspace-relative db path, and return the root.
879    fn seed_workspace(root: &Path) {
880        let ctx = root.join(".ctx");
881        std::fs::create_dir_all(ctx.join("data")).unwrap();
882        std::fs::write(
883            ctx.join("config.toml"),
884            "[db]\npath = \".ctx/data/ctx.sqlite\"\n\n\
885             [chunking]\nmax_tokens = 700\noverlap_tokens = 0\n\n\
886             [retrieval]\nfinal_limit = 12\n\n\
887             [server]\nbind = \"127.0.0.1:7331\"\n",
888        )
889        .unwrap();
890    }
891
892    #[test]
893    fn two_workspaces_resolve_to_distinct_absolute_db_paths() {
894        // SPEC-0014 R55–R58 + the plan's re-rooting risk check: each workspace
895        // must address its own store, anchored at its own root (not the cwd).
896        let tmp_a = tempfile::TempDir::new().unwrap();
897        let tmp_b = tempfile::TempDir::new().unwrap();
898        seed_workspace(tmp_a.path());
899        seed_workspace(tmp_b.path());
900
901        let mut workspaces = BTreeMap::new();
902        workspaces.insert(
903            "a".to_string(),
904            WorkspaceEntry {
905                root: tmp_a.path().to_path_buf(),
906                config: None,
907                enabled: true,
908            },
909        );
910        workspaces.insert(
911            "b".to_string(),
912            WorkspaceEntry {
913                root: tmp_b.path().to_path_buf(),
914                config: None,
915                enabled: true,
916            },
917        );
918        let registry = WorkspaceRegistry {
919            defaults: RegistryDefaults::default(),
920            workspaces,
921        };
922
923        let router = build_multi_router(&registry).unwrap();
924        let a = router.resolve(Some("a")).unwrap();
925        let b = router.resolve(Some("b")).unwrap();
926
927        assert!(a.config.db.path.is_absolute(), "a db path must be absolute");
928        assert!(b.config.db.path.is_absolute(), "b db path must be absolute");
929        assert_ne!(
930            a.config.db.path, b.config.db.path,
931            "stores must be distinct"
932        );
933        assert!(a.config.db.path.starts_with(tmp_a.path()));
934        assert!(b.config.db.path.starts_with(tmp_b.path()));
935        assert_eq!(a.resolution, ResolutionMode::RootMerge);
936    }
937
938    #[test]
939    fn relative_root_makes_workspace_unavailable() {
940        let mut workspaces = BTreeMap::new();
941        workspaces.insert(
942            "rel".to_string(),
943            WorkspaceEntry {
944                root: PathBuf::from("relative/path"),
945                config: None,
946                enabled: true,
947            },
948        );
949        let registry = WorkspaceRegistry {
950            defaults: RegistryDefaults::default(),
951            workspaces,
952        };
953        let router = build_multi_router(&registry).unwrap();
954        // Listed by discovery, but queries are rejected as unavailable (R9).
955        assert_eq!(router.list().len(), 1);
956        assert_eq!(
957            router.resolve(Some("rel")).unwrap_err().code(),
958            "workspace_unavailable"
959        );
960    }
961
962    #[test]
963    fn cmd_add_list_remove_roundtrip() {
964        let cfg_tmp = tempfile::TempDir::new().unwrap();
965        let reg_path = cfg_tmp.path().join("workspaces.toml");
966        let ws_a = tempfile::TempDir::new().unwrap();
967        let ws_b = tempfile::TempDir::new().unwrap();
968
969        cmd_add(&reg_path, "alpha", ws_a.path(), None, true).unwrap();
970        cmd_add(&reg_path, "beta", ws_b.path(), None, false).unwrap();
971
972        let reg = WorkspaceRegistry::load(&reg_path).unwrap();
973        assert_eq!(reg.workspaces.len(), 2);
974        assert!(reg.workspaces["alpha"].enabled);
975        assert!(!reg.workspaces["beta"].enabled);
976        assert!(reg.workspaces["alpha"].root.is_absolute());
977
978        // Duplicate id, invalid id, and nonexistent root are all rejected.
979        assert!(cmd_add(&reg_path, "alpha", ws_a.path(), None, true).is_err());
980        assert!(cmd_add(&reg_path, "bad id", ws_a.path(), None, true).is_err());
981        assert!(cmd_add(
982            &reg_path,
983            "gamma",
984            Path::new("/no/such/path/xyz123"),
985            None,
986            true
987        )
988        .is_err());
989
990        cmd_remove(&reg_path, "beta").unwrap();
991        let reg = WorkspaceRegistry::load(&reg_path).unwrap();
992        assert_eq!(reg.workspaces.len(), 1);
993        assert!(cmd_remove(&reg_path, "beta").is_err());
994    }
995
996    #[test]
997    fn registry_roundtrips_through_toml() {
998        let toml_src = "[defaults]\nworkspace = \"a\"\n\n\
999                        [workspaces.a]\nroot = \"/abs/a\"\nenabled = true\n";
1000        let reg: WorkspaceRegistry = toml::from_str(toml_src).unwrap();
1001        assert_eq!(reg.defaults.workspace.as_deref(), Some("a"));
1002        assert_eq!(reg.workspaces["a"].root, PathBuf::from("/abs/a"));
1003        assert!(reg.workspaces["a"].enabled);
1004        // Round-trip back to TOML and re-parse.
1005        let out = reg.to_toml().unwrap();
1006        let reparsed: WorkspaceRegistry = toml::from_str(&out).unwrap();
1007        assert_eq!(reparsed.workspaces["a"].root, PathBuf::from("/abs/a"));
1008    }
1009
1010    #[test]
1011    fn registry_parses_search_deadline_ms() {
1012        let toml = "[defaults]\nworkspace = \"a\"\nsearch_deadline_ms = 1234\n\n\
1013                    [workspaces.a]\nroot = \"/abs/a\"\nenabled = true\n";
1014        let reg: WorkspaceRegistry = toml::from_str(toml).unwrap();
1015        assert_eq!(reg.defaults.search_deadline_ms, Some(1234));
1016        // Round-trips and is omitted when unset.
1017        let reg2 = WorkspaceRegistry {
1018            defaults: RegistryDefaults::default(),
1019            workspaces: reg.workspaces.clone(),
1020        };
1021        let out = toml::to_string(&reg2).unwrap();
1022        assert!(
1023            !out.contains("search_deadline_ms"),
1024            "unset field is omitted"
1025        );
1026    }
1027
1028    #[test]
1029    fn workspace_timeout_error_code() {
1030        let e = RouterError::WorkspaceTimeout {
1031            id: "beta".to_string(),
1032            deadline_ms: 5000,
1033        };
1034        assert_eq!(e.code(), "workspace_timeout");
1035        assert!(e.to_string().contains("beta"));
1036        assert!(e.to_string().contains("5000"));
1037    }
1038
1039    #[test]
1040    fn resolve_all_partitions_by_enabled_and_health() {
1041        let r = WorkspaceRouter::multi(
1042            vec![
1043                runtime("a", true, WorkspaceHealth::Ok),
1044                runtime("b", false, WorkspaceHealth::Ok), // disabled -> excluded
1045                runtime("c", true, WorkspaceHealth::Unavailable("boom".to_string())),
1046                runtime("d", true, WorkspaceHealth::Ok),
1047            ],
1048            None,
1049        );
1050        let (healthy, errors) = r.resolve_all();
1051        // Healthy, in registry order: a, d. Disabled b excluded.
1052        assert_eq!(
1053            healthy.iter().map(|rt| rt.id.clone()).collect::<Vec<_>>(),
1054            vec!["a".to_string(), "d".to_string()]
1055        );
1056        // c is enabled-but-unavailable -> one error entry.
1057        assert_eq!(errors.len(), 1);
1058        assert_eq!(errors[0].0, "c");
1059        assert_eq!(errors[0].1.code(), "workspace_unavailable");
1060    }
1061
1062    #[test]
1063    fn with_search_deadline_overrides_default() {
1064        let r = WorkspaceRouter::multi(vec![runtime("a", true, WorkspaceHealth::Ok)], None);
1065        assert_eq!(
1066            r.search_deadline().as_millis(),
1067            DEFAULT_SEARCH_DEADLINE_MS as u128
1068        );
1069        let r = r.with_search_deadline(Some(250));
1070        assert_eq!(r.search_deadline().as_millis(), 250);
1071        // None leaves it unchanged.
1072        let r = r.with_search_deadline(None);
1073        assert_eq!(r.search_deadline().as_millis(), 250);
1074    }
1075
1076    #[tokio::test]
1077    async fn fan_out_classifies_and_preserves_order() {
1078        let items = vec![
1079            ("ok".to_string(), 1u32),
1080            ("fail".to_string(), 2u32),
1081            ("slow".to_string(), 3u32),
1082        ];
1083        let outcomes = fan_out(items, Duration::from_millis(50), 4, |n: u32| async move {
1084            match n {
1085                1 => Ok("done".to_string()),
1086                2 => Err(anyhow::anyhow!("boom")),
1087                _ => {
1088                    tokio::time::sleep(Duration::from_millis(500)).await;
1089                    Ok("late".to_string())
1090                }
1091            }
1092        })
1093        .await;
1094
1095        assert_eq!(
1096            outcomes
1097                .iter()
1098                .map(|(id, _)| id.clone())
1099                .collect::<Vec<_>>(),
1100            vec!["ok".to_string(), "fail".to_string(), "slow".to_string()],
1101            "order follows input order"
1102        );
1103        assert!(matches!(&outcomes[0].1, FanOut::Ok(s) if s == "done"));
1104        assert!(matches!(&outcomes[1].1, FanOut::Failed(m) if m.contains("boom")));
1105        assert!(matches!(&outcomes[2].1, FanOut::TimedOut));
1106    }
1107
1108    #[tokio::test]
1109    async fn fan_out_respects_concurrency_cap() {
1110        use std::sync::atomic::{AtomicUsize, Ordering};
1111        let inflight = std::sync::Arc::new(AtomicUsize::new(0));
1112        let max_seen = std::sync::Arc::new(AtomicUsize::new(0));
1113        let items: Vec<(String, ())> = (0..8).map(|i| (i.to_string(), ())).collect();
1114        let inflight_op = inflight.clone();
1115        let max_op = max_seen.clone();
1116        let outcomes = fan_out(items, Duration::from_millis(1000), 2, move |_: ()| {
1117            let inflight = inflight_op.clone();
1118            let max_seen = max_op.clone();
1119            async move {
1120                let cur = inflight.fetch_add(1, Ordering::SeqCst) + 1;
1121                max_seen.fetch_max(cur, Ordering::SeqCst);
1122                tokio::time::sleep(Duration::from_millis(20)).await;
1123                inflight.fetch_sub(1, Ordering::SeqCst);
1124                Ok::<_, anyhow::Error>(())
1125            }
1126        })
1127        .await;
1128        assert_eq!(outcomes.len(), 8);
1129        assert!(
1130            max_seen.load(Ordering::SeqCst) <= 2,
1131            "observed {} concurrent tasks, cap was 2",
1132            max_seen.load(Ordering::SeqCst)
1133        );
1134    }
1135}