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}` but no `runtimes/{runtime}.yaml` was found")]
71    UnknownRuntime {
72        project: String,
73        agent: String,
74        runtime: String,
75    },
76}
77
78pub fn validate(compose: &Compose) -> Vec<ValidationError> {
79    let mut errs = Vec::new();
80
81    // Known runtimes. Missing runtimes/ dir is OK (validator doesn't require
82    // them), but if the dir exists we enforce every referenced runtime has a
83    // descriptor.
84    let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
85    let check_runtime = !runtimes.is_empty();
86
87    match compose.global.broker.r#type.as_str() {
88        "sqlite" => {}
89        other => errs.push(ValidationError::UnknownBroker(other.into())),
90    }
91    match compose.global.supervisor.r#type.as_str() {
92        "tmux" | "systemd" | "launchd" => {}
93        other => errs.push(ValidationError::UnknownSupervisor(other.into())),
94    }
95
96    let mut seen_projects = BTreeSet::new();
97    for p in &compose.projects {
98        if !seen_projects.insert(p.project.id.clone()) {
99            errs.push(ValidationError::DuplicateProject(p.project.id.clone()));
100        }
101
102        let mgr_ids: BTreeSet<&str> = p.managers.keys().map(|s| s.as_str()).collect();
103        let wrk_ids: BTreeSet<&str> = p.workers.keys().map(|s| s.as_str()).collect();
104        for dup in mgr_ids.intersection(&wrk_ids) {
105            errs.push(ValidationError::DuplicateAgent(
106                p.project.id.clone(),
107                (*dup).to_string(),
108            ));
109        }
110        let all_agents: BTreeSet<&str> = mgr_ids.union(&wrk_ids).copied().collect();
111
112        // Channel members reference known agents.
113        let channel_names: BTreeSet<&str> = p.channels.iter().map(|c| c.name.as_str()).collect();
114        for ch in &p.channels {
115            if let ChannelMembers::Explicit(members) = &ch.members {
116                for m in members {
117                    if !all_agents.contains(m.as_str()) {
118                        errs.push(ValidationError::ChannelUnknownMember {
119                            project: p.project.id.clone(),
120                            channel: ch.name.clone(),
121                            agent: m.clone(),
122                        });
123                    }
124                }
125            }
126        }
127
128        // Per-agent checks.
129        let check_agent = |errs: &mut Vec<ValidationError>,
130                           id: &str,
131                           a: &crate::compose::Agent,
132                           is_manager: bool| {
133            if a.telegram_inbox && !is_manager {
134                errs.push(ValidationError::TelegramInboxOnWorker {
135                    project: p.project.id.clone(),
136                    agent: id.into(),
137                });
138            }
139            if a.reports_to_user && !is_manager {
140                errs.push(ValidationError::ReportsToUserOnWorker {
141                    project: p.project.id.clone(),
142                    agent: id.into(),
143                });
144            }
145            for t in &a.can_dm {
146                if !all_agents.contains(t.as_str()) {
147                    errs.push(ValidationError::DmUnknownTarget {
148                        project: p.project.id.clone(),
149                        agent: id.into(),
150                        target: t.clone(),
151                    });
152                }
153            }
154            for c in &a.can_broadcast {
155                if !channel_names.contains(c.as_str()) {
156                    errs.push(ValidationError::BroadcastUnknownChannel {
157                        project: p.project.id.clone(),
158                        agent: id.into(),
159                        channel: c.clone(),
160                    });
161                }
162            }
163            if let Some(t) = &a.reports_to {
164                if !mgr_ids.contains(t.as_str()) {
165                    errs.push(ValidationError::UnknownManager {
166                        project: p.project.id.clone(),
167                        agent: id.into(),
168                        target: t.clone(),
169                    });
170                }
171            }
172            if check_runtime && !runtimes.contains_key(a.runtime.as_str()) {
173                errs.push(ValidationError::UnknownRuntime {
174                    project: p.project.id.clone(),
175                    agent: id.into(),
176                    runtime: a.runtime.clone(),
177                });
178            }
179        };
180
181        for (id, a) in &p.managers {
182            check_agent(&mut errs, id, a, true);
183        }
184        for (id, a) in &p.workers {
185            check_agent(&mut errs, id, a, false);
186        }
187    }
188
189    errs
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::compose::*;
196    use std::collections::BTreeMap;
197    use std::path::PathBuf;
198
199    fn toy_compose(agent_dm_target: &str) -> Compose {
200        let mut managers = BTreeMap::new();
201        managers.insert(
202            "mgr".into(),
203            Agent {
204                runtime: "claude-code".into(),
205                model: Some("claude-opus-4-7".into()),
206                role_prompt: None,
207                permission_mode: None,
208                telegram_inbox: true,
209                reports_to_user: true,
210                autonomy: "low_risk_only".into(),
211                can_dm: vec![agent_dm_target.into()],
212                can_broadcast: vec!["team".into()],
213                reports_to: None,
214            },
215        );
216        let mut workers = BTreeMap::new();
217        workers.insert(
218            "dev".into(),
219            Agent {
220                runtime: "claude-code".into(),
221                model: None,
222                role_prompt: None,
223                permission_mode: None,
224                telegram_inbox: false,
225                reports_to_user: false,
226                autonomy: "low_risk_only".into(),
227                can_dm: vec!["mgr".into()],
228                can_broadcast: vec!["team".into()],
229                reports_to: Some("mgr".into()),
230            },
231        );
232        Compose {
233            root: PathBuf::from("."),
234            global: Global {
235                version: 2,
236                broker: Default::default(),
237                supervisor: Default::default(),
238                budget: Default::default(),
239                hitl: 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}