Skip to main content

team_core/
compose.rs

1//! YAML schema for `team-compose.yaml` and `projects/<id>.yaml`.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8/// Top-level `team-compose.yaml`.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct Global {
11    pub version: u32,
12
13    #[serde(default)]
14    pub broker: Broker,
15
16    #[serde(default)]
17    pub supervisor: SupervisorCfg,
18
19    #[serde(default)]
20    pub budget: Budget,
21
22    #[serde(default)]
23    pub hitl: Hitl,
24
25    #[serde(default)]
26    pub rate_limits: RateLimits,
27
28    /// Human-facing inbound channels. Telegram is one adapter; Discord,
29    /// iMessage, CLI, and webhook share the same shape.
30    #[serde(default)]
31    pub interfaces: Vec<Interface>,
32
33    /// Relative paths from the compose root.
34    #[serde(default)]
35    pub projects: Vec<ProjectRef>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Interface {
40    /// Adapter type: `telegram`, `discord`, `imessage`, `cli`, `webhook`, ...
41    pub r#type: String,
42    /// Free-form name; used in logs and to route approvals.
43    pub name: String,
44    /// Adapter-specific config (bot token, channel id, allowlist, …).
45    #[serde(default)]
46    pub config: serde_yaml::Value,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, Default)]
50pub struct Budget {
51    #[serde(default)]
52    pub daily_usd_limit: Option<f64>,
53    #[serde(default)]
54    pub warn_threshold_pct: Option<u32>,
55    #[serde(default)]
56    pub message_ttl_hours: Option<u32>,
57    #[serde(default)]
58    pub per_project_usd_limit: std::collections::BTreeMap<String, f64>,
59}
60
61/// Rate-limit handling policy.
62#[derive(Debug, Clone, Default, Serialize, Deserialize)]
63pub struct RateLimits {
64    /// Default hook-name chain to run on a hit. Empty means `[wait]`.
65    #[serde(default)]
66    pub default_on_hit: Vec<String>,
67
68    /// Named hooks. Agents reference these by name in their `on_rate_limit:`.
69    #[serde(default)]
70    pub hooks: Vec<RateLimitHook>,
71
72    /// Fallback wait when the hit can't be parsed for a reset time.
73    /// Default 30 minutes.
74    #[serde(default = "default_fallback_wait")]
75    pub fallback_wait_seconds: u64,
76}
77
78fn default_fallback_wait() -> u64 {
79    30 * 60
80}
81
82/// One named action that can run on a rate-limit hit.
83///
84/// `action` is one of:
85/// - `wait` — sleep until `resets_at` (or `fallback_wait_seconds`).
86/// - `send` — write a message into the mailbox; `to` and `template` required.
87/// - `webhook` — POST/GET to `url` (or `url_env`); the rate-limit row
88///   serializes as JSON in the body.
89/// - `run` — exec `command` with placeholders substituted.
90///
91/// Placeholders in `template` and `command` arguments:
92/// `{agent}`, `{runtime}`, `{hit_at}`, `{resets_at}`, `{resets_at_local}`,
93/// `{raw_match}`.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct RateLimitHook {
96    pub name: String,
97    pub action: String,
98    #[serde(default)]
99    pub to: Option<String>,
100    #[serde(default)]
101    pub template: Option<String>,
102    #[serde(default)]
103    pub url: Option<String>,
104    #[serde(default)]
105    pub url_env: Option<String>,
106    #[serde(default)]
107    pub method: Option<String>,
108    #[serde(default)]
109    pub command: Vec<String>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct Hitl {
114    #[serde(default = "default_sensitive_actions")]
115    pub globally_sensitive_actions: Vec<String>,
116    #[serde(default)]
117    pub auto_approve_windows: Vec<AutoApprove>,
118}
119
120impl Default for Hitl {
121    fn default() -> Self {
122        Self {
123            globally_sensitive_actions: default_sensitive_actions(),
124            auto_approve_windows: Vec::new(),
125        }
126    }
127}
128
129fn default_sensitive_actions() -> Vec<String> {
130    vec![
131        "publish".into(),
132        "release".into(),
133        "payment".into(),
134        "external_email".into(),
135        "external_api_post".into(),
136        "merge_to_main".into(),
137        "dns_change".into(),
138        "deploy".into(),
139    ]
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct AutoApprove {
144    pub action: String,
145    #[serde(default)]
146    pub project: Option<String>,
147    #[serde(default)]
148    pub agent: Option<String>,
149    #[serde(default)]
150    pub scope: Option<String>,
151    /// RFC 3339 timestamp in UTC.
152    pub until: String,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct ProjectRef {
157    pub file: PathBuf,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
161pub struct Broker {
162    #[serde(default = "default_broker_type")]
163    pub r#type: String,
164    #[serde(default = "default_mailbox_path")]
165    pub path: PathBuf,
166}
167
168impl Default for Broker {
169    fn default() -> Self {
170        Self {
171            r#type: default_broker_type(),
172            path: default_mailbox_path(),
173        }
174    }
175}
176
177fn default_broker_type() -> String {
178    "sqlite".into()
179}
180
181fn default_mailbox_path() -> PathBuf {
182    PathBuf::from("state/mailbox.db")
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
186pub struct SupervisorCfg {
187    #[serde(default = "default_supervisor_type")]
188    pub r#type: String,
189    #[serde(default = "default_tmux_prefix")]
190    pub tmux_prefix: String,
191    /// Seconds reload waits for an agent to exit gracefully after
192    /// SIGINT before falling through to a hard `kill-session`. Default
193    /// 10 — enough for an in-flight Claude Code tool call to finish
194    /// in the common case, short enough that operators don't sit
195    /// staring at a frozen reload. Set to 0 to disable graceful
196    /// drain (matches pre-PR-B hard-kill behaviour).
197    #[serde(default = "default_drain_timeout_secs")]
198    pub drain_timeout_secs: u64,
199}
200
201impl Default for SupervisorCfg {
202    fn default() -> Self {
203        Self {
204            r#type: default_supervisor_type(),
205            tmux_prefix: default_tmux_prefix(),
206            drain_timeout_secs: default_drain_timeout_secs(),
207        }
208    }
209}
210
211fn default_supervisor_type() -> String {
212    "tmux".into()
213}
214
215fn default_drain_timeout_secs() -> u64 {
216    10
217}
218
219fn default_tmux_prefix() -> String {
220    "a-".into()
221}
222
223/// Per-project file, e.g. `projects/hello.yaml`.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct Project {
226    pub version: u32,
227    pub project: ProjectMeta,
228
229    #[serde(default)]
230    pub channels: Vec<Channel>,
231
232    #[serde(default)]
233    pub managers: BTreeMap<String, Agent>,
234
235    #[serde(default)]
236    pub workers: BTreeMap<String, Agent>,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct ProjectMeta {
241    pub id: String,
242    pub name: String,
243    pub cwd: PathBuf,
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize)]
247pub struct Channel {
248    pub name: String,
249    /// Either a list of agent ids or the literal string `"*"`.
250    #[serde(default)]
251    pub members: ChannelMembers,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
255#[serde(untagged)]
256pub enum ChannelMembers {
257    All(String),
258    Explicit(Vec<String>),
259}
260
261impl Default for ChannelMembers {
262    fn default() -> Self {
263        Self::Explicit(Vec::new())
264    }
265}
266
267impl ChannelMembers {
268    pub fn includes(&self, agent: &str, all_agents: &[&str]) -> bool {
269        match self {
270            ChannelMembers::All(s) if s == "*" => all_agents.contains(&agent),
271            ChannelMembers::Explicit(v) => v.iter().any(|a| a == agent),
272            _ => false,
273        }
274    }
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct Agent {
279    #[serde(default = "default_runtime")]
280    pub runtime: String,
281    pub model: Option<String>,
282    pub role_prompt: Option<PathBuf>,
283    #[serde(default)]
284    pub permission_mode: Option<String>,
285    #[serde(default)]
286    pub telegram_inbox: bool,
287    #[serde(default)]
288    pub reports_to_user: bool,
289    #[serde(default = "default_autonomy")]
290    pub autonomy: String,
291    #[serde(default)]
292    pub can_dm: Vec<String>,
293    #[serde(default)]
294    pub can_broadcast: Vec<String>,
295    #[serde(default)]
296    pub reports_to: Option<String>,
297
298    /// Override the global rate-limit hook chain for this agent.
299    #[serde(default)]
300    pub on_rate_limit: Option<Vec<String>>,
301
302    /// Per-agent reasoning effort. Renders as `EFFORT=<value>` in the
303    /// agent env file; the wrapper passes it to the runtime (e.g.
304    /// `claude --effort <value>`). Strict enum: typos like `hgih` fail
305    /// compose validation rather than silently falling back to the
306    /// wrapper default.
307    #[serde(default)]
308    pub effort: Option<EffortLevel>,
309}
310
311/// Reasoning-effort level forwarded to the runtime. Maps 1:1 to
312/// `claude --effort <value>` today; if the runtime taxonomy evolves we
313/// extend the enum and bump the schema version.
314#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
315#[serde(rename_all = "lowercase")]
316pub enum EffortLevel {
317    Low,
318    Medium,
319    High,
320    Xhigh,
321    Max,
322}
323
324impl EffortLevel {
325    /// Lowercase rendering for the env-file `EFFORT=<value>` line and
326    /// the `claude --effort <value>` CLI flag.
327    pub fn as_str(self) -> &'static str {
328        match self {
329            EffortLevel::Low => "low",
330            EffortLevel::Medium => "medium",
331            EffortLevel::High => "high",
332            EffortLevel::Xhigh => "xhigh",
333            EffortLevel::Max => "max",
334        }
335    }
336}
337
338fn default_runtime() -> String {
339    "claude-code".into()
340}
341
342fn default_autonomy() -> String {
343    "low_risk_only".into()
344}
345
346/// Fully loaded compose tree: global + resolved projects.
347#[derive(Debug, Clone)]
348pub struct Compose {
349    pub root: PathBuf,
350    pub global: Global,
351    pub projects: Vec<Project>,
352}
353
354impl Compose {
355    /// Walk up from `start` looking for the **first** `.team/team-compose.yaml`
356    /// and return the directory containing the compose file (the "root"),
357    /// suitable for passing to [`Compose::load`]. The first hit wins; we do
358    /// not keep walking past it to look for a parent `.team/`.
359    ///
360    /// This is the equivalent of git's `.git/` discovery — once a repo carries
361    /// a `.team/` folder, every `teamctl` subcommand finds it from anywhere
362    /// inside the tree. T-008 retired the legacy flat-layout fallback and
363    /// the second-hit / parent-`.team/` walk: the convention is `.team/` and
364    /// the nearest one wins, no exceptions.
365    pub fn discover(start: &Path) -> anyhow::Result<PathBuf> {
366        let start = start
367            .canonicalize()
368            .map_err(|e| anyhow::anyhow!("canonicalize {}: {e}", start.display()))?;
369        let mut cur: Option<&Path> = Some(&start);
370        while let Some(dir) = cur {
371            let candidate = dir.join(".team").join("team-compose.yaml");
372            if candidate.is_file() {
373                return Ok(dir.join(".team"));
374            }
375            cur = dir.parent();
376        }
377        Err(anyhow::anyhow!(
378            "no `.team/team-compose.yaml` found in {} or any parent",
379            start.display()
380        ))
381    }
382
383    /// Parse `team-compose.yaml` at `root` and every referenced project file.
384    pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
385        let root = root.as_ref().to_path_buf();
386        let global_path = root.join("team-compose.yaml");
387        let global: Global = serde_yaml::from_str(
388            &std::fs::read_to_string(&global_path)
389                .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?,
390        )
391        .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
392
393        let mut projects = Vec::with_capacity(global.projects.len());
394        for r in &global.projects {
395            let p = root.join(&r.file);
396            let parsed: Project = serde_yaml::from_str(
397                &std::fs::read_to_string(&p)
398                    .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
399            )
400            .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
401            projects.push(parsed);
402        }
403
404        Ok(Self {
405            root,
406            global,
407            projects,
408        })
409    }
410
411    /// Return every agent in the compose tree tagged with manager/worker.
412    pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
413        self.projects.iter().flat_map(|p| {
414            p.managers
415                .iter()
416                .map(move |(id, a)| AgentHandle {
417                    project: &p.project.id,
418                    agent: id,
419                    spec: a,
420                    is_manager: true,
421                })
422                .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
423                    project: &p.project.id,
424                    agent: id,
425                    spec: a,
426                    is_manager: false,
427                }))
428        })
429    }
430}
431
432#[derive(Debug, Clone, Copy)]
433pub struct AgentHandle<'a> {
434    pub project: &'a str,
435    pub agent: &'a str,
436    pub spec: &'a Agent,
437    pub is_manager: bool,
438}
439
440impl AgentHandle<'_> {
441    /// Canonical id as `<project>:<agent>`.
442    pub fn id(&self) -> String {
443        format!("{}:{}", self.project, self.agent)
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn channel_members_all_expands() {
453        let all = ChannelMembers::All("*".into());
454        assert!(all.includes("dev1", &["dev1", "dev2"]));
455        assert!(!all.includes("ghost", &["dev1", "dev2"]));
456    }
457
458    #[test]
459    fn channel_members_explicit_checks_list() {
460        let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
461        assert!(exp.includes("dev1", &[]));
462        assert!(!exp.includes("dev2", &[]));
463    }
464
465    #[test]
466    fn agent_defaults_are_stable() {
467        let a: Agent = serde_yaml::from_str("model: claude-opus-4-7\n").unwrap();
468        assert_eq!(a.runtime, "claude-code");
469        assert_eq!(a.autonomy, "low_risk_only");
470        assert!(!a.telegram_inbox);
471        assert!(a.effort.is_none());
472    }
473
474    #[test]
475    fn effort_parses_all_five_levels() {
476        for (yaml, expected) in [
477            ("effort: low\n", EffortLevel::Low),
478            ("effort: medium\n", EffortLevel::Medium),
479            ("effort: high\n", EffortLevel::High),
480            ("effort: xhigh\n", EffortLevel::Xhigh),
481            ("effort: max\n", EffortLevel::Max),
482        ] {
483            let a: Agent = serde_yaml::from_str(yaml).expect(yaml);
484            assert_eq!(a.effort, Some(expected), "yaml: {yaml}");
485        }
486    }
487
488    #[test]
489    fn effort_unknown_value_is_rejected() {
490        let err = serde_yaml::from_str::<Agent>("effort: hgih\n")
491            .expect_err("typo'd effort value must fail to parse");
492        let msg = err.to_string();
493        assert!(
494            msg.contains("low") && msg.contains("max"),
495            "error should enumerate valid variants; got: {msg}"
496        );
497    }
498
499    #[test]
500    fn effort_renders_to_lowercase_string() {
501        assert_eq!(EffortLevel::Low.as_str(), "low");
502        assert_eq!(EffortLevel::Xhigh.as_str(), "xhigh");
503        assert_eq!(EffortLevel::Max.as_str(), "max");
504    }
505
506    #[test]
507    fn discover_prefers_dot_team() {
508        let tmp = tempfile::tempdir().unwrap();
509        let repo = tmp.path();
510        std::fs::create_dir_all(repo.join(".team")).unwrap();
511        std::fs::write(repo.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
512        // a stray flat-layout file in the same dir should NOT be preferred.
513        std::fs::write(repo.join("team-compose.yaml"), "version: 2\n").unwrap();
514
515        // Walking up from a sub-dir should still find the .team/ root.
516        let sub = repo.join("src/deep/nested");
517        std::fs::create_dir_all(&sub).unwrap();
518        let found = Compose::discover(&sub).unwrap();
519        assert_eq!(found, repo.canonicalize().unwrap().join(".team"));
520    }
521
522    #[test]
523    fn discover_no_longer_falls_back_to_flat_layout() {
524        // T-008: a flat `team-compose.yaml` at cwd (no `.team/` wrapper) is
525        // not discoverable. The convention is `.team/`. Operators must
526        // either `init` a `.team/` or pass `--root` explicitly.
527        let tmp = tempfile::tempdir().unwrap();
528        std::fs::write(tmp.path().join("team-compose.yaml"), "version: 2\n").unwrap();
529        let err = Compose::discover(tmp.path()).unwrap_err();
530        assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
531    }
532
533    #[test]
534    fn discover_returns_first_dot_team_walking_up() {
535        // T-008 boundary: nested `.team/`s win over outer ones. We do NOT
536        // keep walking past the first hit.
537        let tmp = tempfile::tempdir().unwrap();
538        let outer = tmp.path();
539        let inner = outer.join("packages/inner");
540        std::fs::create_dir_all(outer.join(".team")).unwrap();
541        std::fs::write(outer.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
542        std::fs::create_dir_all(inner.join(".team")).unwrap();
543        std::fs::write(inner.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
544
545        let from_inner = inner.join("src/deep");
546        std::fs::create_dir_all(&from_inner).unwrap();
547        let found = Compose::discover(&from_inner).unwrap();
548        assert_eq!(found, inner.canonicalize().unwrap().join(".team"));
549    }
550
551    #[test]
552    fn discover_errors_when_nothing_found() {
553        let tmp = tempfile::tempdir().unwrap();
554        let err = Compose::discover(tmp.path()).unwrap_err();
555        assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
556    }
557}