返回 CodeWhale
store.rs
根目录 / crates / tui / src / fleet / store.rs
1 //! The saved named Fleet — the single configuration concept for the whole
2 //! Fleet surface (v2, `schema = "fleet"`).
3 //!
4 //! A Fleet is one self-contained TOML file. It owns:
5 //!
6 //! - its **operator** route (provider + exact model + reasoning), or the
7 //! explicit absence of one ("inherit the session route");
8 //! - its **roster**: each member's role, exact model pin or inherit policy,
9 //! provider (pins only — never inferred from a model string), reasoning
10 //! level, optional instructions, and capability requirements;
11 //! - its **save scope and source**: personal (`$CODEWHALE_HOME/fleets/`) or
12 //! workspace (`.codewhale/fleets/`), with the exact file path surfaced.
13 //!
14 //! There is exactly one store. The legacy per-role profile files
15 //! (`~/.codewhale/agents/*.toml`, `.codewhale/agents/*.toml`,
16 //! `[fleet.profiles]`) and the workflow crate's `exact`/legacy named-fleet
17 //! files are migration/compat input only — read here, never shadowed, never
18 //! the runtime winner alongside a v2 Fleet.
19 //!
20 //! Selection is a scope-explicit file: `fleets/selected` under the personal
21 //! root is the user-global default; the same file under the workspace root is
22 //! an intentional workspace selection. Workspace selection wins; both are
23 //! labeled in the UI. A workspace selection can never hide or rewrite a
24 //! personal Fleet.
25
26 use std::collections::BTreeMap;
27 use std::fs;
28 use std::path::{Path, PathBuf};
29
30 use serde::{Deserialize, Serialize};
31 use thiserror::Error;
32
33 use super::roster::FleetRoster;
34
35 pub const FLEET_SCHEMA_KIND: &str = "fleet";
36 pub const FLEET_SCHEMA_REVISION: u32 = 2;
37
38 /// The directory name used by both roots (next to `agents/` for legacy
39 /// profiles). Also used by the workflow crate for its own legacy/exact files;
40 /// v2 files in the same directory are simply a newer schema.
41 pub const FLEET_DIR: &str = "fleets";
42 pub const SELECTED_FILE: &str = "selected";
43
44 /// Where a Fleet was saved. This is the pin target: personal = user-global,
45 /// workspace = folder-scoped.
46 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47 #[serde(rename_all = "snake_case")]
48 pub enum FleetScope {
49 Personal,
50 Workspace,
51 }
52
53 impl FleetScope {
54 /// Short label for UI and receipts: "user" / "folder".
55 #[must_use]
56 pub const fn label(self) -> &'static str {
57 match self {
58 Self::Personal => "user",
59 Self::Workspace => "folder",
60 }
61 }
62
63 #[must_use]
64 pub const fn long_label(self) -> &'static str {
65 match self {
66 Self::Personal => "user-global",
67 Self::Workspace => "folder (this workspace)",
68 }
69 }
70
71 #[must_use]
72 pub const fn toggled(self) -> Self {
73 match self {
74 Self::Personal => Self::Workspace,
75 Self::Workspace => Self::Personal,
76 }
77 }
78 }
79
80 /// A Fleet's own operator route. Absent = inherit the live session route.
81 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82 #[serde(deny_unknown_fields)]
83 pub struct FleetOperator {
84 /// Exact provider id (a `[providers.<id>]` key or a built-in id).
85 pub provider: String,
86 /// Exact model id on that provider's route.
87 pub model: String,
88 /// Reasoning level, only when the resolved route genuinely supports it.
89 /// Absent = inherit the session tier.
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub reasoning: Option<String>,
92 }
93
94 /// Capability requirements a member must satisfy. The vocabulary is closed so
95 /// an unknown requirement is a specific error, never a silent reinterpretation.
96 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
97 pub enum MemberCapability {
98 /// Image input: the member must run on a route that accepts images.
99 Vision,
100 }
101
102 impl MemberCapability {
103 pub const VOCABULARY: [&'static str; 1] = ["vision"];
104
105 pub fn parse(value: &str) -> Option<Self> {
106 match value.trim().to_ascii_lowercase().as_str() {
107 "vision" | "image" | "image-input" => Some(Self::Vision),
108 _ => None,
109 }
110 }
111
112 #[must_use]
113 pub const fn wire_name(self) -> &'static str {
114 match self {
115 Self::Vision => "vision",
116 }
117 }
118 }
119
120 /// One roster member of a Fleet.
121 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
122 #[serde(deny_unknown_fields)]
123 pub struct FleetMember {
124 /// Stable member id — the role identity (e.g. `scout`, `builder`).
125 pub id: String,
126 /// Role label; defaults to `id` when absent.
127 #[serde(default, skip_serializing_if = "String::is_empty")]
128 pub role: String,
129 /// Exact model pin. Absent with `provider` absent = inherit the session
130 /// route (the operator route when the Fleet has one).
131 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub model: Option<String>,
133 /// Exact provider id for `model`. Pins only: a member must never carry
134 /// `provider` without `model` (rejected at parse), and the provider is
135 /// never inferred from the model string.
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub provider: Option<String>,
138 /// Reasoning level for this member, only when the resolved route
139 /// supports it. Absent = inherit.
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub reasoning: Option<String>,
142 /// Optional instruction overlay for the role.
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub instructions: Option<String>,
145 /// Capability requirements, e.g. `["vision"]`. Validated against
146 /// [`MemberCapability::VOCABULARY`] at parse.
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
148 pub requires: Vec<String>,
149 }
150
151 /// The saved named Fleet document (`schema = "fleet"`, revision 2).
152 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
153 #[serde(deny_unknown_fields)]
154 pub struct FleetFile {
155 pub schema: String,
156 pub schema_revision: u32,
157 /// Editable display name. Unique per scope (the file slug is derived
158 /// from it); the same name may exist in both scopes, distinguished by
159 /// origin, never silently shadowed.
160 pub name: String,
161 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub description: Option<String>,
163 /// The Fleet's own operator route. Absent = inherit the session route.
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub operator: Option<FleetOperator>,
166 #[serde(default)]
167 pub members: Vec<FleetMember>,
168 }
169
170 /// Why a Fleet file could not be used.
171 #[derive(Debug, Clone, PartialEq, Eq, Error)]
172 pub enum FleetStoreError {
173 #[error("invalid fleet: {0}")]
174 Invalid(String),
175 #[error(
176 "fleet `{0}` is defined in both {1} and {2}; name one explicitly as {1}/{0} or {2}/{0}"
177 )]
178 Ambiguous(String, String, String),
179 #[error("fleet file not found: {0}")]
180 NotFound(String),
181 #[error("failed to read {path}: {message}")]
182 Io { path: String, message: String },
183 #[error("failed to parse {path}: {message}")]
184 Parse { path: String, message: String },
185 #[error("a fleet named `{name}` already exists at {path}; rename it or choose another name")]
186 NameTaken { name: String, path: String },
187 }
188
189 impl FleetFile {
190 /// Create a validated v2 Fleet file.
191 pub fn new(name: String, description: Option<String>) -> Result<Self, FleetStoreError> {
192 let fleet = Self {
193 schema: FLEET_SCHEMA_KIND.to_string(),
194 schema_revision: FLEET_SCHEMA_REVISION,
195 name,
196 description,
197 operator: None,
198 members: Vec::new(),
199 };
200 fleet.validate()?;
201 Ok(fleet)
202 }
203
204 /// Validate the document: name, member ids, pin symmetry, capability
205 /// vocabulary. Invalid input is rejected with a specific error — never
206 /// silently reinterpreted.
207 pub fn validate(&self) -> Result<(), FleetStoreError> {
208 if self.schema != FLEET_SCHEMA_KIND {
209 return Err(FleetStoreError::Invalid(format!(
210 "unknown schema `{}`; expected `{FLEET_SCHEMA_KIND}`",
211 self.schema
212 )));
213 }
214 if self.schema_revision != FLEET_SCHEMA_REVISION {
215 return Err(FleetStoreError::Invalid(format!(
216 "unsupported schema revision {}; this build reads revision {FLEET_SCHEMA_REVISION}",
217 self.schema_revision
218 )));
219 }
220 let name = self.name.trim();
221 if name.is_empty() {
222 return Err(FleetStoreError::Invalid(
223 "fleet name must not be empty".to_string(),
224 ));
225 }
226 let mut seen: BTreeMap<&str, ()> = BTreeMap::new();
227 for member in &self.members {
228 if member.id.trim().is_empty() {
229 return Err(FleetStoreError::Invalid(
230 "member id must not be empty".to_string(),
231 ));
232 }
233 if seen.insert(member.id.as_str(), ()).is_some() {
234 return Err(FleetStoreError::Invalid(format!(
235 "duplicate member id `{}`",
236 member.id
237 )));
238 }
239 match (&member.provider, &member.model) {
240 (Some(_), None) | (None, Some(_)) => {
241 return Err(FleetStoreError::Invalid(format!(
242 "member `{}` must pin both provider and model, or neither (inherit); a lone {} is rejected",
243 member.id,
244 if member.provider.is_some() {
245 "provider"
246 } else {
247 "model"
248 }
249 )));
250 }
251 _ => {}
252 }
253 for requirement in &member.requires {
254 if MemberCapability::parse(requirement).is_none() {
255 return Err(FleetStoreError::Invalid(format!(
256 "member `{}` requires unknown capability `{requirement}`; valid values: {}",
257 member.id,
258 MemberCapability::VOCABULARY.join(", ")
259 )));
260 }
261 }
262 }
263 Ok(())
264 }
265
266 /// Render the canonical TOML document.
267 pub fn render_toml(&self) -> Result<String, FleetStoreError> {
268 self.validate()?;
269 let rendered = toml::to_string_pretty(self)
270 .map_err(|e| FleetStoreError::Invalid(format!("failed to serialize fleet: {e}")))?;
271 Ok(rendered)
272 }
273
274 /// Parse a v2 fleet document from TOML text.
275 pub fn parse(text: &str) -> Result<Self, FleetStoreError> {
276 let fleet: Self = toml::from_str(text)
277 .map_err(|e| FleetStoreError::Invalid(format!("invalid fleet TOML: {e}")))?;
278 fleet.validate()?;
279 Ok(fleet)
280 }
281
282 /// A stable file slug derived from the display name. Safe across the
283 /// filesystems Codewhale supports; collisions are detected at save.
284 #[must_use]
285 pub fn file_slug(&self) -> String {
286 slugify(&self.name)
287 }
288
289 /// Look up a member by role id.
290 #[must_use]
291 pub fn member(&self, id: &str) -> Option<&FleetMember> {
292 self.members.iter().find(|m| m.id == id)
293 }
294
295 /// Whether the roster contains a scout member (the fast exploratory role).
296 #[must_use]
297 pub fn has_scout(&self) -> bool {
298 self.member("scout").is_some()
299 }
300 }
301
302 /// Sanitize a display name into a safe file slug.
303 fn slugify(name: &str) -> String {
304 let mut slug = String::with_capacity(name.len());
305 for ch in name.trim().chars() {
306 if ch.is_ascii_alphanumeric() {
307 slug.push(ch.to_ascii_lowercase());
308 } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !slug.ends_with('-') {
309 slug.push('-');
310 }
311 }
312 while slug.ends_with('-') {
313 slug.pop();
314 }
315 if slug.is_empty() {
316 "fleet".to_string()
317 } else {
318 slug
319 }
320 }
321
322 /// One entry in the Fleet list: name, scope, exact path, and health.
323 #[derive(Debug, Clone, PartialEq, Eq)]
324 pub struct FleetEntry {
325 pub name: String,
326 pub scope: FleetScope,
327 /// Exact path of the saved file.
328 pub path: PathBuf,
329 /// Parse failure, when the file exists but cannot be read as a v2 Fleet.
330 pub parse_error: Option<String>,
331 /// Whether the file is a legacy (pre-v2) named-fleet file (exact or
332 /// roles map) that is read for compatibility but not editable as v2.
333 pub legacy: bool,
334 }
335
336 /// The resolved selection: which Fleet a session should start on, and which
337 /// scope made the choice.
338 #[derive(Debug, Clone, PartialEq, Eq)]
339 pub struct SelectedFleet {
340 pub name: String,
341 pub scope: FleetScope,
342 pub path: PathBuf,
343 }
344
345 fn personal_fleets_dir() -> Result<PathBuf, FleetStoreError> {
346 codewhale_config::codewhale_home()
347 .map(|home| home.join(FLEET_DIR))
348 .map_err(|e| FleetStoreError::Io {
349 path: "$CODEWHALE_HOME/fleets".to_string(),
350 message: e.to_string(),
351 })
352 }
353
354 fn workspace_fleets_dir(workspace: &Path) -> PathBuf {
355 workspace.join(".codewhale").join(FLEET_DIR)
356 }
357
358 /// The fleet directory for a scope, creating it if needed.
359 fn ensure_fleets_dir(scope: FleetScope, workspace: &Path) -> Result<PathBuf, FleetStoreError> {
360 let dir = match scope {
361 FleetScope::Personal => personal_fleets_dir()?,
362 FleetScope::Workspace => workspace_fleets_dir(workspace),
363 };
364 fs::create_dir_all(&dir).map_err(|e| FleetStoreError::Io {
365 path: dir.display().to_string(),
366 message: e.to_string(),
367 })?;
368 Ok(dir)
369 }
370
371 /// List every named Fleet across both scopes, personal first. A file that is
372 /// not a v2 Fleet is listed as `legacy` with its parse error, so an old exact
373 /// fleet is visible — never silently absent — while the user decides whether
374 /// to migrate it.
375 pub fn list_fleets(workspace: &Path) -> Vec<FleetEntry> {
376 let mut entries = Vec::new();
377 if let Ok(dir) = personal_fleets_dir() {
378 collect_entries(&dir, FleetScope::Personal, &mut entries);
379 }
380 collect_entries(
381 &workspace_fleets_dir(workspace),
382 FleetScope::Workspace,
383 &mut entries,
384 );
385 entries.sort_by(|a, b| {
386 a.scope
387 .label()
388 .cmp(b.scope.label())
389 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
390 });
391 entries
392 }
393
394 fn collect_entries(dir: &Path, scope: FleetScope, out: &mut Vec<FleetEntry>) {
395 let Ok(read) = fs::read_dir(dir) else {
396 return;
397 };
398 let mut files: Vec<PathBuf> = read
399 .filter_map(|entry| entry.ok())
400 .map(|entry| entry.path())
401 .filter(|path| path.extension().is_some_and(|ext| ext == "toml"))
402 .collect();
403 files.sort();
404 for path in files {
405 let stem = path
406 .file_stem()
407 .map(|s| s.to_string_lossy().into_owned())
408 .unwrap_or_default();
409 let text = fs::read_to_string(&path).ok();
410 let parse_error = text
411 .as_deref()
412 .and_then(|text| FleetFile::parse(text).err())
413 .map(|e| e.to_string());
414 let legacy = parse_error.as_deref().is_some_and(|err| {
415 err.contains("unknown schema") || err.contains("invalid fleet TOML")
416 });
417 // The row shows the Fleet's own display name, never the file slug —
418 // a file saved as `Temp Fleet` must not appear as `temp-fleet`.
419 let name = text
420 .as_deref()
421 .and_then(|text| toml::from_str::<toml::Value>(text).ok())
422 .and_then(|value| {
423 value
424 .get("name")
425 .and_then(|n| n.as_str())
426 .map(str::trim)
427 .filter(|n| !n.is_empty())
428 .map(str::to_string)
429 })
430 .unwrap_or(stem);
431 out.push(FleetEntry {
432 name,
433 scope,
434 path,
435 parse_error,
436 legacy,
437 });
438 }
439 }
440
441 /// Load a v2 Fleet by name. Ambiguity between the two scopes is an error that
442 /// names both origins — the caller (UI) resolves it by asking for a scope.
443 /// (Kept for the qualified-name flow and the ambiguity tests; the list/detail
444 /// UI resolves by scope via load_fleet_in_scope.)
445 #[allow(dead_code)]
446 pub fn load_fleet(
447 name: &str,
448 workspace: &Path,
449 ) -> Result<(FleetFile, FleetScope, PathBuf), FleetStoreError> {
450 let name = name.trim();
451 if name.is_empty() {
452 return Err(FleetStoreError::NotFound("<empty name>".to_string()));
453 }
454 let mut found: Vec<(FleetScope, PathBuf)> = Vec::new();
455 if let Ok(dir) = personal_fleets_dir() {
456 let path = dir.join(format!("{}.toml", slugify(name)));
457 if path.is_file() {
458 found.push((FleetScope::Personal, path));
459 }
460 }
461 let ws_path = workspace_fleets_dir(workspace).join(format!("{}.toml", slugify(name)));
462 if ws_path.is_file() {
463 found.push((FleetScope::Workspace, ws_path));
464 }
465 if found.len() > 1 {
466 return Err(FleetStoreError::Ambiguous(
467 name.to_string(),
468 FleetScope::Personal.label().to_string(),
469 FleetScope::Workspace.label().to_string(),
470 ));
471 }
472 let Some((scope, path)) = found.pop() else {
473 return Err(FleetStoreError::NotFound(name.to_string()));
474 };
475 let text = fs::read_to_string(&path).map_err(|e| FleetStoreError::Io {
476 path: path.display().to_string(),
477 message: e.to_string(),
478 })?;
479 let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse {
480 path: path.display().to_string(),
481 message: e.to_string(),
482 })?;
483 Ok((fleet, scope, path))
484 }
485
486 /// Load a v2 Fleet by name in one explicit scope. Unlike [`load_fleet`],
487 /// this never resolves ambiguity — the caller already knows where the Fleet
488 /// lives (e.g. the row the user just picked).
489 pub fn load_fleet_in_scope(
490 name: &str,
491 scope: FleetScope,
492 workspace: &Path,
493 ) -> Result<(FleetFile, PathBuf), FleetStoreError> {
494 let dir = match scope {
495 FleetScope::Personal => personal_fleets_dir()?,
496 FleetScope::Workspace => workspace_fleets_dir(workspace),
497 };
498 let path = dir.join(format!("{}.toml", slugify(name)));
499 if !path.is_file() {
500 return Err(FleetStoreError::NotFound(format!(
501 "{} ({})",
502 name,
503 scope.label()
504 )));
505 }
506 let text = fs::read_to_string(&path).map_err(|e| FleetStoreError::Io {
507 path: path.display().to_string(),
508 message: e.to_string(),
509 })?;
510 let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse {
511 path: path.display().to_string(),
512 message: e.to_string(),
513 })?;
514 Ok((fleet, path))
515 }
516
517 /// Load a v2 Fleet from a specific path (used by the editor on the currently
518 /// open entry, so the saved scope is exact). API surface for the path-based
519 /// editor flows; currently exercised by tests.
520 #[allow(dead_code)]
521 pub fn load_fleet_at(path: &Path) -> Result<(FleetFile, FleetScope), FleetStoreError> {
522 let text = fs::read_to_string(path).map_err(|e| FleetStoreError::Io {
523 path: path.display().to_string(),
524 message: e.to_string(),
525 })?;
526 let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse {
527 path: path.display().to_string(),
528 message: e.to_string(),
529 })?;
530 let scope = if path.starts_with(personal_fleets_dir().unwrap_or_default()) {
531 FleetScope::Personal
532 } else {
533 FleetScope::Workspace
534 };
535 Ok((fleet, scope))
536 }
537
538 /// Save a Fleet to a scope with an atomic write. Refuses to clobber a
539 /// different Fleet of the same slug (the name is the identity).
540 pub fn save_fleet(
541 fleet: &FleetFile,
542 scope: FleetScope,
543 workspace: &Path,
544 ) -> Result<PathBuf, FleetStoreError> {
545 fleet.validate()?;
546 let dir = ensure_fleets_dir(scope, workspace)?;
547 let path = dir.join(format!("{}.toml", fleet.file_slug()));
548 if path.is_file()
549 && let Ok(text) = fs::read_to_string(&path)
550 && let Ok(existing) = FleetFile::parse(&text)
551 && existing.name != fleet.name
552 {
553 return Err(FleetStoreError::NameTaken {
554 name: fleet.name.clone(),
555 path: path.display().to_string(),
556 });
557 }
558 let rendered = fleet.render_toml()?;
559 atomic_write(&path, rendered.as_bytes())?;
560 Ok(path)
561 }
562
563 /// Delete a saved Fleet (UI confirms first). Returns the removed path.
564 pub fn delete_fleet(
565 name: &str,
566 scope: FleetScope,
567 workspace: &Path,
568 ) -> Result<PathBuf, FleetStoreError> {
569 let dir = match scope {
570 FleetScope::Personal => personal_fleets_dir()?,
571 FleetScope::Workspace => workspace_fleets_dir(workspace),
572 };
573 let path = dir.join(format!("{}.toml", slugify(name)));
574 if !path.is_file() {
575 return Err(FleetStoreError::NotFound(name.to_string()));
576 }
577 fs::remove_file(&path).map_err(|e| FleetStoreError::Io {
578 path: path.display().to_string(),
579 message: e.to_string(),
580 })?;
581 // A selection that pointed at the deleted Fleet must not linger: it would
582 // render as a phantom selection. The write is best-effort; a leftover
583 // selection is reported by the reader as missing, never as valid.
584 clear_selection_if_matching(scope, workspace, name);
585 Ok(path)
586 }
587
588 /// The active selection: workspace selection wins, then the personal
589 /// user-global default. Each file is scope-explicit; a workspace selection
590 /// can never hide the personal Fleet — the personal default is only overridden
591 /// for this folder, visibly.
592 pub fn selected_fleet(workspace: &Path) -> Option<SelectedFleet> {
593 let ws_dir = workspace_fleets_dir(workspace);
594 if let Some(name) = read_selection(&ws_dir) {
595 // A workspace selection may name a personal Fleet (selected for this
596 // folder only): resolve workspace first, then personal, and report
597 // the scope the Fleet actually lives in.
598 let ws_path = ws_dir.join(format!("{}.toml", slugify(&name)));
599 if ws_path.is_file() {
600 return Some(SelectedFleet {
601 name,
602 scope: FleetScope::Workspace,
603 path: ws_path,
604 });
605 }
606 if let Ok(dir) = personal_fleets_dir() {
607 let personal_path = dir.join(format!("{}.toml", slugify(&name)));
608 if personal_path.is_file() {
609 return Some(SelectedFleet {
610 name,
611 scope: FleetScope::Personal,
612 path: personal_path,
613 });
614 }
615 }
616 }
617 if let Ok(dir) = personal_fleets_dir()
618 && let Some(name) = read_selection(&dir)
619 {
620 let path = dir.join(format!("{}.toml", slugify(&name)));
621 if path.is_file() {
622 return Some(SelectedFleet {
623 name,
624 scope: FleetScope::Personal,
625 path,
626 });
627 }
628 }
629 None
630 }
631
632 fn read_selection(dir: &Path) -> Option<String> {
633 let text = fs::read_to_string(dir.join(SELECTED_FILE)).ok()?;
634 let name = text.trim();
635 if name.is_empty() {
636 None
637 } else {
638 Some(name.to_string())
639 }
640 }
641
642 /// Write the selection for a scope. Returns the exact file written.
643 ///
644 /// The selection file lives in the scope's `fleets/` directory, but the
645 /// Fleet it names may live in either scope: a workspace selection may point
646 /// at a personal Fleet (selecting it for this folder only), and a personal
647 /// selection always points at a personal Fleet. The validation only refuses
648 /// a name that exists NOWHERE — a phantom selection would be a lie.
649 pub fn set_selected(
650 name: &str,
651 scope: FleetScope,
652 workspace: &Path,
653 ) -> Result<PathBuf, FleetStoreError> {
654 let dir = ensure_fleets_dir(scope, workspace)?;
655 let name = name.trim();
656 let exists_in_scope = |target: FleetScope| {
657 let target_dir = match target {
658 FleetScope::Personal => personal_fleets_dir().ok(),
659 FleetScope::Workspace => Some(workspace_fleets_dir(workspace)),
660 };
661 target_dir
662 .map(|d| d.join(format!("{}.toml", slugify(name))).is_file())
663 .unwrap_or(false)
664 };
665 let exists = exists_in_scope(scope) || exists_in_scope(FleetScope::Personal);
666 if !exists {
667 return Err(FleetStoreError::NotFound(format!(
668 "{} ({})",
669 name,
670 scope.label()
671 )));
672 }
673 let selected = dir.join(SELECTED_FILE);
674 atomic_write(&selected, name.as_bytes())?;
675 Ok(selected)
676 }
677
678 fn clear_selection_if_matching(scope: FleetScope, workspace: &Path, name: &str) {
679 let dir = match scope {
680 FleetScope::Personal => personal_fleets_dir().ok(),
681 FleetScope::Workspace => Some(workspace_fleets_dir(workspace)),
682 };
683 let Some(dir) = dir else { return };
684 let selected = dir.join(SELECTED_FILE);
685 if read_selection(&dir).as_deref() == Some(name.trim()) {
686 let _ = fs::remove_file(selected);
687 }
688 }
689
690 /// Atomic write: temp file in the same directory, then rename. A failed write
691 /// never leaves a half-written Fleet or selection.
692 fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), FleetStoreError> {
693 let tmp = path.with_extension("tmp");
694 fs::write(&tmp, bytes).map_err(|e| FleetStoreError::Io {
695 path: tmp.display().to_string(),
696 message: e.to_string(),
697 })?;
698 if let Err(e) = fs::rename(&tmp, path) {
699 let _ = fs::remove_file(&tmp);
700 return Err(FleetStoreError::Io {
701 path: path.display().to_string(),
702 message: e.to_string(),
703 });
704 }
705 Ok(())
706 }
707
708 /// One row of the migration receipt: how a legacy role profile maps into the
709 /// new Fleet.
710 #[derive(Debug, Clone, PartialEq, Eq)]
711 pub struct MigrationRow {
712 /// Role id, e.g. `scout`.
713 pub id: String,
714 /// The pin that will be saved (model + provider, or "inherit").
715 pub pin: Option<(String, String)>,
716 /// The winning origin under the legacy precedence.
717 pub winner: String,
718 /// A lower-precedence copy with identical content — not a conflict.
719 pub identical_shadow: Option<String>,
720 /// A lower-precedence copy that differed and was NOT carried over.
721 pub conflicting_shadow: Option<String>,
722 }
723
724 /// The result of migrating the legacy per-role roster into a v2 Fleet.
725 #[derive(Debug, Clone, PartialEq, Eq)]
726 pub struct MigrationReceipt {
727 /// The Fleet that was (or would be) saved.
728 pub fleet: FleetFile,
729 /// Per-role mapping, including every conflict that was resolved.
730 pub rows: Vec<MigrationRow>,
731 /// Path the Fleet was saved to.
732 pub saved_to: PathBuf,
733 }
734
735 /// Build (and optionally save) a v2 Fleet from the legacy per-role roster:
736 /// built-ins + `[fleet.profiles]` + personal + workspace profile files.
737 ///
738 /// Nothing is discarded: every role becomes a member, every pin survives, and
739 /// each lower-precedence copy that differed is named in the receipt. The
740 /// legacy files themselves are left untouched — they become migration input,
741 /// not live config, once a Fleet is selected.
742 pub fn migrate_legacy_roster(
743 fleet_config: &codewhale_config::FleetConfigToml,
744 workspace: &Path,
745 save: bool,
746 save_scope: FleetScope,
747 ) -> Result<MigrationReceipt, FleetStoreError> {
748 let roster = FleetRoster::load(fleet_config, workspace);
749 let mut fleet = FleetFile::new(
750 "Default".to_string(),
751 Some("Migrated from the legacy per-role profile configuration.".to_string()),
752 )?;
753 let mut rows = Vec::new();
754 for member in roster.members() {
755 let profile = &member.profile;
756 let (model, provider) = match (&profile.model, &profile.provider) {
757 (Some(model), Some(provider)) => (Some(model.clone()), Some(provider.clone())),
758 _ => (None, None),
759 };
760 // Legacy profiles carry no capability requirements; a migration
761 // never invents one. Requirements start empty in the v2 Fleet.
762 let requires: Vec<String> = Vec::new();
763 let row = MigrationRow {
764 id: member.id.clone(),
765 pin: model
766 .as_ref()
767 .map(|m| (m.clone(), provider.clone().unwrap_or_default())),
768 winner: member.origin.to_string(),
769 identical_shadow: None,
770 conflicting_shadow: None,
771 };
772 // Record shadowed copies (the roster already resolved them; here we
773 // name them so the conflict is visible before anyone accepts it).
774 let shadows: Vec<String> = roster
775 .shadowed()
776 .iter()
777 .filter(|s| s.id == member.id)
778 .map(|s| {
779 format!(
780 "{} copy at {} ignored in favor of {}",
781 s.shadowed_origin,
782 s.shadowed_source.display(),
783 s.winner_origin
784 )
785 })
786 .collect();
787 let mut row = row;
788 if let Some(first) = shadows.first() {
789 if shadows.len() == 1 && first.contains("built-in") {
790 row.identical_shadow = Some(first.clone());
791 } else {
792 row.conflicting_shadow = Some(shadows.join("; "));
793 }
794 }
795 rows.push(row);
796 fleet.members.push(FleetMember {
797 id: member.id.clone(),
798 role: profile.role.name.clone(),
799 model,
800 provider,
801 reasoning: profile.reasoning_effort.clone(),
802 instructions: profile.role.instructions.clone(),
803 requires,
804 });
805 }
806 fleet.validate()?;
807 let saved_to = if save {
808 save_fleet(&fleet, save_scope, workspace)?
809 } else {
810 match save_scope {
811 FleetScope::Personal => {
812 personal_fleets_dir()?.join(format!("{}.toml", fleet.file_slug()))
813 }
814 FleetScope::Workspace => {
815 workspace_fleets_dir(workspace).join(format!("{}.toml", fleet.file_slug()))
816 }
817 }
818 };
819 Ok(MigrationReceipt {
820 fleet,
821 rows,
822 saved_to,
823 })
824 }
825
826 #[cfg(test)]
827 mod tests {
828 use super::*;
829 use std::sync::OnceLock;
830
831 /// A sealed CODEWHALE_HOME for personal-scope tests, created once per
832 /// process. Tests must still hold `lock_test_env` before touching it.
833 fn sealed_home() -> &'static Path {
834 static HOME: OnceLock<PathBuf> = OnceLock::new();
835 HOME.get_or_init(|| {
836 let dir = tempfile::TempDir::new()
837 .expect("temp dir for sealed home")
838 .keep();
839 std::fs::create_dir_all(dir.join("fleets")).expect("fleets dir");
840 dir
841 })
842 }
843
844 struct EnvGuard {
845 prev: Option<std::ffi::OsString>,
846 }
847
848 impl Drop for EnvGuard {
849 fn drop(&mut self) {
850 // SAFETY: serialised by lock_test_env held by the caller.
851 unsafe {
852 match &self.prev {
853 Some(v) => std::env::set_var("CODEWHALE_HOME", v),
854 None => std::env::remove_var("CODEWHALE_HOME"),
855 }
856 }
857 }
858 }
859
860 /// Point CODEWHALE_HOME at a sealed temp dir. Caller must hold
861 /// `lock_test_env`.
862 fn set_sealed_home() -> EnvGuard {
863 let prev = std::env::var_os("CODEWHALE_HOME");
864 // SAFETY: serialised by lock_test_env held by the caller.
865 unsafe {
866 std::env::set_var("CODEWHALE_HOME", sealed_home());
867 }
868 EnvGuard { prev }
869 }
870
871 fn sample_fleet() -> FleetFile {
872 FleetFile::new("DeepSeek Flash".to_string(), None)
873 .expect("valid fleet")
874 .with_operator(FleetOperator {
875 provider: "deepseek".to_string(),
876 model: "deepseek-v4-flash".to_string(),
877 reasoning: Some("low".to_string()),
878 })
879 .with_member(FleetMember {
880 id: "scout".to_string(),
881 role: "scout".to_string(),
882 provider: None,
883 model: None,
884 reasoning: None,
885 instructions: None,
886 requires: Vec::new(),
887 })
888 .with_member(FleetMember {
889 id: "builder".to_string(),
890 role: "builder".to_string(),
891 provider: Some("deepseek".to_string()),
892 model: Some("deepseek-v4-pro".to_string()),
893 reasoning: Some("high".to_string()),
894 instructions: Some("Implement exactly the task slice.".to_string()),
895 requires: vec!["vision".to_string()],
896 })
897 }
898
899 trait FleetBuilder {
900 fn with_operator(self, operator: FleetOperator) -> Self;
901 fn with_member(self, member: FleetMember) -> Self;
902 }
903
904 impl FleetBuilder for FleetFile {
905 fn with_operator(mut self, operator: FleetOperator) -> Self {
906 self.operator = Some(operator);
907 self
908 }
909 fn with_member(mut self, member: FleetMember) -> Self {
910 self.members.push(member);
911 self
912 }
913 }
914
915 #[test]
916 fn validation_rejects_bad_documents_with_specific_errors() {
917 let _lock = crate::test_support::lock_test_env();
918
919 // Empty name.
920 let err = FleetFile::new(" ".to_string(), None).unwrap_err();
921 assert!(err.to_string().contains("name must not be empty"), "{err}");
922
923 // Duplicate member ids.
924 let mut fleet = sample_fleet();
925 fleet.members.push(fleet.members[0].clone());
926 let err = fleet.validate().unwrap_err();
927 assert!(
928 err.to_string().contains("duplicate member id `scout`"),
929 "{err}"
930 );
931
932 // Lone provider / lone model: never silently reinterpreted.
933 let mut fleet = sample_fleet();
934 fleet.members[0].provider = Some("deepseek".to_string());
935 let err = fleet.validate().unwrap_err();
936 assert!(
937 err.to_string().contains("must pin both provider and model"),
938 "{err}"
939 );
940 let mut fleet = sample_fleet();
941 fleet.members[0].model = Some("deepseek-v4-pro".to_string());
942 let err = fleet.validate().unwrap_err();
943 assert!(
944 err.to_string().contains("must pin both provider and model"),
945 "{err}"
946 );
947
948 // Unknown capability requirement.
949 let mut fleet = sample_fleet();
950 fleet.members[0].requires = vec!["telepathy".to_string()];
951 let err = fleet.validate().unwrap_err();
952 assert!(
953 err.to_string().contains("unknown capability `telepathy`"),
954 "{err}"
955 );
956 assert!(err.to_string().contains("vision"), "{err}");
957 }
958
959 #[test]
960 fn render_parse_round_trip_preserves_every_field() {
961 let fleet = sample_fleet();
962 let text = fleet.render_toml().expect("render");
963 let parsed = FleetFile::parse(&text).expect("parse");
964 assert_eq!(parsed, fleet);
965 assert!(text.contains("schema = \"fleet\""));
966 assert!(text.contains("schema_revision = 2"));
967 assert!(text.contains("deepseek-v4-flash"));
968 }
969
970 #[test]
971 fn save_load_round_trips_in_workspace_scope() {
972 let _lock = crate::test_support::lock_test_env();
973 let ws = tempfile::TempDir::new().unwrap();
974 let fleet = sample_fleet();
975 let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).expect("save");
976 assert!(
977 path.ends_with(".codewhale/fleets/deepseek-flash.toml"),
978 "{path:?}"
979 );
980
981 let (loaded, scope, path) = load_fleet("DeepSeek Flash", ws.path()).expect("load");
982 assert_eq!(loaded, fleet);
983 assert_eq!(scope, FleetScope::Workspace);
984 assert_eq!(path, load_fleet("DeepSeek Flash", ws.path()).unwrap().2);
985
986 // A same-name Fleet in the personal scope makes the bare name
987 // ambiguous — the reader names both origins instead of shadowing.
988 let _home = set_sealed_home();
989 save_fleet(&fleet, FleetScope::Personal, ws.path()).expect("save personal");
990 let err = load_fleet("DeepSeek Flash", ws.path()).unwrap_err();
991 let msg = err.to_string();
992 assert!(msg.contains("defined in both"), "{msg}");
993 assert!(msg.contains("user") && msg.contains("folder"), "{msg}");
994 }
995
996 #[test]
997 fn selection_is_scope_explicit_and_workspace_wins() {
998 let _lock = crate::test_support::lock_test_env();
999 let _home = set_sealed_home();
1000 let ws = tempfile::TempDir::new().unwrap();
1001 let fleet = sample_fleet();
1002
1003 // No selection yet.
1004 assert!(selected_fleet(ws.path()).is_none());
1005
1006 // Personal selection: the user-global default.
1007 save_fleet(&fleet, FleetScope::Personal, ws.path()).unwrap();
1008 let selected_file =
1009 set_selected("DeepSeek Flash", FleetScope::Personal, ws.path()).expect("select");
1010 assert!(
1011 selected_file.ends_with("fleets/selected"),
1012 "{selected_file:?}"
1013 );
1014 let sel = selected_fleet(ws.path()).expect("selected");
1015 assert_eq!(sel.scope, FleetScope::Personal);
1016 assert_eq!(sel.name, "DeepSeek Flash");
1017
1018 // A selection naming a missing Fleet is refused — a phantom selection
1019 // would be a lie.
1020 let err = set_selected("No Such Fleet", FleetScope::Personal, ws.path()).unwrap_err();
1021 assert!(err.to_string().contains("No Such Fleet"), "{err}");
1022
1023 // Workspace selection overrides for this folder only.
1024 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1025 set_selected("DeepSeek Flash", FleetScope::Workspace, ws.path()).unwrap();
1026 let sel = selected_fleet(ws.path()).expect("selected");
1027 assert_eq!(sel.scope, FleetScope::Workspace);
1028
1029 // Deleting the workspace Fleet clears the workspace selection; the
1030 // personal default reappears rather than a phantom.
1031 delete_fleet("DeepSeek Flash", FleetScope::Workspace, ws.path()).unwrap();
1032 let sel = selected_fleet(ws.path()).expect("personal default returns");
1033 assert_eq!(sel.scope, FleetScope::Personal);
1034 }
1035
1036 #[test]
1037 fn list_marks_legacy_files_without_hiding_them() {
1038 let _lock = crate::test_support::lock_test_env();
1039 let _home = set_sealed_home();
1040 let ws = tempfile::TempDir::new().unwrap();
1041
1042 save_fleet(&sample_fleet(), FleetScope::Personal, ws.path()).unwrap();
1043 // A legacy exact fleet file (workflow schema) in the same directory
1044 // must be listed as legacy, never silently absent.
1045 let legacy = r#"schema = "exact"
1046 schema_revision = 1
1047 name = "stopship"
1048 members = []"#;
1049 std::fs::write(sealed_home().join("fleets/stopship.toml"), legacy).unwrap();
1050
1051 let entries = list_fleets(ws.path());
1052 assert_eq!(entries.len(), 2, "{entries:?}");
1053 let stopship = entries
1054 .iter()
1055 .find(|e| e.name == "stopship")
1056 .expect("legacy fleet listed");
1057 assert!(stopship.legacy, "{stopship:?}");
1058 assert!(stopship.parse_error.is_some(), "{stopship:?}");
1059 let flash = entries.iter().find(|e| e.name == "DeepSeek Flash").unwrap();
1060 assert!(!flash.legacy && flash.parse_error.is_none(), "{flash:?}");
1061 }
1062
1063 #[test]
1064 fn save_refuses_to_clobber_a_different_fleet_of_the_same_slug() {
1065 let _lock = crate::test_support::lock_test_env();
1066 let ws = tempfile::TempDir::new().unwrap();
1067 let fleet = sample_fleet();
1068 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1069
1070 let mut other = FleetFile::new("DeepSeek Flash!".to_string(), None).unwrap();
1071 other.members = fleet.members.clone();
1072 let err = save_fleet(&other, FleetScope::Workspace, ws.path()).unwrap_err();
1073 assert!(err.to_string().contains("already exists"), "{err}");
1074 }
1075
1076 #[test]
1077 fn migration_preserves_pins_and_names_shadowing() {
1078 let _lock = crate::test_support::lock_test_env();
1079 let ws = tempfile::TempDir::new().unwrap();
1080
1081 // A workspace legacy profile file with a pin.
1082 let agents_dir = ws.path().join(".codewhale/agents");
1083 std::fs::create_dir_all(&agents_dir).unwrap();
1084 std::fs::write(
1085 agents_dir.join("scout.toml"),
1086 r#"id = "scout"
1087 role_hint = "scout"
1088 model = "deepseek-v4-flash"
1089 provider = "deepseek"
1090 "#,
1091 )
1092 .unwrap();
1093
1094 let receipt = migrate_legacy_roster(
1095 &codewhale_config::FleetConfigToml::default(),
1096 ws.path(),
1097 true,
1098 FleetScope::Workspace,
1099 )
1100 .expect("migration");
1101
1102 assert_eq!(receipt.fleet.name, "Default");
1103 let scout = receipt.fleet.member("scout").expect("scout member");
1104 assert_eq!(scout.model.as_deref(), Some("deepseek-v4-flash"));
1105 assert_eq!(scout.provider.as_deref(), Some("deepseek"));
1106 assert!(receipt.saved_to.ends_with("fleets/default.toml"));
1107 // The legacy profile file itself is untouched.
1108 assert!(
1109 std::fs::read_to_string(agents_dir.join("scout.toml"))
1110 .unwrap()
1111 .contains("model = \"deepseek-v4-flash\"")
1112 );
1113 }
1114 }
1115
1115 lines RUST