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    /// Human-facing inbound channels. Telegram is one adapter; Discord,
26    /// iMessage, CLI, and webhook share the same shape.
27    #[serde(default)]
28    pub interfaces: Vec<Interface>,
29
30    /// Relative paths from the compose root.
31    #[serde(default)]
32    pub projects: Vec<ProjectRef>,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Interface {
37    /// Adapter type: `telegram`, `discord`, `imessage`, `cli`, `webhook`, ...
38    pub r#type: String,
39    /// Free-form name; used in logs and to route approvals.
40    pub name: String,
41    /// Adapter-specific config (bot token, channel id, allowlist, …).
42    #[serde(default)]
43    pub config: serde_yaml::Value,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, Default)]
47pub struct Budget {
48    #[serde(default)]
49    pub daily_usd_limit: Option<f64>,
50    #[serde(default)]
51    pub warn_threshold_pct: Option<u32>,
52    #[serde(default)]
53    pub message_ttl_hours: Option<u32>,
54    #[serde(default)]
55    pub per_project_usd_limit: std::collections::BTreeMap<String, f64>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct Hitl {
60    #[serde(default = "default_sensitive_actions")]
61    pub globally_sensitive_actions: Vec<String>,
62    #[serde(default)]
63    pub auto_approve_windows: Vec<AutoApprove>,
64}
65
66impl Default for Hitl {
67    fn default() -> Self {
68        Self {
69            globally_sensitive_actions: default_sensitive_actions(),
70            auto_approve_windows: Vec::new(),
71        }
72    }
73}
74
75fn default_sensitive_actions() -> Vec<String> {
76    vec![
77        "publish".into(),
78        "release".into(),
79        "payment".into(),
80        "external_email".into(),
81        "external_api_post".into(),
82        "merge_to_main".into(),
83        "dns_change".into(),
84        "deploy".into(),
85    ]
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct AutoApprove {
90    pub action: String,
91    #[serde(default)]
92    pub project: Option<String>,
93    #[serde(default)]
94    pub agent: Option<String>,
95    #[serde(default)]
96    pub scope: Option<String>,
97    /// RFC 3339 timestamp in UTC.
98    pub until: String,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ProjectRef {
103    pub file: PathBuf,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
107pub struct Broker {
108    #[serde(default = "default_broker_type")]
109    pub r#type: String,
110    #[serde(default = "default_mailbox_path")]
111    pub path: PathBuf,
112}
113
114impl Default for Broker {
115    fn default() -> Self {
116        Self {
117            r#type: default_broker_type(),
118            path: default_mailbox_path(),
119        }
120    }
121}
122
123fn default_broker_type() -> String {
124    "sqlite".into()
125}
126
127fn default_mailbox_path() -> PathBuf {
128    PathBuf::from("state/mailbox.db")
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132pub struct SupervisorCfg {
133    #[serde(default = "default_supervisor_type")]
134    pub r#type: String,
135    #[serde(default = "default_tmux_prefix")]
136    pub tmux_prefix: String,
137}
138
139impl Default for SupervisorCfg {
140    fn default() -> Self {
141        Self {
142            r#type: default_supervisor_type(),
143            tmux_prefix: default_tmux_prefix(),
144        }
145    }
146}
147
148fn default_supervisor_type() -> String {
149    "tmux".into()
150}
151
152fn default_tmux_prefix() -> String {
153    "a-".into()
154}
155
156/// Per-project file, e.g. `projects/hello.yaml`.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct Project {
159    pub version: u32,
160    pub project: ProjectMeta,
161
162    #[serde(default)]
163    pub channels: Vec<Channel>,
164
165    #[serde(default)]
166    pub managers: BTreeMap<String, Agent>,
167
168    #[serde(default)]
169    pub workers: BTreeMap<String, Agent>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct ProjectMeta {
174    pub id: String,
175    pub name: String,
176    pub cwd: PathBuf,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct Channel {
181    pub name: String,
182    /// Either a list of agent ids or the literal string `"*"`.
183    #[serde(default)]
184    pub members: ChannelMembers,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(untagged)]
189pub enum ChannelMembers {
190    All(String),
191    Explicit(Vec<String>),
192}
193
194impl Default for ChannelMembers {
195    fn default() -> Self {
196        Self::Explicit(Vec::new())
197    }
198}
199
200impl ChannelMembers {
201    pub fn includes(&self, agent: &str, all_agents: &[&str]) -> bool {
202        match self {
203            ChannelMembers::All(s) if s == "*" => all_agents.contains(&agent),
204            ChannelMembers::Explicit(v) => v.iter().any(|a| a == agent),
205            _ => false,
206        }
207    }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct Agent {
212    #[serde(default = "default_runtime")]
213    pub runtime: String,
214    pub model: Option<String>,
215    pub role_prompt: Option<PathBuf>,
216    #[serde(default)]
217    pub permission_mode: Option<String>,
218    #[serde(default)]
219    pub telegram_inbox: bool,
220    #[serde(default)]
221    pub reports_to_user: bool,
222    #[serde(default = "default_autonomy")]
223    pub autonomy: String,
224    #[serde(default)]
225    pub can_dm: Vec<String>,
226    #[serde(default)]
227    pub can_broadcast: Vec<String>,
228    #[serde(default)]
229    pub reports_to: Option<String>,
230}
231
232fn default_runtime() -> String {
233    "claude-code".into()
234}
235
236fn default_autonomy() -> String {
237    "low_risk_only".into()
238}
239
240/// Fully loaded compose tree: global + resolved projects.
241#[derive(Debug, Clone)]
242pub struct Compose {
243    pub root: PathBuf,
244    pub global: Global,
245    pub projects: Vec<Project>,
246}
247
248impl Compose {
249    /// Parse `team-compose.yaml` at `root` and every referenced project file.
250    pub fn load(root: impl AsRef<Path>) -> anyhow::Result<Self> {
251        let root = root.as_ref().to_path_buf();
252        let global_path = root.join("team-compose.yaml");
253        let global: Global = serde_yaml::from_str(
254            &std::fs::read_to_string(&global_path)
255                .map_err(|e| anyhow::anyhow!("read {}: {e}", global_path.display()))?,
256        )
257        .map_err(|e| anyhow::anyhow!("parse {}: {e}", global_path.display()))?;
258
259        let mut projects = Vec::with_capacity(global.projects.len());
260        for r in &global.projects {
261            let p = root.join(&r.file);
262            let parsed: Project = serde_yaml::from_str(
263                &std::fs::read_to_string(&p)
264                    .map_err(|e| anyhow::anyhow!("read {}: {e}", p.display()))?,
265            )
266            .map_err(|e| anyhow::anyhow!("parse {}: {e}", p.display()))?;
267            projects.push(parsed);
268        }
269
270        Ok(Self {
271            root,
272            global,
273            projects,
274        })
275    }
276
277    /// Return every agent in the compose tree tagged with manager/worker.
278    pub fn agents(&self) -> impl Iterator<Item = AgentHandle<'_>> {
279        self.projects.iter().flat_map(|p| {
280            p.managers
281                .iter()
282                .map(move |(id, a)| AgentHandle {
283                    project: &p.project.id,
284                    agent: id,
285                    spec: a,
286                    is_manager: true,
287                })
288                .chain(p.workers.iter().map(move |(id, a)| AgentHandle {
289                    project: &p.project.id,
290                    agent: id,
291                    spec: a,
292                    is_manager: false,
293                }))
294        })
295    }
296}
297
298#[derive(Debug, Clone, Copy)]
299pub struct AgentHandle<'a> {
300    pub project: &'a str,
301    pub agent: &'a str,
302    pub spec: &'a Agent,
303    pub is_manager: bool,
304}
305
306impl AgentHandle<'_> {
307    /// Canonical id as `<project>:<agent>`.
308    pub fn id(&self) -> String {
309        format!("{}:{}", self.project, self.agent)
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn channel_members_all_expands() {
319        let all = ChannelMembers::All("*".into());
320        assert!(all.includes("dev1", &["dev1", "dev2"]));
321        assert!(!all.includes("ghost", &["dev1", "dev2"]));
322    }
323
324    #[test]
325    fn channel_members_explicit_checks_list() {
326        let exp = ChannelMembers::Explicit(vec!["dev1".into(), "critic".into()]);
327        assert!(exp.includes("dev1", &[]));
328        assert!(!exp.includes("dev2", &[]));
329    }
330
331    #[test]
332    fn agent_defaults_are_stable() {
333        let a: Agent = serde_yaml::from_str("model: claude-opus-4-7\n").unwrap();
334        assert_eq!(a.runtime, "claude-code");
335        assert_eq!(a.autonomy, "low_risk_only");
336        assert!(!a.telegram_inbox);
337    }
338}