fix(fleet): workflow(fleet:) runs saved v2 Fleets as a frozen exact snapshot

`workflow(fleet:)` rejected every Fleet saved from the Fleet UI with
"unknown fleet schema `fleet`; expected `exact`". It now resolves the
saved v2 store first (slice S1 of the Fleet self-config plan):

- `fleet::exact::load_fleet_document` tries `store::load_fleet` first and
  freezes the saved Fleet into an exact document: an explicit member pin
  wins, then the Fleet operator route, then the live session route and
  tier from the session config the same caller preflights with, so the
  receipt names the route that ran. The frozen text goes through the
  workflow crate's own exact parser and snapshot hash.
- A bare name that exists both as a saved Fleet and as a legacy/exact
  file is ambiguous and the error names every path; `user/<name>` and
  `folder/<name>` qualify the saved one, and a search-root origin reads
  that root's file in whichever form it is.
- `store::load_fleet` counts only files that declare `schema = "fleet"`
  (the personal fleets/ directory is shared with exact files) and drops
  its dead_code expect.
- Member `instructions`/`requires` are refused rather than silently
  dropped: the exact snapshot has no field for them yet.
- `FleetDocument::from_frozen_saved_fleet` records the saved file as the
  source; the unknown-schema error now points at saved Fleets.
- docs/FLEET.md: how a Workflow runs a saved Fleet (the location fix
  itself landed in 26cfaf8de).

Checks: cargo test -p codewhale-tui --lib -- fleet::exact fleet::store
tools::workflow: 204 passed, 0 failed. cargo test -p codewhale-workflow
--lib -- named_fleet: 11 passed, 0 failed. rustfmt --check clean on the
touched files. Full gate and clippy not run.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01TvKGZz4LrKfrQiAXip48JS
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-22 20:23:47 -07:00
1 parent 26cfaf8de4
commit b4cee564da
5 files changed
+619 -33

No files matched your search

