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 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
92/// T-160: max length for `display_name`. 64 is a sensible upper bound
93/// matching ratatui column widths in the TUI roster pane; longer names
94/// would force unsightly truncation downstream. Counted in Unicode
95/// scalar values (`chars().count()`), not grapheme clusters — most
96/// operator-typed labels are simple text where the two coincide, and
97/// the saved dependency on `unicode-segmentation` is not worth the
98/// fidelity gain for an at-most-64-cell rendering window.
99pub const DISPLAY_NAME_MAX_CHARS: usize = 64;
100
101pub fn validate(compose: &Compose) -> Vec<ValidationError> {
102    let mut errs = Vec::new();
103
104    // Known runtimes. Embedded defaults are always present, so the
105    // validator can always enforce that every referenced runtime resolves
106    // to a descriptor (built in or user-supplied override).
107    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        // Channel members reference known agents.
141        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        // Per-agent checks.
157        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                // T-160: trim before checking so `display_name: "   "`
211                // is rejected as blank rather than slipping through
212                // (whitespace-only labels render as a void cell in the
213                // TUI — same operator-confusion shape as empty).
214                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        // Pre-existing hole in the old `Option<PathBuf>` schema: an
372        // empty string slipped through and rendered
373        // `SYSTEM_PROMPT_PATH=<root>/`. Closed alongside the list
374        // form's empty-list check.
375        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        // T-160: limit applies to Unicode-scalar-value (`chars()`)
438        // count, not bytes. Each `🦀` is one char but four UTF-8
439        // bytes; 64 crabs must still validate even though that's 256
440        // bytes on disk.
441        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        // T-160 qa follow-up: whitespace-only labels render as a void
453        // cell in the TUI — reject under the same `BlankDisplayName`
454        // error as empty strings so the operator gets a clear message.
455        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}