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}` 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 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 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 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}