| 1 | //! `/fleet` command. |
| 2 | //! |
| 3 | //! Fleet = who. Bare `/fleet` (and `/fleet roster`) opens the familiar roster |
| 4 | //! surface for the selected Fleet; `/fleet setup` opens the authoring wizard. |
| 5 | //! `/fleet fleets` (aliases: `saved`, `manage`) opens the named-Fleet picker |
| 6 | //! for switching between saved configurations — never the primary face. |
| 7 | //! `/fleet list|status|interrupt|resume` are control-plane verbs that run |
| 8 | //! against the **durable** workspace ledger through the shared contract in |
| 9 | //! `codewhale-lane`, exactly as `codewhale fleet …` does (#1888, #4022). |
| 10 | //! |
| 11 | //! `/fleet status` used to show the current TUI session's sub-agents. That was |
| 12 | //! a different thing wearing the same name: session sub-agents are not the |
| 13 | //! durable Fleet ledger, and a run started by `codewhale fleet run` never |
| 14 | //! appeared. The session view is still reachable as `/fleet workers` (and |
| 15 | //! `/subagents`), now labelled as what it is. |
| 16 | |
| 17 | use codewhale_lane::control::operations_for_domain; |
| 18 | use codewhale_lane::{ControlDomain, ControlOperation, ControlSurface}; |
| 19 | |
| 20 | use crate::commands::traits::{CommandInfo, RegisterCommand}; |
| 21 | use crate::fleet::control::execute_fleet_control; |
| 22 | use crate::localization::MessageId; |
| 23 | use crate::tui::app::{App, AppAction}; |
| 24 | |
| 25 | use super::CommandResult; |
| 26 | |
| 27 | pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { |
| 28 | name: "fleet", |
| 29 | aliases: &["loadout", "party"], |
| 30 | usage: "/fleet [roster|setup|fleets|list|status|workers|interrupt <worker-id>|resume <run-id>]", |
| 31 | description_id: MessageId::CmdFleetDescription, |
| 32 | }; |
| 33 | |
| 34 | pub(in crate::commands) struct FleetCmd; |
| 35 | |
| 36 | fn help_text() -> String { |
| 37 | let mut out = String::from( |
| 38 | "Usage: /fleet [roster|setup|fleets|list|status|workers|interrupt <worker-id>|resume <run-id>]\n\n\ |
| 39 | Fleet is who. /fleet (or /fleet roster) opens Fleet workers and orchestration state — \ |
| 40 | each member's posture, routing, and origin. /fleet setup opens the authoring wizard. \ |
| 41 | /fleet fleets (or saved/manage) switches between named saved Fleets.\n\n\ |
| 42 | /fleet list, status, interrupt, and resume act on the durable .codewhale/fleet.jsonl \ |
| 43 | ledger for this workspace — the same records `codewhale fleet` reads and writes. \ |
| 44 | /fleet workers (and /subagents) shows sub-agents in the current TUI session only, which \ |
| 45 | is a different set: it does not include durable Fleet runs.\n", |
| 46 | ); |
| 47 | for descriptor in operations_for_domain(ControlDomain::Fleet) { |
| 48 | out.push_str(&format!( |
| 49 | "\n {:<30} {:<6} {}\n CLI: {}\n", |
| 50 | descriptor.slash_invocation(), |
| 51 | descriptor.authority.as_str(), |
| 52 | descriptor.summary, |
| 53 | descriptor.cli_invocation |
| 54 | )); |
| 55 | } |
| 56 | out |
| 57 | } |
| 58 | |
| 59 | /// Split `"<verb> <rest>"` into the verb and its raw target tail. |
| 60 | fn split_verb(arg: Option<&str>) -> Option<(&str, Option<&str>)> { |
| 61 | let rest = arg.map(str::trim).filter(|value| !value.is_empty())?; |
| 62 | Some(match rest.split_once(char::is_whitespace) { |
| 63 | Some((verb, tail)) => (verb, Some(tail.trim())), |
| 64 | None => (rest, None), |
| 65 | }) |
| 66 | } |
| 67 | |
| 68 | fn run_control(app: &App, operation: ControlOperation, target: Option<&str>) -> CommandResult { |
| 69 | let receipt = execute_fleet_control(ControlSurface::Slash, &app.workspace, operation, target); |
| 70 | let rendered = receipt.render(); |
| 71 | if receipt.is_error() { |
| 72 | CommandResult::error(rendered) |
| 73 | } else { |
| 74 | CommandResult::message(rendered) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | impl RegisterCommand for FleetCmd { |
| 79 | fn info() -> &'static CommandInfo { |
| 80 | &COMMAND_INFO |
| 81 | } |
| 82 | |
| 83 | fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { |
| 84 | let Some((verb, target)) = split_verb(arg) else { |
| 85 | // Primary face: the familiar roster for the selected Fleet. |
| 86 | // Named-Fleet switching lives under /fleet fleets — never between |
| 87 | // the operator and their fleet. |
| 88 | return CommandResult::action(AppAction::OpenFleetRoster); |
| 89 | }; |
| 90 | match verb { |
| 91 | "save" | "update" => { |
| 92 | // Explicit persistence of the pending session route into the |
| 93 | // selected Fleet's operator. Only an explicit command can |
| 94 | // write a saved Fleet after an in-session route change. |
| 95 | let message = app.apply_route_save_choice( |
| 96 | crate::tui::views::route_save_prompt::RouteSaveChoice::UpdateFleet, |
| 97 | ); |
| 98 | return CommandResult::message(message); |
| 99 | } |
| 100 | "save-as" | "saveas" => { |
| 101 | let message = app.apply_route_save_choice( |
| 102 | crate::tui::views::route_save_prompt::RouteSaveChoice::SaveAsNewFleet, |
| 103 | ); |
| 104 | return CommandResult::message(message); |
| 105 | } |
| 106 | _ => {} |
| 107 | } |
| 108 | match verb { |
| 109 | "roster" | "party" | "loadout" | "roles" | "role" | "profiles" | "profile" => { |
| 110 | CommandResult::action(AppAction::OpenFleetRoster) |
| 111 | } |
| 112 | "setup" | "edit" | "new" => CommandResult::action(AppAction::OpenFleetSetup), |
| 113 | // Named saved Fleets — secondary surface for multi-Fleet pick/switch. |
| 114 | // Deliberately not "list": that verb is the durable ledger (#4022). |
| 115 | "fleets" | "saved" | "manage" => CommandResult::action(AppAction::OpenFleetList), |
| 116 | // The current-session sub-agent projection, named for what it is. |
| 117 | "workers" | "worker" | "agents" | "subagents" => super::core::subagents(app), |
| 118 | "help" | "?" => CommandResult::message(help_text()), |
| 119 | other => match ControlOperation::parse_verb(ControlDomain::Fleet, other) { |
| 120 | Some(operation) => run_control(app, operation, target), |
| 121 | None => CommandResult::error(format!( |
| 122 | "Unknown /fleet target '{other}'. Use roster, setup, fleets, list, status, \ |
| 123 | workers, interrupt <worker-id>, or resume <run-id>." |
| 124 | )), |
| 125 | }, |
| 126 | } |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | #[cfg(test)] |
| 131 | mod tests { |
| 132 | use super::*; |
| 133 | use crate::config::Config; |
| 134 | use crate::tui::app::TuiOptions; |
| 135 | use std::path::PathBuf; |
| 136 | |
| 137 | fn test_app() -> App { |
| 138 | let options = TuiOptions { |
| 139 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 140 | }; |
| 141 | App::new(options, &Config::default()) |
| 142 | } |
| 143 | |
| 144 | fn app_in(workspace: PathBuf) -> App { |
| 145 | let options = TuiOptions { |
| 146 | ..crate::test_support::test_tui_options(workspace.clone()) |
| 147 | }; |
| 148 | let mut app = App::new(options, &Config::default()); |
| 149 | app.workspace = workspace; |
| 150 | app |
| 151 | } |
| 152 | |
| 153 | #[test] |
| 154 | fn fleet_command_opens_roster_view() { |
| 155 | let mut app = test_app(); |
| 156 | |
| 157 | let result = FleetCmd::execute(&mut app, None); |
| 158 | |
| 159 | assert_eq!(result.action, Some(AppAction::OpenFleetRoster)); |
| 160 | assert!(result.message.is_none()); |
| 161 | } |
| 162 | |
| 163 | #[test] |
| 164 | fn fleet_fleets_args_open_named_fleet_picker() { |
| 165 | for arg in ["fleets", "saved", "manage"] { |
| 166 | let mut app = test_app(); |
| 167 | |
| 168 | let result = FleetCmd::execute(&mut app, Some(arg)); |
| 169 | |
| 170 | assert_eq!(result.action, Some(AppAction::OpenFleetList), "{arg}"); |
| 171 | assert!(result.message.is_none(), "{arg}"); |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | #[test] |
| 176 | fn fleet_roster_aliases_open_roster_view() { |
| 177 | for arg in [ |
| 178 | "roster", "party", "loadout", "roles", "role", "profiles", "profile", |
| 179 | ] { |
| 180 | let mut app = test_app(); |
| 181 | |
| 182 | let result = FleetCmd::execute(&mut app, Some(arg)); |
| 183 | |
| 184 | assert_eq!(result.action, Some(AppAction::OpenFleetRoster), "{arg}"); |
| 185 | assert!(result.message.is_none(), "{arg}"); |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | #[test] |
| 190 | fn fleet_setup_args_open_setup_wizard() { |
| 191 | for arg in ["setup", "edit", "new"] { |
| 192 | let mut app = test_app(); |
| 193 | |
| 194 | let result = FleetCmd::execute(&mut app, Some(arg)); |
| 195 | |
| 196 | assert_eq!(result.action, Some(AppAction::OpenFleetSetup), "{arg}"); |
| 197 | assert!(result.message.is_none(), "{arg}"); |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | /// #4022: the session sub-agent projection keeps its own name. It is no |
| 202 | /// longer allowed to answer for the durable Fleet ledger. |
| 203 | #[test] |
| 204 | fn fleet_workers_arg_opens_the_session_subagent_view() { |
| 205 | for arg in ["workers", "worker", "agents", "subagents"] { |
| 206 | let mut app = test_app(); |
| 207 | |
| 208 | let result = FleetCmd::execute(&mut app, Some(arg)); |
| 209 | |
| 210 | assert_eq!(result.action, Some(AppAction::ListSubAgents), "{arg}"); |
| 211 | assert!(result.message.is_none(), "{arg}"); |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | /// #4022: `/fleet status` must read the durable ledger, not substitute the |
| 216 | /// current session's sub-agents for it. |
| 217 | #[test] |
| 218 | fn fleet_status_reads_the_durable_ledger_not_session_subagents() { |
| 219 | let workspace = tempfile::tempdir().unwrap(); |
| 220 | let mut app = app_in(workspace.path().to_path_buf()); |
| 221 | |
| 222 | let result = FleetCmd::execute(&mut app, Some("status")); |
| 223 | |
| 224 | assert_eq!( |
| 225 | result.action, None, |
| 226 | "/fleet status must not open the session sub-agent view" |
| 227 | ); |
| 228 | let message = result.message.as_deref().unwrap_or_default(); |
| 229 | assert!(message.contains("fleet.status"), "got: {message}"); |
| 230 | // This workspace has no ledger, so the truthful answer is a typed |
| 231 | // unavailability — never an empty-looking "all clear". |
| 232 | assert!(message.contains("no_fleet_ledger"), "got: {message}"); |
| 233 | assert!( |
| 234 | !workspace |
| 235 | .path() |
| 236 | .join(".codewhale") |
| 237 | .join("fleet.jsonl") |
| 238 | .exists(), |
| 239 | "a read verb must not create the durable ledger" |
| 240 | ); |
| 241 | } |
| 242 | |
| 243 | #[test] |
| 244 | fn fleet_control_verbs_route_through_the_shared_contract() { |
| 245 | let workspace = tempfile::tempdir().unwrap(); |
| 246 | for (arg, expected_id) in [ |
| 247 | ("list", "fleet.list"), |
| 248 | ("status", "fleet.status"), |
| 249 | ("interrupt worker-1", "fleet.interrupt"), |
| 250 | ("resume run-1", "fleet.resume"), |
| 251 | ("restart worker-1", "fleet.restart"), |
| 252 | ] { |
| 253 | let mut app = app_in(workspace.path().to_path_buf()); |
| 254 | let result = FleetCmd::execute(&mut app, Some(arg)); |
| 255 | let message = result.message.as_deref().unwrap_or_default(); |
| 256 | assert!( |
| 257 | message.contains(expected_id), |
| 258 | "/fleet {arg} must report {expected_id}, got: {message}" |
| 259 | ); |
| 260 | assert_eq!(result.action, None, "/fleet {arg}"); |
| 261 | } |
| 262 | } |
| 263 | |
| 264 | #[test] |
| 265 | fn fleet_help_arg_distinguishes_durable_from_session_state() { |
| 266 | let mut app = test_app(); |
| 267 | |
| 268 | let result = FleetCmd::execute(&mut app, Some("help")); |
| 269 | |
| 270 | assert!(!result.is_error); |
| 271 | assert!(result.action.is_none()); |
| 272 | let message = result.message.as_deref().unwrap_or_default(); |
| 273 | for surface in [ |
| 274 | "/fleet roster", |
| 275 | "/fleet setup", |
| 276 | "/fleet fleets", |
| 277 | "/fleet status", |
| 278 | ] { |
| 279 | assert!(message.contains(surface), "help must describe {surface}"); |
| 280 | } |
| 281 | for truth in [ |
| 282 | "current TUI session", |
| 283 | "codewhale fleet status", |
| 284 | ".codewhale/fleet.jsonl", |
| 285 | ] { |
| 286 | assert!(message.contains(truth), "help must distinguish {truth}"); |
| 287 | } |
| 288 | for descriptor in operations_for_domain(ControlDomain::Fleet) { |
| 289 | assert!( |
| 290 | message.contains(descriptor.cli_invocation), |
| 291 | "help must name the CLI twin of {}", |
| 292 | descriptor.id |
| 293 | ); |
| 294 | } |
| 295 | } |
| 296 | |
| 297 | #[test] |
| 298 | fn fleet_unknown_arg_reports_error() { |
| 299 | let mut app = test_app(); |
| 300 | |
| 301 | let result = FleetCmd::execute(&mut app, Some("bogus")); |
| 302 | |
| 303 | assert!(result.is_error); |
| 304 | assert!(result.action.is_none()); |
| 305 | assert!( |
| 306 | result |
| 307 | .message |
| 308 | .as_deref() |
| 309 | .is_some_and(|message| message.contains("Unknown /fleet target 'bogus'")) |
| 310 | ); |
| 311 | } |
| 312 | |
| 313 | #[test] |
| 314 | fn fleet_aliases_are_registered_on_command_info() { |
| 315 | assert!(FleetCmd::info().aliases.contains(&"loadout")); |
| 316 | } |
| 317 | |
| 318 | #[test] |
| 319 | fn slash_command_and_cli_agree_on_fleet_verb_ids() { |
| 320 | for descriptor in operations_for_domain(ControlDomain::Fleet) { |
| 321 | assert_eq!(descriptor.slash_command, COMMAND_INFO.name); |
| 322 | assert_eq!(descriptor.hotbar_action_id(), "slash.fleet"); |
| 323 | assert!( |
| 324 | COMMAND_INFO.usage.contains(descriptor.verb) || descriptor.verb == "restart", |
| 325 | "/fleet usage must document {} or declare it CLI-only", |
| 326 | descriptor.verb |
| 327 | ); |
| 328 | assert!(descriptor.offers(ControlSurface::Cli)); |
| 329 | } |
| 330 | assert!( |
| 331 | !COMMAND_INFO.requires_required_argument(), |
| 332 | "/fleet must stay directly runnable from the palette and hotbar" |
| 333 | ); |
| 334 | } |
| 335 | } |
| 336 |