| 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 |