Skip to main content

team_core/
runtimes.rs

1//! Runtime adapter descriptors.
2//!
3//! Canonical descriptors for the runtimes teamctl ships with (Claude Code,
4//! Codex, Gemini) are baked into the binary via [`embedded_defaults`]. Users
5//! can override or extend them by dropping their own `<root>/runtimes/<id>.yaml`
6//! into the compose tree -- file-based descriptors win on key collision.
7
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13
14/// Canonical descriptors that ship with teamctl. Keep this list in sync
15/// with the YAML files under `crates/team-core/runtimes/`.
16const EMBEDDED: &[(&str, &str)] = &[
17    ("claude-code", include_str!("../runtimes/claude-code.yaml")),
18    ("codex", include_str!("../runtimes/codex.yaml")),
19    ("gemini", include_str!("../runtimes/gemini.yaml")),
20];
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Runtime {
24    /// Path / name of the CLI binary (resolved on $PATH by the wrapper).
25    pub binary: String,
26    #[serde(default)]
27    pub supports_mcp: bool,
28    /// Session-resume hint kept as parsed metadata for back-compat —
29    /// no Rust caller reads this field today. The actual resume
30    /// strategy is hard-coded per-runtime in `agent-wrapper.sh`:
31    /// claude-code uses deterministic UUIDv5 `--session-id` (T-118),
32    /// codex resumes via `--profile`, gemini has no equivalent. Kept
33    /// to avoid breaking any operator-authored `runtimes/*.yaml`
34    /// override that still names the field.
35    #[serde(default)]
36    pub session_resume: Option<String>,
37    #[serde(default)]
38    pub default_model: Option<String>,
39    #[serde(default)]
40    pub env: BTreeMap<String, String>,
41
42    /// Patterns that, if matched in the runtime's stdout/stderr, indicate a
43    /// rate-limit hit. `teamctl rl-watch` consumes these.
44    #[serde(default)]
45    pub rate_limit_patterns: Vec<RateLimitPattern>,
46}
47
48/// One rate-limit detector. `match` is a regex tested against each line
49/// of runtime output. If matched, the wrapper records a hit. The optional
50/// captures attempt to extract when the limit lifts.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct RateLimitPattern {
53    /// Regex tested against each output line.
54    pub r#match: String,
55    /// Optional regex with one capture group of an absolute reset clock,
56    /// e.g. "resets at (4pm)" or "resets at (16:00)" or an RFC3339 timestamp.
57    #[serde(default)]
58    pub resets_at_capture: Option<String>,
59    /// Optional regex with one capture group of a relative duration,
60    /// e.g. "in (5h 15m)" or "in (1h)" or "(\\d+) seconds".
61    #[serde(default)]
62    pub resets_in_capture: Option<String>,
63}
64
65/// Embedded canonical runtime descriptors -- the ones teamctl ships with.
66/// Always available; do not require any files on disk.
67pub fn embedded_defaults() -> Result<BTreeMap<String, Runtime>> {
68    EMBEDDED
69        .iter()
70        .map(|(stem, src)| {
71            let r: Runtime = serde_yaml::from_str(src)
72                .with_context(|| format!("parse embedded runtime `{stem}`"))?;
73            Ok(((*stem).to_string(), r))
74        })
75        .collect()
76}
77
78/// Resolve the runtime adapter map for a compose tree.
79///
80/// Starts from the [`embedded_defaults`] (Claude Code / Codex / Gemini) and
81/// overlays any `<root>/runtimes/<name>.yaml` files. File-based descriptors
82/// override the embedded ones when keys collide and can introduce new
83/// runtimes the binary has never heard of.
84pub fn load_all(root: &Path) -> Result<BTreeMap<String, Runtime>> {
85    let mut map = embedded_defaults()?;
86    let dir = root.join("runtimes");
87    if !dir.exists() {
88        return Ok(map);
89    }
90    for entry in std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))? {
91        let entry = entry?;
92        let path: PathBuf = entry.path();
93        if path.extension().and_then(|s| s.to_str()) != Some("yaml") {
94            continue;
95        }
96        let stem = path
97            .file_stem()
98            .and_then(|s| s.to_str())
99            .unwrap_or_default()
100            .to_string();
101        let content =
102            std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
103        let r: Runtime =
104            serde_yaml::from_str(&content).with_context(|| format!("parse {}", path.display()))?;
105        map.insert(stem, r);
106    }
107    Ok(map)
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn embedded_defaults_parse() {
116        let m = embedded_defaults().unwrap();
117        assert!(m.contains_key("claude-code"));
118        assert!(m.contains_key("codex"));
119        assert!(m.contains_key("gemini"));
120        assert_eq!(m["claude-code"].binary, "claude");
121        assert!(m["claude-code"].supports_mcp);
122    }
123
124    #[test]
125    fn load_nonexistent_returns_embedded_defaults() {
126        let tmp = tempfile::tempdir().unwrap();
127        let m = load_all(tmp.path()).unwrap();
128        // No files on disk, but the embedded defaults must still be there.
129        assert!(m.contains_key("claude-code"));
130        assert!(m.contains_key("codex"));
131        assert!(m.contains_key("gemini"));
132    }
133
134    #[test]
135    fn user_file_overrides_embedded_default() {
136        let tmp = tempfile::tempdir().unwrap();
137        let dir = tmp.path().join("runtimes");
138        std::fs::create_dir_all(&dir).unwrap();
139        std::fs::write(
140            dir.join("claude-code.yaml"),
141            "binary: my-claude-fork\nsupports_mcp: false\n",
142        )
143        .unwrap();
144        let m = load_all(tmp.path()).unwrap();
145        assert_eq!(m["claude-code"].binary, "my-claude-fork");
146        assert!(!m["claude-code"].supports_mcp);
147        // Other embedded defaults are untouched.
148        assert_eq!(m["codex"].binary, "codex");
149    }
150
151    #[test]
152    fn user_file_can_add_new_runtime() {
153        let tmp = tempfile::tempdir().unwrap();
154        let dir = tmp.path().join("runtimes");
155        std::fs::create_dir_all(&dir).unwrap();
156        std::fs::write(
157            dir.join("aider.yaml"),
158            "binary: aider\nsupports_mcp: false\n",
159        )
160        .unwrap();
161        let m = load_all(tmp.path()).unwrap();
162        assert_eq!(m["aider"].binary, "aider");
163        // Embedded defaults coexist.
164        assert!(m.contains_key("claude-code"));
165    }
166}