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}
192
193impl Default for SupervisorCfg {
194    fn default() -> Self {
195        Self {
196            r#type: default_supervisor_type(),
197            tmux_prefix: default_tmux_prefix(),
198        }
199    }
200}
201
202fn default_supervisor_type() -> String {
203    "tmux".into()
204}
205
206fn default_tmux_prefix() -> String {
207    "a-".into()
208}
209
210/// Per-project file, e.g. `projects/hello.yaml`.
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Project {
213    pub version: u32,
214    pub project: ProjectMeta,
215
216    #[serde(default)]
217    pub channels: Vec<Channel>,
218
219    #[serde(default)]
220    pub managers: BTreeMap<String, Agent>,
221
222    #[serde(default)]
223    pub workers: BTreeMap<String, Agent>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct ProjectMeta {
228    pub id: String,
229    pub name: String,
230    pub cwd: PathBuf,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct Channel {
235    pub name: String,
236    /// Either a list of agent ids or the literal string `"*"`.
237    #[serde(default)]
238    pub members: ChannelMembers,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242#[serde(untagged)]
243pub enum ChannelMembers {
244    All(String),
245    Explicit(Vec<String>),
246}
247
248impl Default for ChannelMembers {
249    fn default() -> Self {
250        Self::Explicit(Vec::new())
251    }
252}
253
254impl ChannelMembers {
255    pub fn includes(&self, agent: &str, all_agents: &[&str]) -> bool {
256        match self {
257            ChannelMembers::All(s) if s == "*" => all_agents.contains(&agent),
258            ChannelMembers::Explicit(v) => v.iter().any(|a| a == agent),
259            _ => false,
260        }
261    }
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct Agent {
266    #[serde(default = "default_runtime")]
267    pub runtime: String,
268    pub model: Option<String>,
269    pub role_prompt: Option<PathBuf>,
270    #[serde(default)]
271    pub permission_mode: Option<String>,
272    #[serde(default)]
273    pub telegram_inbox: bool,
274    #[serde(default)]
275    pub reports_to_user: bool,
276    #[serde(default = "default_autonomy")]
277    pub autonomy: String,
278    #[serde(default)]
279    pub can_dm: Vec<String>,
280    #[serde(default)]
281    pub can_broadcast: Vec<String>,
282    #[serde(default)]
283    pub reports_to: Option<String>,
284
285    /// Override the global rate-limit hook chain for this agent.
286    #[serde(default)]
287    pub on_rate_limit: Option<Vec<String>>,
288}
289
290fn default_runtime() -> String {
291    "claude-code".into()
292}
293
294fn default_autonomy() -> String {
295    "low_risk_only".into()
296}
297
298/// Fully loaded compose tree: global + resolved projects.
299#[derive(Debug, Clone)]
300pub struct Compose {
301    pub root: PathBuf,
302    pub global: Global,
303    pub projects: Vec<Project>,
304}
305
306impl Compose {
307    /// Walk up from `start` looking for the nearest `.team/team-compose.yaml`,
308    /// then fall back to a flat `team-compose.yaml` in `start` itself. Returns
309    /// the directory containing the compose file (the "root"), suitable for
310    /// passing to [`Compose::load`].
311    ///
312    /// This is the equivalent of git's `.git/` discovery — once a repo carries
313    /// a `.team/` folder, every `teamctl` subcommand finds it from anywhere
314    /// inside the tree.
315    pub fn discover(start: &Path) -> anyhow::Result<PathBuf> {
316        let start = start
317            .canonicalize()
318            .map_err(|e| anyhow::anyhow!("canonicalize {}: {e}", start.display()))?;
319        // 1. Walk up looking for .team/team-compose.yaml.
320        let mut cur: Option<&Path> = Some(&start);
321        while let Some(dir) = cur {
322            let candidate = dir.join(".team").join("team-compose.yaml");
323            if candidate.is_file() {
324                return Ok(dir.join(".team"));
325            }
326            cur = dir.parent();
327        }
328        // 2. Flat layout in `start`.
329        if start.join("team-compose.yaml").is_file() {
330            return Ok(start);
331        }
332        // 3. Walk up for a flat layout (legacy convenience).
333        let mut cur: Option<&Path> = start.parent();
334        while let Some(dir) = cur {
335            if dir.join("team-compose.yaml").is_file() {
336                eprintln!(
337                    "warning: using legacy flat layout at {}; consider migrating to {}",
338                    dir.display(),
339                    dir.join(".team").display()
340                );
341                return Ok(dir.to_path_buf());
342            }
343            cur = dir.parent();
344        }
345        Err(anyhow::anyhow!(
346            "no `.team/team-compose.yaml` found in {} or any parent",
347            start.display()
348        ))
349    }
350
351    /// Parse `team-compose.yaml` at `root` and every referenced project file.
352    pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
353        let root = root.as_ref().to_path_buf();
354        let global_path = root.join("team-compose.yaml");
355        let global: Global = serde_yaml::from_str(
356            &std::fs::read_to_string(&global_path)
357                .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?,
358        )
359        .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
360
361        let mut projects = Vec::with_capacity(global.projects.len());
362        for r in &global.projects {
363            let p = root.join(&r.file);
364            let parsed: Project = serde_yaml::from_str(
365                &std::fs::read_to_string(&p)
366                    .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
367            )
368            .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
369            projects.push(parsed);
370        }
371
372        Ok(Self {
373            root,
374            global,
375            projects,
376        })
377    }
378
379    /// Return every agent in the compose tree tagged with manager/worker.
380    pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
381        self.projects.iter().flat_map(|p| {
382            p.managers
383                .iter()
384                .map(move |(id, a)| AgentHandle {
385                    project: &p.project.id,
386                    agent: id,
387                    spec: a,
388                    is_manager: true,
389                })
390                .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
391                    project: &p.project.id,
392                    agent: id,
393                    spec: a,
394                    is_manager: false,
395                }))
396        })
397    }
398}
399
400#[derive(Debug, Clone, Copy)]
401pub struct AgentHandle<'a> {
402    pub project: &'a str,
403    pub agent: &'a str,
404    pub spec: &'a Agent,
405    pub is_manager: bool,
406}
407
408impl AgentHandle<'_> {
409    /// Canonical id as `<project>:<agent>`.
410    pub fn id(&self) -> String {
411        format!("{}:{}", self.project, self.agent)
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418
419    #[test]
420    fn channel_members_all_expands() {
421        let all = ChannelMembers::All("*".into());
422        assert!(all.includes("dev1", &["dev1", "dev2"]));
423        assert!(!all.includes("ghost", &["dev1", "dev2"]));
424    }
425
426    #[test]
427    fn channel_members_explicit_checks_list() {
428        let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
429        assert!(exp.includes("dev1", &[]));
430        assert!(!exp.includes("dev2", &[]));
431    }
432
433    #[test]
434    fn agent_defaults_are_stable() {
435        let a: Agent = serde_yaml::from_str("model: claude-opus-4-7\n").unwrap();
436        assert_eq!(a.runtime, "claude-code");
437        assert_eq!(a.autonomy, "low_risk_only");
438        assert!(!a.telegram_inbox);
439    }
440
441    #[test]
442    fn discover_prefers_dot_team() {
443        let tmp = tempfile::tempdir().unwrap();
444        let repo = tmp.path();
445        std::fs::create_dir_all(repo.join(".team")).unwrap();
446        std::fs::write(repo.join(".team/team-compose.yaml"), "version: 2\n").unwrap();
447        // a stray flat-layout file in the same dir should NOT be preferred.
448        std::fs::write(repo.join("team-compose.yaml"), "version: 2\n").unwrap();
449
450        // Walking up from a sub-dir should still find the .team/ root.
451        let sub = repo.join("src/deep/nested");
452        std::fs::create_dir_all(&sub).unwrap();
453        let found = Compose::discover(&sub).unwrap();
454        assert_eq!(found, repo.canonicalize().unwrap().join(".team"));
455    }
456
457    #[test]
458    fn discover_falls_back_to_flat_layout() {
459        let tmp = tempfile::tempdir().unwrap();
460        std::fs::write(tmp.path().join("team-compose.yaml"), "version: 2\n").unwrap();
461        let found = Compose::discover(tmp.path()).unwrap();
462        assert_eq!(found, tmp.path().canonicalize().unwrap());
463    }
464
465    #[test]
466    fn discover_errors_when_nothing_found() {
467        let tmp = tempfile::tempdir().unwrap();
468        let err = Compose::discover(tmp.path()).unwrap_err();
469        assert!(err.to_string().contains("no `.team/team-compose.yaml`"));
470    }
471}