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 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 #[error(
76 "project `{project}`: agent `{agent}` has a blank `role_prompt` (empty string or empty list)"
77 )]
78 BlankRolePrompt { project: String, agent: String },
79
80 #[error("project `{project}`: agent `{agent}` has a blank `display_name`")]
81 BlankDisplayName { project: String, agent: String },
82
83 #[error("project `{project}`: agent `{agent}` `display_name` is {got} chars (max {max})")]
84 DisplayNameTooLong {
85 project: String,
86 agent: String,
87 got: usize,
88 max: usize,
89 },
90}
91
92pub const DISPLAY_NAME_MAX_CHARS: usize = 64;
100
101pub fn validate(compose: &Compose) -> Vec<ValidationError> {
102 let mut errs = Vec::new();
103
104 let runtimes = crate::runtimes::load_all(&compose.root).unwrap_or_default();
108 let check_runtime = !runtimes.is_empty();
109
110 match compose.global.broker.r#type.as_str() {
111 "sqlite" => {}
112 other => errs.push(ValidationError::UnknownBroker(other.into())),
113 }
114 match compose.global.supervisor.r#type.as_str() {
115 "tmux" | "systemd" | "launchd" => {}
116 other => errs.push(ValidationError::UnknownSupervisor(other.into())),
117 }
118 if compose.global.supervisor.drain_timeout_secs > 600 {
119 errs.push(ValidationError::DrainTimeoutOutOfRange(
120 compose.global.supervisor.drain_timeout_secs,
121 ));
122 }
123
124 let mut seen_projects = BTreeSet::new();
125 for p in &compose.projects {
126 if !seen_projects.insert(p.project.id.clone()) {
127 errs.push(ValidationError::DuplicateProject(p.project.id.clone()));
128 }
129
130 let mgr_ids: BTreeSet<&str> = p.managers.keys().map(|s| s.as_str()).collect();
131 let wrk_ids: BTreeSet<&str> = p.workers.keys().map(|s| s.as_str()).collect();
132 for dup in mgr_ids.intersection(&wrk_ids) {
133 errs.push(ValidationError::DuplicateAgent(
134 p.project.id.clone(),
135 (*dup).to_string(),
136 ));
137 }
138 let all_agents: BTreeSet<&str> = mgr_ids.union(&wrk_ids).copied().collect();
139
140 let channel_names: BTreeSet<&str> = p.channels.iter().map(|c| c.name.as_str()).collect();
142 for ch in &p.channels {
143 if let ChannelMembers::Explicit(members) = &ch.members {
144 for m in members {
145 if !all_agents.contains(m.as_str()) {
146 errs.push(ValidationError::ChannelUnknownMember {
147 project: p.project.id.clone(),
148 channel: ch.name.clone(),
149 agent: m.clone(),
150 });
151 }
152 }
153 }
154 }
155
156 let check_agent = |errs: &mut Vec<ValidationError>,
158 id: &str,
159 a: &crate::compose::Agent,
160 is_manager: bool| {
161 if a.telegram().is_some() && !is_manager {
162 errs.push(ValidationError::TelegramInboxOnWorker {
163 project: p.project.id.clone(),
164 agent: id.into(),
165 });
166 }
167 for t in &a.can_dm {
168 if !all_agents.contains(t.as_str()) {
169 errs.push(ValidationError::DmUnknownTarget {
170 project: p.project.id.clone(),
171 agent: id.into(),
172 target: t.clone(),
173 });
174 }
175 }
176 for c in &a.can_broadcast {
177 if !channel_names.contains(c.as_str()) {
178 errs.push(ValidationError::BroadcastUnknownChannel {
179 project: p.project.id.clone(),
180 agent: id.into(),
181 channel: c.clone(),
182 });
183 }
184 }
185 if let Some(t) = &a.reports_to {
186 if !mgr_ids.contains(t.as_str()) {
187 errs.push(ValidationError::UnknownManager {
188 project: p.project.id.clone(),
189 agent: id.into(),
190 target: t.clone(),
191 });
192 }
193 }
194 if check_runtime && !runtimes.contains_key(a.runtime.as_str()) {
195 errs.push(ValidationError::UnknownRuntime {
196 project: p.project.id.clone(),
197 agent: id.into(),
198 runtime: a.runtime.clone(),
199 });
200 }
201 if let Some(rp) = &a.role_prompt {
202 if rp.is_blank() {
203 errs.push(ValidationError::BlankRolePrompt {
204 project: p.project.id.clone(),
205 agent: id.into(),
206 });
207 }
208 }
209 if let Some(dn) = &a.display_name {
210 let trimmed_len = dn.trim().chars().count();
215 if trimmed_len == 0 {
216 errs.push(ValidationError::BlankDisplayName {
217 project: p.project.id.clone(),
218 agent: id.into(),
219 });
220 } else if dn.chars().count() > DISPLAY_NAME_MAX_CHARS {
221 errs.push(ValidationError::DisplayNameTooLong {
222 project: p.project.id.clone(),
223 agent: id.into(),
224 got: dn.chars().count(),
225 max: DISPLAY_NAME_MAX_CHARS,
226 });
227 }
228 }
229 };
230
231 for (id, a) in &p.managers {
232 check_agent(&mut errs, id, a, true);
233 }
234 for (id, a) in &p.workers {
235 check_agent(&mut errs, id, a, false);
236 }
237 }
238
239 errs
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use crate::compose::*;
246 use std::collections::BTreeMap;
247 use std::path::PathBuf;
248
249 fn toy_compose(agent_dm_target: &str) -> Compose {
250 let mut managers = BTreeMap::new();
251 managers.insert(
252 "mgr".into(),
253 Agent {
254 runtime: "claude-code".into(),
255 model: Some("claude-opus-4-7".into()),
256 role_prompt: None,
257 permission_mode: None,
258 autonomy: "low_risk_only".into(),
259 can_dm: vec![agent_dm_target.into()],
260 can_broadcast: vec!["team".into()],
261 reports_to: None,
262 on_rate_limit: None,
263 effort: None,
264 interfaces: None,
265 display_name: None,
266 },
267 );
268 let mut workers = BTreeMap::new();
269 workers.insert(
270 "dev".into(),
271 Agent {
272 runtime: "claude-code".into(),
273 model: None,
274 role_prompt: None,
275 permission_mode: None,
276 autonomy: "low_risk_only".into(),
277 can_dm: vec!["mgr".into()],
278 can_broadcast: vec!["team".into()],
279 reports_to: Some("mgr".into()),
280 on_rate_limit: None,
281 effort: None,
282 interfaces: None,
283 display_name: None,
284 },
285 );
286 Compose {
287 root: PathBuf::from("."),
288 global: Global {
289 version: 2,
290 broker: Default::default(),
291 supervisor: Default::default(),
292 budget: Default::default(),
293 hitl: Default::default(),
294 rate_limits: Default::default(),
295 interfaces: vec![],
296 projects: vec![],
297 attachments: Default::default(),
298 },
299 projects: vec![Project {
300 version: 2,
301 project: ProjectMeta {
302 id: "hello".into(),
303 name: "Hello".into(),
304 cwd: PathBuf::from("."),
305 },
306 channels: vec![Channel {
307 name: "team".into(),
308 members: ChannelMembers::All("*".into()),
309 }],
310 managers,
311 workers,
312 }],
313 }
314 }
315
316 #[test]
317 fn clean_compose_validates() {
318 let c = toy_compose("dev");
319 assert_eq!(validate(&c), vec![]);
320 }
321
322 #[test]
323 fn dm_to_unknown_agent_flags() {
324 let c = toy_compose("ghost");
325 let e = validate(&c);
326 assert!(matches!(
327 e.as_slice(),
328 [ValidationError::DmUnknownTarget { .. }]
329 ));
330 }
331
332 #[test]
333 fn unknown_broker_flags() {
334 let mut c = toy_compose("dev");
335 c.global.broker.r#type = "redis".into();
336 assert!(validate(&c)
337 .iter()
338 .any(|e| matches!(e, ValidationError::UnknownBroker(_))));
339 }
340
341 #[test]
342 fn drain_timeout_above_600s_flags() {
343 let mut c = toy_compose("dev");
344 c.global.supervisor.drain_timeout_secs = 86_400;
345 assert!(validate(&c)
346 .iter()
347 .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(86_400))));
348 }
349
350 #[test]
351 fn drain_timeout_zero_is_valid() {
352 let mut c = toy_compose("dev");
353 c.global.supervisor.drain_timeout_secs = 0;
354 assert!(!validate(&c)
355 .iter()
356 .any(|e| matches!(e, ValidationError::DrainTimeoutOutOfRange(_))));
357 }
358
359 #[test]
360 fn empty_role_prompt_list_flags() {
361 let mut c = toy_compose("dev");
362 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
363 Some(crate::compose::RolePrompt::Multiple(vec![]));
364 assert!(validate(&c)
365 .iter()
366 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
367 }
368
369 #[test]
370 fn empty_role_prompt_string_flags() {
371 let mut c = toy_compose("dev");
376 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
377 Some(crate::compose::RolePrompt::Single(PathBuf::from("")));
378 assert!(validate(&c)
379 .iter()
380 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
381 }
382
383 #[test]
384 fn single_role_prompt_validates() {
385 let mut c = toy_compose("dev");
386 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
387 crate::compose::RolePrompt::Single(PathBuf::from("roles/mgr.md")),
388 );
389 assert!(!validate(&c)
390 .iter()
391 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
392 }
393
394 #[test]
395 fn populated_role_prompt_list_validates() {
396 let mut c = toy_compose("dev");
397 c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(
398 crate::compose::RolePrompt::Multiple(vec![PathBuf::from("roles/mgr.md")]),
399 );
400 assert!(!validate(&c)
401 .iter()
402 .any(|e| matches!(e, ValidationError::BlankRolePrompt { .. })));
403 }
404
405 #[test]
406 fn blank_display_name_flags() {
407 let mut c = toy_compose("dev");
408 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(String::new());
409 assert!(validate(&c)
410 .iter()
411 .any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
412 }
413
414 #[test]
415 fn display_name_at_max_length_validates() {
416 let mut c = toy_compose("dev");
417 let exactly_max = "x".repeat(DISPLAY_NAME_MAX_CHARS);
418 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(exactly_max);
419 assert!(!validate(&c).iter().any(|e| matches!(
420 e,
421 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
422 )));
423 }
424
425 #[test]
426 fn display_name_above_max_length_flags() {
427 let mut c = toy_compose("dev");
428 let too_long = "x".repeat(DISPLAY_NAME_MAX_CHARS + 1);
429 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(too_long);
430 assert!(validate(&c)
431 .iter()
432 .any(|e| matches!(e, ValidationError::DisplayNameTooLong { .. })));
433 }
434
435 #[test]
436 fn display_name_counts_chars_not_bytes() {
437 let mut c = toy_compose("dev");
442 let sixty_four_crabs = "🦀".repeat(DISPLAY_NAME_MAX_CHARS);
443 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(sixty_four_crabs);
444 assert!(!validate(&c).iter().any(|e| matches!(
445 e,
446 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
447 )));
448 }
449
450 #[test]
451 fn whitespace_only_display_name_flags_blank() {
452 let mut c = toy_compose("dev");
456 c.projects[0].managers.get_mut("mgr").unwrap().display_name = Some(" ".into());
457 assert!(validate(&c)
458 .iter()
459 .any(|e| matches!(e, ValidationError::BlankDisplayName { .. })));
460 }
461
462 #[test]
463 fn populated_display_name_validates() {
464 let mut c = toy_compose("dev");
465 c.projects[0].managers.get_mut("mgr").unwrap().display_name =
466 Some("Sage (Visionary)".into());
467 assert!(!validate(&c).iter().any(|e| matches!(
468 e,
469 ValidationError::BlankDisplayName { .. } | ValidationError::DisplayNameTooLong { .. }
470 )));
471 }
472}