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    /// Parse `team-compose.yaml` at `root` and every referenced project file.
308    pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
309        let root = root.as_ref().to_path_buf();
310        let global_path = root.join("team-compose.yaml");
311        let global: Global = serde_yaml::from_str(
312            &std::fs::read_to_string(&global_path)
313                .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?,
314        )
315        .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
316
317        let mut projects = Vec::with_capacity(global.projects.len());
318        for r in &global.projects {
319            let p = root.join(&r.file);
320            let parsed: Project = serde_yaml::from_str(
321                &std::fs::read_to_string(&p)
322                    .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
323            )
324            .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
325            projects.push(parsed);
326        }
327
328        Ok(Self {
329            root,
330            global,
331            projects,
332        })
333    }
334
335    /// Return every agent in the compose tree tagged with manager/worker.
336    pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
337        self.projects.iter().flat_map(|p| {
338            p.managers
339                .iter()
340                .map(move |(id, a)| AgentHandle {
341                    project: &p.project.id,
342                    agent: id,
343                    spec: a,
344                    is_manager: true,
345                })
346                .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
347                    project: &p.project.id,
348                    agent: id,
349                    spec: a,
350                    is_manager: false,
351                }))
352        })
353    }
354}
355
356#[derive(Debug, Clone, Copy)]
357pub struct AgentHandle<'a> {
358    pub project: &'a str,
359    pub agent: &'a str,
360    pub spec: &'a Agent,
361    pub is_manager: bool,
362}
363
364impl AgentHandle<'_> {
365    /// Canonical id as `<project>:<agent>`.
366    pub fn id(&self) -> String {
367        format!("{}:{}", self.project, self.agent)
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn channel_members_all_expands() {
377        let all = ChannelMembers::All("*".into());
378        assert!(all.includes("dev1", &["dev1", "dev2"]));
379        assert!(!all.includes("ghost", &["dev1", "dev2"]));
380    }
381
382    #[test]
383    fn channel_members_explicit_checks_list() {
384        let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
385        assert!(exp.includes("dev1", &[]));
386        assert!(!exp.includes("dev2", &[]));
387    }
388
389    #[test]
390    fn agent_defaults_are_stable() {
391        let a: Agent = serde_yaml::from_str("model: claude-opus-4-7\n").unwrap();
392        assert_eq!(a.runtime, "claude-code");
393        assert_eq!(a.autonomy, "low_risk_only");
394        assert!(!a.telegram_inbox);
395    }
396}