Skip to main content

team_core/
validate.rs

1//! Invariant checks for a loaded [`Compose`] tree.
2//!
3//! Errors are collected rather than returned on first failure so the CLI can
4//! pretty-print the full list.
5
6use std::collections::BTreeSet;
7
8use thiserror::Error;
9
10use crate::compose::{ChannelMembers, Compose};
11
12#[derive(Debug, Error, PartialEq, Eq)]
13pub enum ValidationError {
14    #[error("project `{0}`: duplicate agent id `{1}` in managers and workers")]
15    DuplicateAgent(String, String),
16
17    #[error(
18        "project `{project}`: unknown agent `{agent}` referenced in channel `{channel}` members"
19    )]
20    ChannelUnknownMember {
21        project: String,
22        channel: String,
23        agent: String,
24    },
25
26    #[error("project `{project}`: agent `{agent}` `can_dm` lists unknown agent `{target}`")]
27    DmUnknownTarget {
28        project: String,
29        agent: String,
30        target: String,
31    },
32
33    #[error(
34        "project `{project}`: agent `{agent}` `can_broadcast` lists unknown channel `{channel}`"
35    )]
36    BroadcastUnknownChannel {
37        project: String,
38        agent: String,
39        channel: String,
40    },
41
42    #[error(
43        "project `{project}`: agent `{agent}` has an `interfaces.telegram` block but is not a manager"
44    )]
45    TelegramInboxOnWorker { project: String, agent: String },
46
47    #[error(
48        "worker `{project}:{agent}` declares `reports_to: {target}` but no such manager exists"
49    )]
50    UnknownManager {
51        project: String,
52        agent: String,
53        target: String,
54    },
55
56    #[error("broker type `{0}` not supported (known: sqlite)")]
57    UnknownBroker(String),
58
59    #[error("supervisor type `{0}` not supported (known: tmux, systemd, launchd)")]
60    UnknownSupervisor(String),
61
62    #[error("duplicate project id `{0}`")]
63    DuplicateProject(String),
64
65    #[error("project `{project}`: agent `{agent}` uses runtime `{runtime}`, which is not built in and not declared in `<root>/runtimes/{runtime}.yaml`")]
66    UnknownRuntime {
67        project: String,
68        agent: String,
69        runtime: String,
70    },
71
72    #[error("supervisor.drain_timeout_secs={0} is unreasonable; expected 0..=600")]
73    DrainTimeoutOutOfRange(u64),
74}
75
76pub fn validate(compose: &Compose) -> Vec<ValidationError> {
77    let mut errs = Vec::new();
78
79    // Known runtimes. Embedded defaults are always present, so the
80    // validator can always enforce that every referenced runtime resolves
81    // to a descriptor (built in or user-supplied override).
82    let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
83    let check_runtime = !runtimes.is_empty();
84
85    match compose.global.broker.r#type.as_str() {
86        "sqlite" => {}
87        other => errs.push(ValidationError::UnknownBroker(other.into())),
88    }
89    match compose.global.supervisor.r#type.as_str() {
90        "tmux" | "systemd" | "launchd" => {}
91        other => errs.push(ValidationError::UnknownSupervisor(other.into())),
92    }
93    if compose.global.supervisor.drain_timeout_secs > 600 {
94        errs.push(ValidationError::DrainTimeoutOutOfRange(
95            compose.global.supervisor.drain_timeout_secs,
96        ));
97    }
98
99    let mut seen_projects = BTreeSet::new();
100    for p in &compose.projects {
101        if !seen_projects.insert(p.project.id.clone()) {
102            errs.push(ValidationError::DuplicateProject(p.project.id.clone()));
103        }
104
105        let mgr_ids: BTreeSet<&str> = p.managers.keys().map(|s| s.as_str()).collect();
106        let wrk_ids: BTreeSet<&str> = p.workers.keys().map(|s| s.as_str()).collect();
107        for dup in mgr_ids.intersection(&wrk_ids) {
108            errs.push(ValidationError::DuplicateAgent(
109                p.project.id.clone(),
110                (*dup).to_string(),
111            ));
112        }
113        let all_agents: BTreeSet<&str> = mgr_ids.union(&wrk_ids).copied().collect();
114
115        // Channel members reference known agents.
116        let channel_names: BTreeSet<&str> = p.channels.iter().map(|c| c.name.as_str()).collect();
117        for ch in &p.channels {
118            if let ChannelMembers::Explicit(members) = &ch.members {
119                for m in members {
120                    if !all_agents.contains(m.as_str()) {
121                        errs.push(ValidationError::ChannelUnknownMember {
122                            project: p.project.id.clone(),
123                            channel: ch.name.clone(),
124                            agent: m.clone(),
125                        });
126                    }
127                }
128            }
129        }
130
131        // Per-agent checks.
132        let check_agent = |errs: &mut Vec<ValidationError>,
133                           id: &str,
134                           a: &crate::compose::Agent,
135                           is_manager: bool| {
136            if a.telegram().is_some() && !is_manager {
137                errs.push(ValidationError::TelegramInboxOnWorker {
138                    project: p.project.id.clone(),
139                    agent: id.into(),
140                });
141            }
142            for t in &a.can_dm {
143                if !all_agents.contains(t.as_str()) {
144                    errs.push(ValidationError::DmUnknownTarget {
145                        project: p.project.id.clone(),
146                        agent: id.into(),
147                        target: t.clone(),
148                    });
149                }
150            }
151            for c in &a.can_broadcast {
152                if !channel_names.contains(c.as_str()) {
153                    errs.push(ValidationError::BroadcastUnknownChannel {
154                        project: p.project.id.clone(),
155                        agent: id.into(),
156                        channel: c.clone(),
157                    });
158                }
159            }
160            if let Some(t) = &a.reports_to {
161                if !mgr_ids.contains(t.as_str()) {
162                    errs.push(ValidationError::UnknownManager {
163                        project: p.project.id.clone(),
164                        agent: id.into(),
165                        target: t.clone(),
166                    });
167                }
168            }
169            if check_runtime && !runtimes.contains_key(a.runtime.as_str()) {
170                errs.push(ValidationError::UnknownRuntime {
171                    project: p.project.id.clone(),
172                    agent: id.into(),
173                    runtime: a.runtime.clone(),
174                });
175            }
176        };
177
178        for (id, a) in &p.managers {
179            check_agent(&mut errs, id, a, true);
180        }
181        for (id, a) in &p.workers {
182            check_agent(&mut errs, id, a, false);
183        }
184    }
185
186    errs
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::compose::*;
193    use std::collections::BTreeMap;
194    use std::path::PathBuf;
195
196    fn toy_compose(agent_dm_target: &str) -> Compose {
197        let mut managers = BTreeMap::new();
198        managers.insert(
199            "mgr".into(),
200            Agent {
201                runtime: "claude-code".into(),
202                model: Some("claude-opus-4-7".into()),
203                role_prompt: None,
204                permission_mode: None,
205                autonomy: "low_risk_only".into(),
206                can_dm: vec![agent_dm_target.into()],
207                can_broadcast: vec!["team".into()],
208                reports_to: None,
209                on_rate_limit: None,
210                effort: None,
211                interfaces: None,
212            },
213        );
214        let mut workers = BTreeMap::new();
215        workers.insert(
216            "dev".into(),
217            Agent {
218                runtime: "claude-code".into(),
219                model: None,
220                role_prompt: None,
221                permission_mode: None,
222                autonomy: "low_risk_only".into(),
223                can_dm: vec!["mgr".into()],
224                can_broadcast: vec!["team".into()],
225                reports_to: Some("mgr".into()),
226                on_rate_limit: None,
227                effort: None,
228                interfaces: None,
229            },
230        );
231        Compose {
232            root: PathBuf::from("."),
233            global: Global {
234                version: 2,
235                broker: Default::default(),
236                supervisor: Default::default(),
237                budget: Default::default(),
238                hitl: Default::default(),
239                rate_limits: Default::default(),
240                interfaces: vec![],
241                projects: vec![],
242            },
243            projects: vec![Project {
244                version: 2,
245                project: ProjectMeta {
246                    id: "hello".into(),
247                    name: "Hello".into(),
248                    cwd: PathBuf::from("."),
249                },
250                channels: vec![Channel {
251                    name: "team".into(),
252                    members: ChannelMembers::All("*".into()),
253                }],
254                managers,
255                workers,
256            }],
257        }
258    }
259
260    #[test]
261    fn clean_compose_validates() {
262        let c = toy_compose("dev");
263        assert_eq!(validate(&c), vec![]);
264    }
265
266    #[test]
267    fn dm_to_unknown_agent_flags() {
268        let c = toy_compose("ghost");
269        let e = validate(&c);
270        assert!(matches!(
271            e.as_slice(),
272            [ValidationError::DmUnknownTarget { .. }]
273        ));
274    }
275
276    #[test]
277    fn unknown_broker_flags() {
278        let mut c = toy_compose("dev");
279        c.global.broker.r#type = "redis".into();
280        assert!(validate(&c)
281            .iter()
282            .any(|e| matches!(e, ValidationError::UnknownBroker(_))));
283    }
284
285    #[test]
286    fn drain_timeout_above_600s_flags() {
287        let mut c = toy_compose("dev");
288        c.global.supervisor.drain_timeout_secs = 86_400;
289        assert!(validate(&c)
290            .iter()
291            .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(86_400))));
292    }
293
294    #[test]
295    fn drain_timeout_zero_is_valid() {
296        let mut c = toy_compose("dev");
297        c.global.supervisor.drain_timeout_secs = 0;
298        assert!(!validate(&c)
299            .iter()
300            .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(_))));
301    }
302}