1use 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 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 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 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}