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
76pub fn validate(compose: &Compose) -> Vec<ValidationError> {
77 let mut errs = Vec::new();
78
79 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 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 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}