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