Skip to main content

quorum_rs/cli/workspace/
mod.rs

1#[cfg(test)]
2mod tests;
3
4use std::collections::{HashMap, HashSet};
5use std::path::Path;
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9use utoipa::ToSchema;
10
11// Re-export shared SLA type from SDK — single source of truth.
12pub use crate::scheduling::PolicySla;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct WorkspaceConfig {
16    #[serde(default)]
17    pub policies: HashMap<String, PolicyConfig>,
18    #[serde(default)]
19    pub orchestrators: HashMap<String, OrchestratorConfig>,
20    #[serde(default)]
21    pub rooms: HashMap<String, RoomConfig>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub shared: Option<Vec<ContextRef>>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub default_room: Option<String>,
26    /// Agent fleet configuration reference (for `nsed serve`).
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub agents: Option<AgentsConfig>,
29}
30
31/// Agent fleet configuration — points to an existing agent config file.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct AgentsConfig {
34    /// Path to agent fleet config YAML (relative to nsed.yaml parent directory).
35    pub config_file: String,
36    /// Optional port for the agent dashboard HTTP server.
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub dashboard_port: Option<u16>,
39}
40
41/// Unified single-file config (`quorum.yml`): workspace (orchestrators / rooms /
42/// policies) AND the agent fleet (providers / agents) in one place, so every
43/// command reads the same file and operators never pick "which yml to serve".
44///
45/// Splits cleanly into the two existing views via [`Self::to_workspace`] and
46/// [`Self::to_fleet`], so downstream code (run / tui / serve) is unchanged.
47/// Legacy split configs (`nsed.yaml` + `agent.yml`) keep working through their
48/// own loaders.
49#[derive(Debug, Clone, Deserialize, Default)]
50pub struct QuorumConfig {
51    #[serde(default)]
52    pub policies: HashMap<String, PolicyConfig>,
53    #[serde(default)]
54    pub orchestrators: HashMap<String, OrchestratorConfig>,
55    #[serde(default)]
56    pub rooms: HashMap<String, RoomConfig>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub default_room: Option<String>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub shared: Option<Vec<ContextRef>>,
61    /// Fleet: LLM/exec/mcp providers, keyed by id.
62    #[serde(default)]
63    pub providers: HashMap<String, crate::config::ProviderEntry>,
64    /// Fleet: the agents this host runs (`quorum serve`).
65    #[serde(default)]
66    pub agents: Vec<crate::agents::config::AgentConfig>,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub response_sla_secs: Option<u64>,
69    #[serde(default)]
70    pub telemetry: crate::telemetry::TelemetryConfig,
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub dashboard_port: Option<u16>,
73}
74
75impl QuorumConfig {
76    /// Parse a unified `quorum.yml` and validate its workspace half (policy
77    /// agent-count rules etc.), mirroring [`WorkspaceConfig::load`].
78    pub fn load(path: &Path) -> Result<Self, ConfigError> {
79        let contents = std::fs::read_to_string(path)?;
80        let config: Self = serde_yaml::from_str(&contents)?;
81        config.to_workspace().validate()?;
82        config.validate_fleet()?;
83        Ok(config)
84    }
85
86    /// Validate the inline fleet: agent names must be present and unique, and
87    /// any `provider_id` reference must resolve to a defined provider. Kept
88    /// light — per-agent LLM tuning is the agent runner's concern.
89    fn validate_fleet(&self) -> Result<(), ConfigError> {
90        let mut seen = HashSet::new();
91        for (index, agent) in self.agents.iter().enumerate() {
92            if agent.name.trim().is_empty() {
93                return Err(ConfigError::FleetEmptyAgentName { index });
94            }
95            if !seen.insert(agent.name.as_str()) {
96                return Err(ConfigError::FleetDuplicateAgent {
97                    name: agent.name.clone(),
98                });
99            }
100            if !agent.provider_id.is_empty() && !self.providers.contains_key(&agent.provider_id) {
101                return Err(ConfigError::FleetUnknownProvider {
102                    agent: agent.name.clone(),
103                    provider: agent.provider_id.clone(),
104                });
105            }
106        }
107        Ok(())
108    }
109
110    /// Load `path` and return the workspace view. Accepts EITHER a unified
111    /// `quorum.yml` or a legacy `nsed.yaml` — the unified parse is tried first;
112    /// a legacy `nsed.yaml` (whose `agents:` is a `config_file` pointer, not a
113    /// list) fails the unified parse and falls through to [`WorkspaceConfig::load`].
114    /// This lets `run` / `tui` / serve read one file with no format flag.
115    pub fn load_workspace(path: &Path) -> Result<WorkspaceConfig, ConfigError> {
116        match Self::load(path) {
117            Ok(q) => Ok(q.to_workspace()),
118            Err(_) => WorkspaceConfig::load(path),
119        }
120    }
121
122    /// Workspace view — orchestrators / rooms / policies for `run` / `tui` /
123    /// serve's operator-token resolution. The fleet lives inline here, so the
124    /// `agents` pointer is unused (set only to carry `dashboard_port`).
125    pub fn to_workspace(&self) -> WorkspaceConfig {
126        WorkspaceConfig {
127            policies: self.policies.clone(),
128            orchestrators: self.orchestrators.clone(),
129            rooms: self.rooms.clone(),
130            shared: self.shared.clone(),
131            default_room: self.default_room.clone(),
132            agents: None,
133        }
134    }
135
136    /// Fleet view — providers + agents for `quorum serve`. Orchestrators are
137    /// resolved from the workspace view, so the fleet's per-agent orchestrator
138    /// list is left empty here.
139    pub fn to_fleet(&self) -> crate::config::AgentFleetConfig {
140        crate::config::AgentFleetConfig {
141            providers: self.providers.clone(),
142            agents: self.agents.clone(),
143            orchestrators: Vec::new(),
144            response_sla_secs: self.response_sla_secs,
145            telemetry: self.telemetry.clone(),
146            dashboard_port: self.dashboard_port,
147        }
148    }
149}
150
151/// Execution mode for a policy.
152#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema, Default, PartialEq, Eq)]
153#[serde(rename_all = "snake_case")]
154pub enum PolicyMode {
155    Passthrough,
156    Moderator,
157    #[default]
158    Deliberation,
159}
160
161/// Content-addressable deliberation policy.
162/// Defines SLA, rounds, convergence, roles/agents, capabilities, and discovery tags.
163/// `policy_id = sha256(canonical JSON)` — same config produces the same hash.
164#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
165pub struct PolicyConfig {
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub agents: Option<Vec<String>>,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub roles: Option<Vec<RoleConfig>>,
170    #[serde(default = "default_rounds", alias = "rounds")]
171    pub max_rounds: u32,
172    #[serde(alias = "convergence_threshold", default = "default_effort")]
173    pub effort: f32,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub sla: Option<PolicySla>,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub capabilities: Option<Vec<String>>,
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub tags: Option<Vec<String>>,
180    /// Execution mode — controls which orchestration path handles this policy.
181    #[serde(default, skip_serializing_if = "is_deliberation")]
182    pub mode: PolicyMode,
183}
184
185fn is_deliberation(mode: &PolicyMode) -> bool {
186    *mode == PolicyMode::Deliberation
187}
188
189impl PolicyConfig {
190    /// Compute the content-addressable policy ID (SHA-256 hash of canonical JSON).
191    ///
192    /// Strips CLI-only fields (e.g. `context` on roles) to match the
193    /// orchestrator's `PolicyConfig` serialization.
194    pub fn policy_id(&self) -> String {
195        use sha2::{Digest, Sha256};
196
197        /// Minimal role struct matching orchestrator's `PolicyRole` serialization.
198        #[derive(serde::Serialize)]
199        struct HashableRole {
200            role: String,
201            count: u8,
202            capabilities: Vec<String>,
203            #[serde(default, skip_serializing_if = "Option::is_none")]
204            pinned_agents: Option<Vec<String>>,
205            #[serde(default, skip_serializing_if = "std::ops::Not::not")]
206            moderator: bool,
207        }
208
209        // Mirror the orchestrator's PolicyConfig serialization so both sides
210        // canonicalize on the same JSON and compute identical content hashes.
211        // The field name here MUST match the server-side field (`max_rounds`)
212        // — renaming it to `rounds` would produce different policy_ids for
213        // the same logical policy, breaking hash-based lookup.
214        #[derive(serde::Serialize)]
215        struct HashablePolicy {
216            #[serde(default, skip_serializing_if = "Option::is_none")]
217            agents: Option<Vec<String>>,
218            #[serde(default, skip_serializing_if = "Option::is_none")]
219            roles: Option<Vec<HashableRole>>,
220            max_rounds: u32,
221            effort: f32,
222            #[serde(default, skip_serializing_if = "Option::is_none")]
223            sla: Option<PolicySla>,
224            #[serde(default, skip_serializing_if = "Option::is_none")]
225            capabilities: Option<Vec<String>>,
226            #[serde(default, skip_serializing_if = "Option::is_none")]
227            tags: Option<Vec<String>>,
228            #[serde(default, skip_serializing_if = "is_deliberation")]
229            mode: PolicyMode,
230        }
231
232        let hashable = HashablePolicy {
233            agents: self.agents.clone(),
234            roles: self.roles.as_ref().map(|roles| {
235                roles
236                    .iter()
237                    .map(|r| HashableRole {
238                        role: r.role.clone(),
239                        count: r.count,
240                        capabilities: r.capabilities.clone(),
241                        pinned_agents: r.pinned_agents.clone(),
242                        moderator: r.moderator,
243                    })
244                    .collect()
245            }),
246            max_rounds: self.max_rounds,
247            effort: self.effort,
248            sla: self.sla.clone(),
249            capabilities: self.capabilities.clone(),
250            tags: self.tags.clone(),
251            mode: self.mode,
252        };
253
254        let canonical = serde_json::to_string(&hashable).expect("PolicyConfig must serialize");
255        let hash = Sha256::digest(canonical.as_bytes());
256        format!("{hash:x}")
257    }
258}
259
260/// Client-owned room — references a policy and optionally pins an orchestrator.
261/// History is a property of the room (client-side), not the orchestrator.
262#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
263pub struct RoomConfig {
264    pub policy: String,
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub orchestrator: Option<String>,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
270pub struct OrchestratorConfig {
271    /// How this orchestrator is accessed.
272    /// - `embedded`: in-process NATS + orchestrator (future, #171)
273    /// - `remote`: orchestrator running elsewhere, agents register via JWT
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub mode: Option<OrchestratorMode>,
276    /// HTTP address of the orchestrator (e.g. `"http://localhost:8080"`).
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub address: Option<String>,
279    /// Bearer token for API authentication.
280    /// Supports `${ENV_VAR}` syntax for environment variable expansion.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub token: Option<String>,
283    /// Direct NATS URL override (bypasses orchestrator registration).
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub nats_url: Option<String>,
286    /// Path to orchestrator settings YAML (relative to nsed.yaml parent).
287    /// If present, `nsed serve` starts this orchestrator as a subprocess.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub config_file: Option<String>,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
293#[serde(rename_all = "snake_case")]
294pub enum OrchestratorMode {
295    /// In-process NATS + orchestrator (future, #171)
296    Embedded,
297    /// Orchestrator running elsewhere, agents register via JWT
298    Remote,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
302pub struct RoleConfig {
303    pub role: String,
304    #[serde(default = "default_role_count")]
305    pub count: u8,
306    pub capabilities: Vec<String>,
307    #[serde(default)]
308    pub context: Option<Vec<ContextRef>>,
309    /// Pre-assigned agent IDs for this role. Must be ≤ `count`.
310    /// Remaining slots are filled at runtime by capability matching.
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub pinned_agents: Option<Vec<String>>,
313    /// If true, this role receives moderator-routed traffic.
314    /// At most one role per policy may set this.
315    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
316    pub moderator: bool,
317}
318
319#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
320pub struct ContextRef {
321    pub name: String,
322    pub path: String,
323}
324
325fn default_rounds() -> u32 {
326    3
327}
328
329fn default_effort() -> f32 {
330    0.6
331}
332
333fn default_role_count() -> u8 {
334    1
335}
336
337#[derive(Debug, Error)]
338pub enum ConfigError {
339    #[error("no policies defined")]
340    NoPolicies,
341
342    #[error("no orchestrators defined")]
343    NoOrchestrators,
344
345    #[error("no rooms defined")]
346    NoRooms,
347
348    #[error("policy '{policy}': agents and roles are mutually exclusive")]
349    AgentsAndRolesExclusive { policy: String },
350
351    #[error("policy '{policy}': must specify either agents or roles")]
352    NeitherAgentsNorRoles { policy: String },
353
354    #[error("policy '{policy}' ({mode}): requires at least {min} agent(s), got {count}")]
355    TooFewAgents {
356        policy: String,
357        count: usize,
358        min: usize,
359        mode: &'static str,
360    },
361
362    #[error("policy '{policy}': duplicate role name '{role}'")]
363    DuplicateRole { policy: String, role: String },
364
365    #[error("policy '{policy}', role '{role}': count must be >= 1")]
366    RoleCountZero { policy: String, role: String },
367
368    #[error("policy '{policy}', role '{role}': capabilities must not be empty")]
369    EmptyCapabilities { policy: String, role: String },
370
371    #[error("policy '{policy}': effort must be in [0.0, 1.0], got {value}")]
372    InvalidConvergence { policy: String, value: f32 },
373
374    #[error("policy '{policy}': max_rounds must be >= 1")]
375    ZeroRounds { policy: String },
376
377    #[error("policy '{policy}': sla.job_timeout_secs must be > 0")]
378    ZeroTimeout { policy: String },
379
380    #[error("policy '{policy}' ({mode}): total role count is {count}, need at least {min}")]
381    TooFewRoleAgents {
382        policy: String,
383        count: u32,
384        min: u32,
385        mode: &'static str,
386    },
387
388    #[error(
389        "policy '{policy}', role '{role}': pinned_agents count ({pinned}) exceeds role count ({count})"
390    )]
391    TooManyPinnedAgents {
392        policy: String,
393        role: String,
394        pinned: usize,
395        count: u8,
396    },
397
398    #[error("policy '{policy}', role '{role}': duplicate pinned agent '{agent}'")]
399    DuplicatePinnedAgent {
400        policy: String,
401        role: String,
402        agent: String,
403    },
404
405    #[error("policy '{policy}': too many agents ({count}), maximum is 255")]
406    TooManyAgents { policy: String, count: usize },
407
408    #[error("policy '{policy}', role '{role}': invalid capability tag '{tag}': {reason}")]
409    InvalidCapability {
410        policy: String,
411        role: String,
412        tag: String,
413        reason: String,
414    },
415
416    #[error("policy '{policy}': invalid capability tag '{tag}': {reason}")]
417    InvalidPolicyCapability {
418        policy: String,
419        tag: String,
420        reason: String,
421    },
422
423    #[error("policy '{policy}': invalid tag '{tag}': {reason}")]
424    InvalidPolicyTag {
425        policy: String,
426        tag: String,
427        reason: String,
428    },
429
430    #[error("room '{room}': references unknown policy '{policy}'")]
431    UnknownPolicy { room: String, policy: String },
432
433    #[error("room '{room}': references unknown orchestrator '{orchestrator}'")]
434    UnknownOrchestrator { room: String, orchestrator: String },
435
436    #[error("default_room '{name}' does not match any defined room")]
437    InvalidDefaultRoom { name: String },
438
439    #[error("failed to read config file: {0}")]
440    Io(#[from] std::io::Error),
441
442    #[error("failed to parse config YAML: {0}")]
443    Yaml(#[from] serde_yaml::Error),
444
445    #[error("room '{name}' not found (available: {available})")]
446    RoomNotFound { name: String, available: String },
447
448    #[error(
449        "multiple rooms defined but no --room flag or default_room set (available: {available})"
450    )]
451    AmbiguousRoom { available: String },
452
453    #[error("policy '{policy}': mode 'moderator' requires exactly one role with moderator: true")]
454    ModeratorRoleMissing { policy: String },
455
456    #[error("policy '{policy}': at most one role may have moderator: true")]
457    MultipleModeratorRoles { policy: String },
458
459    #[error(
460        "policy '{policy}': mode 'moderator' requires roles (not a flat agents list) \
461         so a role can be designated moderator: true"
462    )]
463    ModeratorRequiresRoles { policy: String },
464
465    #[error("fleet: agent at index {index} has an empty name")]
466    FleetEmptyAgentName { index: usize },
467
468    #[error("fleet: duplicate agent name '{name}'")]
469    FleetDuplicateAgent { name: String },
470
471    #[error("fleet: agent '{agent}' references unknown provider '{provider}'")]
472    FleetUnknownProvider { agent: String, provider: String },
473
474    #[error("{0}")]
475    ConfigFree(String),
476}
477
478impl ConfigError {
479    /// True for provisioning shortfalls — a policy that doesn't yet have
480    /// enough agents to start. These are real, fixable states a management
481    /// view should display (as a red fill indicator) rather than reject at
482    /// load time, unlike structural errors (parse, unknown refs).
483    pub fn is_provisioning(&self) -> bool {
484        matches!(
485            self,
486            ConfigError::TooFewAgents { .. } | ConfigError::TooFewRoleAgents { .. }
487        )
488    }
489}
490
491/// Human-readable name for a `PolicyMode` used in validation error
492/// messages. Kept as a simple free function rather than an `impl
493/// Display` on the enum to avoid pulling the whole `PolicyMode` import
494/// into the error messages module and to keep the strings stable even
495/// if the enum ever gets a Display impl with different formatting.
496fn policy_mode_name(mode: &PolicyMode) -> &'static str {
497    match mode {
498        PolicyMode::Deliberation => "deliberation",
499        PolicyMode::Passthrough => "passthrough",
500        PolicyMode::Moderator => "moderator",
501    }
502}
503
504pub fn validate_capability_tag(tag: &str) -> Result<(), String> {
505    if tag.is_empty() {
506        return Err("capability tag must not be empty".to_string());
507    }
508    if tag == "*" {
509        return Ok(());
510    }
511    if let Some(prefix) = tag.strip_suffix(":*") {
512        if prefix.is_empty() {
513            return Err("namespace before :* must not be empty".to_string());
514        }
515        return validate_tag_segment(prefix);
516    }
517    if tag.contains(':') {
518        let parts: Vec<&str> = tag.splitn(2, ':').collect();
519        validate_tag_segment(parts[0])?;
520        validate_tag_segment(parts[1])?;
521        return Ok(());
522    }
523    validate_tag_segment(tag)
524}
525
526fn validate_tag_segment(segment: &str) -> Result<(), String> {
527    if segment.is_empty() {
528        return Err("tag segment must not be empty".to_string());
529    }
530    if !segment
531        .chars()
532        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
533    {
534        return Err(format!(
535            "tag segment '{segment}' contains invalid characters (allowed: alphanumeric, -, _)"
536        ));
537    }
538    Ok(())
539}
540
541impl WorkspaceConfig {
542    /// Load workspace config from a YAML file, deserialize and validate.
543    pub fn load(path: &Path) -> Result<Self, ConfigError> {
544        let contents = std::fs::read_to_string(path)?;
545        let config: Self = serde_yaml::from_str(&contents)?;
546        config.validate()?;
547        Ok(config)
548    }
549
550    /// Load `nsed.yaml` when present; otherwise synthesize a config-free
551    /// single-orchestrator workspace from the redeemed `~/.nsed/` files
552    /// (see [`crate::cli::endpoint::remote_workspace`]). The discovery and
553    /// submit commands use this so onboarding needs no workspace file —
554    /// `quorum redeem` → `quorum run`/`status`/`rooms` just work.
555    pub fn load_or_remote_default(path: &Path) -> Result<Self, ConfigError> {
556        if path.exists() {
557            // Accepts a unified `quorum.yml` or a legacy `nsed.yaml`.
558            QuorumConfig::load_workspace(path)
559        } else {
560            crate::cli::endpoint::remote_workspace().map_err(ConfigError::ConfigFree)
561        }
562    }
563
564    /// Load for management views (the TUI). Same as
565    /// [`Self::load_or_remote_default`], but treats provisioning shortfalls
566    /// (too-few-agents) as non-fatal: an under-provisioned policy is a real
567    /// state the UI surfaces as a red fill indicator, not a reason to refuse
568    /// to open. Structural errors (parse, unknown refs, missing address) still
569    /// fail.
570    pub fn load_or_remote_default_for_view(path: &Path) -> Result<Self, ConfigError> {
571        if !path.exists() {
572            return crate::cli::endpoint::remote_workspace().map_err(ConfigError::ConfigFree);
573        }
574        let contents = std::fs::read_to_string(path)?;
575        // Unified `quorum.yml` first; a legacy `nsed.yaml` (whose `agents:` is a
576        // `config_file` pointer, not a list) fails the unified parse and falls
577        // through to the workspace parse.
578        let config: Self = match serde_yaml::from_str::<QuorumConfig>(&contents) {
579            Ok(q) => q.to_workspace(),
580            Err(_) => serde_yaml::from_str::<Self>(&contents)?,
581        };
582        match config.validate() {
583            Ok(()) => Ok(config),
584            Err(e) if e.is_provisioning() => Ok(config),
585            Err(e) => Err(e),
586        }
587    }
588
589    /// Resolve which room to use based on the priority chain:
590    /// 1. Explicit `room_flag` (from --room CLI arg)
591    /// 2. `default_room` from config
592    /// 3. Auto-select if exactly one room
593    /// 4. Error if multiple rooms and no default
594    pub fn resolve_room<'a>(
595        &'a self,
596        room_flag: Option<&'a str>,
597    ) -> Result<(&'a str, &'a RoomConfig), ConfigError> {
598        let available = || {
599            self.rooms
600                .keys()
601                .map(|k| k.as_str())
602                .collect::<Vec<_>>()
603                .join(", ")
604        };
605
606        if let Some(name) = room_flag {
607            return self.rooms.get(name).map(|r| (name, r)).ok_or_else(|| {
608                ConfigError::RoomNotFound {
609                    name: name.to_string(),
610                    available: available(),
611                }
612            });
613        }
614
615        if let Some(ref default) = self.default_room {
616            return self
617                .rooms
618                .get_key_value(default.as_str())
619                .map(|(k, v)| (k.as_str(), v))
620                .ok_or_else(|| ConfigError::InvalidDefaultRoom {
621                    name: default.clone(),
622                });
623        }
624
625        if self.rooms.len() == 1 {
626            let (k, v) = self.rooms.iter().next().unwrap();
627            return Ok((k.as_str(), v));
628        }
629
630        Err(ConfigError::AmbiguousRoom {
631            available: available(),
632        })
633    }
634
635    /// True when `room` dispatches to a remote orchestrator — its policy is
636    /// resolved server-side, so the local workspace needn't define it.
637    fn room_is_remote(&self, room: &RoomConfig) -> bool {
638        room.orchestrator
639            .as_ref()
640            .and_then(|name| self.orchestrators.get(name))
641            .map(|orch| orch.mode == Some(OrchestratorMode::Remote))
642            .unwrap_or(false)
643    }
644
645    pub fn validate(&self) -> Result<(), ConfigError> {
646        // Minimal config: at least agents or (orchestrators + policies + rooms)
647        let has_rooms = !self.rooms.is_empty();
648
649        // When rooms are defined, require full routing config. Local policies
650        // are only needed for rooms dispatched locally — a room bound to a
651        // remote orchestrator resolves its policy server-side, so "use only my
652        // remote settings" is a valid policy-free workspace.
653        if has_rooms {
654            if self.orchestrators.is_empty() {
655                return Err(ConfigError::NoOrchestrators);
656            }
657            let needs_local_policy = self.rooms.values().any(|r| !self.room_is_remote(r));
658            if needs_local_policy && self.policies.is_empty() {
659                return Err(ConfigError::NoPolicies);
660            }
661        }
662
663        // Remote orchestrators may omit `address`/`token`: `quorum serve`
664        // falls back to the redeemed `~/.nsed/{orchestrator,operator.token}`
665        // (and `$QUORUM_ORCHESTRATOR`) at resolution time, and the post-boot
666        // registration self-check loudly flags any agent left unattributed.
667        // So a config file need only name the orchestrator to reach it.
668
669        // Validate policies
670        for (policy_name, policy) in &self.policies {
671            Self::validate_policy(policy_name, policy)?;
672        }
673
674        // Validate rooms
675        for (room_name, room) in &self.rooms {
676            if let Some(ref orch) = room.orchestrator
677                && !self.orchestrators.contains_key(orch)
678            {
679                return Err(ConfigError::UnknownOrchestrator {
680                    room: room_name.clone(),
681                    orchestrator: orch.clone(),
682                });
683            }
684            // A remote-dispatched room's policy is an orchestrator-side id, not
685            // a local definition — skip the local-policy existence check.
686            if !self.room_is_remote(room) && !self.policies.contains_key(&room.policy) {
687                return Err(ConfigError::UnknownPolicy {
688                    room: room_name.clone(),
689                    policy: room.policy.clone(),
690                });
691            }
692        }
693
694        if let Some(ref default) = self.default_room
695            && !self.rooms.contains_key(default)
696        {
697            return Err(ConfigError::InvalidDefaultRoom {
698                name: default.clone(),
699            });
700        }
701
702        Ok(())
703    }
704
705    fn validate_policy(name: &str, policy: &PolicyConfig) -> Result<(), ConfigError> {
706        match (&policy.agents, &policy.roles) {
707            (Some(_), Some(_)) => {
708                return Err(ConfigError::AgentsAndRolesExclusive {
709                    policy: name.to_string(),
710                });
711            }
712            (None, None) => {
713                return Err(ConfigError::NeitherAgentsNorRoles {
714                    policy: name.to_string(),
715                });
716            }
717            (Some(agents), None) => {
718                if policy.mode == PolicyMode::Moderator {
719                    return Err(ConfigError::ModeratorRequiresRoles {
720                        policy: name.to_string(),
721                    });
722                }
723                let min_agents = match policy.mode {
724                    PolicyMode::Deliberation => 2,
725                    PolicyMode::Passthrough | PolicyMode::Moderator => 1,
726                };
727                if agents.len() < min_agents {
728                    return Err(ConfigError::TooFewAgents {
729                        policy: name.to_string(),
730                        count: agents.len(),
731                        min: min_agents,
732                        mode: policy_mode_name(&policy.mode),
733                    });
734                }
735                if agents.len() > 255 {
736                    return Err(ConfigError::TooManyAgents {
737                        policy: name.to_string(),
738                        count: agents.len(),
739                    });
740                }
741            }
742            (None, Some(roles)) => {
743                let mut seen_roles = HashSet::new();
744                let mut seen_pinned: HashSet<&String> = HashSet::new();
745                let mut total_count: u32 = 0;
746                let mut moderator_count: u32 = 0;
747
748                for role in roles {
749                    if !seen_roles.insert(&role.role) {
750                        return Err(ConfigError::DuplicateRole {
751                            policy: name.to_string(),
752                            role: role.role.clone(),
753                        });
754                    }
755                    if role.count == 0 {
756                        return Err(ConfigError::RoleCountZero {
757                            policy: name.to_string(),
758                            role: role.role.clone(),
759                        });
760                    }
761                    if role.capabilities.is_empty() {
762                        return Err(ConfigError::EmptyCapabilities {
763                            policy: name.to_string(),
764                            role: role.role.clone(),
765                        });
766                    }
767                    for tag in &role.capabilities {
768                        validate_capability_tag(tag).map_err(|reason| {
769                            ConfigError::InvalidCapability {
770                                policy: name.to_string(),
771                                role: role.role.clone(),
772                                tag: tag.clone(),
773                                reason,
774                            }
775                        })?;
776                    }
777                    if let Some(ref pinned) = role.pinned_agents {
778                        if pinned.len() > role.count as usize {
779                            return Err(ConfigError::TooManyPinnedAgents {
780                                policy: name.to_string(),
781                                role: role.role.clone(),
782                                pinned: pinned.len(),
783                                count: role.count,
784                            });
785                        }
786                        for agent in pinned {
787                            if !seen_pinned.insert(agent) {
788                                return Err(ConfigError::DuplicatePinnedAgent {
789                                    policy: name.to_string(),
790                                    role: role.role.clone(),
791                                    agent: agent.clone(),
792                                });
793                            }
794                        }
795                    }
796                    if role.moderator {
797                        moderator_count += 1;
798                    }
799                    total_count += role.count as u32;
800                }
801
802                if moderator_count > 1 {
803                    return Err(ConfigError::MultipleModeratorRoles {
804                        policy: name.to_string(),
805                    });
806                }
807                if policy.mode == PolicyMode::Moderator && moderator_count == 0 {
808                    return Err(ConfigError::ModeratorRoleMissing {
809                        policy: name.to_string(),
810                    });
811                }
812
813                let min_total: u32 = match policy.mode {
814                    PolicyMode::Deliberation => 2,
815                    PolicyMode::Passthrough | PolicyMode::Moderator => 1,
816                };
817                if total_count < min_total {
818                    return Err(ConfigError::TooFewRoleAgents {
819                        policy: name.to_string(),
820                        count: total_count,
821                        min: min_total,
822                        mode: policy_mode_name(&policy.mode),
823                    });
824                }
825            }
826        }
827
828        if policy.max_rounds == 0 {
829            return Err(ConfigError::ZeroRounds {
830                policy: name.to_string(),
831            });
832        }
833
834        if let Some(caps) = &policy.capabilities {
835            for tag in caps {
836                validate_capability_tag(tag).map_err(|reason| {
837                    ConfigError::InvalidPolicyCapability {
838                        policy: name.to_string(),
839                        tag: tag.clone(),
840                        reason,
841                    }
842                })?;
843            }
844        }
845
846        if let Some(tags) = &policy.tags {
847            for tag in tags {
848                validate_capability_tag(tag).map_err(|reason| ConfigError::InvalidPolicyTag {
849                    policy: name.to_string(),
850                    tag: tag.clone(),
851                    reason,
852                })?;
853            }
854        }
855
856        if !(0.0..=1.0).contains(&policy.effort) {
857            return Err(ConfigError::InvalidConvergence {
858                policy: name.to_string(),
859                value: policy.effort,
860            });
861        }
862
863        if let Some(sla) = &policy.sla
864            && sla.job_timeout_secs == 0
865        {
866            return Err(ConfigError::ZeroTimeout {
867                policy: name.to_string(),
868            });
869        }
870
871        Ok(())
872    }
873}