1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8#[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 #[serde(default)]
31 pub interfaces: Vec<Interface>,
32
33 #[serde(default)]
35 pub projects: Vec<ProjectRef>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Interface {
40 pub r#type: String,
42 pub name: String,
44 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
63pub struct RateLimits {
64 #[serde(default)]
66 pub default_on_hit: Vec<String>,
67
68 #[serde(default)]
70 pub hooks: Vec<RateLimitHook>,
71
72 #[serde(default = "default_fallback_wait")]
75 pub fallback_wait_seconds: u64,
76}
77
78fn default_fallback_wait() -> u64 {
79 30 * 60
80}
81
82#[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 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#[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 #[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 #[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#[derive(Debug, Clone)]
300pub struct Compose {
301 pub root: PathBuf,
302 pub global: Global,
303 pub projects: Vec<Project>,
304}
305
306impl Compose {
307 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 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 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}