返回 CodeWhale
fleet.rs
根目录 / crates / tui / src / commands / groups / core / fleet.rs
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
336 lines RUST