返回 CodeWhale
named_fleet.rs
根目录 / crates / workflow / src / 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
18 use std::collections::BTreeMap;
19 use std::path::{Path, PathBuf};
20
21 use serde::{Deserialize, Serialize};
22 use sha2::{Digest, Sha256};
23 use thiserror::Error;
24
25 use crate::fleet_exact::{
26 EXACT_FLEET_SCHEMA_KIND, EXACT_FLEET_SCHEMA_REVISION, ExactFleet, ExactFleetError,
27 LEGACY_FLEET_SCHEMA_KIND, declared_schema_kind,
28 };
29 use 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)]
37 pub 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
44 impl 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)`.
54 fn 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)]
66 pub 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)]
75 pub 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")]
103 pub 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)]
112 pub struct FleetDocument {
113 schema: FleetSchema,
114 source: Option<PathBuf>,
115 source_hash: String,
116 }
117
118 impl 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]
348 pub const fn exact_schema_revision() -> u32 {
349 EXACT_FLEET_SCHEMA_REVISION
350 }
351
352 pub(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.
358 pub(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).
371 pub const STOPSHIP_REQUIRED_ROLES: &[&str] = &[
372 "scout",
373 "implementer",
374 "reviewer",
375 "verifier",
376 "release_lead",
377 ];
378
379 /// Parse a fleet TOML document.
380 pub 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.
397 fn 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
431 fn 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).
474 pub 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
488 pub 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
514 impl 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)]
543 mod tests {
544 use super::*;
545
546 const STOPSHIP_TOML: &str = r#"
547 name = "stopship"
548 description = "Stopship dogfood fleet"
549
550 [roles]
551 scout = "scout"
552 implementer = "builder"
553 reviewer = "reviewer"
554 verifier = "verifier"
555 release_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#"
581 name = "issue-references"
582 description = "Tracks #4178 dogfood" # real comment
583
584 [roles]
585 scout = "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#"
631 name = "glm-pair"
632 schema = "exact"
633
634 [[members]]
635 id = "implementer"
636 provider = "zai"
637 model = "glm-5"
638 reasoning = "auto"
639
640 [[members]]
641 id = "router"
642 kind = "router"
643 provider = "zai"
644 model = "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 }
689
689 lines RUST