返回 CodeWhale
store.rs
根目录 / crates / tui / src / fleet / store.rs
1 //! The saved named Fleet — the single configuration concept for the whole
2 //! Fleet surface. Its v2 compatibility storage keeps `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 stable id, optional human-facing name, role,
9 //! exact model pin or inherit policy, provider (pins only — never inferred
10 //! from a model string), reasoning level, optional instructions, and
11 //! capability requirements;
12 //! - its **save scope and source**: personal (`$CODEWHALE_HOME/fleets/`) or
13 //! workspace (`.codewhale/fleets/`), with the exact file path surfaced.
14 //!
15 //! There is exactly one store. The legacy per-role profile files
16 //! (`~/.codewhale/agents/*.toml`, `.codewhale/agents/*.toml`,
17 //! `[fleet.profiles]`) and the workflow crate's `exact`/legacy named-fleet
18 //! files are migration/compat input only — read here, never shadowed, never
19 //! the runtime winner alongside a v2 Fleet.
20 //!
21 //! Selection is a scope-explicit file: `fleets/selected` under the personal
22 //! root is the user-global default; the same file under the workspace root is
23 //! an intentional workspace selection. Workspace selection wins; both are
24 //! labeled in the UI. A workspace selection can never hide or rewrite a
25 //! personal Fleet.
26
27 use std::collections::BTreeMap;
28 use std::fs;
29 use std::path::{Path, PathBuf};
30
31 use crate::config::ApiProvider;
32
33 use serde::{Deserialize, Serialize};
34 use thiserror::Error;
35
36 use super::roster::FleetRoster;
37
38 pub const FLEET_SCHEMA_KIND: &str = "fleet";
39 pub const FLEET_SCHEMA_REVISION: u32 = 2;
40 const MAX_MEMBER_DISPLAY_NAME_CHARS: usize = 80;
41
42 /// The directory name used by both roots (next to `agents/` for legacy
43 /// profiles). Also used by the workflow crate for its own legacy/exact files;
44 /// v2 files in the same directory are simply a newer schema.
45 pub const FLEET_DIR: &str = "fleets";
46 pub const SELECTED_FILE: &str = "selected";
47
48 /// Where a Fleet was saved. This is the pin target: personal = user-global,
49 /// workspace = folder-scoped.
50 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51 #[serde(rename_all = "snake_case")]
52 pub enum FleetScope {
53 Personal,
54 Workspace,
55 }
56
57 impl FleetScope {
58 /// Short label for UI and receipts: "user" / "folder".
59 #[must_use]
60 pub const fn label(self) -> &'static str {
61 match self {
62 Self::Personal => "user",
63 Self::Workspace => "folder",
64 }
65 }
66
67 #[must_use]
68 pub const fn long_label(self) -> &'static str {
69 match self {
70 Self::Personal => "user-global",
71 Self::Workspace => "folder (this workspace)",
72 }
73 }
74
75 #[must_use]
76 pub const fn toggled(self) -> Self {
77 match self {
78 Self::Personal => Self::Workspace,
79 Self::Workspace => Self::Personal,
80 }
81 }
82 }
83
84 /// A Fleet's own operator route. Absent = inherit the live session route.
85 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86 #[serde(deny_unknown_fields)]
87 pub struct FleetOperator {
88 /// Exact provider id (a `[providers.<id>]` key or a built-in id).
89 pub provider: String,
90 /// Exact model id on that provider's route.
91 pub model: String,
92 /// Reasoning level, only when the resolved route genuinely supports it.
93 /// Absent = inherit the session tier.
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub reasoning: Option<String>,
96 }
97
98 /// Capability requirements a member must satisfy. The vocabulary is closed so
99 /// an unknown requirement is a specific error, never a silent reinterpretation.
100 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
101 pub enum MemberCapability {
102 /// Image input: the member must run on a route that accepts images.
103 Vision,
104 }
105
106 impl MemberCapability {
107 pub const VOCABULARY: [&'static str; 1] = ["vision"];
108
109 pub fn parse(value: &str) -> Option<Self> {
110 match value.trim().to_ascii_lowercase().as_str() {
111 "vision" | "image" | "image-input" => Some(Self::Vision),
112 _ => None,
113 }
114 }
115
116 #[must_use]
117 pub const fn wire_name(self) -> &'static str {
118 match self {
119 Self::Vision => "vision",
120 }
121 }
122 }
123
124 /// One roster member of a Fleet.
125 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126 #[serde(deny_unknown_fields)]
127 pub struct FleetMember {
128 /// Stable member id — the role identity (e.g. `scout`, `builder`).
129 pub id: String,
130 /// Optional human-facing name used by roster views and member selectors.
131 ///
132 /// `name` is accepted as an authoring alias, while canonical saves use
133 /// `display_name`. Existing revision-2 files omit this field and continue
134 /// to deserialize unchanged.
135 #[serde(default, alias = "name", skip_serializing_if = "Option::is_none")]
136 pub display_name: Option<String>,
137 /// A role-less model choice, not an executable roster member. Omitted
138 /// in legacy files, whose role/id interpretation stays unchanged.
139 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
140 pub shortlist: bool,
141 /// Role label; defaults to `id` when absent on a non-shortlist member.
142 #[serde(default, skip_serializing_if = "String::is_empty")]
143 pub role: String,
144 /// Exact model pin. Absent with `provider` absent = inherit the session
145 /// route (the operator route when the Fleet has one).
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub model: Option<String>,
148 /// Exact provider id for `model`. Pins only: a member must never carry
149 /// `provider` without `model` (rejected at parse), and the provider is
150 /// never inferred from the model string.
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub provider: Option<String>,
153 /// Reasoning level for this member, only when the resolved route
154 /// supports it. Absent = inherit.
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub reasoning: Option<String>,
157 /// Optional instruction overlay for the role.
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub instructions: Option<String>,
160 /// Capability requirements, e.g. `["vision"]`. Validated against
161 /// [`MemberCapability::VOCABULARY`] at parse.
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
163 pub requires: Vec<String>,
164 }
165
166 impl FleetMember {
167 /// The role this member fills: `role`, or `id` when the document left
168 /// the role field off. A shortlisted model has no role.
169 #[must_use]
170 pub fn role_label(&self) -> &str {
171 if self.shortlist {
172 return "";
173 }
174 let role = self.role.trim();
175 if role.is_empty() {
176 self.id.trim()
177 } else {
178 role
179 }
180 }
181
182 /// A row that was a model pin promoted to a member: no role, and an id
183 /// that is just the model's slug. Such rows are not members.
184 #[must_use]
185 pub fn is_bare_model_pin(&self) -> bool {
186 !self.shortlist
187 && self.role.trim().is_empty()
188 && self
189 .model
190 .as_deref()
191 .is_some_and(|model| self.id.trim().starts_with(slugify(model).as_str()))
192 }
193 }
194
195 /// Provider kinds have documented aliases; named custom routes have exact
196 /// keys. Treating every provider name as case-insensitive merges distinct
197 /// endpoints before the configured route binder can resolve them.
198 pub(crate) fn provider_ids_match(saved: &str, requested: &str) -> bool {
199 saved.trim() == requested.trim()
200 || ApiProvider::parse(saved)
201 .filter(|provider| *provider != ApiProvider::Custom)
202 .is_some_and(|provider| Some(provider) == ApiProvider::parse(requested))
203 }
204
205 /// The member pins exactly `provider`/`model`.
206 pub(crate) fn member_pins(member: &FleetMember, provider: &str, model: &str) -> bool {
207 member
208 .provider
209 .as_deref()
210 .is_some_and(|p| provider_ids_match(p, provider))
211 && member.model.as_deref().is_some_and(|id| id == model)
212 }
213
214 /// The saved named Fleet document (compatibility `schema = "fleet"`, revision 2).
215 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216 #[serde(deny_unknown_fields)]
217 pub struct FleetFile {
218 pub schema: String,
219 pub schema_revision: u32,
220 /// Editable display name. Unique per scope (the file slug is derived
221 /// from it); the same name may exist in both scopes, distinguished by
222 /// origin, never silently shadowed.
223 pub name: String,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub description: Option<String>,
226 /// The Fleet's own operator route. Absent = inherit the session route.
227 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub operator: Option<FleetOperator>,
229 #[serde(default)]
230 pub members: Vec<FleetMember>,
231 }
232
233 /// Why a Fleet file could not be used.
234 #[derive(Debug, Clone, PartialEq, Eq, Error)]
235 pub enum FleetStoreError {
236 #[error("invalid fleet: {0}")]
237 Invalid(String),
238 #[error(
239 "fleet `{0}` is defined in both {1} and {2}; name one explicitly as {1}/{0} or {2}/{0}"
240 )]
241 Ambiguous(String, String, String),
242 #[error("fleet file not found: {0}")]
243 NotFound(String),
244 #[error("failed to read {path}: {message}")]
245 Io { path: String, message: String },
246 #[error("failed to parse {path}: {message}")]
247 Parse { path: String, message: String },
248 #[error("a fleet named `{name}` already exists at {path}; rename it or choose another name")]
249 NameTaken { name: String, path: String },
250 }
251
252 impl FleetFile {
253 /// Create a validated v2 Fleet file.
254 pub fn new(name: String, description: Option<String>) -> Result<Self, FleetStoreError> {
255 let fleet = Self {
256 schema: FLEET_SCHEMA_KIND.to_string(),
257 schema_revision: FLEET_SCHEMA_REVISION,
258 name,
259 description,
260 operator: None,
261 members: Vec::new(),
262 };
263 fleet.validate()?;
264 Ok(fleet)
265 }
266
267 /// Validate the document: name, member ids, pin symmetry, capability
268 /// vocabulary. Invalid input is rejected with a specific error — never
269 /// silently reinterpreted.
270 pub fn validate(&self) -> Result<(), FleetStoreError> {
271 if self.schema != FLEET_SCHEMA_KIND {
272 return Err(FleetStoreError::Invalid(format!(
273 "unknown schema `{}`; expected `{FLEET_SCHEMA_KIND}`",
274 self.schema
275 )));
276 }
277 if self.schema_revision != FLEET_SCHEMA_REVISION {
278 return Err(FleetStoreError::Invalid(format!(
279 "unsupported schema revision {}; this build reads revision {FLEET_SCHEMA_REVISION}",
280 self.schema_revision
281 )));
282 }
283 let name = self.name.trim();
284 if name.is_empty() {
285 return Err(FleetStoreError::Invalid(
286 "fleet name must not be empty".to_string(),
287 ));
288 }
289 let mut seen: BTreeMap<String, String> = BTreeMap::new();
290 for member in &self.members {
291 let member_id = member.id.trim();
292 if member_id.is_empty() {
293 return Err(FleetStoreError::Invalid(
294 "member id must not be empty".to_string(),
295 ));
296 }
297 let member_key = member_id.to_ascii_lowercase();
298 if let Some(existing) = seen.insert(member_key, member.id.clone()) {
299 return Err(FleetStoreError::Invalid(format!(
300 "duplicate member id `{}` conflicts case-insensitively with `{existing}`",
301 member.id,
302 )));
303 }
304 if let Some(display_name) = member.display_name.as_deref() {
305 let trimmed = display_name.trim();
306 if trimmed.is_empty() {
307 return Err(FleetStoreError::Invalid(format!(
308 "member `{}` display_name must not be empty",
309 member.id,
310 )));
311 }
312 if trimmed != display_name
313 || display_name.chars().any(char::is_control)
314 || display_name.chars().count() > MAX_MEMBER_DISPLAY_NAME_CHARS
315 {
316 return Err(FleetStoreError::Invalid(format!(
317 "member `{}` display_name must be one trimmed printable line no longer than {MAX_MEMBER_DISPLAY_NAME_CHARS} characters",
318 member.id,
319 )));
320 }
321 }
322 match (&member.provider, &member.model) {
323 (Some(_), None) | (None, Some(_)) => {
324 return Err(FleetStoreError::Invalid(format!(
325 "member `{}` must pin both provider and model, or neither (inherit); a lone {} is rejected",
326 member.id,
327 if member.provider.is_some() {
328 "provider"
329 } else {
330 "model"
331 }
332 )));
333 }
334 _ => {}
335 }
336 if member.shortlist
337 && (!member.role.trim().is_empty()
338 || member
339 .provider
340 .as_deref()
341 .is_none_or(|id| id.trim().is_empty())
342 || member
343 .model
344 .as_deref()
345 .is_none_or(|id| id.trim().is_empty()))
346 {
347 return Err(FleetStoreError::Invalid(format!(
348 "shortlisted member `{}` must have no role and pin both provider and model",
349 member.id,
350 )));
351 }
352 if member.shortlist
353 && (member
354 .reasoning
355 .as_deref()
356 .is_some_and(|value| !value.trim().is_empty())
357 || member
358 .instructions
359 .as_deref()
360 .is_some_and(|value| !value.trim().is_empty())
361 || !member.requires.is_empty())
362 {
363 return Err(FleetStoreError::Invalid(format!(
364 "shortlisted member `{}` cannot set role reasoning, instructions, or capability requirements",
365 member.id,
366 )));
367 }
368 for requirement in &member.requires {
369 if MemberCapability::parse(requirement).is_none() {
370 return Err(FleetStoreError::Invalid(format!(
371 "member `{}` requires unknown capability `{requirement}`; valid values: {}",
372 member.id,
373 MemberCapability::VOCABULARY.join(", ")
374 )));
375 }
376 }
377 }
378 Ok(())
379 }
380
381 /// Render the canonical TOML document.
382 pub fn render_toml(&self) -> Result<String, FleetStoreError> {
383 self.validate()?;
384 let rendered = toml::to_string_pretty(self)
385 .map_err(|e| FleetStoreError::Invalid(format!("failed to serialize fleet: {e}")))?;
386 Ok(rendered)
387 }
388
389 /// Parse a v2 fleet document from TOML text.
390 pub fn parse(text: &str) -> Result<Self, FleetStoreError> {
391 let mut fleet: Self = toml::from_str(text)
392 .map_err(|e| FleetStoreError::Invalid(format!("invalid fleet TOML: {e}")))?;
393 // Compat (0.9.12): every model the user ever selected was enrolled
394 // as a role-less member with a slug id. Roles are the members; drop
395 // those rows on read so the roster reads as roles again. The next
396 // save writes the clean document.
397 fleet.members.retain(|member| !member.is_bare_model_pin());
398 // #6037: a member pinned to the fleet's own operator route resolves
399 // to that route either way; the pin only stops it following when the
400 // operator moves (a vendor retiring the id, an operator switching
401 // models). Read the redundant pin as the inheritance it always meant.
402 // Shortlist rows keep their pin — it is their entire content.
403 if let Some(operator) = &fleet.operator {
404 for member in &mut fleet.members {
405 if !member.shortlist && member_pins(member, &operator.provider, &operator.model) {
406 member.provider = None;
407 member.model = None;
408 }
409 }
410 }
411 fleet.validate()?;
412 Ok(fleet)
413 }
414
415 /// A stable file slug derived from the display name. Safe across the
416 /// filesystems Codewhale supports; collisions are detected at save.
417 #[must_use]
418 pub fn file_slug(&self) -> String {
419 slugify(&self.name)
420 }
421
422 /// Look up an executable member by role id. Shortlisted model ids never
423 /// select a role, even when they happen to match one.
424 #[must_use]
425 pub fn member(&self, id: &str) -> Option<&FleetMember> {
426 let id = id.trim();
427 self.members
428 .iter()
429 .find(|member| !member.shortlist && member.id.trim().eq_ignore_ascii_case(id))
430 }
431
432 /// Whether the roster contains a scout member (the fast exploratory role).
433 #[must_use]
434 pub fn has_scout(&self) -> bool {
435 self.member("scout").is_some()
436 }
437 }
438
439 /// Sanitize a display name into a safe file slug.
440 pub(crate) fn slugify(name: &str) -> String {
441 let mut slug = String::with_capacity(name.len());
442 for ch in name.trim().chars() {
443 if ch.is_ascii_alphanumeric() {
444 slug.push(ch.to_ascii_lowercase());
445 } else if (ch.is_whitespace() || ch == '-' || ch == '_') && !slug.ends_with('-') {
446 slug.push('-');
447 }
448 }
449 while slug.ends_with('-') {
450 slug.pop();
451 }
452 if slug.is_empty() {
453 "fleet".to_string()
454 } else {
455 slug
456 }
457 }
458
459 /// One entry in the Fleet list: name, scope, exact path, and health.
460 #[derive(Debug, Clone, PartialEq, Eq)]
461 pub struct FleetEntry {
462 pub name: String,
463 pub scope: FleetScope,
464 /// Exact path of the saved file.
465 pub path: PathBuf,
466 /// Parse failure, when the file exists but cannot be read as a v2 Fleet.
467 pub parse_error: Option<String>,
468 /// Whether the file is a legacy (pre-v2) named-fleet file (exact or
469 /// roles map) that is read for compatibility but not editable as v2.
470 pub legacy: bool,
471 }
472
473 /// The resolved selection: which Fleet a session should start on, and which
474 /// scope made the choice.
475 #[derive(Debug, Clone, PartialEq, Eq)]
476 pub struct SelectedFleet {
477 pub name: String,
478 pub scope: FleetScope,
479 pub path: PathBuf,
480 }
481
482 fn personal_fleets_dir() -> Result<PathBuf, FleetStoreError> {
483 #[cfg(test)]
484 if !crate::test_support::guarded_environment_provides_state_paths() {
485 return Ok(crate::test_support::unsealed_test_state_root().join(FLEET_DIR));
486 }
487 codewhale_config::codewhale_home()
488 .map(|home| home.join(FLEET_DIR))
489 .map_err(|e| FleetStoreError::Io {
490 path: "$CODEWHALE_HOME/fleets".to_string(),
491 message: e.to_string(),
492 })
493 }
494
495 fn workspace_fleets_dir(workspace: &Path) -> PathBuf {
496 workspace.join(".codewhale").join(FLEET_DIR)
497 }
498
499 /// The fleet directory for a scope, creating it if needed.
500 fn ensure_fleets_dir(scope: FleetScope, workspace: &Path) -> Result<PathBuf, FleetStoreError> {
501 let dir = match scope {
502 FleetScope::Personal => personal_fleets_dir()?,
503 FleetScope::Workspace => workspace_fleets_dir(workspace),
504 };
505 fs::create_dir_all(&dir).map_err(|e| FleetStoreError::Io {
506 path: dir.display().to_string(),
507 message: e.to_string(),
508 })?;
509 Ok(dir)
510 }
511
512 /// List every named Fleet across both scopes, personal first. A file that is
513 /// not a v2 Fleet is listed as `legacy` with its parse error, so an old exact
514 /// fleet is visible — never silently absent — while the user decides whether
515 /// to migrate it.
516 pub fn list_fleets(workspace: &Path) -> Vec<FleetEntry> {
517 let mut entries = Vec::new();
518 if let Ok(dir) = personal_fleets_dir() {
519 collect_entries(&dir, FleetScope::Personal, &mut entries);
520 }
521 collect_entries(
522 &workspace_fleets_dir(workspace),
523 FleetScope::Workspace,
524 &mut entries,
525 );
526 entries.sort_by(|a, b| {
527 a.scope
528 .label()
529 .cmp(b.scope.label())
530 .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
531 });
532 entries
533 }
534
535 fn collect_entries(dir: &Path, scope: FleetScope, out: &mut Vec<FleetEntry>) {
536 let Ok(read) = fs::read_dir(dir) else {
537 return;
538 };
539 let mut files: Vec<PathBuf> = read
540 .filter_map(|entry| entry.ok())
541 .map(|entry| entry.path())
542 .filter(|path| path.extension().is_some_and(|ext| ext == "toml"))
543 .collect();
544 files.sort();
545 for path in files {
546 let stem = path
547 .file_stem()
548 .map(|s| s.to_string_lossy().into_owned())
549 .unwrap_or_default();
550 let text = fs::read_to_string(&path).ok();
551 let parse_error = text
552 .as_deref()
553 .and_then(|text| FleetFile::parse(text).err())
554 .map(|e| e.to_string());
555 let legacy = parse_error.as_deref().is_some_and(|err| {
556 err.contains("unknown schema") || err.contains("invalid fleet TOML")
557 });
558 // The row shows the Fleet's own display name, never the file slug —
559 // a file saved as `Temp Fleet` must not appear as `temp-fleet`.
560 let name = text
561 .as_deref()
562 .and_then(|text| toml::from_str::<toml::Value>(text).ok())
563 .and_then(|value| {
564 value
565 .get("name")
566 .and_then(|n| n.as_str())
567 .map(str::trim)
568 .filter(|n| !n.is_empty())
569 .map(str::to_string)
570 })
571 .unwrap_or(stem);
572 out.push(FleetEntry {
573 name,
574 scope,
575 path,
576 parse_error,
577 legacy,
578 });
579 }
580 }
581
582 /// Load a v2 Fleet by name. Ambiguity between the two scopes is an error that
583 /// names both origins — the caller (UI) resolves it by asking for a scope.
584 /// (Kept for the qualified-name flow and the ambiguity tests; the list/detail
585 /// UI resolves by scope via load_fleet_in_scope.)
586 #[cfg_attr(not(test), expect(dead_code))]
587 pub fn load_fleet(
588 name: &str,
589 workspace: &Path,
590 ) -> Result<(FleetFile, FleetScope, PathBuf), FleetStoreError> {
591 let name = name.trim();
592 if name.is_empty() {
593 return Err(FleetStoreError::NotFound("<empty name>".to_string()));
594 }
595 let mut found: Vec<(FleetScope, PathBuf)> = Vec::new();
596 if let Ok(dir) = personal_fleets_dir() {
597 let path = dir.join(format!("{}.toml", slugify(name)));
598 if path.is_file() {
599 found.push((FleetScope::Personal, path));
600 }
601 }
602 let ws_path = workspace_fleets_dir(workspace).join(format!("{}.toml", slugify(name)));
603 if ws_path.is_file() {
604 found.push((FleetScope::Workspace, ws_path));
605 }
606 if found.len() > 1 {
607 return Err(FleetStoreError::Ambiguous(
608 name.to_string(),
609 FleetScope::Personal.label().to_string(),
610 FleetScope::Workspace.label().to_string(),
611 ));
612 }
613 let Some((scope, path)) = found.pop() else {
614 return Err(FleetStoreError::NotFound(name.to_string()));
615 };
616 let text = fs::read_to_string(&path).map_err(|e| FleetStoreError::Io {
617 path: path.display().to_string(),
618 message: e.to_string(),
619 })?;
620 let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse {
621 path: path.display().to_string(),
622 message: e.to_string(),
623 })?;
624 Ok((fleet, scope, path))
625 }
626
627 /// Load a v2 Fleet by name in one explicit scope. Unlike [`load_fleet`],
628 /// this never resolves ambiguity — the caller already knows where the Fleet
629 /// lives (e.g. the row the user just picked).
630 pub fn load_fleet_in_scope(
631 name: &str,
632 scope: FleetScope,
633 workspace: &Path,
634 ) -> Result<(FleetFile, PathBuf), FleetStoreError> {
635 let dir = match scope {
636 FleetScope::Personal => personal_fleets_dir()?,
637 FleetScope::Workspace => workspace_fleets_dir(workspace),
638 };
639 let path = dir.join(format!("{}.toml", slugify(name)));
640 if !path.is_file() {
641 return Err(FleetStoreError::NotFound(format!(
642 "{} ({})",
643 name,
644 scope.label()
645 )));
646 }
647 let text = fs::read_to_string(&path).map_err(|e| FleetStoreError::Io {
648 path: path.display().to_string(),
649 message: e.to_string(),
650 })?;
651 let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse {
652 path: path.display().to_string(),
653 message: e.to_string(),
654 })?;
655 Ok((fleet, path))
656 }
657
658 /// Load a v2 Fleet from a specific path (used by the editor on the currently
659 /// open entry, so the saved scope is exact). API surface for the path-based
660 /// editor flows; currently exercised by tests.
661 pub fn load_fleet_at(path: &Path) -> Result<(FleetFile, FleetScope), FleetStoreError> {
662 let text = fs::read_to_string(path).map_err(|e| FleetStoreError::Io {
663 path: path.display().to_string(),
664 message: e.to_string(),
665 })?;
666 let fleet = FleetFile::parse(&text).map_err(|e| FleetStoreError::Parse {
667 path: path.display().to_string(),
668 message: e.to_string(),
669 })?;
670 let scope = if path.starts_with(personal_fleets_dir().unwrap_or_default()) {
671 FleetScope::Personal
672 } else {
673 FleetScope::Workspace
674 };
675 Ok((fleet, scope))
676 }
677
678 /// Save a Fleet to a scope with an atomic write. Refuses to clobber a
679 /// different Fleet of the same slug (the name is the identity).
680 pub fn save_fleet(
681 fleet: &FleetFile,
682 scope: FleetScope,
683 workspace: &Path,
684 ) -> Result<PathBuf, FleetStoreError> {
685 fleet.validate()?;
686 let dir = ensure_fleets_dir(scope, workspace)?;
687 let path = dir.join(format!("{}.toml", fleet.file_slug()));
688 if path.is_file()
689 && let Ok(text) = fs::read_to_string(&path)
690 && let Ok(existing) = FleetFile::parse(&text)
691 && existing.name != fleet.name
692 {
693 return Err(FleetStoreError::NameTaken {
694 name: fleet.name.clone(),
695 path: path.display().to_string(),
696 });
697 }
698 let rendered = fleet.render_toml()?;
699 atomic_write(&path, rendered.as_bytes())?;
700 Ok(path)
701 }
702
703 /// Delete a saved Fleet (UI confirms first). Returns the removed path.
704 pub fn delete_fleet(
705 name: &str,
706 scope: FleetScope,
707 workspace: &Path,
708 ) -> Result<PathBuf, FleetStoreError> {
709 let dir = match scope {
710 FleetScope::Personal => personal_fleets_dir()?,
711 FleetScope::Workspace => workspace_fleets_dir(workspace),
712 };
713 let path = dir.join(format!("{}.toml", slugify(name)));
714 if !path.is_file() {
715 return Err(FleetStoreError::NotFound(name.to_string()));
716 }
717 fs::remove_file(&path).map_err(|e| FleetStoreError::Io {
718 path: path.display().to_string(),
719 message: e.to_string(),
720 })?;
721 // A selection that pointed at the deleted Fleet must not linger: it would
722 // render as a phantom selection. The write is best-effort; a leftover
723 // selection is reported by the reader as missing, never as valid.
724 clear_selection_if_matching(scope, workspace, name);
725 Ok(path)
726 }
727
728 /// The active selection: workspace selection wins, then the personal
729 /// user-global default. Each file is scope-explicit; a workspace selection
730 /// can never hide the personal Fleet — the personal default is only overridden
731 /// for this folder, visibly.
732 pub fn resolve_selected_fleet(workspace: &Path) -> Result<Option<SelectedFleet>, FleetStoreError> {
733 let ws_dir = workspace_fleets_dir(workspace);
734 if let Some(name) = read_selection_result(&ws_dir)? {
735 // A workspace selection may name a personal Fleet (selected for this
736 // folder only): resolve workspace first, then personal, and report
737 // the scope the Fleet actually lives in.
738 let ws_path = ws_dir.join(format!("{}.toml", slugify(&name)));
739 if ws_path.is_file() {
740 return Ok(Some(SelectedFleet {
741 name,
742 scope: FleetScope::Workspace,
743 path: ws_path,
744 }));
745 }
746 if let Ok(dir) = personal_fleets_dir() {
747 let personal_path = dir.join(format!("{}.toml", slugify(&name)));
748 if personal_path.is_file() {
749 return Ok(Some(SelectedFleet {
750 name,
751 scope: FleetScope::Personal,
752 path: personal_path,
753 }));
754 }
755 }
756 return Err(FleetStoreError::NotFound(format!(
757 "selected fleet `{name}` (folder selection at {})",
758 ws_dir.join(SELECTED_FILE).display()
759 )));
760 }
761 if let Ok(dir) = personal_fleets_dir()
762 && let Some(name) = read_selection_result(&dir)?
763 {
764 let path = dir.join(format!("{}.toml", slugify(&name)));
765 if path.is_file() {
766 return Ok(Some(SelectedFleet {
767 name,
768 scope: FleetScope::Personal,
769 path,
770 }));
771 }
772 return Err(FleetStoreError::NotFound(format!(
773 "selected fleet `{name}` (user selection at {})",
774 dir.join(SELECTED_FILE).display()
775 )));
776 }
777 Ok(None)
778 }
779
780 /// Compatibility projection for display-only callers. Runtime callers must
781 /// use [`resolve_selected_fleet`] so a broken explicit selection cannot be
782 /// mistaken for "no selection" and silently fall back to legacy profiles.
783 #[must_use]
784 pub fn selected_fleet(workspace: &Path) -> Option<SelectedFleet> {
785 resolve_selected_fleet(workspace).ok().flatten()
786 }
787
788 fn read_selection_result(dir: &Path) -> Result<Option<String>, FleetStoreError> {
789 let path = dir.join(SELECTED_FILE);
790 let text = match fs::read_to_string(&path) {
791 Ok(text) => text,
792 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
793 Err(error) => {
794 return Err(FleetStoreError::Io {
795 path: path.display().to_string(),
796 message: error.to_string(),
797 });
798 }
799 };
800 let name = text.trim();
801 if name.is_empty() {
802 Ok(None)
803 } else {
804 Ok(Some(name.to_string()))
805 }
806 }
807
808 fn read_selection(dir: &Path) -> Option<String> {
809 read_selection_result(dir).ok().flatten()
810 }
811
812 /// Write the selection for a scope. Returns the exact file written.
813 ///
814 /// The selection file lives in the scope's `fleets/` directory, but the
815 /// Fleet it names may live in either scope: a workspace selection may point
816 /// at a personal Fleet (selecting it for this folder only), and a personal
817 /// selection always points at a personal Fleet. The validation only refuses
818 /// a name that exists NOWHERE — a phantom selection would be a lie.
819 pub fn set_selected(
820 name: &str,
821 scope: FleetScope,
822 workspace: &Path,
823 ) -> Result<PathBuf, FleetStoreError> {
824 let dir = ensure_fleets_dir(scope, workspace)?;
825 let name = name.trim();
826 let exists_in_scope = |target: FleetScope| {
827 let target_dir = match target {
828 FleetScope::Personal => personal_fleets_dir().ok(),
829 FleetScope::Workspace => Some(workspace_fleets_dir(workspace)),
830 };
831 target_dir
832 .map(|d| d.join(format!("{}.toml", slugify(name))).is_file())
833 .unwrap_or(false)
834 };
835 let exists = exists_in_scope(scope) || exists_in_scope(FleetScope::Personal);
836 if !exists {
837 return Err(FleetStoreError::NotFound(format!(
838 "{} ({})",
839 name,
840 scope.label()
841 )));
842 }
843 let selected = dir.join(SELECTED_FILE);
844 atomic_write(&selected, name.as_bytes())?;
845 Ok(selected)
846 }
847
848 fn clear_selection_if_matching(scope: FleetScope, workspace: &Path, name: &str) {
849 let dir = match scope {
850 FleetScope::Personal => personal_fleets_dir().ok(),
851 FleetScope::Workspace => Some(workspace_fleets_dir(workspace)),
852 };
853 let Some(dir) = dir else { return };
854 let selected = dir.join(SELECTED_FILE);
855 if read_selection(&dir).as_deref() == Some(name.trim()) {
856 let _ = fs::remove_file(selected);
857 }
858 }
859
860 /// Atomic write: temp file in the same directory, then rename. A failed write
861 /// never leaves a half-written Fleet or selection.
862 fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), FleetStoreError> {
863 let tmp = path.with_extension("tmp");
864 fs::write(&tmp, bytes).map_err(|e| FleetStoreError::Io {
865 path: tmp.display().to_string(),
866 message: e.to_string(),
867 })?;
868 if let Err(e) = fs::rename(&tmp, path) {
869 let _ = fs::remove_file(&tmp);
870 return Err(FleetStoreError::Io {
871 path: path.display().to_string(),
872 message: e.to_string(),
873 });
874 }
875 Ok(())
876 }
877
878 /// One row of the migration receipt: how a legacy role profile maps into the
879 /// new Fleet.
880 #[derive(Debug, Clone, PartialEq, Eq)]
881 pub struct MigrationRow {
882 /// Role id, e.g. `scout`.
883 pub id: String,
884 /// The pin that will be saved (model + provider, or "inherit").
885 pub pin: Option<(String, String)>,
886 /// The winning origin under the legacy precedence.
887 pub winner: String,
888 /// A lower-precedence copy with identical content — not a conflict.
889 pub identical_shadow: Option<String>,
890 /// A lower-precedence copy that differed and was NOT carried over.
891 pub conflicting_shadow: Option<String>,
892 }
893
894 /// The result of migrating the legacy per-role roster into a v2 Fleet.
895 #[derive(Debug, Clone, PartialEq, Eq)]
896 pub struct MigrationReceipt {
897 /// The Fleet that was (or would be) saved.
898 pub fleet: FleetFile,
899 /// Per-role mapping, including every conflict that was resolved.
900 pub rows: Vec<MigrationRow>,
901 /// Path the Fleet was saved to.
902 pub saved_to: PathBuf,
903 }
904
905 /// Build (and optionally save) a v2 Fleet from the legacy per-role roster:
906 /// built-ins + `[fleet.profiles]` + personal + workspace profile files.
907 ///
908 /// Nothing is discarded: every role becomes a member, every pin survives, and
909 /// each lower-precedence copy that differed is named in the receipt. The
910 /// legacy files themselves are left untouched — they become migration input,
911 /// not live config, once a Fleet is selected.
912 pub fn migrate_legacy_roster(
913 fleet_config: &codewhale_config::FleetConfigToml,
914 workspace: &Path,
915 save: bool,
916 save_scope: FleetScope,
917 ) -> Result<MigrationReceipt, FleetStoreError> {
918 let roster = FleetRoster::load(fleet_config, workspace);
919 let mut fleet = FleetFile::new(
920 "Default".to_string(),
921 Some("Migrated from the legacy per-role profile configuration.".to_string()),
922 )?;
923 let mut rows = Vec::new();
924 for member in roster.members() {
925 let profile = &member.profile;
926 let (model, provider) = match (&profile.model, &profile.provider) {
927 (Some(model), Some(provider)) => (Some(model.clone()), Some(provider.clone())),
928 _ => (None, None),
929 };
930 // Legacy profiles carry no capability requirements; a migration
931 // never invents one. Requirements start empty in the v2 Fleet.
932 let requires: Vec<String> = Vec::new();
933 let row = MigrationRow {
934 id: member.id.clone(),
935 pin: model
936 .as_ref()
937 .map(|m| (m.clone(), provider.clone().unwrap_or_default())),
938 winner: member.origin.to_string(),
939 identical_shadow: None,
940 conflicting_shadow: None,
941 };
942 // Record shadowed copies (the roster already resolved them; here we
943 // name them so the conflict is visible before anyone accepts it).
944 let shadows: Vec<String> = roster
945 .shadowed()
946 .iter()
947 .filter(|s| s.id == member.id)
948 .map(|s| {
949 format!(
950 "{} copy at {} ignored in favor of {}",
951 s.shadowed_origin,
952 s.shadowed_source.display(),
953 s.winner_origin
954 )
955 })
956 .collect();
957 let mut row = row;
958 if let Some(first) = shadows.first() {
959 if shadows.len() == 1 && first.contains("built-in") {
960 row.identical_shadow = Some(first.clone());
961 } else {
962 row.conflicting_shadow = Some(shadows.join("; "));
963 }
964 }
965 rows.push(row);
966 fleet.members.push(FleetMember {
967 id: member.id.clone(),
968 display_name: member.display_name.clone(),
969 shortlist: false,
970 role: profile.role.name.clone(),
971 model,
972 provider,
973 reasoning: profile.reasoning_effort.clone(),
974 instructions: profile.role.instructions.clone(),
975 requires,
976 });
977 }
978 fleet.validate()?;
979 let saved_to = if save {
980 save_fleet(&fleet, save_scope, workspace)?
981 } else {
982 match save_scope {
983 FleetScope::Personal => {
984 personal_fleets_dir()?.join(format!("{}.toml", fleet.file_slug()))
985 }
986 FleetScope::Workspace => {
987 workspace_fleets_dir(workspace).join(format!("{}.toml", fleet.file_slug()))
988 }
989 }
990 };
991 Ok(MigrationReceipt {
992 fleet,
993 rows,
994 saved_to,
995 })
996 }
997
998 #[cfg(test)]
999 mod tests {
1000 use super::*;
1001 use std::sync::OnceLock;
1002
1003 /// A sealed CODEWHALE_HOME for personal-scope tests, created once per
1004 /// process. Tests must still hold `lock_test_env` before touching it.
1005 fn sealed_home() -> &'static Path {
1006 static HOME: OnceLock<PathBuf> = OnceLock::new();
1007 HOME.get_or_init(|| {
1008 let dir = tempfile::TempDir::new()
1009 .expect("temp dir for sealed home")
1010 .keep();
1011 std::fs::create_dir_all(dir.join("fleets")).expect("fleets dir");
1012 dir
1013 })
1014 }
1015
1016 /// Point CODEWHALE_HOME at a sealed temp dir. Caller must hold
1017 /// `lock_test_env`.
1018 fn set_sealed_home() -> crate::test_support::EnvVarGuard {
1019 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", sealed_home())
1020 }
1021
1022 #[test]
1023 fn unsealed_personal_routes_ignore_ambient_home() {
1024 const PROBE: &str = "CODEWHALE_TEST_AMBIENT_FLEET_PROBE";
1025 if std::env::var_os(PROBE).is_some() {
1026 let workspace = tempfile::tempdir().unwrap();
1027 for hold_env_lock in [false, true] {
1028 let _lock = hold_env_lock.then(crate::test_support::lock_test_env);
1029 let root = crate::test_support::unsealed_test_state_root();
1030 assert_eq!(personal_fleets_dir().unwrap(), root.join(FLEET_DIR));
1031 assert_eq!(
1032 crate::fleet::profile::personal_agent_profile_dir().unwrap(),
1033 root.join("agents")
1034 );
1035 assert!(resolve_selected_fleet(workspace.path()).unwrap().is_none());
1036 assert!(list_fleets(workspace.path()).is_empty());
1037 let roster = crate::fleet::identity::load_effective_roster(
1038 &Default::default(),
1039 workspace.path(),
1040 None,
1041 );
1042 assert!(roster.load_error().is_none());
1043 assert!(roster.members().iter().all(|member| {
1044 member.origin == crate::fleet::roster::ProfileOrigin::BuiltIn
1045 }));
1046 }
1047 return;
1048 }
1049
1050 // A fresh process inherits populated operator state, without earning
1051 // the explicit EnvVarGuard seal used by deliberate path fixtures.
1052 let ambient = tempfile::tempdir().unwrap();
1053 let state = ambient.path().join(".codewhale");
1054 let fleets = state.join(FLEET_DIR);
1055 std::fs::create_dir_all(&fleets).unwrap();
1056 let fleet = sample_fleet();
1057 let fleet_path = fleets.join(format!("{}.toml", fleet.file_slug()));
1058 let contents = fleet.render_toml().unwrap();
1059 std::fs::write(&fleet_path, &contents).unwrap();
1060 std::fs::write(fleets.join(SELECTED_FILE), &fleet.name).unwrap();
1061 for explicit_override in [false, true] {
1062 let mut command = std::process::Command::new(std::env::current_exe().unwrap());
1063 command
1064 .args([
1065 "--exact",
1066 "fleet::store::tests::unsealed_personal_routes_ignore_ambient_home",
1067 "--test-threads=1",
1068 ])
1069 .env(PROBE, "1")
1070 .env("HOME", ambient.path())
1071 .env("USERPROFILE", ambient.path())
1072 .env_remove("CODEWHALE_HOME")
1073 .env_remove("CODEWHALE_CONFIG_PATH")
1074 .env_remove("DEEPSEEK_CONFIG_PATH");
1075 if explicit_override {
1076 command.env("CODEWHALE_HOME", &state);
1077 }
1078 let output = command.output().unwrap();
1079 assert!(
1080 output.status.success(),
1081 "ambient route probe failed (override={explicit_override})\n{}\n{}",
1082 String::from_utf8_lossy(&output.stdout),
1083 String::from_utf8_lossy(&output.stderr)
1084 );
1085 }
1086 assert_eq!(std::fs::read_to_string(fleet_path).unwrap(), contents);
1087 assert_eq!(
1088 std::fs::read_to_string(fleets.join(SELECTED_FILE)).unwrap(),
1089 fleet.name
1090 );
1091 }
1092
1093 fn sample_fleet() -> FleetFile {
1094 FleetFile::new("DeepSeek Flash".to_string(), None)
1095 .expect("valid fleet")
1096 .with_operator(FleetOperator {
1097 provider: "deepseek".to_string(),
1098 model: "deepseek-v4-flash".to_string(),
1099 reasoning: Some("low".to_string()),
1100 })
1101 .with_member(FleetMember {
1102 id: "scout".to_string(),
1103 display_name: Some("Flash Scout".to_string()),
1104 shortlist: false,
1105 role: "scout".to_string(),
1106 provider: None,
1107 model: None,
1108 reasoning: None,
1109 instructions: None,
1110 requires: Vec::new(),
1111 })
1112 .with_member(FleetMember {
1113 id: "builder".to_string(),
1114 display_name: None,
1115 shortlist: false,
1116 role: "builder".to_string(),
1117 provider: Some("deepseek".to_string()),
1118 model: Some("deepseek-v4-pro".to_string()),
1119 reasoning: Some("high".to_string()),
1120 instructions: Some("Implement exactly the task slice.".to_string()),
1121 requires: vec!["vision".to_string()],
1122 })
1123 }
1124
1125 trait FleetBuilder {
1126 fn with_operator(self, operator: FleetOperator) -> Self;
1127 fn with_member(self, member: FleetMember) -> Self;
1128 }
1129
1130 impl FleetBuilder for FleetFile {
1131 fn with_operator(mut self, operator: FleetOperator) -> Self {
1132 self.operator = Some(operator);
1133 self
1134 }
1135 fn with_member(mut self, member: FleetMember) -> Self {
1136 self.members.push(member);
1137 self
1138 }
1139 }
1140
1141 #[test]
1142 fn validation_rejects_bad_documents_with_specific_errors() {
1143 let _lock = crate::test_support::lock_test_env();
1144
1145 // Empty name.
1146 let err = FleetFile::new(" ".to_string(), None).unwrap_err();
1147 assert!(err.to_string().contains("name must not be empty"), "{err}");
1148
1149 // Duplicate member ids.
1150 let mut fleet = sample_fleet();
1151 fleet.members.push(fleet.members[0].clone());
1152 let err = fleet.validate().unwrap_err();
1153 assert!(
1154 err.to_string().contains("duplicate member id `scout`"),
1155 "{err}"
1156 );
1157
1158 // Dispatch identity is case-insensitive, so validation must reject a
1159 // pair lookup could not distinguish.
1160 let mut fleet = sample_fleet();
1161 let mut duplicate = fleet.members[0].clone();
1162 duplicate.id = "SCOUT".to_string();
1163 fleet.members.push(duplicate);
1164 let err = fleet.validate().unwrap_err();
1165 assert!(err.to_string().contains("case-insensitively"), "{err}");
1166
1167 // Human-facing names stay bounded and single-line before they can
1168 // enter selectors, roster discovery, or terminal rendering.
1169 let mut fleet = sample_fleet();
1170 fleet.members[0].display_name = Some("x".repeat(MAX_MEMBER_DISPLAY_NAME_CHARS + 1));
1171 let err = fleet.validate().unwrap_err();
1172 assert!(err.to_string().contains("no longer than 80"), "{err}");
1173 let mut fleet = sample_fleet();
1174 fleet.members[0].display_name = Some("Flash\nScout".to_string());
1175 let err = fleet.validate().unwrap_err();
1176 assert!(
1177 err.to_string().contains("one trimmed printable line"),
1178 "{err}"
1179 );
1180
1181 // Lone provider / lone model: never silently reinterpreted.
1182 let mut fleet = sample_fleet();
1183 fleet.members[0].provider = Some("deepseek".to_string());
1184 let err = fleet.validate().unwrap_err();
1185 assert!(
1186 err.to_string().contains("must pin both provider and model"),
1187 "{err}"
1188 );
1189 let mut fleet = sample_fleet();
1190 fleet.members[0].model = Some("deepseek-v4-pro".to_string());
1191 let err = fleet.validate().unwrap_err();
1192 assert!(
1193 err.to_string().contains("must pin both provider and model"),
1194 "{err}"
1195 );
1196
1197 // Unknown capability requirement.
1198 let mut fleet = sample_fleet();
1199 fleet.members[0].requires = vec!["telepathy".to_string()];
1200 let err = fleet.validate().unwrap_err();
1201 assert!(
1202 err.to_string().contains("unknown capability `telepathy`"),
1203 "{err}"
1204 );
1205 assert!(err.to_string().contains("vision"), "{err}");
1206 }
1207
1208 #[test]
1209 fn member_pin_matching_the_operator_route_reads_as_inheritance() {
1210 // #6037: a role member pinned to the fleet's own operator route
1211 // resolves to that route either way — the pin only stops it
1212 // following when the operator moves. Parse drops the redundant pin;
1213 // a different-route pin and a shortlist row keep theirs.
1214 let fleet = FleetFile::parse(
1215 r#"schema = "fleet"
1216 schema_revision = 2
1217 name = "Inherit"
1218
1219 [operator]
1220 provider = "openrouter"
1221 model = "z-ai/glm-5.3"
1222
1223 [[members]]
1224 id = "planner"
1225 role = "planner"
1226 provider = "openrouter"
1227 model = "z-ai/glm-5.3"
1228
1229 [[members]]
1230 id = "builder"
1231 role = "builder"
1232 provider = "openrouter"
1233 model = "z-ai/glm-5.3-pro"
1234
1235 [[members]]
1236 id = "choice"
1237 shortlist = true
1238 provider = "openrouter"
1239 model = "z-ai/glm-5.3"
1240 "#,
1241 )
1242 .expect("parse");
1243 let planner = fleet.member("planner").expect("planner member");
1244 assert_eq!(planner.provider, None);
1245 assert_eq!(planner.model, None);
1246 let builder = fleet.member("builder").expect("builder member");
1247 assert_eq!(builder.provider.as_deref(), Some("openrouter"));
1248 assert_eq!(builder.model.as_deref(), Some("z-ai/glm-5.3-pro"));
1249 let choice = fleet
1250 .members
1251 .iter()
1252 .find(|member| member.shortlist)
1253 .expect("shortlist row");
1254 assert_eq!(choice.provider.as_deref(), Some("openrouter"));
1255 assert_eq!(choice.model.as_deref(), Some("z-ai/glm-5.3"));
1256 // The listing still attributes the inherited role to the route it runs.
1257 let models = crate::fleet::members::models_of(&fleet);
1258 assert_eq!(models[0].model, "z-ai/glm-5.3");
1259 assert_eq!(models[0].roles, ["operator", "planner"]);
1260 assert_eq!(models[1].roles, ["builder"]);
1261 // The cleaned document round-trips: inherit stays inherit.
1262 assert_eq!(
1263 FleetFile::parse(&fleet.render_toml().expect("render")).expect("reparse"),
1264 fleet
1265 );
1266 }
1267
1268 #[test]
1269 fn render_parse_round_trip_preserves_every_field() {
1270 let fleet = sample_fleet();
1271 let text = fleet.render_toml().expect("render");
1272 let parsed = FleetFile::parse(&text).expect("parse");
1273 assert_eq!(parsed, fleet);
1274 assert!(text.contains("schema = \"fleet\""));
1275 assert!(text.contains("schema_revision = 2"));
1276 assert!(text.contains("display_name = \"Flash Scout\""));
1277 assert!(text.contains("deepseek-v4-flash"));
1278 assert!(
1279 !text.contains("shortlist"),
1280 "legacy members do not gain a marker"
1281 );
1282 }
1283
1284 #[test]
1285 fn marked_shortlist_round_trips_without_becoming_a_role_or_legacy_bare_pin() {
1286 let fleet = FleetFile::parse(
1287 r#"schema = "fleet"
1288 schema_revision = 2
1289 name = "Shortlist"
1290
1291 [[members]]
1292 id = "scout"
1293 shortlist = true
1294 provider = "fixture-provider"
1295 model = "scout"
1296 "#,
1297 )
1298 .expect("explicitly marked model survives legacy bare-pin migration");
1299 assert_eq!(fleet.members.len(), 1);
1300 let choice = &fleet.members[0];
1301 assert!(choice.shortlist);
1302 assert_eq!(choice.provider.as_deref(), Some("fixture-provider"));
1303 assert_eq!(choice.model.as_deref(), Some("scout"));
1304 assert!(choice.role_label().is_empty());
1305 assert!(
1306 !fleet.has_scout(),
1307 "a model named scout cannot select the scout role"
1308 );
1309 assert!(fleet.member("scout").is_none());
1310 let models = crate::fleet::members::models_of(&fleet);
1311 assert_eq!(models.len(), 1);
1312 assert!(models[0].roles.is_empty());
1313 let text = fleet.render_toml().expect("serialize marker");
1314 assert!(text.contains("shortlist = true"));
1315 assert!(!text.contains("role ="));
1316 assert_eq!(FleetFile::parse(&text).expect("reload marker"), fleet);
1317 }
1318
1319 #[test]
1320 fn shortlist_marker_rejects_roles_inheritance_and_incomplete_routes() {
1321 for (role, provider, model) in [
1322 ("scout", Some("deepseek"), Some("deepseek-v4-flash")),
1323 ("", None, None),
1324 ("", None, Some("deepseek-v4-flash")),
1325 ("", Some("deepseek"), None),
1326 ("", Some(" "), Some("deepseek-v4-flash")),
1327 ("", Some("deepseek"), Some(" ")),
1328 ] {
1329 let member: FleetMember = serde_json::from_value(serde_json::json!({
1330 "id": "choice", "shortlist": true, "role": role,
1331 "provider": provider, "model": model,
1332 }))
1333 .expect("typed fixture");
1334 let mut fleet = FleetFile::new("Malformed shortlist".into(), None).unwrap();
1335 fleet.members.push(member);
1336 assert!(
1337 fleet.validate().is_err(),
1338 "accepted invalid marker: {fleet:?}"
1339 );
1340 assert!(
1341 fleet.render_toml().is_err(),
1342 "render accepted invalid marker"
1343 );
1344 let text = toml::to_string(&fleet).expect("unchecked fixture serialization");
1345 assert!(
1346 FleetFile::parse(&text).is_err(),
1347 "parse accepted invalid marker: {text}"
1348 );
1349 }
1350
1351 for metadata in [
1352 serde_json::json!({"reasoning": "high"}),
1353 serde_json::json!({"instructions": "Review the changes."}),
1354 serde_json::json!({"requires": ["vision"]}),
1355 ] {
1356 let mut row = serde_json::json!({
1357 "id": "choice", "shortlist": true,
1358 "provider": "deepseek", "model": "deepseek-v4-flash",
1359 });
1360 row.as_object_mut()
1361 .unwrap()
1362 .extend(metadata.as_object().unwrap().clone());
1363 let mut fleet = FleetFile::new("Malformed shortlist".into(), None).unwrap();
1364 fleet.members.push(serde_json::from_value(row).unwrap());
1365 let text = toml::to_string(&fleet).expect("unchecked metadata fixture");
1366 for error in [
1367 fleet.validate().unwrap_err(),
1368 fleet.render_toml().unwrap_err(),
1369 FleetFile::parse(&text).unwrap_err(),
1370 ] {
1371 assert!(
1372 error.to_string().contains("cannot set role reasoning"),
1373 "metadata was not rejected as role-only: {error}"
1374 );
1375 }
1376 }
1377 }
1378
1379 #[test]
1380 fn member_name_alias_is_accepted_and_old_files_remain_valid() {
1381 let aliased = FleetFile::parse(
1382 r#"schema = "fleet"
1383 schema_revision = 2
1384 name = "Named"
1385
1386 [[members]]
1387 id = "scout"
1388 name = "Scout One"
1389 role = "scout"
1390 "#,
1391 )
1392 .expect("name alias");
1393 assert_eq!(
1394 aliased.members[0].display_name.as_deref(),
1395 Some("Scout One")
1396 );
1397 let canonical = aliased.render_toml().expect("canonical render");
1398 assert!(canonical.contains("display_name = \"Scout One\""));
1399
1400 let without_name = FleetFile::parse(
1401 r#"schema = "fleet"
1402 schema_revision = 2
1403 name = "Existing"
1404
1405 [[members]]
1406 id = "scout"
1407 role = "scout"
1408 "#,
1409 )
1410 .expect("pre-display-name revision-2 file");
1411 assert!(without_name.members[0].display_name.is_none());
1412 }
1413
1414 #[test]
1415 fn save_load_round_trips_in_workspace_scope() {
1416 let _lock = crate::test_support::lock_test_env();
1417 let ws = tempfile::TempDir::new().unwrap();
1418 let fleet = sample_fleet();
1419 let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).expect("save");
1420 assert!(
1421 path.ends_with(".codewhale/fleets/deepseek-flash.toml"),
1422 "{path:?}"
1423 );
1424
1425 let (loaded, scope, path) = load_fleet("DeepSeek Flash", ws.path()).expect("load");
1426 assert_eq!(loaded, fleet);
1427 assert_eq!(scope, FleetScope::Workspace);
1428 assert_eq!(path, load_fleet("DeepSeek Flash", ws.path()).unwrap().2);
1429
1430 // A same-name Fleet in the personal scope makes the bare name
1431 // ambiguous — the reader names both origins instead of shadowing.
1432 let _home = set_sealed_home();
1433 save_fleet(&fleet, FleetScope::Personal, ws.path()).expect("save personal");
1434 let err = load_fleet("DeepSeek Flash", ws.path()).unwrap_err();
1435 let msg = err.to_string();
1436 assert!(msg.contains("defined in both"), "{msg}");
1437 assert!(msg.contains("user") && msg.contains("folder"), "{msg}");
1438 }
1439
1440 #[test]
1441 fn selection_is_scope_explicit_and_workspace_wins() {
1442 let _lock = crate::test_support::lock_test_env();
1443 let _home = set_sealed_home();
1444 let ws = tempfile::TempDir::new().unwrap();
1445 let fleet = sample_fleet();
1446
1447 // No selection yet.
1448 assert!(selected_fleet(ws.path()).is_none());
1449
1450 // Personal selection: the user-global default.
1451 save_fleet(&fleet, FleetScope::Personal, ws.path()).unwrap();
1452 let selected_file =
1453 set_selected("DeepSeek Flash", FleetScope::Personal, ws.path()).expect("select");
1454 assert!(
1455 selected_file.ends_with("fleets/selected"),
1456 "{selected_file:?}"
1457 );
1458 let sel = selected_fleet(ws.path()).expect("selected");
1459 assert_eq!(sel.scope, FleetScope::Personal);
1460 assert_eq!(sel.name, "DeepSeek Flash");
1461
1462 // A selection naming a missing Fleet is refused — a phantom selection
1463 // would be a lie.
1464 let err = set_selected("No Such Fleet", FleetScope::Personal, ws.path()).unwrap_err();
1465 assert!(err.to_string().contains("No Such Fleet"), "{err}");
1466
1467 // Workspace selection overrides for this folder only.
1468 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1469 set_selected("DeepSeek Flash", FleetScope::Workspace, ws.path()).unwrap();
1470 let sel = selected_fleet(ws.path()).expect("selected");
1471 assert_eq!(sel.scope, FleetScope::Workspace);
1472 // Deleting the workspace Fleet clears the workspace selection; the
1473 // personal default reappears rather than a phantom.
1474 delete_fleet("DeepSeek Flash", FleetScope::Workspace, ws.path()).unwrap();
1475 let sel = selected_fleet(ws.path()).expect("personal default returns");
1476 assert_eq!(sel.scope, FleetScope::Personal);
1477 }
1478
1479 #[test]
1480 fn stale_explicit_selection_is_an_error_not_legacy_fallback() {
1481 let _lock = crate::test_support::lock_test_env();
1482 let _home = set_sealed_home();
1483 let ws = tempfile::TempDir::new().unwrap();
1484 let dir = workspace_fleets_dir(ws.path());
1485 std::fs::create_dir_all(&dir).unwrap();
1486 std::fs::write(dir.join(SELECTED_FILE), "Missing Fleet\n").unwrap();
1487
1488 let error = resolve_selected_fleet(ws.path()).expect_err("stale selection must fail");
1489 assert!(
1490 error.to_string().contains("selected fleet `Missing Fleet`"),
1491 "{error}"
1492 );
1493 assert!(error.to_string().contains("folder selection"), "{error}");
1494 }
1495
1496 #[test]
1497 fn list_marks_legacy_files_without_hiding_them() {
1498 let _lock = crate::test_support::lock_test_env();
1499 let _home = set_sealed_home();
1500 let ws = tempfile::TempDir::new().unwrap();
1501
1502 save_fleet(&sample_fleet(), FleetScope::Personal, ws.path()).unwrap();
1503 // A legacy exact fleet file (workflow schema) in the same directory
1504 // must be listed as legacy, never silently absent.
1505 let legacy = r#"schema = "exact"
1506 schema_revision = 1
1507 name = "stopship"
1508 members = []"#;
1509 std::fs::write(sealed_home().join("fleets/stopship.toml"), legacy).unwrap();
1510
1511 let entries = list_fleets(ws.path());
1512 assert_eq!(entries.len(), 2, "{entries:?}");
1513 let stopship = entries
1514 .iter()
1515 .find(|e| e.name == "stopship")
1516 .expect("legacy fleet listed");
1517 assert!(stopship.legacy, "{stopship:?}");
1518 assert!(stopship.parse_error.is_some(), "{stopship:?}");
1519 let flash = entries.iter().find(|e| e.name == "DeepSeek Flash").unwrap();
1520 assert!(!flash.legacy && flash.parse_error.is_none(), "{flash:?}");
1521 }
1522
1523 #[test]
1524 fn save_refuses_to_clobber_a_different_fleet_of_the_same_slug() {
1525 let _lock = crate::test_support::lock_test_env();
1526 let ws = tempfile::TempDir::new().unwrap();
1527 let fleet = sample_fleet();
1528 save_fleet(&fleet, FleetScope::Workspace, ws.path()).unwrap();
1529
1530 let mut other = FleetFile::new("DeepSeek Flash!".to_string(), None).unwrap();
1531 other.members = fleet.members.clone();
1532 let err = save_fleet(&other, FleetScope::Workspace, ws.path()).unwrap_err();
1533 assert!(err.to_string().contains("already exists"), "{err}");
1534 }
1535
1536 #[test]
1537 fn migration_preserves_pins_and_names_shadowing() {
1538 let _lock = crate::test_support::lock_test_env();
1539 let ws = tempfile::TempDir::new().unwrap();
1540
1541 // A workspace legacy profile file with a pin.
1542 let agents_dir = ws.path().join(".codewhale/agents");
1543 std::fs::create_dir_all(&agents_dir).unwrap();
1544 std::fs::write(
1545 agents_dir.join("scout.toml"),
1546 r#"id = "scout"
1547 display_name = "Scout One"
1548 role_hint = "scout"
1549 model = "deepseek-v4-flash"
1550 provider = "deepseek"
1551 "#,
1552 )
1553 .unwrap();
1554
1555 let receipt = migrate_legacy_roster(
1556 &codewhale_config::FleetConfigToml::default(),
1557 ws.path(),
1558 true,
1559 FleetScope::Workspace,
1560 )
1561 .expect("migration");
1562
1563 assert_eq!(receipt.fleet.name, "Default");
1564 let scout = receipt.fleet.member("scout").expect("scout member");
1565 assert_eq!(scout.display_name.as_deref(), Some("Scout One"));
1566 assert_eq!(scout.model.as_deref(), Some("deepseek-v4-flash"));
1567 assert_eq!(scout.provider.as_deref(), Some("deepseek"));
1568 assert!(receipt.saved_to.ends_with("fleets/default.toml"));
1569 // The legacy profile file itself is untouched.
1570 assert!(
1571 std::fs::read_to_string(agents_dir.join("scout.toml"))
1572 .unwrap()
1573 .contains("model = \"deepseek-v4-flash\"")
1574 );
1575 }
1576 }
1577
1577 lines RUST