Skip to main content

codewhale_workflow/
named_fleet.rs

1//! Named fleet roster files for dogfood lanes (#4178).
2//!
3//! Format: TOML at `fleets/<name>.toml` (workspace) or
4//! `$CODEWHALE_HOME/fleets/<name>.toml`.
5//!
6//! Fleet resolves roles → profile ids only. Runtime owns tmux/worktrees.
7
8use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12use thiserror::Error;
13
14/// Parsed named fleet file.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct NamedFleet {
17    pub name: String,
18    #[serde(default)]
19    pub description: Option<String>,
20    /// role name → AgentProfile id
21    pub roles: BTreeMap<String, String>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq, Error)]
25pub enum NamedFleetError {
26    #[error("fleet file not found: {0}")]
27    NotFound(String),
28    #[error("failed to read fleet file {path}: {message}")]
29    Io { path: String, message: String },
30    #[error("failed to parse fleet file {path}: {message}")]
31    Parse { path: String, message: String },
32    #[error("fleet `{fleet}` is missing required role `{role}`")]
33    MissingRole { fleet: String, role: String },
34    #[error("fleet name mismatch: file declares `{declared}`, expected `{expected}`")]
35    NameMismatch { declared: String, expected: String },
36}
37
38/// Required roles for the stopship dogfood fleet (#4178).
39pub const STOPSHIP_REQUIRED_ROLES: &[&str] = &[
40    "scout",
41    "implementer",
42    "reviewer",
43    "verifier",
44    "release_lead",
45];
46
47/// Parse a fleet TOML document.
48pub fn parse_named_fleet(toml_text: &str) -> Result<NamedFleet, NamedFleetError> {
49    // Minimal TOML subset without adding a toml dep to workflow:
50    // accept JSON as well for tests; for TOML use a tiny hand parser for
51    // the documented shape, or serde via json for unit tests.
52    // Prefer JSON if the text looks like JSON; otherwise use line-oriented TOML.
53    let trimmed = toml_text.trim();
54    if trimmed.starts_with('{') {
55        return serde_json::from_str(trimmed).map_err(|e| NamedFleetError::Parse {
56            path: "<memory>".into(),
57            message: e.to_string(),
58        });
59    }
60    parse_fleet_toml_minimal(trimmed)
61}
62
63fn parse_fleet_toml_minimal(text: &str) -> Result<NamedFleet, NamedFleetError> {
64    let mut name = None;
65    let mut description = None;
66    let mut roles = BTreeMap::new();
67    let mut section = "";
68    for raw in text.lines() {
69        let line = raw.split('#').next().unwrap_or("").trim();
70        if line.is_empty() {
71            continue;
72        }
73        if line.starts_with('[') && line.ends_with(']') {
74            section = &line[1..line.len() - 1];
75            continue;
76        }
77        let Some((key, value)) = line.split_once('=') else {
78            continue;
79        };
80        let key = key.trim();
81        let value = value.trim().trim_matches('"').to_string();
82        match section {
83            "" => match key {
84                "name" => name = Some(value),
85                "description" => description = Some(value),
86                _ => {}
87            },
88            "roles" => {
89                roles.insert(key.to_string(), value);
90            }
91            _ => {}
92        }
93    }
94    let name = name.ok_or_else(|| NamedFleetError::Parse {
95        path: "<memory>".into(),
96        message: "missing name".into(),
97    })?;
98    Ok(NamedFleet {
99        name,
100        description,
101        roles,
102    })
103}
104
105/// Load fleet by name from search paths (first hit wins).
106pub fn load_named_fleet(
107    name: &str,
108    search_roots: &[PathBuf],
109) -> Result<NamedFleet, NamedFleetError> {
110    let file_name = format!("{name}.toml");
111    for root in search_roots {
112        let path = root.join("fleets").join(&file_name);
113        if path.is_file() {
114            return load_named_fleet_file(&path, Some(name));
115        }
116    }
117    Err(NamedFleetError::NotFound(name.to_string()))
118}
119
120pub fn load_named_fleet_file(
121    path: &Path,
122    expect_name: Option<&str>,
123) -> Result<NamedFleet, NamedFleetError> {
124    let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
125        path: path.display().to_string(),
126        message: e.to_string(),
127    })?;
128    let fleet = parse_named_fleet(&text).map_err(|e| match e {
129        NamedFleetError::Parse { message, .. } => NamedFleetError::Parse {
130            path: path.display().to_string(),
131            message,
132        },
133        other => other,
134    })?;
135    if let Some(expected) = expect_name
136        && fleet.name != expected
137    {
138        return Err(NamedFleetError::NameMismatch {
139            declared: fleet.name,
140            expected: expected.to_string(),
141        });
142    }
143    Ok(fleet)
144}
145
146impl NamedFleet {
147    /// Resolve a role name to a profile id.
148    pub fn resolve(&self, role: &str) -> Result<&str, NamedFleetError> {
149        let key = role.trim().to_ascii_lowercase();
150        self.roles
151            .get(&key)
152            .or_else(|| {
153                self.roles
154                    .iter()
155                    .find(|(k, _)| k.eq_ignore_ascii_case(role))
156                    .map(|(_, v)| v)
157            })
158            .map(String::as_str)
159            .ok_or_else(|| NamedFleetError::MissingRole {
160                fleet: self.name.clone(),
161                role: role.to_string(),
162            })
163    }
164
165    /// Ensure all required stopship roles are present.
166    pub fn validate_stopship_roles(&self) -> Result<(), NamedFleetError> {
167        for role in STOPSHIP_REQUIRED_ROLES {
168            self.resolve(role)?;
169        }
170        Ok(())
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    const STOPSHIP_TOML: &str = r#"
179name = "stopship"
180description = "Stopship dogfood fleet"
181
182[roles]
183scout = "scout"
184implementer = "builder"
185reviewer = "reviewer"
186verifier = "verifier"
187release_lead = "manager"
188"#;
189
190    #[test]
191    fn stopship_fleet_resolves_all_five_roles() {
192        let fleet = parse_named_fleet(STOPSHIP_TOML).expect("parse");
193        assert_eq!(fleet.name, "stopship");
194        fleet.validate_stopship_roles().expect("all roles");
195        assert_eq!(fleet.resolve("scout").unwrap(), "scout");
196        assert_eq!(fleet.resolve("implementer").unwrap(), "builder");
197        assert_eq!(fleet.resolve("reviewer").unwrap(), "reviewer");
198        assert_eq!(fleet.resolve("verifier").unwrap(), "verifier");
199        assert_eq!(fleet.resolve("release_lead").unwrap(), "manager");
200    }
201
202    #[test]
203    fn unknown_role_fails_clearly() {
204        let fleet = parse_named_fleet(STOPSHIP_TOML).unwrap();
205        let err = fleet.resolve("wizard").unwrap_err();
206        assert!(matches!(err, NamedFleetError::MissingRole { .. }));
207    }
208
209    #[test]
210    fn loads_workspace_fleet_file() {
211        // Relative to crate CARGO_MANIFEST_DIR → repo root fleets/
212        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
213            .join("..")
214            .join("..");
215        let fleet = load_named_fleet("stopship", &[root]).expect("load workspace fleet");
216        fleet.validate_stopship_roles().unwrap();
217    }
218}