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//! Two forms share this one store — there is no parallel fleet directory:
7//!
8//! - **Legacy**: `[roles]` maps role → AgentProfile id. Fleet resolves roles →
9//!   profile ids only; Runtime owns tmux/worktrees. Legacy files declare no
10//!   `schema` key, which is what makes the form explicitly detectable rather
11//!   than guessed from a missing table.
12//! - **Exact**: `schema = "exact"` with fully resolved `[[members]]`. See
13//!   [`crate::fleet_exact`].
14//!
15//! [`FleetDocument`] is the form-agnostic entry point: it reports which form a
16//! file is in and carries the content hash a Workflow snapshot records.
17
18use std::collections::BTreeMap;
19use std::path::{Path, PathBuf};
20
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23use thiserror::Error;
24
25use crate::fleet_exact::{
26    EXACT_FLEET_SCHEMA_KIND, EXACT_FLEET_SCHEMA_REVISION, ExactFleet, ExactFleetError,
27    LEGACY_FLEET_SCHEMA_KIND, declared_schema_kind,
28};
29use crate::fleet_snapshot::QualifiedFleetId;
30
31/// One labelled place fleet files are looked up.
32///
33/// The label is what makes a Fleet identity *qualified*: `workspace/glm-pair`
34/// and `codewhale_home/glm-pair` are different Fleets, and the loader refuses
35/// to guess between them for exact definitions.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct FleetSearchRoot {
38    /// Non-secret origin label, e.g. `workspace` or `codewhale_home`.
39    pub origin: String,
40    /// Directory that contains a `fleets/` subdirectory.
41    pub root: PathBuf,
42}
43
44impl FleetSearchRoot {
45    pub fn new(origin: impl Into<String>, root: impl Into<PathBuf>) -> Self {
46        Self {
47            origin: origin.into(),
48            root: root.into(),
49        }
50    }
51}
52
53/// Split `origin/name` into its parts. A bare name yields `(None, name)`.
54fn split_qualified_fleet_name(name: &str) -> (Option<&str>, &str) {
55    let trimmed = name.trim();
56    match trimmed.split_once('/') {
57        Some((origin, bare)) if !origin.trim().is_empty() && !bare.trim().is_empty() => {
58            (Some(origin.trim()), bare.trim())
59        }
60        _ => (None, trimmed),
61    }
62}
63
64/// Parsed named fleet file.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct NamedFleet {
67    pub name: String,
68    #[serde(default)]
69    pub description: Option<String>,
70    /// role name → AgentProfile id
71    pub roles: BTreeMap<String, String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Error)]
75pub enum NamedFleetError {
76    #[error("fleet file not found: {0}")]
77    NotFound(String),
78    #[error("failed to read fleet file {path}: {message}")]
79    Io { path: String, message: String },
80    #[error("failed to parse fleet file {path}: {message}")]
81    Parse { path: String, message: String },
82    #[error("fleet `{fleet}` is missing required role `{role}`")]
83    MissingRole { fleet: String, role: String },
84    #[error("fleet name mismatch: file declares `{declared}`, expected `{expected}`")]
85    NameMismatch { declared: String, expected: String },
86    #[error(
87        "fleet `{name}` is defined in more than one place ({}); an exact fleet must not be \
88         resolved by shadowing. Name one explicitly as `origin/{name}`.",
89        origins.join(", ")
90    )]
91    AmbiguousFleet { name: String, origins: Vec<String> },
92    #[error("exact fleet `{fleet}`: {source}")]
93    Exact {
94        fleet: String,
95        #[source]
96        source: ExactFleetError,
97    },
98}
99
100/// Which form a `fleets/<name>.toml` file is in.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102#[serde(rename_all = "snake_case", tag = "kind")]
103pub enum FleetSchema {
104    /// Pre-exact role → AgentProfile id map.
105    Legacy(NamedFleet),
106    /// Fully resolved exact members.
107    Exact(ExactFleet),
108}
109
110/// A parsed fleet file plus the provenance a Workflow snapshot needs.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct FleetDocument {
113    schema: FleetSchema,
114    source: Option<PathBuf>,
115    source_hash: String,
116}
117
118impl FleetDocument {
119    /// Parse either form. The exact form is selected by an explicit
120    /// `schema = "exact"`; everything else is the legacy form.
121    pub fn parse(text: &str) -> Result<Self, NamedFleetError> {
122        let schema = match declared_schema_kind(text).as_deref() {
123            Some(EXACT_FLEET_SCHEMA_KIND) => {
124                let exact = ExactFleet::parse(text).map_err(|source| NamedFleetError::Exact {
125                    fleet: "<memory>".to_string(),
126                    source,
127                })?;
128                FleetSchema::Exact(exact)
129            }
130            Some(other) => {
131                return Err(NamedFleetError::Parse {
132                    path: "<memory>".into(),
133                    message: format!("unknown fleet schema `{other}`; expected `exact`"),
134                });
135            }
136            None => FleetSchema::Legacy(parse_named_fleet(text)?),
137        };
138        Ok(Self {
139            schema,
140            source: None,
141            source_hash: content_hash(text),
142        })
143    }
144
145    pub fn load(path: &Path, expect_name: Option<&str>) -> Result<Self, NamedFleetError> {
146        let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
147            path: path.display().to_string(),
148            message: e.to_string(),
149        })?;
150        let mut document = Self::parse(&text).map_err(|e| match e {
151            NamedFleetError::Parse { message, .. } => NamedFleetError::Parse {
152                path: path.display().to_string(),
153                message,
154            },
155            NamedFleetError::Exact { source, .. } => NamedFleetError::Exact {
156                fleet: path.display().to_string(),
157                source,
158            },
159            other => other,
160        })?;
161        if let Some(expected) = expect_name
162            && document.name() != expected
163        {
164            return Err(NamedFleetError::NameMismatch {
165                declared: document.name().to_string(),
166                expected: expected.to_string(),
167            });
168        }
169        document.source = Some(path.to_path_buf());
170        Ok(document)
171    }
172
173    /// Load a fleet document by name from labelled search roots.
174    ///
175    /// A bare `name` that exists under more than one origin is **ambiguous**
176    /// once any candidate is an exact fleet: a personal `~/.codewhale` Fleet
177    /// silently shadowing (or being shadowed by) a project Fleet would change
178    /// which exact provider/model actually runs, so the caller is asked for a
179    /// qualified `origin/name` instead. Purely legacy collisions keep the
180    /// historic first-hit-wins behavior, because a role→profile map resolves
181    /// through the same profile store either way.
182    ///
183    /// Ambiguity is decided from a `schema`-key probe, not from a full parse of
184    /// every candidate: a malformed file in a *shadowed* origin must not break a
185    /// legacy load that has always worked. A file whose TOML does not even
186    /// parse therefore counts as legacy for this decision, and the first hit
187    /// still wins — the same outcome the pre-exact loader gave.
188    ///
189    /// Accepts `origin/name` to name one origin explicitly.
190    pub fn load_by_name(
191        name: &str,
192        search_roots: &[FleetSearchRoot],
193    ) -> Result<(Self, QualifiedFleetId), NamedFleetError> {
194        let (requested_origin, bare_name) = split_qualified_fleet_name(name);
195        let file_name = format!("{bare_name}.toml");
196
197        let mut candidates: Vec<(&FleetSearchRoot, PathBuf)> = Vec::new();
198        for root in search_roots {
199            if let Some(origin) = requested_origin
200                && !root.origin.eq_ignore_ascii_case(origin)
201            {
202                continue;
203            }
204            let path = root.root.join("fleets").join(&file_name);
205            if path.is_file() {
206                candidates.push((root, path));
207            }
208        }
209
210        let Some((first_root, first_path)) = candidates.first() else {
211            return Err(NamedFleetError::NotFound(name.to_string()));
212        };
213
214        if candidates.len() > 1 {
215            // Decide ambiguity from the `schema` key alone — a cheap probe that
216            // does not parse the rest of the file. Fully parsing every sibling
217            // would mean a malformed *shadowed* file could fail a load that
218            // legacy first-hit-wins has always satisfied, which is a regression
219            // in a path the exact schema was never meant to touch. The
220            // ambiguity that actually matters is "one of these is exact", and
221            // the probe answers exactly that.
222            let mut any_exact = false;
223            for (_, path) in &candidates {
224                let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
225                    path: path.display().to_string(),
226                    message: e.to_string(),
227                })?;
228                if declared_schema_kind(&text).is_some() {
229                    any_exact = true;
230                    break;
231                }
232            }
233            if any_exact {
234                return Err(NamedFleetError::AmbiguousFleet {
235                    name: bare_name.to_string(),
236                    origins: candidates
237                        .iter()
238                        .map(|(root, path)| {
239                            format!("{}/{bare_name} ({})", root.origin, path.display())
240                        })
241                        .collect(),
242                });
243            }
244            // Purely legacy collision: first hit wins, and only the first hit
245            // is parsed.
246        }
247
248        let document = Self::load(first_path, Some(bare_name))?;
249        Ok((
250            document,
251            QualifiedFleetId {
252                name: bare_name.to_string(),
253                origin: first_root.origin.clone(),
254            },
255        ))
256    }
257
258    #[must_use]
259    pub fn name(&self) -> &str {
260        match &self.schema {
261            FleetSchema::Legacy(fleet) => &fleet.name,
262            FleetSchema::Exact(fleet) => &fleet.name,
263        }
264    }
265
266    #[must_use]
267    pub fn description(&self) -> Option<&str> {
268        match &self.schema {
269            FleetSchema::Legacy(fleet) => fleet.description.as_deref(),
270            FleetSchema::Exact(fleet) => fleet.description.as_deref(),
271        }
272    }
273
274    #[must_use]
275    pub fn schema(&self) -> &FleetSchema {
276        &self.schema
277    }
278
279    /// Explicit legacy detection — never inferred from a missing table.
280    #[must_use]
281    pub const fn is_legacy(&self) -> bool {
282        matches!(self.schema, FleetSchema::Legacy(_))
283    }
284
285    #[must_use]
286    pub fn legacy(&self) -> Option<&NamedFleet> {
287        match &self.schema {
288            FleetSchema::Legacy(fleet) => Some(fleet),
289            FleetSchema::Exact(_) => None,
290        }
291    }
292
293    #[must_use]
294    pub fn exact(&self) -> Option<&ExactFleet> {
295        match &self.schema {
296            FleetSchema::Exact(fleet) => Some(fleet),
297            FleetSchema::Legacy(_) => None,
298        }
299    }
300
301    #[must_use]
302    pub fn schema_kind(&self) -> &'static str {
303        match self.schema {
304            FleetSchema::Legacy(_) => LEGACY_FLEET_SCHEMA_KIND,
305            FleetSchema::Exact(_) => EXACT_FLEET_SCHEMA_KIND,
306        }
307    }
308
309    #[must_use]
310    pub fn schema_revision(&self) -> u32 {
311        match &self.schema {
312            // Legacy files carry no revision; report 0 so a snapshot can tell
313            // "pre-versioned" from exact revision 1.
314            FleetSchema::Legacy(_) => 0,
315            FleetSchema::Exact(fleet) => fleet.schema_revision,
316        }
317    }
318
319    /// SHA-256 of the exact file bytes this document was parsed from.
320    #[must_use]
321    pub fn source_hash(&self) -> &str {
322        &self.source_hash
323    }
324
325    #[must_use]
326    pub fn source_path(&self) -> Option<&Path> {
327        self.source.as_deref()
328    }
329
330    /// Build a document around an already-constructed exact roster.
331    ///
332    /// Test-only, and deliberately so: it is how a roster that never passed
333    /// through the TOML parser reaches [`crate::FleetSnapshot::capture`], which
334    /// is exactly the bypass capture-time revalidation exists to close.
335    #[cfg(test)]
336    #[must_use]
337    pub(crate) fn from_exact_for_tests(exact: ExactFleet) -> Self {
338        Self {
339            schema: FleetSchema::Exact(exact),
340            source: None,
341            source_hash: content_hash("<constructed>"),
342        }
343    }
344}
345
346/// The schema revision an exact document is expected to declare.
347#[must_use]
348pub const fn exact_schema_revision() -> u32 {
349    EXACT_FLEET_SCHEMA_REVISION
350}
351
352pub(crate) fn content_hash(text: &str) -> String {
353    sha256_label(text.as_bytes())
354}
355
356/// `sha256:<hex>` over arbitrary bytes. Mirrors the hex helper in `replay.rs`
357/// rather than relying on a digest `LowerHex` impl.
358pub(crate) fn sha256_label(bytes: &[u8]) -> String {
359    use std::fmt::Write as _;
360
361    let digest = Sha256::digest(bytes);
362    let mut out = String::with_capacity(7 + digest.len() * 2);
363    out.push_str("sha256:");
364    for byte in digest.iter() {
365        let _ = write!(&mut out, "{byte:02x}");
366    }
367    out
368}
369
370/// Required roles for the stopship dogfood fleet (#4178).
371pub const STOPSHIP_REQUIRED_ROLES: &[&str] = &[
372    "scout",
373    "implementer",
374    "reviewer",
375    "verifier",
376    "release_lead",
377];
378
379/// Parse a fleet TOML document.
380pub fn parse_named_fleet(toml_text: &str) -> Result<NamedFleet, NamedFleetError> {
381    // Minimal TOML subset without adding a toml dep to workflow:
382    // accept JSON as well for tests; for TOML use a tiny hand parser for
383    // the documented shape, or serde via json for unit tests.
384    // Prefer JSON if the text looks like JSON; otherwise use line-oriented TOML.
385    let trimmed = toml_text.trim();
386    if trimmed.starts_with('{') {
387        return serde_json::from_str(trimmed).map_err(|e| NamedFleetError::Parse {
388            path: "<memory>".into(),
389            message: e.to_string(),
390        });
391    }
392    parse_fleet_toml_minimal(trimmed)
393}
394
395/// Strip comments from the single-line basic and literal strings supported by
396/// the minimal fleet parser.
397fn strip_toml_comment(line: &str) -> &str {
398    let mut quote = None;
399    let mut escaped = false;
400
401    for (index, character) in line.char_indices() {
402        match quote {
403            Some('"') => {
404                if escaped {
405                    escaped = false;
406                } else {
407                    match character {
408                        '\\' => escaped = true,
409                        '"' => quote = None,
410                        _ => {}
411                    }
412                }
413            }
414            Some('\'') => {
415                if character == '\'' {
416                    quote = None;
417                }
418            }
419            Some(_) => unreachable!("only TOML string delimiters are tracked"),
420            None => match character {
421                '"' | '\'' => quote = Some(character),
422                '#' => return &line[..index],
423                _ => {}
424            },
425        }
426    }
427
428    line
429}
430
431fn parse_fleet_toml_minimal(text: &str) -> Result<NamedFleet, NamedFleetError> {
432    let mut name = None;
433    let mut description = None;
434    let mut roles = BTreeMap::new();
435    let mut section = "";
436    for raw in text.lines() {
437        let line = strip_toml_comment(raw).trim();
438        if line.is_empty() {
439            continue;
440        }
441        if line.starts_with('[') && line.ends_with(']') {
442            section = &line[1..line.len() - 1];
443            continue;
444        }
445        let Some((key, value)) = line.split_once('=') else {
446            continue;
447        };
448        let key = key.trim();
449        let value = value.trim().trim_matches('"').to_string();
450        match section {
451            "" => match key {
452                "name" => name = Some(value),
453                "description" => description = Some(value),
454                _ => {}
455            },
456            "roles" => {
457                roles.insert(key.to_string(), value);
458            }
459            _ => {}
460        }
461    }
462    let name = name.ok_or_else(|| NamedFleetError::Parse {
463        path: "<memory>".into(),
464        message: "missing name".into(),
465    })?;
466    Ok(NamedFleet {
467        name,
468        description,
469        roles,
470    })
471}
472
473/// Load fleet by name from search paths (first hit wins).
474pub fn load_named_fleet(
475    name: &str,
476    search_roots: &[PathBuf],
477) -> Result<NamedFleet, NamedFleetError> {
478    let file_name = format!("{name}.toml");
479    for root in search_roots {
480        let path = root.join("fleets").join(&file_name);
481        if path.is_file() {
482            return load_named_fleet_file(&path, Some(name));
483        }
484    }
485    Err(NamedFleetError::NotFound(name.to_string()))
486}
487
488pub fn load_named_fleet_file(
489    path: &Path,
490    expect_name: Option<&str>,
491) -> Result<NamedFleet, NamedFleetError> {
492    let text = std::fs::read_to_string(path).map_err(|e| NamedFleetError::Io {
493        path: path.display().to_string(),
494        message: e.to_string(),
495    })?;
496    let fleet = parse_named_fleet(&text).map_err(|e| match e {
497        NamedFleetError::Parse { message, .. } => NamedFleetError::Parse {
498            path: path.display().to_string(),
499            message,
500        },
501        other => other,
502    })?;
503    if let Some(expected) = expect_name
504        && fleet.name != expected
505    {
506        return Err(NamedFleetError::NameMismatch {
507            declared: fleet.name,
508            expected: expected.to_string(),
509        });
510    }
511    Ok(fleet)
512}
513
514impl NamedFleet {
515    /// Resolve a role name to a profile id.
516    pub fn resolve(&self, role: &str) -> Result<&str, NamedFleetError> {
517        let key = role.trim().to_ascii_lowercase();
518        self.roles
519            .get(&key)
520            .or_else(|| {
521                self.roles
522                    .iter()
523                    .find(|(k, _)| k.eq_ignore_ascii_case(role))
524                    .map(|(_, v)| v)
525            })
526            .map(String::as_str)
527            .ok_or_else(|| NamedFleetError::MissingRole {
528                fleet: self.name.clone(),
529                role: role.to_string(),
530            })
531    }
532
533    /// Ensure all required stopship roles are present.
534    pub fn validate_stopship_roles(&self) -> Result<(), NamedFleetError> {
535        for role in STOPSHIP_REQUIRED_ROLES {
536            self.resolve(role)?;
537        }
538        Ok(())
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    const STOPSHIP_TOML: &str = r#"
547name = "stopship"
548description = "Stopship dogfood fleet"
549
550[roles]
551scout = "scout"
552implementer = "builder"
553reviewer = "reviewer"
554verifier = "verifier"
555release_lead = "manager"
556"#;
557
558    #[test]
559    fn stopship_fleet_resolves_all_five_roles() {
560        let fleet = parse_named_fleet(STOPSHIP_TOML).expect("parse");
561        assert_eq!(fleet.name, "stopship");
562        fleet.validate_stopship_roles().expect("all roles");
563        assert_eq!(fleet.resolve("scout").unwrap(), "scout");
564        assert_eq!(fleet.resolve("implementer").unwrap(), "builder");
565        assert_eq!(fleet.resolve("reviewer").unwrap(), "reviewer");
566        assert_eq!(fleet.resolve("verifier").unwrap(), "verifier");
567        assert_eq!(fleet.resolve("release_lead").unwrap(), "manager");
568    }
569
570    #[test]
571    fn unknown_role_fails_clearly() {
572        let fleet = parse_named_fleet(STOPSHIP_TOML).unwrap();
573        let err = fleet.resolve("wizard").unwrap_err();
574        assert!(matches!(err, NamedFleetError::MissingRole { .. }));
575    }
576
577    #[test]
578    fn quoted_hashes_are_not_treated_as_comments() {
579        let fleet = parse_named_fleet(
580            r#"
581name = "issue-references"
582description = "Tracks #4178 dogfood" # real comment
583
584[roles]
585scout = "scout#stable"
586"#,
587        )
588        .expect("parse");
589
590        assert_eq!(fleet.description.as_deref(), Some("Tracks #4178 dogfood"));
591        assert_eq!(fleet.resolve("scout").unwrap(), "scout#stable");
592    }
593
594    #[test]
595    fn comment_stripping_tracks_toml_quotes_and_escapes() {
596        assert_eq!(
597            strip_toml_comment(r##"description = "say \"#still-value\"" # comment"##).trim_end(),
598            r##"description = "say \"#still-value\"""##
599        );
600        assert_eq!(
601            strip_toml_comment("description = 'tracks #4178' # comment").trim_end(),
602            "description = 'tracks #4178'"
603        );
604        assert_eq!(
605            strip_toml_comment(r#"name = "stopship" # comment"#).trim_end(),
606            r#"name = "stopship""#
607        );
608    }
609
610    #[test]
611    fn legacy_fleet_files_still_deserialize_and_resolve_through_the_document_api() {
612        let document = FleetDocument::parse(STOPSHIP_TOML).expect("legacy parse");
613
614        // Legacy is explicitly detectable, not inferred.
615        assert!(document.is_legacy());
616        assert_eq!(document.schema_kind(), "legacy");
617        assert_eq!(document.schema_revision(), 0);
618        assert!(document.exact().is_none());
619
620        let legacy = document.legacy().expect("legacy body");
621        legacy.validate_stopship_roles().expect("all roles");
622        assert_eq!(legacy.resolve("implementer").unwrap(), "builder");
623        assert_eq!(document.name(), "stopship");
624        assert!(document.source_hash().starts_with("sha256:"));
625    }
626
627    #[test]
628    fn exact_fleet_files_are_selected_by_an_explicit_schema_key() {
629        let document = FleetDocument::parse(
630            r#"
631name = "glm-pair"
632schema = "exact"
633
634[[members]]
635id = "implementer"
636provider = "zai"
637model = "glm-5"
638reasoning = "auto"
639
640[[members]]
641id = "router"
642kind = "router"
643provider = "zai"
644model = "glm-5-turbo"
645"#,
646        )
647        .expect("exact parse");
648
649        assert!(!document.is_legacy());
650        assert_eq!(document.schema_kind(), "exact");
651        assert_eq!(document.schema_revision(), exact_schema_revision());
652        assert!(document.legacy().is_none());
653        let exact = document.exact().expect("exact body");
654        assert!(exact.has_auto_member());
655        // The prototype inline form still parses, and is reported as the legacy
656        // inline router rather than as a second runtime concept.
657        assert!(exact.legacy_inline_router().is_some());
658        assert!(exact.router_ref().is_some());
659    }
660
661    #[test]
662    fn an_unknown_schema_key_fails_instead_of_falling_back_to_legacy() {
663        let err = FleetDocument::parse("name = \"f\"\nschema = \"experimental\"\n")
664            .expect_err("unknown schema must not silently parse as legacy");
665        assert!(matches!(err, NamedFleetError::Parse { .. }), "{err:?}");
666    }
667
668    #[test]
669    fn document_hash_follows_the_file_bytes() {
670        let a = FleetDocument::parse(STOPSHIP_TOML).expect("parse");
671        let b = FleetDocument::parse(STOPSHIP_TOML).expect("parse");
672        let c = FleetDocument::parse(&STOPSHIP_TOML.replace("builder", "implementer_profile"))
673            .expect("parse");
674
675        assert_eq!(a.source_hash(), b.source_hash());
676        assert_ne!(a.source_hash(), c.source_hash());
677    }
678
679    #[test]
680    fn loads_workspace_fleet_file() {
681        // Relative to crate CARGO_MANIFEST_DIR → repo root fleets/
682        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
683            .join("..")
684            .join("..");
685        let fleet = load_named_fleet("stopship", &[root]).expect("load workspace fleet");
686        fleet.validate_stopship_roles().unwrap();
687    }
688}