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
63/// Strip comments from the single-line basic and literal strings supported by
64/// the minimal fleet parser.
65fn strip_toml_comment(line: &str) -> &str {
66    let mut quote = None;
67    let mut escaped = false;
68
69    for (index, character) in line.char_indices() {
70        match quote {
71            Some('"') => {
72                if escaped {
73                    escaped = false;
74                } else {
75                    match character {
76                        '\\' => escaped = true,
77                        '"' => quote = None,
78                        _ => {}
79                    }
80                }
81            }
82            Some('\'') => {
83                if character == '\'' {
84                    quote = None;
85                }
86            }
87            Some(_) => unreachable!("only TOML string delimiters are tracked"),
88            None => match character {
89                '"' | '\'' => quote = Some(character),
90                '#' => return &line[..index],
91                _ => {}
92            },
93        }
94    }
95
96    line
97}
98
99fn parse_fleet_toml_minimal(text: &str) -> Result<NamedFleet, NamedFleetError> {
100    let mut name = None;
101    let mut description = None;
102    let mut roles = BTreeMap::new();
103    let mut section = "";
104    for raw in text.lines() {
105        let line = strip_toml_comment(raw).trim();
106        if line.is_empty() {
107            continue;
108        }
109        if line.starts_with('[') && line.ends_with(']') {
110            section = &line[1..line.len() - 1];
111            continue;
112        }
113        let Some((key, value)) = line.split_once('=') else {
114            continue;
115        };
116        let key = key.trim();
117        let value = value.trim().trim_matches('"').to_string();
118        match section {
119            "" => match key {
120                "name" => name = Some(value),
121                "description" => description = Some(value),
122                _ => {}
123            },
124            "roles" => {
125                roles.insert(key.to_string(), value);
126            }
127            _ => {}
128        }
129    }
130    let name = name.ok_or_else(|| NamedFleetError::Parse {
131        path: "<memory>".into(),
132        message: "missing name".into(),
133    })?;
134    Ok(NamedFleet {
135        name,
136        description,
137        roles,
138    })
139}
140
141/// Load fleet by name from search paths (first hit wins).
142pub fn load_named_fleet(
143    name: &str,
144    search_roots: &[PathBuf],
145) -> Result<NamedFleet, NamedFleetError> {
146    let file_name = format!("{name}.toml");
147    for root in search_roots {
148        let path = root.join("fleets").join(&file_name);
149        if path.is_file() {
150            return load_named_fleet_file(&path, Some(name));
151        }
152    }
153    Err(NamedFleetError::NotFound(name.to_string()))
154}
155
156pub fn load_named_fleet_file(
157    path: &Path,
158    expect_name: Option<&str>,
159) -> Result<NamedFleet, NamedFleetError> {
160    let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
161        path: path.display().to_string(),
162        message: e.to_string(),
163    })?;
164    let fleet = parse_named_fleet(&text).map_err(|e| match e {
165        NamedFleetError::Parse { message, .. } => NamedFleetError::Parse {
166            path: path.display().to_string(),
167            message,
168        },
169        other => other,
170    })?;
171    if let Some(expected) = expect_name
172        && fleet.name != expected
173    {
174        return Err(NamedFleetError::NameMismatch {
175            declared: fleet.name,
176            expected: expected.to_string(),
177        });
178    }
179    Ok(fleet)
180}
181
182impl NamedFleet {
183    /// Resolve a role name to a profile id.
184    pub fn resolve(&self, role: &str) -> Result<&str, NamedFleetError> {
185        let key = role.trim().to_ascii_lowercase();
186        self.roles
187            .get(&key)
188            .or_else(|| {
189                self.roles
190                    .iter()
191                    .find(|(k, _)| k.eq_ignore_ascii_case(role))
192                    .map(|(_, v)| v)
193            })
194            .map(String::as_str)
195            .ok_or_else(|| NamedFleetError::MissingRole {
196                fleet: self.name.clone(),
197                role: role.to_string(),
198            })
199    }
200
201    /// Ensure all required stopship roles are present.
202    pub fn validate_stopship_roles(&self) -> Result<(), NamedFleetError> {
203        for role in STOPSHIP_REQUIRED_ROLES {
204            self.resolve(role)?;
205        }
206        Ok(())
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    const STOPSHIP_TOML: &str = r#"
215name = "stopship"
216description = "Stopship dogfood fleet"
217
218[roles]
219scout = "scout"
220implementer = "builder"
221reviewer = "reviewer"
222verifier = "verifier"
223release_lead = "manager"
224"#;
225
226    #[test]
227    fn stopship_fleet_resolves_all_five_roles() {
228        let fleet = parse_named_fleet(STOPSHIP_TOML).expect("parse");
229        assert_eq!(fleet.name, "stopship");
230        fleet.validate_stopship_roles().expect("all roles");
231        assert_eq!(fleet.resolve("scout").unwrap(), "scout");
232        assert_eq!(fleet.resolve("implementer").unwrap(), "builder");
233        assert_eq!(fleet.resolve("reviewer").unwrap(), "reviewer");
234        assert_eq!(fleet.resolve("verifier").unwrap(), "verifier");
235        assert_eq!(fleet.resolve("release_lead").unwrap(), "manager");
236    }
237
238    #[test]
239    fn unknown_role_fails_clearly() {
240        let fleet = parse_named_fleet(STOPSHIP_TOML).unwrap();
241        let err = fleet.resolve("wizard").unwrap_err();
242        assert!(matches!(err, NamedFleetError::MissingRole { .. }));
243    }
244
245    #[test]
246    fn quoted_hashes_are_not_treated_as_comments() {
247        let fleet = parse_named_fleet(
248            r#"
249name = "issue-references"
250description = "Tracks #4178 dogfood" # real comment
251
252[roles]
253scout = "scout#stable"
254"#,
255        )
256        .expect("parse");
257
258        assert_eq!(fleet.description.as_deref(), Some("Tracks #4178 dogfood"));
259        assert_eq!(fleet.resolve("scout").unwrap(), "scout#stable");
260    }
261
262    #[test]
263    fn comment_stripping_tracks_toml_quotes_and_escapes() {
264        assert_eq!(
265            strip_toml_comment(r##"description = "say \"#still-value\"" # comment"##).trim_end(),
266            r##"description = "say \"#still-value\"""##
267        );
268        assert_eq!(
269            strip_toml_comment("description = 'tracks #4178' # comment").trim_end(),
270            "description = 'tracks #4178'"
271        );
272        assert_eq!(
273            strip_toml_comment(r#"name = "stopship" # comment"#).trim_end(),
274            r#"name = "stopship""#
275        );
276    }
277
278    #[test]
279    fn loads_workspace_fleet_file() {
280        // Relative to crate CARGO_MANIFEST_DIR → repo root fleets/
281        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
282            .join("..")
283            .join("..");
284        let fleet = load_named_fleet("stopship", &[root]).expect("load workspace fleet");
285        fleet.validate_stopship_roles().unwrap();
286    }
287}