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