返回 CodeWhale
lane.rs
根目录 / crates / tui / src / commands / groups / core / lane.rs
1 //! `/lane` command — durable Lane control from the composer (#1888, #4022).
2 //!
3 //! A **Lane** is one running Workflow. This command does not reimplement Lane
4 //! lifecycle: it resolves the verb through the shared control-plane contract
5 //! in `codewhale-lane` and calls the same executor `codewhale lane …` calls,
6 //! so the slash surface, its hotbar action, and the CLI produce identical
7 //! availability, target selection, outcomes, and receipts.
8
9 use codewhale_lane::control::{execute_lane_control, operations_for_domain};
10 use codewhale_lane::{ControlDomain, ControlOperation, ControlSurface};
11
12 use crate::commands::traits::{CommandInfo, RegisterCommand};
13 use crate::localization::MessageId;
14 use crate::tui::app::App;
15
16 use super::CommandResult;
17
18 pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
19 name: "lane",
20 aliases: &["lanes"],
21 usage: "/lane [list|status <lane-id>|interrupt <lane-id>|restart <lane-id>|resume <lane-id>]",
22 description_id: MessageId::CmdLaneDescription,
23 };
24
25 pub(in crate::commands) struct LaneCmd;
26
27 /// Split `"<verb> <rest>"` into the verb and its raw target tail.
28 fn split_verb(arg: Option<&str>) -> (&str, Option<&str>) {
29 let Some(rest) = arg.map(str::trim).filter(|value| !value.is_empty()) else {
30 // Bare `/lane` (and therefore a bare hotbar dispatch) lists, matching
31 // `codewhale lane list`. Listing is read-only, so a one-key hotbar
32 // press can never mutate durable state.
33 return ("list", None);
34 };
35 match rest.split_once(char::is_whitespace) {
36 Some((verb, tail)) => (verb, Some(tail.trim())),
37 None => (rest, None),
38 }
39 }
40
41 fn help_text() -> String {
42 let mut out = String::from(
43 "Usage: /lane [list|status <lane-id>|interrupt <lane-id>|restart <lane-id>|resume <lane-id>]\n\n\
44 A Lane is one running Workflow; Runtime owns where/how it runs. These verbs act on the \
45 durable Lane registry under $CODEWHALE_HOME/lanes/, the same records `codewhale lane` \
46 reads. Append @<lifecycle-seq> to a lane id to fence a write to the exact generation you \
47 observed.\n\n\
48 Reads here do not reconcile: folding a finished Runtime exit into the record means \
49 probing tmux and taking a lock, which would block the composer. Statuses are as last \
50 recorded — `codewhale lane list` reconciles. Interrupt is submitted to an off-loop \
51 worker and answered immediately with a queued receipt and a ticket; the terminal \
52 result (transitioned, no_change, or conflict) arrives under that ticket.\n",
53 );
54 for descriptor in operations_for_domain(ControlDomain::Lane) {
55 out.push_str(&format!(
56 "\n {:<28} {:<6} {}\n CLI: {}\n",
57 descriptor.slash_invocation(),
58 descriptor.authority.as_str(),
59 descriptor.summary,
60 descriptor.cli_invocation
61 ));
62 }
63 out
64 }
65
66 impl RegisterCommand for LaneCmd {
67 fn info() -> &'static CommandInfo {
68 &COMMAND_INFO
69 }
70
71 fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
72 let (verb, target) = split_verb(arg);
73 if matches!(verb, "help" | "?") {
74 return CommandResult::message(help_text());
75 }
76 let Some(operation) = ControlOperation::parse_verb(ControlDomain::Lane, verb) else {
77 return CommandResult::error(format!(
78 "Unknown /lane verb '{verb}'. Use list, status, interrupt, restart, or resume."
79 ));
80 };
81 // Writes tear down a Runtime (subprocess + advisory lock). They are
82 // submitted to the off-loop worker and answered with a `queued`
83 // receipt; reads run inline because they only touch the registry.
84 let receipt = if operation.descriptor().authority.is_write() {
85 app.lane_control.submit(operation, target, None)
86 } else {
87 execute_lane_control(ControlSurface::Slash, operation, target)
88 };
89 let rendered = receipt.render();
90 if receipt.is_error() {
91 CommandResult::error(rendered)
92 } else {
93 CommandResult::message(rendered)
94 }
95 }
96 }
97
98 #[cfg(test)]
99 mod tests {
100 use super::*;
101 use crate::config::Config;
102 use crate::tui::app::TuiOptions;
103 use std::path::PathBuf;
104
105 fn test_app() -> App {
106 let options = TuiOptions {
107 ..crate::test_support::test_tui_options(PathBuf::from("."))
108 };
109 App::new(options, &Config::default())
110 }
111
112 #[test]
113 fn bare_lane_and_list_resolve_to_the_same_read_verb() {
114 assert_eq!(split_verb(None), ("list", None));
115 assert_eq!(split_verb(Some(" ")), ("list", None));
116 assert_eq!(split_verb(Some("list")), ("list", None));
117 assert_eq!(
118 split_verb(Some("status lane-a1b2c3d4")),
119 ("status", Some("lane-a1b2c3d4"))
120 );
121 assert_eq!(
122 split_verb(Some("interrupt lane-a1b2c3d4@3 ")),
123 ("interrupt", Some("lane-a1b2c3d4@3"))
124 );
125 }
126
127 #[test]
128 fn every_slash_verb_maps_onto_a_shared_lane_operation() {
129 for descriptor in operations_for_domain(ControlDomain::Lane) {
130 let (verb, _) = split_verb(Some(descriptor.verb));
131 assert_eq!(
132 ControlOperation::parse_verb(ControlDomain::Lane, verb),
133 Some(descriptor.operation),
134 "/lane {} must resolve to {}",
135 descriptor.verb,
136 descriptor.id
137 );
138 }
139 // Compatibility spellings resolve onto the same verbs, never new ones.
140 for (spelling, expected) in [
141 ("stop", ControlOperation::LaneInterrupt),
142 ("cancel", ControlOperation::LaneInterrupt),
143 ("inspect", ControlOperation::LaneStatus),
144 ("ls", ControlOperation::LaneList),
145 ] {
146 assert_eq!(
147 ControlOperation::parse_verb(ControlDomain::Lane, spelling),
148 Some(expected)
149 );
150 }
151 }
152
153 #[test]
154 fn unknown_verbs_are_rejected_without_touching_the_registry() {
155 let mut app = test_app();
156 let result = LaneCmd::execute(&mut app, Some("obliterate lane-a1b2c3d4"));
157 assert!(result.is_error);
158 assert!(
159 result
160 .message
161 .as_deref()
162 .is_some_and(|message| message.contains("Unknown /lane verb 'obliterate'"))
163 );
164 }
165
166 #[test]
167 fn help_lists_every_verb_with_its_authority_and_cli_twin() {
168 let help = help_text();
169 for descriptor in operations_for_domain(ControlDomain::Lane) {
170 assert!(
171 help.contains(descriptor.cli_invocation),
172 "help must name the CLI twin of {}",
173 descriptor.id
174 );
175 assert!(
176 help.contains(&descriptor.slash_invocation()),
177 "help must name the slash form of {}",
178 descriptor.id
179 );
180 }
181 assert!(help.contains("read"));
182 assert!(help.contains("write"));
183 assert!(
184 help.contains("$CODEWHALE_HOME/lanes/"),
185 "help must say which durable store it reads"
186 );
187 }
188
189 #[test]
190 fn restart_and_resume_report_that_they_have_no_backend() {
191 // #1888: a surface must never advertise a backend that does not exist.
192 let mut app = test_app();
193 for verb in ["restart", "resume"] {
194 let result = LaneCmd::execute(&mut app, Some(&format!("{verb} lane-a1b2c3d4")));
195 assert!(result.is_error, "/lane {verb}");
196 let message = result.message.as_deref().unwrap_or_default();
197 assert!(
198 message.contains("backend_not_implemented") || message.contains("no_lane_registry"),
199 "/lane {verb} must explain itself, got: {message}"
200 );
201 }
202 }
203
204 #[test]
205 fn slash_command_and_cli_agree_on_lane_verb_ids() {
206 for descriptor in operations_for_domain(ControlDomain::Lane) {
207 assert_eq!(descriptor.slash_command, COMMAND_INFO.name);
208 assert!(
209 COMMAND_INFO.usage.contains(descriptor.verb),
210 "/lane usage must document {}",
211 descriptor.verb
212 );
213 assert!(descriptor.offers(ControlSurface::Slash));
214 assert!(descriptor.offers(ControlSurface::Cli));
215 }
216 }
217
218 /// #4022: only `lane.list` is reachable from a bare hotbar press, and the
219 /// hotbar reports the slash surface because that is what actually runs.
220 #[test]
221 fn only_the_bare_dispatch_verb_is_hotbar_reachable() {
222 for descriptor in operations_for_domain(ControlDomain::Lane) {
223 let (verb, target) = split_verb(None);
224 let bare = ControlOperation::parse_verb(ControlDomain::Lane, verb);
225 assert_eq!(target, None);
226 if descriptor.hotbar_bare_dispatch {
227 assert_eq!(
228 bare,
229 Some(descriptor.operation),
230 "{} claims bare dispatch but `/lane` resolves elsewhere",
231 descriptor.id
232 );
233 } else {
234 assert_ne!(bare, Some(descriptor.operation), "{}", descriptor.id);
235 }
236 }
237 }
238
239 #[test]
240 fn bare_lane_is_read_only_so_the_hotbar_cannot_mutate_state() {
241 // The hotbar registers one action per slash command and fires it with
242 // no arguments, so a bare `/lane` must resolve to a read verb.
243 assert_eq!(split_verb(None).0, "list");
244 let descriptor = ControlOperation::LaneList.descriptor();
245 assert_eq!(
246 descriptor.authority,
247 codewhale_lane::ControlAuthority::Read,
248 "a bare hotbar press must not be a write"
249 );
250 assert!(
251 !COMMAND_INFO.requires_required_argument(),
252 "/lane must be directly runnable from the palette and hotbar"
253 );
254 assert_eq!(descriptor.hotbar_action_id(), "slash.lane");
255 }
256 }
257
257 lines RUST