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