+488 -6
View File
@@ -92,11 +92,251 @@ pub(crate) fn fleet_search_roots(workspace: &std::path::Path) -> Vec<FleetSearch
/// Load a Fleet document by (optionally qualified) name from the standard
/// roots. Ambiguity between origins is surfaced, never resolved by shadowing.
///
/// Saved v2 Fleets (`schema = "fleet"`, from `.codewhale/fleets/` or
/// `$CODEWHALE_HOME/fleets/`) are looked up first and frozen into an exact
/// snapshot here — see [`freeze_saved_fleet`]. A miss falls through to the
/// workflow crate's legacy/exact loader. A bare name that exists both as a v2
/// Fleet and as a legacy/exact file is ambiguous: neither shadows the other,
/// and the error names every path. v2 Fleets qualify as `user/<name>` and
/// `folder/<name>` (the store's own scope labels); a search-root origin
/// (`codewhale_home/`, `workspace/`, `workspace_root/`) reads that root's file
/// in whichever form it is.
///
/// `config` is the session config the caller preflights with: inheriting
/// members resolve against it at this point, immediately before the same
/// config preflights the frozen routes, so a receipt names the route that ran.
pub(crate) fn load_fleet_document(
name: &str,
workspace: &std::path::Path,
config: Option<&Config>,
) -> Result<(FleetDocument, QualifiedFleetId), NamedFleetError> {
FleetDocument::load_by_name(name, &fleet_search_roots(workspace))
use super::store::{self, FleetScope};
let roots = fleet_search_roots(workspace);
let trimmed = name.trim();
let (origin, bare) = match trimmed.split_once('/') {
Some((origin, bare)) if !origin.trim().is_empty() && !bare.trim().is_empty() => {
(Some(origin.trim()), bare.trim())
}
_ => (None, trimmed),
};
let store_error = |error: store::FleetStoreError| match error {
store::FleetStoreError::NotFound(what) => NamedFleetError::NotFound(what),
store::FleetStoreError::Io { path, message } => NamedFleetError::Io { path, message },
store::FleetStoreError::Parse { path, message } => NamedFleetError::Parse { path, message },
other => NamedFleetError::Parse {
path: bare.to_string(),
message: other.to_string(),
},
};
let v2_scope = match origin.map(str::to_ascii_lowercase).as_deref() {
None => None,
Some("user" | "personal") => Some(FleetScope::Personal),
Some("folder") => Some(FleetScope::Workspace),
// Any other origin names a legacy/exact search root. A saved v2
// Fleet can live there too (the personal `fleets/` directory is
// shared), so the qualified file is read in whichever form it is.
Some(origin) => {
let saved = roots
.iter()
.find(|root| root.origin.eq_ignore_ascii_case(origin))
.map(|root| {
root.root
.join(store::FLEET_DIR)
.join(format!("{bare}.toml"))
})
.filter(|path| {
std::fs::read_to_string(path).ok().is_some_and(|text| {
codewhale_workflow::fleet_exact::declared_schema_kind(&text).as_deref()
== Some(store::FLEET_SCHEMA_KIND)
})
});
let Some(path) = saved else {
return FleetDocument::load_by_name(name, &roots);
};
let (fleet, scope) = store::load_fleet_at(&path).map_err(store_error)?;
return freeze_saved_fleet(&fleet, scope, &path, config);
}
};
if let Some(scope) = v2_scope {
let (fleet, path) =
store::load_fleet_in_scope(bare, scope, workspace).map_err(store_error)?;
return freeze_saved_fleet(&fleet, scope, &path, config);
}
// Legacy/exact files under the same bare name, in any root. A v2 file in
// the shared personal directory is the store's, not a second Fleet.
let file_name = format!("{bare}.toml");
let other_forms: Vec<String> = roots
.iter()
.filter_map(|root| {
let path = root.root.join(store::FLEET_DIR).join(&file_name);
let text = std::fs::read_to_string(&path).ok()?;
(codewhale_workflow::fleet_exact::declared_schema_kind(&text).as_deref()
!= Some(store::FLEET_SCHEMA_KIND))
.then(|| format!("{}/{bare} ({})", root.origin, path.display()))
})
.collect();
let v2_candidates = store::v2_fleet_candidates(bare, workspace);
let v2_labels = || {
v2_candidates
.iter()
.map(|(scope, path)| format!("{}/{bare} ({})", scope.label(), path.display()))
};
if v2_candidates.len() > 1 || (!v2_candidates.is_empty() && !other_forms.is_empty()) {
return Err(NamedFleetError::AmbiguousFleet {
name: bare.to_string(),
origins: v2_labels().chain(other_forms).collect(),
});
}
match store::load_fleet(bare, workspace) {
Ok((fleet, scope, path)) => freeze_saved_fleet(&fleet, scope, &path, config),
Err(store::FleetStoreError::NotFound(_)) => FleetDocument::load_by_name(name, &roots),
Err(error) => Err(store_error(error)),
}
}
/// Freeze a saved v2 Fleet into an exact snapshot document.
///
/// Every executable member leaves here with one concrete provider/model and
/// one concrete reasoning request: an explicit member pin wins, then the
/// Fleet's operator route, then the live session route from `config`. The
/// result is rendered as an exact document and parsed by the workflow crate's
/// own exact parser, so it passes the same validation as a hand-written exact
/// file, and the snapshot hash covers what was frozen. Editing the v2 file
/// afterwards changes only the next Workflow.
///
/// Member `instructions` and `requires` are refused rather than dropped: the
/// exact snapshot has no field for either, and a Workflow that silently ran a
/// member without its instructions or capability requirement would not be the
/// saved Fleet.
fn freeze_saved_fleet(
fleet: &super::store::FleetFile,
scope: super::store::FleetScope,
path: &std::path::Path,
config: Option<&Config>,
) -> Result<(FleetDocument, QualifiedFleetId), NamedFleetError> {
#[derive(serde::Serialize)]
struct FrozenFleet {
schema: &'static str,
schema_revision: u32,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
members: Vec<FrozenMember>,
}
#[derive(serde::Serialize)]
struct FrozenMember {
id: String,
role: String,
provider: String,
model: String,
reasoning: String,
}
let slug = fleet.file_slug();
let fail = |message: String| NamedFleetError::Parse {
path: path.display().to_string(),
message,
};
let operator = fleet.operator.as_ref();
let session_route = config.map(|config| {
(
config.provider_identity_for(config.api_provider()),
config.default_model(),
)
});
let session_reasoning = || {
let effort = config
.and_then(Config::reasoning_effort)
.map(ReasoningEffort::from_setting)
.unwrap_or_default();
// A session-level `auto` is per-turn adaptivity, not a Router
// request; a frozen member takes the concrete default tier instead.
tier_of(effort)
.unwrap_or(ReasoningTier::Max)
.as_str()
.to_string()
};
let mut unsupported = Vec::new();
let mut members = Vec::new();
for member in fleet.members.iter().filter(|member| !member.shortlist) {
let id = member.id.trim().to_string();
if member
.instructions
.as_deref()
.is_some_and(|text| !text.trim().is_empty())
{
unsupported.push(format!("`{id}` has instructions"));
}
if !member.requires.is_empty() {
unsupported.push(format!("`{id}` has requires"));
}
let (provider, model) = match (&member.provider, &member.model, operator, &session_route) {
(Some(provider), Some(model), _, _) => (provider.clone(), model.clone()),
(None, None, Some(operator), _) => (operator.provider.clone(), operator.model.clone()),
(None, None, None, Some((provider, model))) => (provider.clone(), model.clone()),
(None, None, None, None) => {
return Err(fail(format!(
"member `{id}` inherits the session route, but no session config is \
available to resolve it"
)));
}
_ => {
return Err(fail(format!(
"member `{id}` has a partial provider/model pin"
)));
}
};
let reasoning = member
.reasoning
.clone()
.or_else(|| operator.and_then(|operator| operator.reasoning.clone()))
.unwrap_or_else(session_reasoning);
members.push(FrozenMember {
role: member.role_label().to_string(),
id,
provider,
model,
reasoning,
});
}
if !unsupported.is_empty() {
return Err(fail(format!(
"saved Fleet `{}` cannot run as a Workflow Fleet yet: {}. Workflow snapshots freeze \
each member's route and reasoning only; remove those fields or run the members \
with `agent`.",
fleet.name,
unsupported.join(", ")
)));
}
let frozen = FrozenFleet {
schema: codewhale_workflow::EXACT_FLEET_SCHEMA_KIND,
schema_revision: codewhale_workflow::EXACT_FLEET_SCHEMA_REVISION,
name: slug.clone(),
description: fleet.description.clone(),
members,
};
let text = toml::to_string(&frozen)
.map_err(|error| fail(format!("failed to freeze saved Fleet: {error}")))?;
let document = FleetDocument::from_frozen_saved_fleet(&text, path).map_err(|error| {
fail(format!(
"saved Fleet `{}` cannot run as a Workflow Fleet: {error}",
fleet.name
))
})?;
Ok((
document,
QualifiedFleetId {
name: slug,
origin: scope.label().to_string(),
},
))
}
// ── Preflight: freeze the route, and check it while freezing ─────────────────
@@ -2840,7 +3080,8 @@ permissions = "read_only"
let saved = ws.path().join(".codewhale").join("fleets");
std::fs::create_dir_all(&saved).expect("saved fleets dir");
std::fs::write(saved.join("glm-pair.toml"), GLM_FLEET).expect("write saved");
let (document, id) = load_fleet_document("glm-pair", ws.path()).expect("saved fleet loads");
let (document, id) =
load_fleet_document("glm-pair", ws.path(), None).expect("saved fleet loads");
assert_eq!(document.name(), "glm-pair");
assert_eq!(id.origin, "workspace");
@@ -2852,7 +3093,7 @@ permissions = "read_only"
)
.expect("write checked-in");
let (document, id) =
load_fleet_document("stopship", ws.path()).expect("checked-in fleet still loads");
load_fleet_document("stopship", ws.path(), None).expect("checked-in fleet still loads");
assert_eq!(document.name(), "stopship");
assert_eq!(id.origin, "workspace_root");
@@ -2860,10 +3101,11 @@ permissions = "read_only"
// origin can be named explicitly.
std::fs::write(checked_in.join("glm-pair.toml"), GLM_FLEET).expect("write twin");
assert!(matches!(
load_fleet_document("glm-pair", ws.path()),
load_fleet_document("glm-pair", ws.path(), None),
Err(NamedFleetError::AmbiguousFleet { .. })
));
let (_, id) = load_fleet_document("workspace_root/glm-pair", ws.path()).expect("qualified");
let (_, id) =
load_fleet_document("workspace_root/glm-pair", ws.path(), None).expect("qualified");
assert_eq!(id.origin, "workspace_root");
}
@@ -2884,7 +3126,7 @@ permissions = "read_only"
let fleet = FleetFile::new("Folder Pair".to_string(), None).expect("fleet");
let path = save_fleet(&fleet, FleetScope::Workspace, ws.path()).expect("save");
match load_fleet_document(&fleet.file_slug(), ws.path()) {
match load_fleet_document(&fleet.file_slug(), ws.path(), None) {
// A v2 bridge may label the store scope `folder` rather than the
// `workspace` search-root origin; either names this workspace.
Ok((_, id)) => assert!(
@@ -2903,3 +3145,243 @@ permissions = "read_only"
}
}
}
/// `workflow(fleet:)` resolving saved v2 Fleets (store-first lookup, freeze
/// into an exact snapshot, ambiguity against legacy/exact files).
#[cfg(test)]
mod saved_fleet_tests {
use super::*;
use crate::fleet::store::{FleetFile, FleetMember, FleetOperator, FleetScope, save_fleet};
use crate::test_support::{EnvVarGuard, lock_test_env};
fn member(id: &str, pin: Option<(&str, &str)>) -> FleetMember {
FleetMember {
id: id.to_string(),
display_name: None,
shortlist: false,
role: String::new(),
model: pin.map(|(_, model)| model.to_string()),
provider: pin.map(|(provider, _)| provider.to_string()),
reasoning: None,
instructions: None,
requires: Vec::new(),
}
}
fn fleet(name: &str, members: Vec<FleetMember>) -> FleetFile {
let mut fleet = FleetFile::new(name.to_string(), None).expect("fleet");
fleet.members = members;
fleet
}
fn zai_session() -> Config {
Config {
provider: Some("zai".to_string()),
reasoning_effort: Some("high".to_string()),
..Default::default()
}
}
fn session_ceiling() -> PermissionCeiling {
PermissionCeiling {
write: true,
network_tool: true,
shell: codewhale_workflow::ShellCeiling::Full,
delegation_depth: codewhale_config::DEFAULT_SPAWN_DEPTH,
tools: true,
}
}
#[test]
fn a_personal_saved_fleet_loads_as_a_frozen_exact_document() {
let _lock = lock_test_env();
let home = tempfile::tempdir().expect("home");
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
let ws = tempfile::tempdir().expect("workspace");
let config = zai_session();
let saved = fleet(
"My fleet",
vec![
member("builder", Some(("zai", crate::config::ZAI_GLM_5_2_MODEL))),
member("reviewer", None),
],
);
let path = save_fleet(&saved, FleetScope::Personal, ws.path()).expect("save");
let (document, id) =
load_fleet_document("My fleet", ws.path(), Some(&config)).expect("v2 loads");
assert_eq!(id.origin, "user");
assert_eq!(id.name, "my-fleet");
assert_eq!(document.source_path(), Some(path.as_path()));
let exact = document.exact().expect("frozen into the exact schema");
let builder = exact.member("builder").expect("builder");
assert_eq!(
(builder.provider.as_str(), builder.model.as_str()),
("zai", crate::config::ZAI_GLM_5_2_MODEL)
);
// No pin and no operator: the member inherits the live session route
// and tier, resolved now rather than left open.
let reviewer = exact.member("reviewer").expect("reviewer");
assert_eq!(
reviewer.provider,
config.provider_identity_for(config.api_provider())
);
assert_eq!(reviewer.model, config.default_model());
assert_eq!(reviewer.reasoning.as_str(), "high");
// The slug also resolves, and so does the qualified store scope.
load_fleet_document("my-fleet", ws.path(), Some(&config)).expect("slug loads");
load_fleet_document("user/My fleet", ws.path(), Some(&config)).expect("user/ loads");
}
#[test]
fn a_workspace_saved_fleet_loads_and_members_follow_the_operator_route() {
let _lock = lock_test_env();
let home = tempfile::tempdir().expect("home");
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
let ws = tempfile::tempdir().expect("workspace");
let mut saved = fleet("reviewers", vec![member("auditor", None)]);
saved.operator = Some(FleetOperator {
provider: "zai".to_string(),
model: crate::config::ZAI_GLM_5_2_MODEL.to_string(),
reasoning: Some("low".to_string()),
});
let path = save_fleet(&saved, FleetScope::Workspace, ws.path()).expect("save");
assert!(path.starts_with(ws.path().join(".codewhale").join("fleets")));
// No session config is needed: nothing inherits the session route.
let (document, id) = load_fleet_document("reviewers", ws.path(), None).expect("loads");
assert_eq!(id.origin, "folder");
let auditor = document.exact().unwrap().member("auditor").unwrap();
assert_eq!(auditor.model, crate::config::ZAI_GLM_5_2_MODEL);
assert_eq!(auditor.reasoning.as_str(), "low");
}
#[test]
fn a_saved_fleet_colliding_with_an_exact_file_is_ambiguous_and_names_both_paths() {
let _lock = lock_test_env();
let home = tempfile::tempdir().expect("home");
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
let ws = tempfile::tempdir().expect("workspace");
let saved_path = save_fleet(
&fleet("glm-pair", vec![member("builder", None)]),
FleetScope::Personal,
ws.path(),
)
.expect("save");
let exact_dir = ws.path().join("fleets");
std::fs::create_dir_all(&exact_dir).unwrap();
let exact_path = exact_dir.join("glm-pair.toml");
std::fs::write(
&exact_path,
"name = \"glm-pair\"\nschema = \"exact\"\n\n[[members]]\nid = \"builder\"\nprovider = \"zai\"\nmodel = \"glm-5\"\n",
)
.unwrap();
let error = load_fleet_document("glm-pair", ws.path(), Some(&zai_session()))
.expect_err("a v2 and an exact Fleet of one name must not shadow each other");
let message = error.to_string();
assert!(
matches!(error, NamedFleetError::AmbiguousFleet { .. }),
"{message}"
);
assert!(
message.contains(&saved_path.display().to_string()),
"{message}"
);
assert!(
message.contains(&exact_path.display().to_string()),
"{message}"
);
// Qualifying either side resolves it.
let (document, _) =
load_fleet_document("user/glm-pair", ws.path(), Some(&zai_session())).expect("v2");
assert_eq!(document.source_path(), Some(saved_path.as_path()));
}
#[test]
fn member_instructions_are_refused_rather_than_silently_dropped() {
let _lock = lock_test_env();
let home = tempfile::tempdir().expect("home");
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
let ws = tempfile::tempdir().expect("workspace");
let mut coach = member("coach", Some(("zai", crate::config::ZAI_GLM_5_2_MODEL)));
coach.instructions = Some("Always cite sources.".to_string());
save_fleet(
&fleet("coached", vec![coach]),
FleetScope::Workspace,
ws.path(),
)
.expect("save");
let error = load_fleet_document("coached", ws.path(), None).expect_err("refused");
assert!(
error.to_string().contains("`coach` has instructions"),
"{error}"
);
}
/// The frozen snapshot is what runs: editing the saved file after capture
/// moves nothing, and the inherited member's preflighted route is the same
/// session route the snapshot names.
#[test]
fn frozen_routes_survive_a_mid_run_edit_and_inherit_matches_preflight() {
let _lock = lock_test_env();
let home = tempfile::tempdir().expect("home");
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _key = EnvVarGuard::set("ZAI_API_KEY", "zai-key");
let ws = tempfile::tempdir().expect("workspace");
let config = zai_session();
let mut saved = fleet(
"release",
vec![
member("builder", Some(("zai", crate::config::ZAI_GLM_5_2_MODEL))),
member("reviewer", None),
],
);
save_fleet(&saved, FleetScope::Workspace, ws.path()).expect("save");
let (document, id) =
load_fleet_document("release", ws.path(), Some(&config)).expect("loads");
let roots = fleet_search_roots(ws.path());
let workflow = ExactFleetWorkflow::capture(
&document,
id,
"2026-09-22T00:00:00Z",
Some(&config),
&roots,
)
.expect("capture");
// Edit the saved Fleet mid-run.
saved.members[0].model = Some("glm-4.6".to_string());
save_fleet(&saved, FleetScope::Workspace, ws.path()).expect("re-save");
let builder = workflow
.bind_member(Some("builder"), None, session_ceiling())
.expect("bind builder");
assert_eq!(builder.route.wire_model, crate::config::ZAI_GLM_5_2_MODEL);
let reviewer = workflow
.bind_member(Some("reviewer"), None, session_ceiling())
.expect("bind reviewer");
let frozen = workflow
.snapshot()
.members()
.iter()
.find(|member| member.id == "reviewer")
.expect("reviewer in snapshot");
assert_eq!(frozen.route.model, config.default_model());
assert_eq!(reviewer.route.frozen().model, reviewer.route.wire_model);
assert_eq!(
reviewer.route.wire_model,
crate::config::requested_model_for_provider(
config.api_provider(),
&config.default_model()
)
.expect("session model is a known route")
);
}
}
+44 -15
View File
@@ -579,11 +579,50 @@ fn collect_entries(dir: &Path, scope: FleetScope, out: &mut Vec<FleetEntry>) {
}
}
/// Every v2 Fleet file that answers to `name`, personal first.
///
/// Only files that declare `schema = "fleet"` count. The personal `fleets/`
/// directory is shared with the workflow crate's legacy/exact files, and a
/// file in another schema is a different Fleet form, not a v2 Fleet that
/// failed to parse — the caller that owns that form reports on it.
pub(crate) fn v2_fleet_candidates(name: &str, workspace: &Path) -> Vec<(FleetScope, PathBuf)> {
let file_name = format!("{}.toml", slugify(name.trim()));
let mut found = Vec::new();
let personal = personal_fleets_dir().ok().map(|dir| dir.join(&file_name));
let workspace = Some(workspace_fleets_dir(workspace).join(&file_name));
for (scope, path) in [
(FleetScope::Personal, personal),
(FleetScope::Workspace, workspace),
] {
if let Some(path) = path
&& path.is_file()
&& declares_v2_schema(&path)
{
found.push((scope, path));
}
}
found
}
/// Whether a file declares the v2 `schema = "fleet"`. Unreadable or
/// malformed TOML is not a v2 declaration.
fn declares_v2_schema(path: &Path) -> bool {
fs::read_to_string(path)
.ok()
.and_then(|text| toml::from_str::<toml::Value>(&text).ok())
.and_then(|value| {
value
.get("schema")
.and_then(toml::Value::as_str)
.map(|schema| schema.trim().eq_ignore_ascii_case(FLEET_SCHEMA_KIND))
})
.unwrap_or(false)
}
/// Load a v2 Fleet by name. Ambiguity between the two scopes is an error that
/// names both origins — the caller (UI) resolves it by asking for a scope.
/// (Kept for the qualified-name flow and the ambiguity tests; the list/detail
/// UI resolves by scope via load_fleet_in_scope.)
#[cfg_attr(not(test), expect(dead_code))]
/// names both origins — the caller resolves it by asking for a scope. A file
/// under the same name in another schema (legacy/exact) is not a v2 hit.
/// Used by `workflow(fleet:)` through `fleet::exact::load_fleet_document`.
pub fn load_fleet(
name: &str,
workspace: &Path,
@@ -592,17 +631,7 @@ pub fn load_fleet(
if name.is_empty() {
return Err(FleetStoreError::NotFound("<empty name>".to_string()));
}
let mut found: Vec<(FleetScope, PathBuf)> = Vec::new();
if let Ok(dir) = personal_fleets_dir() {
let path = dir.join(format!("{}.toml", slugify(name)));
if path.is_file() {
found.push((FleetScope::Personal, path));
}
}
let ws_path = workspace_fleets_dir(workspace).join(format!("{}.toml", slugify(name)));
if ws_path.is_file() {
found.push((FleetScope::Workspace, ws_path));
}
let mut found = v2_fleet_candidates(name, workspace);
if found.len() > 1 {
return Err(FleetStoreError::Ambiguous(
name.to_string(),
+13 -11
View File
@@ -1703,17 +1703,19 @@ fn workflow_fleet_binding(
return Ok(WorkflowFleetBinding::None);
};
let roots = crate::fleet::exact::fleet_search_roots(&context.workspace);
let (document, id) = crate::fleet::exact::load_fleet_document(&name, &context.workspace)
.map_err(|err| {
ToolError::invalid_input(format!(
"Failed to load workflow Fleet '{name}' from {}: {err}",
roots
.iter()
.map(|root| format!("{}/{}", root.origin, root.root.display()))
.collect::<Vec<_>>()
.join(", ")
))
})?;
let (document, id) =
crate::fleet::exact::load_fleet_document(&name, &context.workspace, api_config).map_err(
|err| {
ToolError::invalid_input(format!(
"Failed to load workflow Fleet '{name}' from {}: {err}",
roots
.iter()
.map(|root| format!("{}/{}", root.origin, root.root.display()))
.collect::<Vec<_>>()
.join(", ")
))
},
)?;
if let Some(legacy) = document.legacy() {
let roles = FleetRoleMap::from_pairs(
+65 -1
View File
@@ -130,7 +130,11 @@ impl FleetDocument {
Some(other) => {
return Err(NamedFleetError::Parse {
path: "<memory>".into(),
message: format!("unknown fleet schema `{other}`; expected `exact`"),
message: format!(
"unknown fleet schema `{other}`; expected `exact` or a saved Fleet \
(`schema = \"fleet\"`, loaded from `.codewhale/fleets/` or \
`$CODEWHALE_HOME/fleets/`)"
),
});
}
None => FleetSchema::Legacy(parse_named_fleet(text)?),
@@ -327,6 +331,34 @@ impl FleetDocument {
self.source.as_deref()
}
/// An exact document frozen from a saved v2 Fleet at Workflow start.
///
/// `frozen_text` is the exact-schema rendering of the saved Fleet with every
/// route and reasoning request resolved; it goes through the same exact
/// parser as a hand-written file, and [`Self::source_hash`] covers those
/// frozen bytes — what actually runs — while [`Self::source_path`] names the
/// saved Fleet file it came from.
pub fn from_frozen_saved_fleet(
frozen_text: &str,
source: &Path,
) -> Result<Self, NamedFleetError> {
if declared_schema_kind(frozen_text).as_deref() != Some(EXACT_FLEET_SCHEMA_KIND) {
return Err(NamedFleetError::Parse {
path: source.display().to_string(),
message: "a frozen saved Fleet must be in the exact schema".to_string(),
});
}
let mut document = Self::parse(frozen_text).map_err(|error| match error {
NamedFleetError::Exact { source: inner, .. } => NamedFleetError::Exact {
fleet: source.display().to_string(),
source: inner,
},
other => other,
})?;
document.source = Some(source.to_path_buf());
Ok(document)
}
/// Build a document around an already-constructed exact roster.
///
/// Test-only, and deliberately so: it is how a roster that never passed
@@ -680,4 +712,36 @@ model = "glm-5-turbo"
let fleet = load_named_fleet("stopship", &[root]).expect("load workspace fleet");
fleet.validate_stopship_roles().unwrap();
}
#[test]
fn a_frozen_saved_fleet_is_exact_and_names_its_source_file() {
let source = Path::new("/saved/.codewhale/fleets/release.toml");
let frozen = "schema = \"exact\"\nschema_revision = 1\nname = \"release\"\n\n\
[[members]]\nid = \"builder\"\nrole = \"implement\"\n\
provider = \"zai\"\nmodel = \"glm-5\"\nreasoning = \"high\"\n";
let document = FleetDocument::from_frozen_saved_fleet(frozen, source).expect("frozen");
assert!(document.exact().is_some());
assert_eq!(document.source_path(), Some(source));
assert_eq!(document.source_hash(), content_hash(frozen));
// Anything that is not the exact schema is refused, never parsed as a
// legacy role map.
let error = FleetDocument::from_frozen_saved_fleet(
"name = \"release\"\n[roles]\nimplement = \"builder\"\n",
source,
)
.expect_err("legacy text is not a frozen snapshot");
assert!(error.to_string().contains("exact schema"), "{error}");
}
#[test]
fn a_saved_fleet_schema_is_named_in_the_unknown_schema_error() {
let error = FleetDocument::parse("schema = \"fleet\"\nname = \"x\"\n").unwrap_err();
let message = error.to_string();
assert!(
message.contains("expected `exact` or a saved Fleet"),
"{message}"
);
assert!(message.contains(".codewhale/fleets/"), "{message}");
}
}
+9
View File
@@ -348,6 +348,15 @@ The workflow crate's older `schema = "exact"`, revision 1 files are migration
input only. Do not author revision-1 files; the selected roster and setup UI
read and write only `schema = "fleet"`, revision 2.
`workflow(fleet: "release")` runs a saved Fleet without selecting it. At
Workflow start, a member with no pin takes the Fleet's `[operator]` route or,
without one, the session route and reasoning tier; that frozen route is what
runs and what receipts name, and editing the file mid-run changes only the next
Workflow. If a saved Fleet and an older exact/legacy file share a name, the
Workflow refuses to guess; qualify the saved one as `user/<name>` or
`folder/<name>`. Members with `instructions` or `requires` cannot run in a
Workflow yet.
Reasoning is a separate route-execution decision, not fleet identity. The
optional Reasoning Router is a reusable Runtime service, not a fleet member.
Save one profile at `routers/<name>.toml` in either search root and reference it