| 1 | //! Shared command / control-plane contract (#1888, #4022). |
| 2 | //! |
| 3 | //! Slash commands, hotbar actions, and CLI entrypoints for the same lifecycle |
| 4 | //! operation must agree on *one* typed descriptor, one target parser, one |
| 5 | //! result/receipt shape, and one renderer. This module owns that contract. |
| 6 | //! |
| 7 | //! Vocabulary is the shipped public vocabulary and nothing else: |
| 8 | //! **Fleet** = who, **Workflow** = order, **Lane** = one running Workflow, |
| 9 | //! **Runtime** = where/how. There is no "Operation" product noun here — the |
| 10 | //! `ControlOperation` type names *control-plane verbs*, which is an internal |
| 11 | //! contract detail, never a user-facing noun. |
| 12 | //! |
| 13 | //! Why this lives in `codewhale-lane`: it is the lowest crate that both the |
| 14 | //! thin `codewhale` CLI facade and the TUI (slash commands, hotbar, and the |
| 15 | //! `codewhale fleet …` entrypoints that the facade delegates to) already |
| 16 | //! depend on. Putting the contract anywhere else would fork it. |
| 17 | |
| 18 | use std::fmt; |
| 19 | use std::path::Path; |
| 20 | use std::sync::OnceLock; |
| 21 | |
| 22 | use serde::{Deserialize, Serialize}; |
| 23 | |
| 24 | use crate::registry::{LaneRecord, LaneStatus, TerminalTransition}; |
| 25 | |
| 26 | /// Maximum rows any surface may render for a run list in one payload. |
| 27 | pub const DEFAULT_RUN_LIST_LIMIT: usize = 50; |
| 28 | /// Hard ceiling for a run list, even when a caller asks for more. |
| 29 | pub const MAX_RUN_LIST_LIMIT: usize = 200; |
| 30 | /// Maximum characters in one sanitized detail/failure line. |
| 31 | pub const MAX_DETAIL_LINE_CHARS: usize = 240; |
| 32 | /// Maximum sanitized detail lines carried on one receipt. |
| 33 | pub const MAX_DETAIL_LINES: usize = 40; |
| 34 | /// Replacement token written wherever a secret-shaped value was removed. |
| 35 | pub const REDACTED: &str = "[redacted]"; |
| 36 | |
| 37 | // --------------------------------------------------------------------------- |
| 38 | // Surfaces |
| 39 | // --------------------------------------------------------------------------- |
| 40 | |
| 41 | /// A user-facing command surface that can invoke a control-plane verb. |
| 42 | /// |
| 43 | /// There are exactly two. **The hotbar is not a surface**: a hotbar slot binds |
| 44 | /// a slash command and fires it through `commands::execute` with no argument, |
| 45 | /// so what actually runs is the slash surface and the receipt says `slash`. |
| 46 | /// Modelling the hotbar as a third surface let the contract advertise |
| 47 | /// target-taking verbs (`lane.interrupt`, `fleet.resume`) as hotbar-reachable |
| 48 | /// when a bare press can never supply an id. See |
| 49 | /// [`OperationDescriptor::hotbar_bare_dispatch`] for what a press really does. |
| 50 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] |
| 51 | #[serde(rename_all = "snake_case")] |
| 52 | pub enum ControlSurface { |
| 53 | /// `codewhale …` (and the `codewhale-tui …` entrypoints it delegates to). |
| 54 | Cli, |
| 55 | /// A `/command` typed into the composer — or dispatched by a hotbar slot, |
| 56 | /// which is the same code path with the same authority. |
| 57 | Slash, |
| 58 | } |
| 59 | |
| 60 | impl ControlSurface { |
| 61 | pub const ALL: &'static [ControlSurface] = &[Self::Cli, Self::Slash]; |
| 62 | |
| 63 | #[must_use] |
| 64 | pub const fn as_str(self) -> &'static str { |
| 65 | match self { |
| 66 | Self::Cli => "cli", |
| 67 | Self::Slash => "slash", |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | /// Whether this surface may block on Runtime teardown (subprocesses, |
| 72 | /// advisory locks, worktree removal). |
| 73 | /// |
| 74 | /// The CLI owns its process and may block. The slash surface runs on the |
| 75 | /// TUI composer thread, where a `tmux kill-session` or a `git worktree` |
| 76 | /// removal would freeze the UI, so it may not. |
| 77 | #[must_use] |
| 78 | pub const fn may_block(self) -> bool { |
| 79 | matches!(self, Self::Cli) |
| 80 | } |
| 81 | } |
| 82 | |
| 83 | impl fmt::Display for ControlSurface { |
| 84 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 85 | f.write_str(self.as_str()) |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | const ALL_SURFACES: &[ControlSurface] = ControlSurface::ALL; |
| 90 | const CLI_ONLY: &[ControlSurface] = &[ControlSurface::Cli]; |
| 91 | |
| 92 | /// How much work a caller is allowed to do on the thread it is running on. |
| 93 | /// |
| 94 | /// Reconciliation folds a finished Runtime exit into the durable record, which |
| 95 | /// for tmux means probing `tmux has-session` (a subprocess) and taking the |
| 96 | /// per-Lane advisory lock. That is correct on the CLI and unacceptable on the |
| 97 | /// TUI composer thread, so the slash surface reads the registry without it and |
| 98 | /// says so on the receipt rather than freezing the UI or lying about freshness. |
| 99 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 100 | pub enum ControlExecution { |
| 101 | /// Reconcile durable state and perform Runtime teardown. CLI only. |
| 102 | Blocking, |
| 103 | /// Registry reads only: no subprocess, no teardown, no reconciliation. |
| 104 | NonBlocking, |
| 105 | } |
| 106 | |
| 107 | impl ControlExecution { |
| 108 | /// The execution mode a surface is allowed to use. |
| 109 | #[must_use] |
| 110 | pub const fn for_surface(surface: ControlSurface) -> Self { |
| 111 | if surface.may_block() { |
| 112 | Self::Blocking |
| 113 | } else { |
| 114 | Self::NonBlocking |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | #[must_use] |
| 119 | pub const fn reconciles(self) -> bool { |
| 120 | matches!(self, Self::Blocking) |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | // --------------------------------------------------------------------------- |
| 125 | // Domain / authority / persistence / target |
| 126 | // --------------------------------------------------------------------------- |
| 127 | |
| 128 | /// Which durable control plane a verb acts on. |
| 129 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] |
| 130 | #[serde(rename_all = "snake_case")] |
| 131 | pub enum ControlDomain { |
| 132 | /// One running Workflow, recorded in `$CODEWHALE_HOME/lanes/`. |
| 133 | Lane, |
| 134 | /// Fleet workers and runs, recorded in `<workspace>/.codewhale/fleet.jsonl`. |
| 135 | Fleet, |
| 136 | } |
| 137 | |
| 138 | impl ControlDomain { |
| 139 | #[must_use] |
| 140 | pub const fn as_str(self) -> &'static str { |
| 141 | match self { |
| 142 | Self::Lane => "lane", |
| 143 | Self::Fleet => "fleet", |
| 144 | } |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | /// Read-vs-write authority a verb needs. |
| 149 | /// |
| 150 | /// This is *not* a permission posture. Auto-Review is a permission posture; |
| 151 | /// this says whether the verb only observes durable state or mutates it. |
| 152 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] |
| 153 | #[serde(rename_all = "snake_case")] |
| 154 | pub enum ControlAuthority { |
| 155 | Read, |
| 156 | Write, |
| 157 | } |
| 158 | |
| 159 | impl ControlAuthority { |
| 160 | #[must_use] |
| 161 | pub const fn as_str(self) -> &'static str { |
| 162 | match self { |
| 163 | Self::Read => "read", |
| 164 | Self::Write => "write", |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | #[must_use] |
| 169 | pub const fn is_write(self) -> bool { |
| 170 | matches!(self, Self::Write) |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | /// Where the durable effect of a verb lands. |
| 175 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] |
| 176 | #[serde(rename_all = "snake_case")] |
| 177 | pub enum PersistenceScope { |
| 178 | /// Nothing outlives the process. |
| 179 | Ephemeral, |
| 180 | /// Current TUI session state only. |
| 181 | Session, |
| 182 | /// `$CODEWHALE_HOME/lanes/` records and logs. |
| 183 | LaneRegistry, |
| 184 | /// `<workspace>/.codewhale/fleet.jsonl`. |
| 185 | FleetLedger, |
| 186 | } |
| 187 | |
| 188 | impl PersistenceScope { |
| 189 | #[must_use] |
| 190 | pub const fn as_str(self) -> &'static str { |
| 191 | match self { |
| 192 | Self::Ephemeral => "ephemeral", |
| 193 | Self::Session => "session", |
| 194 | Self::LaneRegistry => "lane_registry", |
| 195 | Self::FleetLedger => "fleet_ledger", |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | #[must_use] |
| 200 | pub const fn is_durable(self) -> bool { |
| 201 | matches!(self, Self::LaneRegistry | Self::FleetLedger) |
| 202 | } |
| 203 | } |
| 204 | |
| 205 | /// What kind of exact identity a verb targets. |
| 206 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] |
| 207 | #[serde(rename_all = "snake_case")] |
| 208 | pub enum TargetKind { |
| 209 | /// The verb acts on the whole ledger/registry; it takes no target. |
| 210 | None, |
| 211 | /// One Lane id (`lane-a1b2c3d4`). |
| 212 | LaneRun, |
| 213 | /// One Fleet worker id. |
| 214 | FleetWorker, |
| 215 | /// One Fleet run id. |
| 216 | FleetRun, |
| 217 | } |
| 218 | |
| 219 | impl TargetKind { |
| 220 | #[must_use] |
| 221 | pub const fn as_str(self) -> &'static str { |
| 222 | match self { |
| 223 | Self::None => "none", |
| 224 | Self::LaneRun => "lane_run", |
| 225 | Self::FleetWorker => "fleet_worker", |
| 226 | Self::FleetRun => "fleet_run", |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | #[must_use] |
| 231 | pub const fn label(self) -> &'static str { |
| 232 | match self { |
| 233 | Self::None => "target", |
| 234 | Self::LaneRun => "lane id", |
| 235 | Self::FleetWorker => "worker id", |
| 236 | Self::FleetRun => "run id", |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | #[must_use] |
| 241 | pub const fn requires_identity(self) -> bool { |
| 242 | !matches!(self, Self::None) |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | /// Whether re-issuing the verb after a failure is safe. |
| 247 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] |
| 248 | #[serde(rename_all = "snake_case")] |
| 249 | pub enum Retryability { |
| 250 | /// Idempotent: repeating it converges on the same durable state. |
| 251 | Idempotent, |
| 252 | /// Repeating it may produce additional work; ask before retrying. |
| 253 | Unsafe, |
| 254 | } |
| 255 | |
| 256 | impl Retryability { |
| 257 | #[must_use] |
| 258 | pub const fn as_str(self) -> &'static str { |
| 259 | match self { |
| 260 | Self::Idempotent => "idempotent", |
| 261 | Self::Unsafe => "unsafe", |
| 262 | } |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | // --------------------------------------------------------------------------- |
| 267 | // Verbs |
| 268 | // --------------------------------------------------------------------------- |
| 269 | |
| 270 | /// The lifecycle verbs every surface shares. |
| 271 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] |
| 272 | #[serde(rename_all = "snake_case")] |
| 273 | pub enum ControlOperation { |
| 274 | LaneList, |
| 275 | LaneStatus, |
| 276 | LaneInterrupt, |
| 277 | LaneRestart, |
| 278 | LaneResume, |
| 279 | FleetList, |
| 280 | FleetStatus, |
| 281 | FleetInterrupt, |
| 282 | FleetRestart, |
| 283 | FleetResume, |
| 284 | } |
| 285 | |
| 286 | impl ControlOperation { |
| 287 | pub const ALL: &'static [ControlOperation] = &[ |
| 288 | Self::LaneList, |
| 289 | Self::LaneStatus, |
| 290 | Self::LaneInterrupt, |
| 291 | Self::LaneRestart, |
| 292 | Self::LaneResume, |
| 293 | Self::FleetList, |
| 294 | Self::FleetStatus, |
| 295 | Self::FleetInterrupt, |
| 296 | Self::FleetRestart, |
| 297 | Self::FleetResume, |
| 298 | ]; |
| 299 | |
| 300 | /// Stable wire id shared by every surface, receipt, and test. |
| 301 | #[must_use] |
| 302 | pub fn id(self) -> &'static str { |
| 303 | self.descriptor().id |
| 304 | } |
| 305 | |
| 306 | #[must_use] |
| 307 | pub fn descriptor(self) -> &'static OperationDescriptor { |
| 308 | OPERATIONS |
| 309 | .iter() |
| 310 | .find(|descriptor| descriptor.operation == self) |
| 311 | .expect("every ControlOperation has exactly one descriptor") |
| 312 | } |
| 313 | |
| 314 | /// Resolve a descriptor from its stable id (`"lane.status"`). |
| 315 | #[must_use] |
| 316 | pub fn from_id(id: &str) -> Option<Self> { |
| 317 | OPERATIONS |
| 318 | .iter() |
| 319 | .find(|descriptor| descriptor.id == id) |
| 320 | .map(|descriptor| descriptor.operation) |
| 321 | } |
| 322 | |
| 323 | /// Resolve a descriptor from a domain plus the verb word a user typed. |
| 324 | /// |
| 325 | /// Every surface routes through this so `/lane interrupt`, a hotbar |
| 326 | /// dispatch of the same command, and `codewhale lane interrupt` cannot |
| 327 | /// drift onto different verbs. Compatibility spellings live here once. |
| 328 | #[must_use] |
| 329 | pub fn parse_verb(domain: ControlDomain, verb: &str) -> Option<Self> { |
| 330 | let verb = verb.trim().to_ascii_lowercase(); |
| 331 | let canonical = match verb.as_str() { |
| 332 | "list" | "ls" | "runs" => "list", |
| 333 | "status" | "show" | "info" | "inspect" => "status", |
| 334 | // `stop` and `cancel` are the historical Lane/Fleet spellings for |
| 335 | // the same durable transition; they are aliases, not new verbs. |
| 336 | "interrupt" | "stop" | "cancel" | "kill" => "interrupt", |
| 337 | "restart" | "retry" => "restart", |
| 338 | "resume" | "reconcile" => "resume", |
| 339 | _ => return None, |
| 340 | }; |
| 341 | OPERATIONS |
| 342 | .iter() |
| 343 | .find(|descriptor| descriptor.domain == domain && descriptor.verb == canonical) |
| 344 | .map(|descriptor| descriptor.operation) |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | impl fmt::Display for ControlOperation { |
| 349 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 350 | f.write_str(self.id()) |
| 351 | } |
| 352 | } |
| 353 | |
| 354 | // --------------------------------------------------------------------------- |
| 355 | // Backend capability + availability |
| 356 | // --------------------------------------------------------------------------- |
| 357 | |
| 358 | /// Whether a backend exists for a verb at all, and on which surfaces. |
| 359 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 360 | pub enum BackendCapability { |
| 361 | /// Wired end to end on every surface the descriptor lists. |
| 362 | Implemented, |
| 363 | /// Declared in the contract but not built. No surface may offer it. |
| 364 | NotImplemented { hint: &'static str }, |
| 365 | /// Built, but only reachable from some surfaces. The rest must say so. |
| 366 | SurfaceLimited { |
| 367 | available_on: &'static [ControlSurface], |
| 368 | hint: &'static str, |
| 369 | }, |
| 370 | } |
| 371 | |
| 372 | /// Typed reason a surface cannot run a verb right now. |
| 373 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] |
| 374 | #[serde(rename_all = "snake_case")] |
| 375 | pub enum UnavailableReason { |
| 376 | /// The descriptor does not offer this verb on this surface. |
| 377 | SurfaceNotOffered, |
| 378 | /// The backend exists but is not reachable from this surface. |
| 379 | SurfaceNotSupported, |
| 380 | /// No backend has been built for this verb. |
| 381 | BackendNotImplemented, |
| 382 | /// `$CODEWHALE_HOME/lanes/` has no records yet. |
| 383 | NoLaneRegistry, |
| 384 | /// This workspace has no `.codewhale/fleet.jsonl`. |
| 385 | NoFleetLedger, |
| 386 | } |
| 387 | |
| 388 | impl UnavailableReason { |
| 389 | #[must_use] |
| 390 | pub const fn as_str(self) -> &'static str { |
| 391 | match self { |
| 392 | Self::SurfaceNotOffered => "surface_not_offered", |
| 393 | Self::SurfaceNotSupported => "surface_not_supported", |
| 394 | Self::BackendNotImplemented => "backend_not_implemented", |
| 395 | Self::NoLaneRegistry => "no_lane_registry", |
| 396 | Self::NoFleetLedger => "no_fleet_ledger", |
| 397 | } |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | /// Availability of one verb on one surface in one context. |
| 402 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 403 | #[serde(rename_all = "snake_case", tag = "state")] |
| 404 | pub enum Availability { |
| 405 | Available, |
| 406 | Unavailable { |
| 407 | reason: UnavailableReason, |
| 408 | /// Sanitized, bounded operator-facing explanation. |
| 409 | hint: String, |
| 410 | }, |
| 411 | } |
| 412 | |
| 413 | impl Availability { |
| 414 | #[must_use] |
| 415 | pub fn is_available(&self) -> bool { |
| 416 | matches!(self, Self::Available) |
| 417 | } |
| 418 | |
| 419 | #[must_use] |
| 420 | pub fn reason(&self) -> Option<UnavailableReason> { |
| 421 | match self { |
| 422 | Self::Available => None, |
| 423 | Self::Unavailable { reason, .. } => Some(*reason), |
| 424 | } |
| 425 | } |
| 426 | |
| 427 | #[must_use] |
| 428 | pub fn hint(&self) -> Option<&str> { |
| 429 | match self { |
| 430 | Self::Available => None, |
| 431 | Self::Unavailable { hint, .. } => Some(hint.as_str()), |
| 432 | } |
| 433 | } |
| 434 | |
| 435 | fn unavailable(reason: UnavailableReason, hint: impl AsRef<str>) -> Self { |
| 436 | Self::Unavailable { |
| 437 | reason, |
| 438 | hint: sanitize_line(hint.as_ref()), |
| 439 | } |
| 440 | } |
| 441 | } |
| 442 | |
| 443 | /// Observed environment used to decide availability. |
| 444 | /// |
| 445 | /// Probing is deliberately read-only: a status command must never create the |
| 446 | /// durable store it is reporting on. |
| 447 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] |
| 448 | pub struct ControlContext { |
| 449 | pub lane_registry_present: bool, |
| 450 | pub fleet_ledger_present: bool, |
| 451 | } |
| 452 | |
| 453 | impl ControlContext { |
| 454 | #[must_use] |
| 455 | pub const fn new(lane_registry_present: bool, fleet_ledger_present: bool) -> Self { |
| 456 | Self { |
| 457 | lane_registry_present, |
| 458 | fleet_ledger_present, |
| 459 | } |
| 460 | } |
| 461 | |
| 462 | /// Probe both durable stores without creating either of them. |
| 463 | #[must_use] |
| 464 | pub fn probe(lane_registry_root: Option<&Path>, fleet_ledger_path: Option<&Path>) -> Self { |
| 465 | Self { |
| 466 | lane_registry_present: lane_registry_root.is_some_and(Path::is_dir), |
| 467 | fleet_ledger_present: fleet_ledger_path.is_some_and(Path::is_file), |
| 468 | } |
| 469 | } |
| 470 | } |
| 471 | |
| 472 | // --------------------------------------------------------------------------- |
| 473 | // Descriptor |
| 474 | // --------------------------------------------------------------------------- |
| 475 | |
| 476 | /// The single typed descriptor every surface reads. |
| 477 | #[derive(Debug, Clone, Copy)] |
| 478 | pub struct OperationDescriptor { |
| 479 | pub operation: ControlOperation, |
| 480 | /// Stable wire id, `"<domain>.<verb>"`. |
| 481 | pub id: &'static str, |
| 482 | pub domain: ControlDomain, |
| 483 | /// Canonical verb word (`list`, `status`, `interrupt`, `restart`, `resume`). |
| 484 | pub verb: &'static str, |
| 485 | pub authority: ControlAuthority, |
| 486 | pub persistence: PersistenceScope, |
| 487 | pub target: TargetKind, |
| 488 | pub retry: Retryability, |
| 489 | pub surfaces: &'static [ControlSurface], |
| 490 | pub backend: BackendCapability, |
| 491 | /// Whether a read of this verb may fold a finished Runtime exit into the |
| 492 | /// durable record (see [`ControlExecution::Blocking`]). |
| 493 | /// |
| 494 | /// This is declared, not discovered: a `Read` verb that can transition a |
| 495 | /// record must say so up front, and the receipt reports whether it |
| 496 | /// actually did (`ControlReceipt::reconciled`). |
| 497 | pub reconciles: bool, |
| 498 | /// Whether a bare hotbar press of the owning slash command reaches *this* |
| 499 | /// verb. |
| 500 | /// |
| 501 | /// A hotbar slot fires `/<slash_command>` with no argument. Only the verb |
| 502 | /// that a bare invocation resolves to is reachable, and it necessarily |
| 503 | /// takes no target. Everything else needs an id the press cannot supply. |
| 504 | pub hotbar_bare_dispatch: bool, |
| 505 | /// Slash command name that owns this verb (hotbar id is `slash.<name>`). |
| 506 | pub slash_command: &'static str, |
| 507 | /// Exact CLI invocation, for cross-surface hints and docs. |
| 508 | pub cli_invocation: &'static str, |
| 509 | /// One-line summary, shared by every surface's help text. |
| 510 | pub summary: &'static str, |
| 511 | } |
| 512 | |
| 513 | impl OperationDescriptor { |
| 514 | /// Hotbar action id derived from the owning slash command. |
| 515 | /// |
| 516 | /// The hotbar registers one action per slash command and dispatches it |
| 517 | /// through `commands::execute`, so this is the whole binding — there is no |
| 518 | /// second hotbar-side verb table to drift. Binding the action does **not** |
| 519 | /// mean this verb runs when the slot is pressed; see |
| 520 | /// [`Self::hotbar_bare_dispatch`]. |
| 521 | #[must_use] |
| 522 | pub fn hotbar_action_id(&self) -> String { |
| 523 | format!("slash.{}", self.slash_command) |
| 524 | } |
| 525 | |
| 526 | /// Exact slash invocation for this verb. |
| 527 | #[must_use] |
| 528 | pub fn slash_invocation(&self) -> String { |
| 529 | if self.target.requires_identity() { |
| 530 | format!( |
| 531 | "/{} {} <{}>", |
| 532 | self.slash_command, |
| 533 | self.verb, |
| 534 | self.target.label() |
| 535 | ) |
| 536 | } else { |
| 537 | format!("/{} {}", self.slash_command, self.verb) |
| 538 | } |
| 539 | } |
| 540 | |
| 541 | #[must_use] |
| 542 | pub fn offers(&self, surface: ControlSurface) -> bool { |
| 543 | self.surfaces.contains(&surface) |
| 544 | } |
| 545 | |
| 546 | /// Availability of this verb on `surface`, given a probed context. |
| 547 | #[must_use] |
| 548 | pub fn availability(&self, surface: ControlSurface, ctx: ControlContext) -> Availability { |
| 549 | if !self.offers(surface) { |
| 550 | return Availability::unavailable( |
| 551 | UnavailableReason::SurfaceNotOffered, |
| 552 | format!("{} is not offered on the {surface} surface", self.id), |
| 553 | ); |
| 554 | } |
| 555 | match self.backend { |
| 556 | BackendCapability::NotImplemented { hint } => { |
| 557 | return Availability::unavailable(UnavailableReason::BackendNotImplemented, hint); |
| 558 | } |
| 559 | BackendCapability::SurfaceLimited { available_on, hint } => { |
| 560 | if !available_on.contains(&surface) { |
| 561 | return Availability::unavailable(UnavailableReason::SurfaceNotSupported, hint); |
| 562 | } |
| 563 | } |
| 564 | BackendCapability::Implemented => {} |
| 565 | } |
| 566 | match self.persistence { |
| 567 | PersistenceScope::LaneRegistry if !ctx.lane_registry_present => { |
| 568 | Availability::unavailable( |
| 569 | UnavailableReason::NoLaneRegistry, |
| 570 | "no Lane registry yet; start one with `codewhale lane start`", |
| 571 | ) |
| 572 | } |
| 573 | PersistenceScope::FleetLedger if !ctx.fleet_ledger_present => { |
| 574 | Availability::unavailable( |
| 575 | UnavailableReason::NoFleetLedger, |
| 576 | "this workspace has no .codewhale/fleet.jsonl; create it with \ |
| 577 | `codewhale fleet init`", |
| 578 | ) |
| 579 | } |
| 580 | _ => Availability::Available, |
| 581 | } |
| 582 | } |
| 583 | } |
| 584 | |
| 585 | const LANE_RESTART_HINT: &str = "Lane restart has no backend: a Lane is one running Workflow and is re-created by \ |
| 586 | `codewhale lane start` / `codewhale workflow run`, not restarted in place."; |
| 587 | const LANE_RESUME_HINT: &str = "Lane resume has no backend: a stopped Lane's Runtime session is gone, so there is \ |
| 588 | nothing to resume. Start a new Lane against the same issue/goal."; |
| 589 | const FLEET_RESTART_HINT: &str = "Fleet restart re-leases a task and then drives the manager loop to completion, which \ |
| 590 | only the CLI runs. Use `codewhale fleet restart <worker-id>`."; |
| 591 | /// Lane interrupt tears down the Runtime (tmux kill-session, worktree TTL |
| 592 | /// cleanup), which must never run on the TUI composer thread. It is *not* |
| 593 | /// CLI-only: the slash surface submits it to an off-loop worker and returns a |
| 594 | /// `queued` receipt with a ticket. See `codewhale-tui::lane_control`. |
| 595 | const LANE_INTERRUPT_OFF_LOOP: &str = |
| 596 | "submitted to the Lane control worker; the terminal receipt arrives under this ticket"; |
| 597 | |
| 598 | /// The one descriptor table. Every surface reads it; none copies it. |
| 599 | pub static OPERATIONS: &[OperationDescriptor] = &[ |
| 600 | OperationDescriptor { |
| 601 | operation: ControlOperation::LaneList, |
| 602 | id: "lane.list", |
| 603 | domain: ControlDomain::Lane, |
| 604 | verb: "list", |
| 605 | authority: ControlAuthority::Read, |
| 606 | persistence: PersistenceScope::LaneRegistry, |
| 607 | target: TargetKind::None, |
| 608 | retry: Retryability::Idempotent, |
| 609 | surfaces: ALL_SURFACES, |
| 610 | backend: BackendCapability::Implemented, |
| 611 | reconciles: true, |
| 612 | hotbar_bare_dispatch: true, |
| 613 | slash_command: "lane", |
| 614 | cli_invocation: "codewhale lane list", |
| 615 | summary: "List durable Lanes newest first.", |
| 616 | }, |
| 617 | OperationDescriptor { |
| 618 | operation: ControlOperation::LaneStatus, |
| 619 | id: "lane.status", |
| 620 | domain: ControlDomain::Lane, |
| 621 | verb: "status", |
| 622 | authority: ControlAuthority::Read, |
| 623 | persistence: PersistenceScope::LaneRegistry, |
| 624 | target: TargetKind::LaneRun, |
| 625 | retry: Retryability::Idempotent, |
| 626 | surfaces: ALL_SURFACES, |
| 627 | backend: BackendCapability::Implemented, |
| 628 | reconciles: true, |
| 629 | hotbar_bare_dispatch: false, |
| 630 | slash_command: "lane", |
| 631 | cli_invocation: "codewhale lane status <lane-id>", |
| 632 | summary: "Show one Lane's durable status, Runtime, and attach metadata.", |
| 633 | }, |
| 634 | OperationDescriptor { |
| 635 | operation: ControlOperation::LaneInterrupt, |
| 636 | id: "lane.interrupt", |
| 637 | domain: ControlDomain::Lane, |
| 638 | verb: "interrupt", |
| 639 | authority: ControlAuthority::Write, |
| 640 | persistence: PersistenceScope::LaneRegistry, |
| 641 | target: TargetKind::LaneRun, |
| 642 | retry: Retryability::Idempotent, |
| 643 | surfaces: ALL_SURFACES, |
| 644 | backend: BackendCapability::Implemented, |
| 645 | reconciles: true, |
| 646 | hotbar_bare_dispatch: false, |
| 647 | slash_command: "lane", |
| 648 | cli_invocation: "codewhale lane interrupt <lane-id>", |
| 649 | summary: "Stop a running Lane and run its worktree TTL cleanup.", |
| 650 | }, |
| 651 | OperationDescriptor { |
| 652 | operation: ControlOperation::LaneRestart, |
| 653 | id: "lane.restart", |
| 654 | domain: ControlDomain::Lane, |
| 655 | verb: "restart", |
| 656 | authority: ControlAuthority::Write, |
| 657 | persistence: PersistenceScope::LaneRegistry, |
| 658 | target: TargetKind::LaneRun, |
| 659 | retry: Retryability::Unsafe, |
| 660 | surfaces: ALL_SURFACES, |
| 661 | backend: BackendCapability::NotImplemented { |
| 662 | hint: LANE_RESTART_HINT, |
| 663 | }, |
| 664 | reconciles: false, |
| 665 | hotbar_bare_dispatch: false, |
| 666 | slash_command: "lane", |
| 667 | cli_invocation: "codewhale lane restart <lane-id>", |
| 668 | summary: "Restart a Lane in place (no backend).", |
| 669 | }, |
| 670 | OperationDescriptor { |
| 671 | operation: ControlOperation::LaneResume, |
| 672 | id: "lane.resume", |
| 673 | domain: ControlDomain::Lane, |
| 674 | verb: "resume", |
| 675 | authority: ControlAuthority::Write, |
| 676 | persistence: PersistenceScope::LaneRegistry, |
| 677 | target: TargetKind::LaneRun, |
| 678 | retry: Retryability::Unsafe, |
| 679 | surfaces: ALL_SURFACES, |
| 680 | backend: BackendCapability::NotImplemented { |
| 681 | hint: LANE_RESUME_HINT, |
| 682 | }, |
| 683 | reconciles: false, |
| 684 | hotbar_bare_dispatch: false, |
| 685 | slash_command: "lane", |
| 686 | cli_invocation: "codewhale lane resume <lane-id>", |
| 687 | summary: "Resume a stopped Lane (no backend).", |
| 688 | }, |
| 689 | OperationDescriptor { |
| 690 | operation: ControlOperation::FleetList, |
| 691 | id: "fleet.list", |
| 692 | domain: ControlDomain::Fleet, |
| 693 | verb: "list", |
| 694 | authority: ControlAuthority::Read, |
| 695 | persistence: PersistenceScope::FleetLedger, |
| 696 | target: TargetKind::None, |
| 697 | retry: Retryability::Idempotent, |
| 698 | surfaces: ALL_SURFACES, |
| 699 | backend: BackendCapability::Implemented, |
| 700 | reconciles: false, |
| 701 | hotbar_bare_dispatch: false, |
| 702 | slash_command: "fleet", |
| 703 | cli_invocation: "codewhale fleet list", |
| 704 | summary: "List durable Fleet runs from the workspace ledger.", |
| 705 | }, |
| 706 | OperationDescriptor { |
| 707 | operation: ControlOperation::FleetStatus, |
| 708 | id: "fleet.status", |
| 709 | domain: ControlDomain::Fleet, |
| 710 | verb: "status", |
| 711 | authority: ControlAuthority::Read, |
| 712 | persistence: PersistenceScope::FleetLedger, |
| 713 | target: TargetKind::None, |
| 714 | retry: Retryability::Idempotent, |
| 715 | surfaces: ALL_SURFACES, |
| 716 | backend: BackendCapability::Implemented, |
| 717 | reconciles: false, |
| 718 | hotbar_bare_dispatch: false, |
| 719 | slash_command: "fleet", |
| 720 | cli_invocation: "codewhale fleet status", |
| 721 | summary: "Show durable Fleet run/worker counts from the workspace ledger.", |
| 722 | }, |
| 723 | OperationDescriptor { |
| 724 | operation: ControlOperation::FleetInterrupt, |
| 725 | id: "fleet.interrupt", |
| 726 | domain: ControlDomain::Fleet, |
| 727 | verb: "interrupt", |
| 728 | authority: ControlAuthority::Write, |
| 729 | persistence: PersistenceScope::FleetLedger, |
| 730 | target: TargetKind::FleetWorker, |
| 731 | retry: Retryability::Idempotent, |
| 732 | surfaces: ALL_SURFACES, |
| 733 | backend: BackendCapability::Implemented, |
| 734 | reconciles: false, |
| 735 | hotbar_bare_dispatch: false, |
| 736 | slash_command: "fleet", |
| 737 | cli_invocation: "codewhale fleet interrupt <worker-id>", |
| 738 | summary: "Cancel a Fleet worker's active task in the durable ledger.", |
| 739 | }, |
| 740 | OperationDescriptor { |
| 741 | operation: ControlOperation::FleetRestart, |
| 742 | id: "fleet.restart", |
| 743 | domain: ControlDomain::Fleet, |
| 744 | verb: "restart", |
| 745 | authority: ControlAuthority::Write, |
| 746 | persistence: PersistenceScope::FleetLedger, |
| 747 | target: TargetKind::FleetWorker, |
| 748 | retry: Retryability::Unsafe, |
| 749 | surfaces: ALL_SURFACES, |
| 750 | backend: BackendCapability::SurfaceLimited { |
| 751 | available_on: CLI_ONLY, |
| 752 | hint: FLEET_RESTART_HINT, |
| 753 | }, |
| 754 | reconciles: false, |
| 755 | hotbar_bare_dispatch: false, |
| 756 | slash_command: "fleet", |
| 757 | cli_invocation: "codewhale fleet restart <worker-id>", |
| 758 | summary: "Re-lease a Fleet worker's task and drive the manager loop.", |
| 759 | }, |
| 760 | OperationDescriptor { |
| 761 | operation: ControlOperation::FleetResume, |
| 762 | id: "fleet.resume", |
| 763 | domain: ControlDomain::Fleet, |
| 764 | verb: "resume", |
| 765 | authority: ControlAuthority::Write, |
| 766 | persistence: PersistenceScope::FleetLedger, |
| 767 | target: TargetKind::FleetRun, |
| 768 | retry: Retryability::Idempotent, |
| 769 | surfaces: ALL_SURFACES, |
| 770 | backend: BackendCapability::Implemented, |
| 771 | reconciles: false, |
| 772 | hotbar_bare_dispatch: false, |
| 773 | slash_command: "fleet", |
| 774 | cli_invocation: "codewhale fleet resume <run-id>", |
| 775 | summary: "Reconcile a durable Fleet run's orphaned leases after a manager restart.", |
| 776 | }, |
| 777 | ]; |
| 778 | |
| 779 | /// Descriptors for one domain, in table order. |
| 780 | #[must_use] |
| 781 | pub fn operations_for_domain(domain: ControlDomain) -> Vec<&'static OperationDescriptor> { |
| 782 | OPERATIONS |
| 783 | .iter() |
| 784 | .filter(|descriptor| descriptor.domain == domain) |
| 785 | .collect() |
| 786 | } |
| 787 | |
| 788 | // --------------------------------------------------------------------------- |
| 789 | // Target identity |
| 790 | // --------------------------------------------------------------------------- |
| 791 | |
| 792 | /// Exact identity a write verb acts on. |
| 793 | /// |
| 794 | /// `expected_lifecycle_seq` is the caller's fence: when present, the executor |
| 795 | /// must refuse to act if the durable record has moved on. That is what makes |
| 796 | /// interrupt/restart/resume act on *this* run rather than "whatever is there |
| 797 | /// now". |
| 798 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 799 | pub struct ControlTarget { |
| 800 | pub kind: TargetKind, |
| 801 | pub id: String, |
| 802 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 803 | pub expected_lifecycle_seq: Option<u64>, |
| 804 | } |
| 805 | |
| 806 | impl ControlTarget { |
| 807 | #[must_use] |
| 808 | pub fn new(kind: TargetKind, id: impl Into<String>) -> Self { |
| 809 | Self { |
| 810 | kind, |
| 811 | id: id.into(), |
| 812 | expected_lifecycle_seq: None, |
| 813 | } |
| 814 | } |
| 815 | |
| 816 | /// Whether `observed` satisfies this target's fence. |
| 817 | #[must_use] |
| 818 | pub fn matches_lifecycle(&self, observed: u64) -> bool { |
| 819 | self.expected_lifecycle_seq |
| 820 | .is_none_or(|expected| expected == observed) |
| 821 | } |
| 822 | } |
| 823 | |
| 824 | impl fmt::Display for ControlTarget { |
| 825 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 826 | match self.expected_lifecycle_seq { |
| 827 | Some(seq) => write!(f, "{}@{seq}", self.id), |
| 828 | None => f.write_str(&self.id), |
| 829 | } |
| 830 | } |
| 831 | } |
| 832 | |
| 833 | /// Maximum characters in a run identity accepted by any surface. |
| 834 | pub const MAX_TARGET_ID_CHARS: usize = 128; |
| 835 | |
| 836 | fn is_valid_identity(id: &str) -> bool { |
| 837 | !id.is_empty() |
| 838 | && id.chars().count() <= MAX_TARGET_ID_CHARS |
| 839 | && id |
| 840 | .chars() |
| 841 | .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')) |
| 842 | // `.` is allowed inside ids but a bare traversal segment is not. |
| 843 | && id != "." |
| 844 | && id != ".." |
| 845 | } |
| 846 | |
| 847 | /// Parse the target for `descriptor` out of the raw argument tail. |
| 848 | /// |
| 849 | /// Every surface calls this, so target selection cannot diverge: exact ids |
| 850 | /// only (no prefix or fuzzy matching), one token, optional `@<lifecycle-seq>` |
| 851 | /// fence, and a hard reject when a targetless verb is handed an argument. |
| 852 | pub fn parse_target( |
| 853 | descriptor: &OperationDescriptor, |
| 854 | raw: Option<&str>, |
| 855 | ) -> Result<Option<ControlTarget>, ControlFailure> { |
| 856 | let raw = raw.map(str::trim).filter(|value| !value.is_empty()); |
| 857 | if !descriptor.target.requires_identity() { |
| 858 | return match raw { |
| 859 | None => Ok(None), |
| 860 | Some(extra) => Err(ControlFailure::invalid_target(format!( |
| 861 | "{} takes no {}; got {:?}", |
| 862 | descriptor.id, |
| 863 | descriptor.target.label(), |
| 864 | sanitize_line(extra) |
| 865 | ))), |
| 866 | }; |
| 867 | } |
| 868 | let Some(raw) = raw else { |
| 869 | return Err(ControlFailure::invalid_target(format!( |
| 870 | "{} needs an exact {}: {}", |
| 871 | descriptor.id, |
| 872 | descriptor.target.label(), |
| 873 | descriptor.cli_invocation |
| 874 | ))); |
| 875 | }; |
| 876 | let mut tokens = raw.split_whitespace(); |
| 877 | let token = tokens.next().unwrap_or_default(); |
| 878 | if tokens.next().is_some() { |
| 879 | return Err(ControlFailure::invalid_target(format!( |
| 880 | "{} takes exactly one {}", |
| 881 | descriptor.id, |
| 882 | descriptor.target.label() |
| 883 | ))); |
| 884 | } |
| 885 | let (id, expected_lifecycle_seq) = match token.rsplit_once('@') { |
| 886 | Some((id, seq)) => { |
| 887 | let parsed = seq.parse::<u64>().map_err(|_| { |
| 888 | ControlFailure::invalid_target(format!( |
| 889 | "lifecycle fence after '@' must be a number; got {:?}", |
| 890 | sanitize_line(seq) |
| 891 | )) |
| 892 | })?; |
| 893 | (id, Some(parsed)) |
| 894 | } |
| 895 | None => (token, None), |
| 896 | }; |
| 897 | if !is_valid_identity(id) { |
| 898 | return Err(ControlFailure::invalid_target(format!( |
| 899 | "{:?} is not a valid {}", |
| 900 | sanitize_line(id), |
| 901 | descriptor.target.label() |
| 902 | ))); |
| 903 | } |
| 904 | Ok(Some(ControlTarget { |
| 905 | kind: descriptor.target, |
| 906 | id: id.to_string(), |
| 907 | expected_lifecycle_seq, |
| 908 | })) |
| 909 | } |
| 910 | |
| 911 | // --------------------------------------------------------------------------- |
| 912 | // Failure |
| 913 | // --------------------------------------------------------------------------- |
| 914 | |
| 915 | /// Typed failure class shared by every surface. |
| 916 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] |
| 917 | #[serde(rename_all = "snake_case")] |
| 918 | pub enum ControlFailureKind { |
| 919 | /// The verb is not available here; see the availability reason. |
| 920 | Unavailable, |
| 921 | /// The argument was not an exact identity of the required kind. |
| 922 | InvalidTarget, |
| 923 | /// No durable record with that exact identity. |
| 924 | NotFound, |
| 925 | /// The record moved on (lifecycle fence or terminal state). |
| 926 | Conflict, |
| 927 | /// The backend refused or errored. |
| 928 | Backend, |
| 929 | /// The off-loop worker queue is full. The verb was not started; retrying |
| 930 | /// after the queue drains is safe. |
| 931 | Saturated, |
| 932 | } |
| 933 | |
| 934 | impl ControlFailureKind { |
| 935 | #[must_use] |
| 936 | pub const fn as_str(self) -> &'static str { |
| 937 | match self { |
| 938 | Self::Unavailable => "unavailable", |
| 939 | Self::InvalidTarget => "invalid_target", |
| 940 | Self::NotFound => "not_found", |
| 941 | Self::Conflict => "conflict", |
| 942 | Self::Backend => "backend", |
| 943 | Self::Saturated => "saturated", |
| 944 | } |
| 945 | } |
| 946 | |
| 947 | #[must_use] |
| 948 | const fn default_retryable(self) -> bool { |
| 949 | matches!(self, Self::Backend | Self::Saturated) |
| 950 | } |
| 951 | } |
| 952 | |
| 953 | /// A bounded, sanitized failure. Never carries a raw path or secret. |
| 954 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 955 | pub struct ControlFailure { |
| 956 | pub kind: ControlFailureKind, |
| 957 | pub message: String, |
| 958 | pub retryable: bool, |
| 959 | } |
| 960 | |
| 961 | impl ControlFailure { |
| 962 | #[must_use] |
| 963 | pub fn new(kind: ControlFailureKind, message: impl AsRef<str>) -> Self { |
| 964 | Self { |
| 965 | kind, |
| 966 | message: sanitize_line(message.as_ref()), |
| 967 | retryable: kind.default_retryable(), |
| 968 | } |
| 969 | } |
| 970 | |
| 971 | #[must_use] |
| 972 | pub fn retryable(mut self, retryable: bool) -> Self { |
| 973 | self.retryable = retryable; |
| 974 | self |
| 975 | } |
| 976 | |
| 977 | #[must_use] |
| 978 | pub fn invalid_target(message: impl AsRef<str>) -> Self { |
| 979 | Self::new(ControlFailureKind::InvalidTarget, message) |
| 980 | } |
| 981 | |
| 982 | #[must_use] |
| 983 | pub fn not_found(message: impl AsRef<str>) -> Self { |
| 984 | Self::new(ControlFailureKind::NotFound, message) |
| 985 | } |
| 986 | |
| 987 | #[must_use] |
| 988 | pub fn conflict(message: impl AsRef<str>) -> Self { |
| 989 | Self::new(ControlFailureKind::Conflict, message) |
| 990 | } |
| 991 | |
| 992 | #[must_use] |
| 993 | pub fn backend(message: impl AsRef<str>) -> Self { |
| 994 | Self::new(ControlFailureKind::Backend, message) |
| 995 | } |
| 996 | |
| 997 | #[must_use] |
| 998 | pub fn unavailable(availability: &Availability) -> Self { |
| 999 | Self::new( |
| 1000 | ControlFailureKind::Unavailable, |
| 1001 | availability.hint().unwrap_or("unavailable"), |
| 1002 | ) |
| 1003 | } |
| 1004 | } |
| 1005 | |
| 1006 | impl fmt::Display for ControlFailure { |
| 1007 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
| 1008 | write!(f, "{}: {}", self.kind.as_str(), self.message) |
| 1009 | } |
| 1010 | } |
| 1011 | |
| 1012 | // --------------------------------------------------------------------------- |
| 1013 | // Outcome + receipt |
| 1014 | // --------------------------------------------------------------------------- |
| 1015 | |
| 1016 | /// What the verb actually did to durable lifecycle state. |
| 1017 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] |
| 1018 | #[serde(rename_all = "snake_case")] |
| 1019 | pub enum LifecycleOutcome { |
| 1020 | /// Read-only: durable state was observed, not changed. |
| 1021 | Inspected, |
| 1022 | /// Accepted and handed to an off-loop worker. Nothing has happened to |
| 1023 | /// durable state *yet*; the receipt carries a ticket and the terminal |
| 1024 | /// outcome arrives later. This is never reported as success. |
| 1025 | Queued, |
| 1026 | /// A durable lifecycle transition happened. |
| 1027 | Transitioned, |
| 1028 | /// Already in the requested state; nothing changed. |
| 1029 | NoChange, |
| 1030 | /// Refused before touching durable state. |
| 1031 | Rejected, |
| 1032 | /// Attempted and failed. |
| 1033 | Failed, |
| 1034 | } |
| 1035 | |
| 1036 | impl LifecycleOutcome { |
| 1037 | #[must_use] |
| 1038 | pub const fn as_str(self) -> &'static str { |
| 1039 | match self { |
| 1040 | Self::Inspected => "inspected", |
| 1041 | Self::Queued => "queued", |
| 1042 | Self::Transitioned => "transitioned", |
| 1043 | Self::NoChange => "no_change", |
| 1044 | Self::Rejected => "rejected", |
| 1045 | Self::Failed => "failed", |
| 1046 | } |
| 1047 | } |
| 1048 | |
| 1049 | #[must_use] |
| 1050 | pub const fn is_failure(self) -> bool { |
| 1051 | matches!(self, Self::Rejected | Self::Failed) |
| 1052 | } |
| 1053 | } |
| 1054 | |
| 1055 | /// The single result every surface returns and renders. |
| 1056 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 1057 | pub struct ControlReceipt { |
| 1058 | pub operation: ControlOperation, |
| 1059 | pub operation_id: String, |
| 1060 | pub surface: ControlSurface, |
| 1061 | pub authority: ControlAuthority, |
| 1062 | pub persistence: PersistenceScope, |
| 1063 | pub availability: Availability, |
| 1064 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 1065 | pub target: Option<ControlTarget>, |
| 1066 | pub outcome: LifecycleOutcome, |
| 1067 | /// Durable lifecycle sequence actually observed, when the store records one. |
| 1068 | pub observed_lifecycle_seq: Known<u64>, |
| 1069 | /// Whether serving this verb *changed* durable state as a side effect of |
| 1070 | /// reconciliation. A `Read` verb may fold a finished Runtime exit into the |
| 1071 | /// record; when it does, it says so here instead of reporting a pure |
| 1072 | /// observation (#4022). |
| 1073 | #[serde(default)] |
| 1074 | pub reconciled: bool, |
| 1075 | pub retryable: bool, |
| 1076 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 1077 | pub failure: Option<ControlFailure>, |
| 1078 | /// Bounded, sanitized human detail lines. |
| 1079 | #[serde(default, skip_serializing_if = "Vec::is_empty")] |
| 1080 | pub detail: Vec<String>, |
| 1081 | /// Identifies an off-loop submission, so the caller can correlate this |
| 1082 | /// receipt with the terminal one that arrives later. |
| 1083 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 1084 | pub ticket: Option<String>, |
| 1085 | /// Bounded run payload, when the verb produced one. |
| 1086 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 1087 | pub runs: Option<RunListPage>, |
| 1088 | /// The raw durable Lane records this verb observed. |
| 1089 | /// |
| 1090 | /// Deliberately **not** serialized: it exists so `codewhale lane |
| 1091 | /// list|status --json` can keep emitting the exact `LaneRecord` shape it |
| 1092 | /// has always emitted, without a second read of the registry and without |
| 1093 | /// leaking a Lane-shaped payload into the cross-domain receipt wire |
| 1094 | /// format. Empty for Fleet verbs. |
| 1095 | #[serde(skip)] |
| 1096 | pub lane_records: Vec<LaneRecord>, |
| 1097 | } |
| 1098 | |
| 1099 | impl ControlReceipt { |
| 1100 | fn base( |
| 1101 | descriptor: &OperationDescriptor, |
| 1102 | surface: ControlSurface, |
| 1103 | availability: Availability, |
| 1104 | target: Option<ControlTarget>, |
| 1105 | outcome: LifecycleOutcome, |
| 1106 | ) -> Self { |
| 1107 | Self { |
| 1108 | operation: descriptor.operation, |
| 1109 | operation_id: descriptor.id.to_string(), |
| 1110 | surface, |
| 1111 | authority: descriptor.authority, |
| 1112 | persistence: descriptor.persistence, |
| 1113 | availability, |
| 1114 | target, |
| 1115 | outcome, |
| 1116 | observed_lifecycle_seq: Known::unknown(), |
| 1117 | reconciled: false, |
| 1118 | retryable: matches!(descriptor.retry, Retryability::Idempotent), |
| 1119 | failure: None, |
| 1120 | detail: Vec::new(), |
| 1121 | ticket: None, |
| 1122 | runs: None, |
| 1123 | lane_records: Vec::new(), |
| 1124 | } |
| 1125 | } |
| 1126 | |
| 1127 | /// Accepted for off-loop execution. Durable state is untouched so far, so |
| 1128 | /// this is explicitly not a success: `outcome` is `queued` and the terminal |
| 1129 | /// receipt arrives under the same `ticket`. |
| 1130 | #[must_use] |
| 1131 | pub fn queued( |
| 1132 | descriptor: &OperationDescriptor, |
| 1133 | surface: ControlSurface, |
| 1134 | target: Option<ControlTarget>, |
| 1135 | ticket: impl Into<String>, |
| 1136 | ) -> Self { |
| 1137 | let mut receipt = Self::base( |
| 1138 | descriptor, |
| 1139 | surface, |
| 1140 | Availability::Available, |
| 1141 | target, |
| 1142 | LifecycleOutcome::Queued, |
| 1143 | ); |
| 1144 | receipt.ticket = Some(ticket.into()); |
| 1145 | receipt |
| 1146 | } |
| 1147 | |
| 1148 | /// Correlate a terminal receipt with the submission that produced it. |
| 1149 | #[must_use] |
| 1150 | pub fn with_ticket(mut self, ticket: impl Into<String>) -> Self { |
| 1151 | self.ticket = Some(ticket.into()); |
| 1152 | self |
| 1153 | } |
| 1154 | |
| 1155 | /// Carry the raw durable Lane records alongside the projected page, for |
| 1156 | /// the CLI's legacy `--json` shape. |
| 1157 | #[must_use] |
| 1158 | pub fn with_lane_records(mut self, records: Vec<LaneRecord>) -> Self { |
| 1159 | self.lane_records = records; |
| 1160 | self |
| 1161 | } |
| 1162 | |
| 1163 | /// A successful read. |
| 1164 | #[must_use] |
| 1165 | pub fn inspected( |
| 1166 | descriptor: &OperationDescriptor, |
| 1167 | surface: ControlSurface, |
| 1168 | target: Option<ControlTarget>, |
| 1169 | ) -> Self { |
| 1170 | Self::base( |
| 1171 | descriptor, |
| 1172 | surface, |
| 1173 | Availability::Available, |
| 1174 | target, |
| 1175 | LifecycleOutcome::Inspected, |
| 1176 | ) |
| 1177 | } |
| 1178 | |
| 1179 | /// A durable transition. |
| 1180 | #[must_use] |
| 1181 | pub fn transitioned( |
| 1182 | descriptor: &OperationDescriptor, |
| 1183 | surface: ControlSurface, |
| 1184 | target: Option<ControlTarget>, |
| 1185 | ) -> Self { |
| 1186 | Self::base( |
| 1187 | descriptor, |
| 1188 | surface, |
| 1189 | Availability::Available, |
| 1190 | target, |
| 1191 | LifecycleOutcome::Transitioned, |
| 1192 | ) |
| 1193 | } |
| 1194 | |
| 1195 | /// A no-op because durable state was already there. |
| 1196 | #[must_use] |
| 1197 | pub fn no_change( |
| 1198 | descriptor: &OperationDescriptor, |
| 1199 | surface: ControlSurface, |
| 1200 | target: Option<ControlTarget>, |
| 1201 | ) -> Self { |
| 1202 | Self::base( |
| 1203 | descriptor, |
| 1204 | surface, |
| 1205 | Availability::Available, |
| 1206 | target, |
| 1207 | LifecycleOutcome::NoChange, |
| 1208 | ) |
| 1209 | } |
| 1210 | |
| 1211 | /// Refused before touching durable state. |
| 1212 | #[must_use] |
| 1213 | pub fn rejected( |
| 1214 | descriptor: &OperationDescriptor, |
| 1215 | surface: ControlSurface, |
| 1216 | target: Option<ControlTarget>, |
| 1217 | failure: ControlFailure, |
| 1218 | ) -> Self { |
| 1219 | let mut receipt = Self::base( |
| 1220 | descriptor, |
| 1221 | surface, |
| 1222 | Availability::Available, |
| 1223 | target, |
| 1224 | LifecycleOutcome::Rejected, |
| 1225 | ); |
| 1226 | receipt.retryable = failure.retryable; |
| 1227 | receipt.failure = Some(failure); |
| 1228 | receipt |
| 1229 | } |
| 1230 | |
| 1231 | /// Refused because the verb is not available on this surface/context. |
| 1232 | #[must_use] |
| 1233 | pub fn unavailable( |
| 1234 | descriptor: &OperationDescriptor, |
| 1235 | surface: ControlSurface, |
| 1236 | availability: Availability, |
| 1237 | ) -> Self { |
| 1238 | let failure = ControlFailure::unavailable(&availability); |
| 1239 | let mut receipt = Self::base( |
| 1240 | descriptor, |
| 1241 | surface, |
| 1242 | availability, |
| 1243 | None, |
| 1244 | LifecycleOutcome::Rejected, |
| 1245 | ); |
| 1246 | receipt.retryable = false; |
| 1247 | receipt.failure = Some(failure); |
| 1248 | receipt |
| 1249 | } |
| 1250 | |
| 1251 | /// Attempted and failed inside the backend. |
| 1252 | #[must_use] |
| 1253 | pub fn failed( |
| 1254 | descriptor: &OperationDescriptor, |
| 1255 | surface: ControlSurface, |
| 1256 | target: Option<ControlTarget>, |
| 1257 | failure: ControlFailure, |
| 1258 | ) -> Self { |
| 1259 | let mut receipt = Self::base( |
| 1260 | descriptor, |
| 1261 | surface, |
| 1262 | Availability::Available, |
| 1263 | target, |
| 1264 | LifecycleOutcome::Failed, |
| 1265 | ); |
| 1266 | receipt.retryable = failure.retryable; |
| 1267 | receipt.failure = Some(failure); |
| 1268 | receipt |
| 1269 | } |
| 1270 | |
| 1271 | /// Record that reconciliation changed durable state while serving this |
| 1272 | /// verb. |
| 1273 | #[must_use] |
| 1274 | pub fn with_reconciled(mut self, reconciled: bool) -> Self { |
| 1275 | self.reconciled = reconciled; |
| 1276 | self |
| 1277 | } |
| 1278 | |
| 1279 | #[must_use] |
| 1280 | pub fn with_lifecycle_seq(mut self, seq: u64) -> Self { |
| 1281 | self.observed_lifecycle_seq = Known::Known(seq); |
| 1282 | self |
| 1283 | } |
| 1284 | |
| 1285 | #[must_use] |
| 1286 | pub fn with_runs(mut self, runs: RunListPage) -> Self { |
| 1287 | self.runs = Some(runs); |
| 1288 | self |
| 1289 | } |
| 1290 | |
| 1291 | /// Append bounded, sanitized detail lines. |
| 1292 | #[must_use] |
| 1293 | pub fn with_detail<I, S>(mut self, lines: I) -> Self |
| 1294 | where |
| 1295 | I: IntoIterator<Item = S>, |
| 1296 | S: AsRef<str>, |
| 1297 | { |
| 1298 | for line in lines { |
| 1299 | if self.detail.len() >= MAX_DETAIL_LINES { |
| 1300 | self.detail |
| 1301 | .push(format!("[detail truncated at {MAX_DETAIL_LINES} lines]")); |
| 1302 | break; |
| 1303 | } |
| 1304 | self.detail.push(sanitize_line(line.as_ref())); |
| 1305 | } |
| 1306 | self |
| 1307 | } |
| 1308 | |
| 1309 | #[must_use] |
| 1310 | pub fn is_error(&self) -> bool { |
| 1311 | self.outcome.is_failure() |
| 1312 | } |
| 1313 | |
| 1314 | /// One renderer for every surface. |
| 1315 | #[must_use] |
| 1316 | pub fn render(&self) -> String { |
| 1317 | let mut out = String::new(); |
| 1318 | out.push_str(&format!( |
| 1319 | "{} [{} · {} · {}]", |
| 1320 | self.operation_id, |
| 1321 | self.surface.as_str(), |
| 1322 | self.authority.as_str(), |
| 1323 | self.persistence.as_str() |
| 1324 | )); |
| 1325 | if let Some(target) = &self.target { |
| 1326 | out.push_str(&format!("\ntarget: {} {target}", target.kind.as_str())); |
| 1327 | } |
| 1328 | out.push_str(&format!("\noutcome: {}", self.outcome.as_str())); |
| 1329 | if let Known::Known(seq) = self.observed_lifecycle_seq { |
| 1330 | out.push_str(&format!(" (lifecycle_seq={seq})")); |
| 1331 | } |
| 1332 | if self.reconciled { |
| 1333 | out.push_str("\nreconciled: durable state was updated from Runtime while reading"); |
| 1334 | } |
| 1335 | if let Some(ticket) = &self.ticket { |
| 1336 | out.push_str(&format!("\nticket: {ticket}")); |
| 1337 | if self.outcome == LifecycleOutcome::Queued { |
| 1338 | out.push_str(&format!("\n{LANE_INTERRUPT_OFF_LOOP}")); |
| 1339 | } |
| 1340 | } |
| 1341 | if let Availability::Unavailable { reason, hint } = &self.availability { |
| 1342 | out.push_str(&format!("\nunavailable: {} — {hint}", reason.as_str())); |
| 1343 | } |
| 1344 | if let Some(failure) = &self.failure { |
| 1345 | out.push_str(&format!( |
| 1346 | "\nfailure: {} — {} (retryable={})", |
| 1347 | failure.kind.as_str(), |
| 1348 | failure.message, |
| 1349 | failure.retryable |
| 1350 | )); |
| 1351 | } |
| 1352 | for line in &self.detail { |
| 1353 | out.push('\n'); |
| 1354 | out.push_str(line); |
| 1355 | } |
| 1356 | if let Some(runs) = &self.runs { |
| 1357 | out.push('\n'); |
| 1358 | if self.operation.descriptor().verb == "list" { |
| 1359 | out.push_str(&render_run_table(runs)); |
| 1360 | } else { |
| 1361 | for run in &runs.runs { |
| 1362 | out.push_str(&run.render_detail()); |
| 1363 | } |
| 1364 | } |
| 1365 | } |
| 1366 | out |
| 1367 | } |
| 1368 | } |
| 1369 | |
| 1370 | // --------------------------------------------------------------------------- |
| 1371 | // Typed unknown |
| 1372 | // --------------------------------------------------------------------------- |
| 1373 | |
| 1374 | /// Why a value is not present. Absence is always explained, never implied. |
| 1375 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] |
| 1376 | #[serde(rename_all = "snake_case")] |
| 1377 | pub enum UnknownReason { |
| 1378 | /// The durable store does not record this value. |
| 1379 | NotRecorded, |
| 1380 | /// The value cannot apply to this record shape. |
| 1381 | NotApplicable, |
| 1382 | /// Present but withheld from this payload. |
| 1383 | Redacted, |
| 1384 | } |
| 1385 | |
| 1386 | impl UnknownReason { |
| 1387 | #[must_use] |
| 1388 | pub const fn as_str(self) -> &'static str { |
| 1389 | match self { |
| 1390 | Self::NotRecorded => "not_recorded", |
| 1391 | Self::NotApplicable => "not_applicable", |
| 1392 | Self::Redacted => "redacted", |
| 1393 | } |
| 1394 | } |
| 1395 | } |
| 1396 | |
| 1397 | /// A value that is either exactly known or explicitly, typed-unknown. |
| 1398 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 1399 | #[serde(rename_all = "snake_case")] |
| 1400 | pub enum Known<T> { |
| 1401 | Known(T), |
| 1402 | Unknown(UnknownReason), |
| 1403 | } |
| 1404 | |
| 1405 | impl<T> Known<T> { |
| 1406 | #[must_use] |
| 1407 | pub fn unknown() -> Self { |
| 1408 | Self::Unknown(UnknownReason::NotRecorded) |
| 1409 | } |
| 1410 | |
| 1411 | #[must_use] |
| 1412 | pub fn not_applicable() -> Self { |
| 1413 | Self::Unknown(UnknownReason::NotApplicable) |
| 1414 | } |
| 1415 | |
| 1416 | #[must_use] |
| 1417 | pub fn redacted() -> Self { |
| 1418 | Self::Unknown(UnknownReason::Redacted) |
| 1419 | } |
| 1420 | |
| 1421 | #[must_use] |
| 1422 | pub fn from_option(value: Option<T>) -> Self { |
| 1423 | match value { |
| 1424 | Some(value) => Self::Known(value), |
| 1425 | None => Self::unknown(), |
| 1426 | } |
| 1427 | } |
| 1428 | |
| 1429 | #[must_use] |
| 1430 | pub fn is_known(&self) -> bool { |
| 1431 | matches!(self, Self::Known(_)) |
| 1432 | } |
| 1433 | |
| 1434 | #[must_use] |
| 1435 | pub fn as_known(&self) -> Option<&T> { |
| 1436 | match self { |
| 1437 | Self::Known(value) => Some(value), |
| 1438 | Self::Unknown(_) => None, |
| 1439 | } |
| 1440 | } |
| 1441 | |
| 1442 | #[must_use] |
| 1443 | pub fn unknown_reason(&self) -> Option<UnknownReason> { |
| 1444 | match self { |
| 1445 | Self::Known(_) => None, |
| 1446 | Self::Unknown(reason) => Some(*reason), |
| 1447 | } |
| 1448 | } |
| 1449 | } |
| 1450 | |
| 1451 | impl<T: fmt::Display> Known<T> { |
| 1452 | /// Render for humans. Unknown renders as its typed reason, never as a |
| 1453 | /// blank or a plausible-looking default. |
| 1454 | #[must_use] |
| 1455 | pub fn render(&self) -> String { |
| 1456 | match self { |
| 1457 | Self::Known(value) => value.to_string(), |
| 1458 | Self::Unknown(reason) => format!("<{}>", reason.as_str()), |
| 1459 | } |
| 1460 | } |
| 1461 | } |
| 1462 | |
| 1463 | fn known_string(value: Option<&str>) -> Known<String> { |
| 1464 | Known::from_option(value.map(str::to_string)) |
| 1465 | } |
| 1466 | |
| 1467 | // --------------------------------------------------------------------------- |
| 1468 | // Run DTOs |
| 1469 | // --------------------------------------------------------------------------- |
| 1470 | |
| 1471 | /// Exact route identity for a run, with typed unknowns. |
| 1472 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 1473 | pub struct RunRouteDto { |
| 1474 | pub provider_id: Known<String>, |
| 1475 | /// The exact configured provider-table id, when one was used. |
| 1476 | pub provider_exact_id: Known<String>, |
| 1477 | pub model: Known<String>, |
| 1478 | /// Reasoning tier the caller asked for. |
| 1479 | pub requested_reasoning: Known<String>, |
| 1480 | /// Reasoning tier actually placed on the request. |
| 1481 | pub effective_reasoning: Known<String>, |
| 1482 | /// How the route was produced (`resolver`, `profile`, …). |
| 1483 | pub route_source: Known<String>, |
| 1484 | } |
| 1485 | |
| 1486 | impl RunRouteDto { |
| 1487 | /// Every field typed-unknown for the same reason. |
| 1488 | #[must_use] |
| 1489 | pub fn all_unknown(reason: UnknownReason) -> Self { |
| 1490 | Self { |
| 1491 | provider_id: Known::Unknown(reason), |
| 1492 | provider_exact_id: Known::Unknown(reason), |
| 1493 | model: Known::Unknown(reason), |
| 1494 | requested_reasoning: Known::Unknown(reason), |
| 1495 | effective_reasoning: Known::Unknown(reason), |
| 1496 | route_source: Known::Unknown(reason), |
| 1497 | } |
| 1498 | } |
| 1499 | |
| 1500 | /// Whether the requested tier survived into the effective tier. |
| 1501 | /// |
| 1502 | /// `None` when either side is unknown — a downgrade must never be inferred |
| 1503 | /// from missing data. |
| 1504 | #[must_use] |
| 1505 | pub fn reasoning_downgraded(&self) -> Option<bool> { |
| 1506 | match (&self.requested_reasoning, &self.effective_reasoning) { |
| 1507 | (Known::Known(requested), Known::Known(effective)) => Some(requested != effective), |
| 1508 | _ => None, |
| 1509 | } |
| 1510 | } |
| 1511 | |
| 1512 | #[must_use] |
| 1513 | pub fn render_line(&self) -> String { |
| 1514 | let arrow = match self.reasoning_downgraded() { |
| 1515 | Some(true) => format!( |
| 1516 | "{} -> {}", |
| 1517 | self.requested_reasoning.render(), |
| 1518 | self.effective_reasoning.render() |
| 1519 | ), |
| 1520 | Some(false) => self.effective_reasoning.render(), |
| 1521 | None => format!( |
| 1522 | "{} -> {}", |
| 1523 | self.requested_reasoning.render(), |
| 1524 | self.effective_reasoning.render() |
| 1525 | ), |
| 1526 | }; |
| 1527 | format!( |
| 1528 | "provider={} exact={} model={} reasoning={} route_source={}", |
| 1529 | self.provider_id.render(), |
| 1530 | self.provider_exact_id.render(), |
| 1531 | self.model.render(), |
| 1532 | arrow, |
| 1533 | self.route_source.render() |
| 1534 | ) |
| 1535 | } |
| 1536 | } |
| 1537 | |
| 1538 | /// Exact usage for a run, with typed unknowns. |
| 1539 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 1540 | pub struct RunUsageDto { |
| 1541 | pub input_tokens: Known<u64>, |
| 1542 | pub output_tokens: Known<u64>, |
| 1543 | pub total_tokens: Known<u64>, |
| 1544 | pub duration_secs: Known<u64>, |
| 1545 | } |
| 1546 | |
| 1547 | impl RunUsageDto { |
| 1548 | #[must_use] |
| 1549 | pub fn all_unknown(reason: UnknownReason) -> Self { |
| 1550 | Self { |
| 1551 | input_tokens: Known::Unknown(reason), |
| 1552 | output_tokens: Known::Unknown(reason), |
| 1553 | total_tokens: Known::Unknown(reason), |
| 1554 | duration_secs: Known::Unknown(reason), |
| 1555 | } |
| 1556 | } |
| 1557 | |
| 1558 | #[must_use] |
| 1559 | pub fn render_line(&self) -> String { |
| 1560 | format!( |
| 1561 | "in={} out={} total={} duration_s={}", |
| 1562 | self.input_tokens.render(), |
| 1563 | self.output_tokens.render(), |
| 1564 | self.total_tokens.render(), |
| 1565 | self.duration_secs.render() |
| 1566 | ) |
| 1567 | } |
| 1568 | } |
| 1569 | |
| 1570 | /// One durable run, shared by CLI and TUI for list and status. |
| 1571 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 1572 | pub struct RunSummaryDto { |
| 1573 | pub domain: ControlDomain, |
| 1574 | /// Exact identity. Never a prefix. |
| 1575 | pub run_id: String, |
| 1576 | pub status: String, |
| 1577 | /// Durable lifecycle sequence, when the store records one. |
| 1578 | pub lifecycle_seq: Known<u64>, |
| 1579 | /// Runtime = where/how. |
| 1580 | pub runtime: Known<String>, |
| 1581 | /// Workflow = order. |
| 1582 | pub workflow: Known<String>, |
| 1583 | /// Fleet = who. |
| 1584 | pub fleet: Known<String>, |
| 1585 | pub issue: Known<String>, |
| 1586 | pub goal: Known<String>, |
| 1587 | pub started_at: Known<String>, |
| 1588 | pub stopped_at: Known<String>, |
| 1589 | /// Redacted worktree location, when there is one. |
| 1590 | pub location: Known<String>, |
| 1591 | /// Git branch backing the run's worktree. |
| 1592 | #[serde(default = "Known::unknown")] |
| 1593 | pub branch: Known<String>, |
| 1594 | /// Runtime session handle (tmux session name), when the Runtime has one. |
| 1595 | #[serde(default = "Known::unknown")] |
| 1596 | pub runtime_session: Known<String>, |
| 1597 | /// Redacted Runtime socket path, when the Runtime has one. |
| 1598 | #[serde(default = "Known::unknown")] |
| 1599 | pub runtime_socket: Known<String>, |
| 1600 | /// Exact command that re-attaches to a running Lane. |
| 1601 | #[serde(default = "Known::unknown")] |
| 1602 | pub attach: Known<String>, |
| 1603 | /// Redacted stream-json log path. |
| 1604 | #[serde(default = "Known::unknown")] |
| 1605 | pub log: Known<String>, |
| 1606 | pub route: RunRouteDto, |
| 1607 | pub usage: RunUsageDto, |
| 1608 | } |
| 1609 | |
| 1610 | impl RunSummaryDto { |
| 1611 | /// Full detail rendering, shared by `lane status` and `/lane status`. |
| 1612 | #[must_use] |
| 1613 | pub fn render_detail(&self) -> String { |
| 1614 | let mut out = String::new(); |
| 1615 | out.push_str(&format!("{}: {}\n", self.domain.as_str(), self.run_id)); |
| 1616 | out.push_str(&format!("status: {}\n", self.status)); |
| 1617 | out.push_str(&format!("lifecycle: {}\n", self.lifecycle_seq.render())); |
| 1618 | out.push_str(&format!("runtime: {}\n", self.runtime.render())); |
| 1619 | out.push_str(&format!("workflow: {}\n", self.workflow.render())); |
| 1620 | out.push_str(&format!("fleet: {}\n", self.fleet.render())); |
| 1621 | out.push_str(&format!("issue: {}\n", self.issue.render())); |
| 1622 | out.push_str(&format!("goal: {}\n", self.goal.render())); |
| 1623 | out.push_str(&format!("started: {}\n", self.started_at.render())); |
| 1624 | out.push_str(&format!("stopped: {}\n", self.stopped_at.render())); |
| 1625 | out.push_str(&format!("location: {}\n", self.location.render())); |
| 1626 | out.push_str(&format!("branch: {}\n", self.branch.render())); |
| 1627 | out.push_str(&format!("session: {}\n", self.runtime_session.render())); |
| 1628 | out.push_str(&format!("socket: {}\n", self.runtime_socket.render())); |
| 1629 | out.push_str(&format!("attach: {}\n", self.attach.render())); |
| 1630 | out.push_str(&format!("log: {}\n", self.log.render())); |
| 1631 | out.push_str(&format!("route: {}\n", self.route.render_line())); |
| 1632 | out.push_str(&format!("usage: {}", self.usage.render_line())); |
| 1633 | out |
| 1634 | } |
| 1635 | } |
| 1636 | |
| 1637 | /// A bounded page of runs. List payloads are never unbounded. |
| 1638 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] |
| 1639 | pub struct RunListPage { |
| 1640 | pub runs: Vec<RunSummaryDto>, |
| 1641 | /// How many durable runs matched before bounding. |
| 1642 | pub total: usize, |
| 1643 | /// How many were dropped to respect `limit`. |
| 1644 | pub truncated: usize, |
| 1645 | pub limit: usize, |
| 1646 | } |
| 1647 | |
| 1648 | impl RunListPage { |
| 1649 | /// Bound `runs` to `limit` (itself clamped to [`MAX_RUN_LIST_LIMIT`]). |
| 1650 | #[must_use] |
| 1651 | pub fn bounded(runs: Vec<RunSummaryDto>, limit: usize) -> Self { |
| 1652 | let limit = limit.clamp(1, MAX_RUN_LIST_LIMIT); |
| 1653 | let total = runs.len(); |
| 1654 | let mut runs = runs; |
| 1655 | runs.truncate(limit); |
| 1656 | Self { |
| 1657 | truncated: total.saturating_sub(runs.len()), |
| 1658 | runs, |
| 1659 | total, |
| 1660 | limit, |
| 1661 | } |
| 1662 | } |
| 1663 | |
| 1664 | #[must_use] |
| 1665 | pub fn is_empty(&self) -> bool { |
| 1666 | self.runs.is_empty() |
| 1667 | } |
| 1668 | } |
| 1669 | |
| 1670 | /// One table renderer for `lane list`, `/lane list`, and the hotbar dispatch |
| 1671 | /// of the same command. |
| 1672 | /// Fit one cell to `width`, truncating with an ellipsis rather than pushing |
| 1673 | /// every later column out of alignment. Ids are bounded at |
| 1674 | /// [`MAX_TARGET_ID_CHARS`], which is far wider than any column here. |
| 1675 | fn cell(value: &str, width: usize) -> String { |
| 1676 | let fitted = truncate_chars(value, width); |
| 1677 | let pad = width.saturating_sub(fitted.chars().count()); |
| 1678 | format!("{fitted}{}", " ".repeat(pad)) |
| 1679 | } |
| 1680 | |
| 1681 | #[must_use] |
| 1682 | pub fn render_run_table(page: &RunListPage) -> String { |
| 1683 | if page.runs.is_empty() { |
| 1684 | return "no durable runs".to_string(); |
| 1685 | } |
| 1686 | let mut out = format!( |
| 1687 | "{} {} {} {} {} {}", |
| 1688 | cell("ID", 18), |
| 1689 | cell("STATUS", 10), |
| 1690 | cell("RUNTIME", 9), |
| 1691 | cell("WORKFLOW", 16), |
| 1692 | cell("FLEET", 14), |
| 1693 | "STARTED" |
| 1694 | ); |
| 1695 | for run in &page.runs { |
| 1696 | out.push_str(&format!( |
| 1697 | "\n{} {} {} {} {} {}", |
| 1698 | cell(&run.run_id, 18), |
| 1699 | cell(&run.status, 10), |
| 1700 | cell(&run.runtime.render(), 9), |
| 1701 | cell(&run.workflow.render(), 16), |
| 1702 | cell(&run.fleet.render(), 14), |
| 1703 | truncate_chars(&run.started_at.render(), 32) |
| 1704 | )); |
| 1705 | } |
| 1706 | if page.truncated > 0 { |
| 1707 | out.push_str(&format!( |
| 1708 | "\n[{} of {} shown; {} omitted by the {}-row bound]", |
| 1709 | page.runs.len(), |
| 1710 | page.total, |
| 1711 | page.truncated, |
| 1712 | page.limit |
| 1713 | )); |
| 1714 | } |
| 1715 | out |
| 1716 | } |
| 1717 | |
| 1718 | // --------------------------------------------------------------------------- |
| 1719 | // Lane adapter |
| 1720 | // --------------------------------------------------------------------------- |
| 1721 | |
| 1722 | /// Project a durable Lane record into the shared run DTO. |
| 1723 | /// |
| 1724 | /// Route and usage are typed-unknown here because the Lane registry genuinely |
| 1725 | /// does not record them. Fleet receipts do, and the Fleet adapter fills them. |
| 1726 | #[must_use] |
| 1727 | pub fn lane_run_summary(record: &LaneRecord) -> RunSummaryDto { |
| 1728 | RunSummaryDto { |
| 1729 | domain: ControlDomain::Lane, |
| 1730 | run_id: record.id.clone(), |
| 1731 | status: record.status.as_str().to_string(), |
| 1732 | lifecycle_seq: Known::Known(record.lifecycle_seq), |
| 1733 | runtime: Known::Known(record.runtime.as_str().to_string()), |
| 1734 | workflow: known_string(record.workflow.as_deref()), |
| 1735 | fleet: known_string(record.fleet.as_deref()), |
| 1736 | issue: known_string(record.issue.as_deref()), |
| 1737 | goal: known_string(record.goal.as_deref()), |
| 1738 | started_at: Known::Known(record.started_at.clone()), |
| 1739 | stopped_at: known_string(record.stopped_at.as_deref()), |
| 1740 | location: Known::from_option(record.worktree_path.as_deref().map(redact_path)), |
| 1741 | branch: known_string(record.branch.as_deref()), |
| 1742 | runtime_session: known_string(record.tmux_session.as_deref()), |
| 1743 | runtime_socket: Known::from_option(record.tmux_socket.as_deref().map(redact_path)), |
| 1744 | attach: known_string(record.attach_target.as_deref()), |
| 1745 | log: Known::Known(redact_path(&record.log_path)), |
| 1746 | route: RunRouteDto::all_unknown(UnknownReason::NotRecorded), |
| 1747 | usage: RunUsageDto::all_unknown(UnknownReason::NotRecorded), |
| 1748 | } |
| 1749 | } |
| 1750 | |
| 1751 | /// Bounded page of Lane summaries, newest-first order preserved. |
| 1752 | #[must_use] |
| 1753 | pub fn lane_run_page(records: &[LaneRecord], limit: usize) -> RunListPage { |
| 1754 | RunListPage::bounded(records.iter().map(lane_run_summary).collect(), limit) |
| 1755 | } |
| 1756 | |
| 1757 | /// Whether a Lane is still interruptible. |
| 1758 | #[must_use] |
| 1759 | pub fn lane_is_interruptible(status: LaneStatus) -> bool { |
| 1760 | status.is_active() |
| 1761 | } |
| 1762 | |
| 1763 | // --------------------------------------------------------------------------- |
| 1764 | // Lane executor — the one code path behind every surface |
| 1765 | // --------------------------------------------------------------------------- |
| 1766 | |
| 1767 | /// Run a Lane control verb against the durable registry. |
| 1768 | /// |
| 1769 | /// `codewhale lane …`, `/lane …`, and the hotbar dispatch of `/lane` all call |
| 1770 | /// exactly this function. There is no second implementation to drift: the |
| 1771 | /// availability check, the target parser, the lifecycle fence, the outcome, |
| 1772 | /// and the sanitized failure are decided here once. |
| 1773 | #[must_use] |
| 1774 | pub fn execute_lane_control( |
| 1775 | surface: ControlSurface, |
| 1776 | operation: ControlOperation, |
| 1777 | raw_target: Option<&str>, |
| 1778 | ) -> ControlReceipt { |
| 1779 | execute_lane_control_in(surface, operation, raw_target, None) |
| 1780 | } |
| 1781 | |
| 1782 | /// [`execute_lane_control`] against an explicit registry root (tests, and any |
| 1783 | /// caller that already resolved `$CODEWHALE_HOME/lanes`). |
| 1784 | #[must_use] |
| 1785 | pub fn execute_lane_control_in( |
| 1786 | surface: ControlSurface, |
| 1787 | operation: ControlOperation, |
| 1788 | raw_target: Option<&str>, |
| 1789 | registry_root: Option<&Path>, |
| 1790 | ) -> ControlReceipt { |
| 1791 | let descriptor = operation.descriptor(); |
| 1792 | if descriptor.domain != ControlDomain::Lane { |
| 1793 | return ControlReceipt::rejected( |
| 1794 | descriptor, |
| 1795 | surface, |
| 1796 | None, |
| 1797 | ControlFailure::new( |
| 1798 | ControlFailureKind::InvalidTarget, |
| 1799 | format!("{} is not a Lane verb", descriptor.id), |
| 1800 | ), |
| 1801 | ); |
| 1802 | } |
| 1803 | |
| 1804 | let root = match registry_root |
| 1805 | .map(|root| Ok(root.to_path_buf())) |
| 1806 | .unwrap_or_else(crate::registry::lane_registry_root) |
| 1807 | { |
| 1808 | Ok(root) => root, |
| 1809 | Err(err) => { |
| 1810 | return ControlReceipt::failed( |
| 1811 | descriptor, |
| 1812 | surface, |
| 1813 | None, |
| 1814 | ControlFailure::backend(format!("{err:#}")), |
| 1815 | ); |
| 1816 | } |
| 1817 | }; |
| 1818 | |
| 1819 | // Probe before opening: a read verb must not create the registry it is |
| 1820 | // reporting on, or "no Lanes yet" becomes indistinguishable from "there |
| 1821 | // is a registry and it is empty". |
| 1822 | let availability = descriptor.availability(surface, ControlContext::probe(Some(&root), None)); |
| 1823 | if !availability.is_available() { |
| 1824 | return ControlReceipt::unavailable(descriptor, surface, availability); |
| 1825 | } |
| 1826 | |
| 1827 | let target = match parse_target(descriptor, raw_target) { |
| 1828 | Ok(target) => target, |
| 1829 | Err(failure) => return ControlReceipt::rejected(descriptor, surface, None, failure), |
| 1830 | }; |
| 1831 | |
| 1832 | let registry = match crate::registry::LaneRegistry::open(&root) { |
| 1833 | Ok(registry) => registry, |
| 1834 | Err(err) => { |
| 1835 | return ControlReceipt::failed( |
| 1836 | descriptor, |
| 1837 | surface, |
| 1838 | target, |
| 1839 | ControlFailure::backend(format!("{err:#}")), |
| 1840 | ); |
| 1841 | } |
| 1842 | }; |
| 1843 | |
| 1844 | let execution = ControlExecution::for_surface(surface); |
| 1845 | match operation { |
| 1846 | ControlOperation::LaneList => lane_list(descriptor, surface, execution, ®istry), |
| 1847 | ControlOperation::LaneStatus | ControlOperation::LaneInterrupt => { |
| 1848 | let Some(target) = target else { |
| 1849 | return ControlReceipt::rejected( |
| 1850 | descriptor, |
| 1851 | surface, |
| 1852 | None, |
| 1853 | ControlFailure::invalid_target(format!( |
| 1854 | "{} needs an exact {}", |
| 1855 | descriptor.id, |
| 1856 | descriptor.target.label() |
| 1857 | )), |
| 1858 | ); |
| 1859 | }; |
| 1860 | lane_one(descriptor, surface, execution, ®istry, target) |
| 1861 | } |
| 1862 | // Unreachable in practice: both are `NotImplemented`, so the |
| 1863 | // availability gate above already rejected them on every surface. |
| 1864 | // Kept explicit so adding a backend cannot silently fall through. |
| 1865 | _ => ControlReceipt::unavailable( |
| 1866 | descriptor, |
| 1867 | surface, |
| 1868 | descriptor.availability(surface, ControlContext::new(true, false)), |
| 1869 | ), |
| 1870 | } |
| 1871 | } |
| 1872 | |
| 1873 | fn lane_list( |
| 1874 | descriptor: &'static OperationDescriptor, |
| 1875 | surface: ControlSurface, |
| 1876 | execution: ControlExecution, |
| 1877 | registry: &crate::registry::LaneRegistry, |
| 1878 | ) -> ControlReceipt { |
| 1879 | let mut records = match registry.list() { |
| 1880 | Ok(records) => records, |
| 1881 | Err(err) => { |
| 1882 | return ControlReceipt::failed( |
| 1883 | descriptor, |
| 1884 | surface, |
| 1885 | None, |
| 1886 | ControlFailure::backend(format!("{err:#}")), |
| 1887 | ); |
| 1888 | } |
| 1889 | }; |
| 1890 | let mut warnings = Vec::new(); |
| 1891 | let mut reconciled = false; |
| 1892 | if execution.reconciles() { |
| 1893 | for record in &mut records { |
| 1894 | match crate::runtime::backend_for(record).reconcile(registry, record) { |
| 1895 | Ok(changed) => { |
| 1896 | if changed { |
| 1897 | reconciled = true; |
| 1898 | warnings.push(format!( |
| 1899 | "reconciled {}: durable status is now {}", |
| 1900 | record.id, |
| 1901 | record.status.as_str() |
| 1902 | )); |
| 1903 | } |
| 1904 | } |
| 1905 | Err(err) => warnings.push(format!("could not reconcile {}: {err:#}", record.id)), |
| 1906 | } |
| 1907 | } |
| 1908 | } else { |
| 1909 | warnings.push( |
| 1910 | "runtime reconciliation skipped on this surface; statuses are as last recorded. \ |
| 1911 | Run `codewhale lane list` for a reconciled view." |
| 1912 | .to_string(), |
| 1913 | ); |
| 1914 | } |
| 1915 | ControlReceipt::inspected(descriptor, surface, None) |
| 1916 | .with_reconciled(reconciled) |
| 1917 | .with_runs(lane_run_page(&records, DEFAULT_RUN_LIST_LIMIT)) |
| 1918 | .with_lane_records(records) |
| 1919 | .with_detail(warnings) |
| 1920 | } |
| 1921 | |
| 1922 | fn lane_one( |
| 1923 | descriptor: &'static OperationDescriptor, |
| 1924 | surface: ControlSurface, |
| 1925 | execution: ControlExecution, |
| 1926 | registry: &crate::registry::LaneRegistry, |
| 1927 | target: ControlTarget, |
| 1928 | ) -> ControlReceipt { |
| 1929 | let mut record = match registry.load(&target.id) { |
| 1930 | Ok(record) => record, |
| 1931 | Err(err) |
| 1932 | if err |
| 1933 | .downcast_ref::<std::io::Error>() |
| 1934 | .is_some_and(|source| source.kind() == std::io::ErrorKind::NotFound) => |
| 1935 | { |
| 1936 | return ControlReceipt::rejected( |
| 1937 | descriptor, |
| 1938 | surface, |
| 1939 | Some(target.clone()), |
| 1940 | ControlFailure::not_found(format!("no Lane with id {}", target.id)), |
| 1941 | ); |
| 1942 | } |
| 1943 | Err(err) => { |
| 1944 | return ControlReceipt::failed( |
| 1945 | descriptor, |
| 1946 | surface, |
| 1947 | Some(target), |
| 1948 | ControlFailure::backend(format!("{err:#}")), |
| 1949 | ); |
| 1950 | } |
| 1951 | }; |
| 1952 | |
| 1953 | let mut detail = Vec::new(); |
| 1954 | let mut reconciled = false; |
| 1955 | let backend = crate::runtime::backend_for(&record); |
| 1956 | if execution.reconciles() { |
| 1957 | match backend.reconcile(registry, &mut record) { |
| 1958 | Ok(changed) => { |
| 1959 | if changed { |
| 1960 | reconciled = true; |
| 1961 | detail.push(format!( |
| 1962 | "reconciled {}: durable status is now {}", |
| 1963 | record.id, |
| 1964 | record.status.as_str() |
| 1965 | )); |
| 1966 | } |
| 1967 | } |
| 1968 | Err(err) => detail.push(format!("could not reconcile {}: {err:#}", record.id)), |
| 1969 | } |
| 1970 | } else { |
| 1971 | detail.push( |
| 1972 | "runtime reconciliation skipped on this surface; status is as last recorded. \ |
| 1973 | Run `codewhale lane status <lane-id>` for a reconciled view." |
| 1974 | .to_string(), |
| 1975 | ); |
| 1976 | } |
| 1977 | |
| 1978 | // Read verbs check the fence against what they just observed: there is no |
| 1979 | // mutation to protect, so a mismatch is simply "that generation is gone". |
| 1980 | // Write verbs deliberately do *not* check it here — see below. |
| 1981 | if descriptor.authority == ControlAuthority::Read { |
| 1982 | if !target.matches_lifecycle(record.lifecycle_seq) { |
| 1983 | return ControlReceipt::rejected( |
| 1984 | descriptor, |
| 1985 | surface, |
| 1986 | Some(target.clone()), |
| 1987 | ControlFailure::conflict(format!( |
| 1988 | "Lane {} is at lifecycle_seq {}, not the requested {}", |
| 1989 | record.id, |
| 1990 | record.lifecycle_seq, |
| 1991 | target |
| 1992 | .expected_lifecycle_seq |
| 1993 | .map(|seq| seq.to_string()) |
| 1994 | .unwrap_or_else(|| "-".to_string()) |
| 1995 | )), |
| 1996 | ) |
| 1997 | .with_reconciled(reconciled) |
| 1998 | .with_lifecycle_seq(record.lifecycle_seq); |
| 1999 | } |
| 2000 | return ControlReceipt::inspected(descriptor, surface, Some(target)) |
| 2001 | .with_reconciled(reconciled) |
| 2002 | .with_lifecycle_seq(record.lifecycle_seq) |
| 2003 | .with_runs(lane_run_page(std::slice::from_ref(&record), 1)) |
| 2004 | .with_lane_records(vec![record]) |
| 2005 | .with_detail(detail); |
| 2006 | } |
| 2007 | |
| 2008 | // The fence is *not* evaluated here. Checking it against this read and then |
| 2009 | // stopping would be a TOCTOU: another process can transition the record in |
| 2010 | // between, and we would tear down a generation the caller never observed. |
| 2011 | // It travels into the registry instead and is checked under the same |
| 2012 | // per-Lane lock that performs the mutation. |
| 2013 | let fence = target.expected_lifecycle_seq; |
| 2014 | let stopped = backend.stop(registry, &mut record, fence); |
| 2015 | match stopped { |
| 2016 | Ok(TerminalTransition::Transitioned) => { |
| 2017 | ControlReceipt::transitioned(descriptor, surface, Some(target)) |
| 2018 | .with_reconciled(reconciled) |
| 2019 | .with_lifecycle_seq(record.lifecycle_seq) |
| 2020 | .with_runs(lane_run_page(std::slice::from_ref(&record), 1)) |
| 2021 | .with_detail(detail) |
| 2022 | } |
| 2023 | // Already terminal — ours or another process's doing. Either way this |
| 2024 | // call changed nothing, and saying "transitioned" would credit us with |
| 2025 | // someone else's transition. |
| 2026 | Ok(TerminalTransition::AlreadyTerminal) => { |
| 2027 | ControlReceipt::no_change(descriptor, surface, Some(target)) |
| 2028 | .with_reconciled(reconciled) |
| 2029 | .with_lifecycle_seq(record.lifecycle_seq) |
| 2030 | .with_runs(lane_run_page(std::slice::from_ref(&record), 1)) |
| 2031 | .with_detail( |
| 2032 | detail |
| 2033 | .into_iter() |
| 2034 | .chain([format!("Lane is already {}", record.status.as_str())]), |
| 2035 | ) |
| 2036 | } |
| 2037 | Ok(TerminalTransition::FenceMismatch { observed }) => ControlReceipt::rejected( |
| 2038 | descriptor, |
| 2039 | surface, |
| 2040 | Some(target.clone()), |
| 2041 | ControlFailure::conflict(format!( |
| 2042 | "Lane {} is at lifecycle_seq {observed}, not the requested {}; nothing was stopped", |
| 2043 | record.id, |
| 2044 | target |
| 2045 | .expected_lifecycle_seq |
| 2046 | .map(|seq| seq.to_string()) |
| 2047 | .unwrap_or_else(|| "-".to_string()) |
| 2048 | )), |
| 2049 | ) |
| 2050 | .with_reconciled(reconciled) |
| 2051 | .with_lifecycle_seq(observed) |
| 2052 | .with_detail(detail), |
| 2053 | Err(err) => ControlReceipt::failed( |
| 2054 | descriptor, |
| 2055 | surface, |
| 2056 | Some(target), |
| 2057 | ControlFailure::backend(format!("{err:#}")), |
| 2058 | ) |
| 2059 | .with_reconciled(reconciled) |
| 2060 | .with_lifecycle_seq(record.lifecycle_seq) |
| 2061 | .with_detail(detail), |
| 2062 | } |
| 2063 | } |
| 2064 | |
| 2065 | // --------------------------------------------------------------------------- |
| 2066 | // Redaction |
| 2067 | // --------------------------------------------------------------------------- |
| 2068 | |
| 2069 | const SECRET_KEY_HINTS: &[&str] = &[ |
| 2070 | "token", |
| 2071 | "secret", |
| 2072 | "password", |
| 2073 | "passwd", |
| 2074 | "apikey", |
| 2075 | "api_key", |
| 2076 | "key", |
| 2077 | "authorization", |
| 2078 | "credential", |
| 2079 | "cookie", |
| 2080 | "session_id", |
| 2081 | "webhook", |
| 2082 | ]; |
| 2083 | |
| 2084 | const SECRET_VALUE_PREFIXES: &[&str] = &[ |
| 2085 | "sk-", |
| 2086 | "sk_", |
| 2087 | "ghp_", |
| 2088 | "gho_", |
| 2089 | "ghu_", |
| 2090 | "github_pat_", |
| 2091 | "xoxb-", |
| 2092 | "xoxp-", |
| 2093 | "hf_", |
| 2094 | "pk_live_", |
| 2095 | "rk_live_", |
| 2096 | "AKIA", |
| 2097 | "Bearer", |
| 2098 | "bearer", |
| 2099 | ]; |
| 2100 | |
| 2101 | fn home_prefix() -> Option<&'static str> { |
| 2102 | static HOME: OnceLock<Option<String>> = OnceLock::new(); |
| 2103 | HOME.get_or_init(|| { |
| 2104 | std::env::var("HOME") |
| 2105 | .ok() |
| 2106 | .or_else(|| std::env::var("USERPROFILE").ok()) |
| 2107 | .filter(|home| !home.is_empty() && home != "/") |
| 2108 | }) |
| 2109 | .as_deref() |
| 2110 | } |
| 2111 | |
| 2112 | /// Replace an absolute path under `$HOME` with `~/…`. |
| 2113 | #[must_use] |
| 2114 | pub fn redact_path(path: &Path) -> String { |
| 2115 | redact_path_str(&path.to_string_lossy()) |
| 2116 | } |
| 2117 | |
| 2118 | fn redact_path_str(value: &str) -> String { |
| 2119 | let Some(home) = home_prefix() else { |
| 2120 | return value.to_string(); |
| 2121 | }; |
| 2122 | let Some(rest) = value.strip_prefix(home) else { |
| 2123 | return value.to_string(); |
| 2124 | }; |
| 2125 | // Boundary check: `$HOME` is `/Users/ada`, so `/Users/ada-backup` is a |
| 2126 | // *different* directory and must not be abbreviated to `~-backup`. Only an |
| 2127 | // exact match or a real path separator after the prefix is `$HOME`. |
| 2128 | match rest.chars().next() { |
| 2129 | None => "~".to_string(), |
| 2130 | Some('/') | Some('\\') => { |
| 2131 | let rest = rest.trim_start_matches(['/', '\\']); |
| 2132 | if rest.is_empty() { |
| 2133 | "~".to_string() |
| 2134 | } else { |
| 2135 | format!("~/{rest}") |
| 2136 | } |
| 2137 | } |
| 2138 | Some(_) => value.to_string(), |
| 2139 | } |
| 2140 | } |
| 2141 | |
| 2142 | fn redact_token(token: &str) -> String { |
| 2143 | // `key=value` / `key:value` pairs whose key looks credential-bearing. |
| 2144 | for separator in ['=', ':'] { |
| 2145 | if let Some((key, value)) = token.split_once(separator) |
| 2146 | && !value.is_empty() |
| 2147 | { |
| 2148 | let lowered = key.to_ascii_lowercase(); |
| 2149 | if SECRET_KEY_HINTS |
| 2150 | .iter() |
| 2151 | .any(|hint| lowered.ends_with(hint) || lowered == *hint) |
| 2152 | { |
| 2153 | return format!("{key}{separator}{REDACTED}"); |
| 2154 | } |
| 2155 | } |
| 2156 | } |
| 2157 | // Case-insensitive: a provider that spells its key `SK-live-…` leaks |
| 2158 | // under an exact-case match (2026-08-04 audit). |
| 2159 | let lowered_token = token.to_ascii_lowercase(); |
| 2160 | if SECRET_VALUE_PREFIXES.iter().any(|prefix| { |
| 2161 | let lowered_prefix = prefix.to_ascii_lowercase(); |
| 2162 | lowered_token.starts_with(&lowered_prefix) && token.len() > prefix.len() |
| 2163 | }) { |
| 2164 | return REDACTED.to_string(); |
| 2165 | } |
| 2166 | redact_path_str(token) |
| 2167 | } |
| 2168 | |
| 2169 | /// Authentication scheme words that carry their secret in the NEXT |
| 2170 | /// whitespace-separated token. |
| 2171 | /// |
| 2172 | /// `Authorization: Bearer <jwt>` used to leak the JWT in full: the bare |
| 2173 | /// `Bearer` token failed the `len() > prefix.len()` guard (it IS the prefix), |
| 2174 | /// and the JWT after it matches no prefix and no `key=value` hint. Every |
| 2175 | /// operator-visible `ControlReceipt` string goes through this sanitizer, so |
| 2176 | /// that was a live credential leak into transcripts, `--json` payloads, and |
| 2177 | /// screenshots (2026-08-04 audit). |
| 2178 | const SECRET_SCHEME_WORDS: &[&str] = &["bearer", "basic", "token", "apikey", "api_key"]; |
| 2179 | |
| 2180 | /// Whether this token is a bare auth scheme word, meaning the token after it |
| 2181 | /// is the secret. |
| 2182 | fn is_secret_scheme_word(token: &str) -> bool { |
| 2183 | let trimmed = token.trim_end_matches([':', ',', ';']); |
| 2184 | SECRET_SCHEME_WORDS |
| 2185 | .iter() |
| 2186 | .any(|word| trimmed.eq_ignore_ascii_case(word)) |
| 2187 | } |
| 2188 | |
| 2189 | fn truncate_chars(value: &str, max: usize) -> String { |
| 2190 | if value.chars().count() <= max { |
| 2191 | return value.to_string(); |
| 2192 | } |
| 2193 | let mut out: String = value.chars().take(max.saturating_sub(1)).collect(); |
| 2194 | out.push('…'); |
| 2195 | out |
| 2196 | } |
| 2197 | |
| 2198 | /// Sanitize one line: redact secrets and home-rooted paths, collapse |
| 2199 | /// whitespace, and bound the length. |
| 2200 | /// |
| 2201 | /// Every operator-visible string on a [`ControlReceipt`] goes through this, |
| 2202 | /// so a backend error carrying an absolute path or a bearer token cannot leak |
| 2203 | /// into a transcript, a `--json` payload, or a shared screenshot. |
| 2204 | /// Leading indentation is structure, not whitespace noise: nested worker and |
| 2205 | /// artifact rows are only readable if their indent survives sanitization. |
| 2206 | /// Bounded so a crafted line cannot pad a receipt out to the length cap. |
| 2207 | const MAX_PRESERVED_INDENT: usize = 8; |
| 2208 | |
| 2209 | #[must_use] |
| 2210 | pub fn sanitize_line(input: &str) -> String { |
| 2211 | let indent = input |
| 2212 | .chars() |
| 2213 | .take_while(|ch| *ch == ' ' || *ch == '\t') |
| 2214 | .count() |
| 2215 | .min(MAX_PRESERVED_INDENT); |
| 2216 | let mut out = " ".repeat(indent); |
| 2217 | let mut first = true; |
| 2218 | // `Bearer <jwt>` splits into two tokens and the secret is the second one, |
| 2219 | // so a scheme word arms redaction of whatever follows it. |
| 2220 | let mut redact_next = false; |
| 2221 | for token in input.split_whitespace() { |
| 2222 | if first { |
| 2223 | first = false; |
| 2224 | } else { |
| 2225 | out.push(' '); |
| 2226 | } |
| 2227 | if std::mem::take(&mut redact_next) { |
| 2228 | out.push_str(REDACTED); |
| 2229 | continue; |
| 2230 | } |
| 2231 | redact_next = is_secret_scheme_word(token); |
| 2232 | out.push_str(&redact_token(token)); |
| 2233 | } |
| 2234 | if first { |
| 2235 | // Whitespace-only input carries no content; do not emit bare indent. |
| 2236 | return String::new(); |
| 2237 | } |
| 2238 | truncate_chars(&out, MAX_DETAIL_LINE_CHARS) |
| 2239 | } |
| 2240 | |
| 2241 | /// Sanitize an arbitrary multi-line blob into bounded, sanitized lines. |
| 2242 | #[must_use] |
| 2243 | pub fn sanitize_lines(input: &str) -> Vec<String> { |
| 2244 | let mut lines: Vec<String> = input |
| 2245 | .lines() |
| 2246 | .map(sanitize_line) |
| 2247 | .filter(|line| !line.is_empty()) |
| 2248 | .take(MAX_DETAIL_LINES) |
| 2249 | .collect(); |
| 2250 | if input.lines().filter(|line| !line.trim().is_empty()).count() > lines.len() { |
| 2251 | lines.push(format!("[detail truncated at {MAX_DETAIL_LINES} lines]")); |
| 2252 | } |
| 2253 | lines |
| 2254 | } |
| 2255 | |
| 2256 | #[cfg(test)] |
| 2257 | mod tests { |
| 2258 | use super::*; |
| 2259 | use crate::runtime::RuntimeBackendKind; |
| 2260 | use std::collections::BTreeSet; |
| 2261 | use std::path::PathBuf; |
| 2262 | |
| 2263 | fn lane_record(id: &str) -> LaneRecord { |
| 2264 | LaneRecord { |
| 2265 | id: id.to_string(), |
| 2266 | workflow: Some("stopship".into()), |
| 2267 | fleet: Some("stopship".into()), |
| 2268 | issue: Some("4022".into()), |
| 2269 | goal: None, |
| 2270 | runtime: RuntimeBackendKind::Tmux, |
| 2271 | status: LaneStatus::Running, |
| 2272 | lifecycle_seq: 2, |
| 2273 | worktree_path: Some(PathBuf::from("/tmp/lanes/x")), |
| 2274 | branch: Some("lane/x".into()), |
| 2275 | tmux_session: Some("cw-x".into()), |
| 2276 | tmux_socket: None, |
| 2277 | log_path: PathBuf::from("/tmp/lanes/logs/x.ndjson"), |
| 2278 | started_at: "2026-07-26T00:00:00Z".into(), |
| 2279 | stopped_at: None, |
| 2280 | attach_target: None, |
| 2281 | worktree_ttl_secs: None, |
| 2282 | } |
| 2283 | } |
| 2284 | |
| 2285 | // -- descriptor table integrity ------------------------------------ |
| 2286 | |
| 2287 | #[test] |
| 2288 | fn every_operation_has_exactly_one_descriptor_with_a_stable_id() { |
| 2289 | let mut ids = BTreeSet::new(); |
| 2290 | for operation in ControlOperation::ALL { |
| 2291 | let descriptor = operation.descriptor(); |
| 2292 | assert_eq!(descriptor.operation, *operation); |
| 2293 | assert_eq!( |
| 2294 | descriptor.id, |
| 2295 | format!("{}.{}", descriptor.domain.as_str(), descriptor.verb), |
| 2296 | "descriptor id must be <domain>.<verb>" |
| 2297 | ); |
| 2298 | assert!(ids.insert(descriptor.id), "duplicate id {}", descriptor.id); |
| 2299 | assert_eq!(ControlOperation::from_id(descriptor.id), Some(*operation)); |
| 2300 | } |
| 2301 | assert_eq!(ids.len(), OPERATIONS.len()); |
| 2302 | } |
| 2303 | |
| 2304 | #[test] |
| 2305 | fn both_domains_declare_the_same_five_lifecycle_verbs() { |
| 2306 | let lane: BTreeSet<&str> = operations_for_domain(ControlDomain::Lane) |
| 2307 | .iter() |
| 2308 | .map(|descriptor| descriptor.verb) |
| 2309 | .collect(); |
| 2310 | let fleet: BTreeSet<&str> = operations_for_domain(ControlDomain::Fleet) |
| 2311 | .iter() |
| 2312 | .map(|descriptor| descriptor.verb) |
| 2313 | .collect(); |
| 2314 | let expected: BTreeSet<&str> = ["list", "status", "interrupt", "restart", "resume"] |
| 2315 | .into_iter() |
| 2316 | .collect(); |
| 2317 | assert_eq!(lane, expected); |
| 2318 | assert_eq!(fleet, expected); |
| 2319 | } |
| 2320 | |
| 2321 | /// #1888: the hotbar is not a surface. It binds the owning slash command |
| 2322 | /// and fires it with no argument, so only the verb a bare invocation |
| 2323 | /// resolves to is actually reachable — and that verb cannot take a target. |
| 2324 | #[test] |
| 2325 | fn hotbar_reachability_is_declared_honestly() { |
| 2326 | for descriptor in OPERATIONS { |
| 2327 | assert_eq!( |
| 2328 | descriptor.hotbar_action_id(), |
| 2329 | format!("slash.{}", descriptor.slash_command) |
| 2330 | ); |
| 2331 | if descriptor.hotbar_bare_dispatch { |
| 2332 | assert_eq!( |
| 2333 | descriptor.target, |
| 2334 | TargetKind::None, |
| 2335 | "{} takes a target a bare hotbar press cannot supply", |
| 2336 | descriptor.id |
| 2337 | ); |
| 2338 | assert_eq!( |
| 2339 | descriptor.authority, |
| 2340 | ControlAuthority::Read, |
| 2341 | "{} would mutate durable state from a single keypress", |
| 2342 | descriptor.id |
| 2343 | ); |
| 2344 | assert!( |
| 2345 | descriptor.offers(ControlSurface::Slash), |
| 2346 | "{} dispatches through the slash surface", |
| 2347 | descriptor.id |
| 2348 | ); |
| 2349 | } |
| 2350 | } |
| 2351 | // Exactly one verb is reachable from a bare press today: `/lane` with |
| 2352 | // no argument lists. `/fleet` with no argument opens the roster, so no |
| 2353 | // Fleet verb is bare-dispatchable. |
| 2354 | let reachable: Vec<&str> = OPERATIONS |
| 2355 | .iter() |
| 2356 | .filter(|descriptor| descriptor.hotbar_bare_dispatch) |
| 2357 | .map(|descriptor| descriptor.id) |
| 2358 | .collect(); |
| 2359 | assert_eq!(reachable, vec!["lane.list"]); |
| 2360 | } |
| 2361 | |
| 2362 | #[test] |
| 2363 | fn both_surfaces_map_to_the_same_operation_ids() { |
| 2364 | // #1888: slash and CLI must not have separate verb tables. |
| 2365 | for descriptor in OPERATIONS { |
| 2366 | for surface in ControlSurface::ALL { |
| 2367 | assert!( |
| 2368 | descriptor.offers(*surface), |
| 2369 | "{} must be declared on {surface}", |
| 2370 | descriptor.id |
| 2371 | ); |
| 2372 | } |
| 2373 | assert_eq!( |
| 2374 | descriptor.hotbar_action_id(), |
| 2375 | format!("slash.{}", descriptor.slash_command), |
| 2376 | "hotbar binds the owning slash command; there is no second table" |
| 2377 | ); |
| 2378 | assert!( |
| 2379 | descriptor.cli_invocation.starts_with("codewhale "), |
| 2380 | "{} needs an exact CLI invocation", |
| 2381 | descriptor.id |
| 2382 | ); |
| 2383 | assert!( |
| 2384 | descriptor |
| 2385 | .cli_invocation |
| 2386 | .contains(descriptor.domain.as_str()), |
| 2387 | "{} CLI invocation must name its domain", |
| 2388 | descriptor.id |
| 2389 | ); |
| 2390 | assert!( |
| 2391 | descriptor.slash_invocation().starts_with(&format!( |
| 2392 | "/{} {}", |
| 2393 | descriptor.slash_command, descriptor.verb |
| 2394 | )), |
| 2395 | "{} slash invocation must name the same verb", |
| 2396 | descriptor.id |
| 2397 | ); |
| 2398 | } |
| 2399 | } |
| 2400 | |
| 2401 | #[test] |
| 2402 | fn verb_aliases_resolve_to_one_operation_per_domain() { |
| 2403 | for (alias, expected) in [ |
| 2404 | ("list", ControlOperation::LaneList), |
| 2405 | ("ls", ControlOperation::LaneList), |
| 2406 | ("status", ControlOperation::LaneStatus), |
| 2407 | ("inspect", ControlOperation::LaneStatus), |
| 2408 | ("interrupt", ControlOperation::LaneInterrupt), |
| 2409 | ("stop", ControlOperation::LaneInterrupt), |
| 2410 | ("cancel", ControlOperation::LaneInterrupt), |
| 2411 | ("restart", ControlOperation::LaneRestart), |
| 2412 | ("resume", ControlOperation::LaneResume), |
| 2413 | ] { |
| 2414 | assert_eq!( |
| 2415 | ControlOperation::parse_verb(ControlDomain::Lane, alias), |
| 2416 | Some(expected), |
| 2417 | "lane alias {alias}" |
| 2418 | ); |
| 2419 | } |
| 2420 | assert_eq!( |
| 2421 | ControlOperation::parse_verb(ControlDomain::Fleet, "STOP"), |
| 2422 | Some(ControlOperation::FleetInterrupt) |
| 2423 | ); |
| 2424 | assert_eq!( |
| 2425 | ControlOperation::parse_verb(ControlDomain::Fleet, "nope"), |
| 2426 | None |
| 2427 | ); |
| 2428 | } |
| 2429 | |
| 2430 | #[test] |
| 2431 | fn authority_and_persistence_are_identical_across_surfaces() { |
| 2432 | for descriptor in OPERATIONS { |
| 2433 | let by_surface: Vec<_> = ControlSurface::ALL |
| 2434 | .iter() |
| 2435 | .map(|surface| { |
| 2436 | let receipt = ControlReceipt::inspected(descriptor, *surface, None); |
| 2437 | (receipt.authority, receipt.persistence, receipt.operation_id) |
| 2438 | }) |
| 2439 | .collect(); |
| 2440 | let first = by_surface[0].clone(); |
| 2441 | for entry in &by_surface { |
| 2442 | assert_eq!(*entry, first, "{} drifted across surfaces", descriptor.id); |
| 2443 | } |
| 2444 | } |
| 2445 | } |
| 2446 | |
| 2447 | #[test] |
| 2448 | fn read_verbs_are_read_authority_and_write_verbs_are_write() { |
| 2449 | for descriptor in OPERATIONS { |
| 2450 | let expected = match descriptor.verb { |
| 2451 | "list" | "status" => ControlAuthority::Read, |
| 2452 | _ => ControlAuthority::Write, |
| 2453 | }; |
| 2454 | assert_eq!(descriptor.authority, expected, "{}", descriptor.id); |
| 2455 | assert!( |
| 2456 | descriptor.persistence.is_durable(), |
| 2457 | "{} must name a durable store, not session state", |
| 2458 | descriptor.id |
| 2459 | ); |
| 2460 | } |
| 2461 | } |
| 2462 | |
| 2463 | #[test] |
| 2464 | fn target_kinds_match_the_verbs_that_need_exact_identity() { |
| 2465 | for descriptor in OPERATIONS { |
| 2466 | let needs_identity = match (descriptor.domain, descriptor.verb) { |
| 2467 | // Both `list` verbs and `fleet status` report on the whole |
| 2468 | // durable store; every other verb acts on one exact run. |
| 2469 | (_, "list") => false, |
| 2470 | (ControlDomain::Fleet, "status") => false, |
| 2471 | _ => true, |
| 2472 | }; |
| 2473 | if needs_identity { |
| 2474 | assert!( |
| 2475 | descriptor.target.requires_identity(), |
| 2476 | "{} acts on one run and must require an exact id", |
| 2477 | descriptor.id |
| 2478 | ); |
| 2479 | } else { |
| 2480 | assert_eq!( |
| 2481 | descriptor.target, |
| 2482 | TargetKind::None, |
| 2483 | "{} reports on the whole store and must not take a target", |
| 2484 | descriptor.id |
| 2485 | ); |
| 2486 | } |
| 2487 | } |
| 2488 | } |
| 2489 | |
| 2490 | // -- availability --------------------------------------------------- |
| 2491 | |
| 2492 | #[test] |
| 2493 | fn no_surface_advertises_an_unimplemented_backend() { |
| 2494 | let ctx = ControlContext::new(true, true); |
| 2495 | for descriptor in OPERATIONS { |
| 2496 | if let BackendCapability::NotImplemented { .. } = descriptor.backend { |
| 2497 | for surface in ControlSurface::ALL { |
| 2498 | let availability = descriptor.availability(*surface, ctx); |
| 2499 | assert_eq!( |
| 2500 | availability.reason(), |
| 2501 | Some(UnavailableReason::BackendNotImplemented), |
| 2502 | "{} must be unavailable on {surface}", |
| 2503 | descriptor.id |
| 2504 | ); |
| 2505 | assert!( |
| 2506 | availability.hint().is_some_and(|hint| !hint.is_empty()), |
| 2507 | "{} must explain why", |
| 2508 | descriptor.id |
| 2509 | ); |
| 2510 | } |
| 2511 | } |
| 2512 | } |
| 2513 | // Both Lane write-restart verbs are the concrete case today. |
| 2514 | assert!( |
| 2515 | !ControlOperation::LaneRestart |
| 2516 | .descriptor() |
| 2517 | .availability(ControlSurface::Cli, ctx) |
| 2518 | .is_available() |
| 2519 | ); |
| 2520 | assert!( |
| 2521 | !ControlOperation::LaneResume |
| 2522 | .descriptor() |
| 2523 | .availability(ControlSurface::Slash, ctx) |
| 2524 | .is_available() |
| 2525 | ); |
| 2526 | } |
| 2527 | |
| 2528 | #[test] |
| 2529 | fn surface_limited_backends_are_available_only_where_they_exist() { |
| 2530 | let ctx = ControlContext::new(true, true); |
| 2531 | let descriptor = ControlOperation::FleetRestart.descriptor(); |
| 2532 | assert!( |
| 2533 | descriptor |
| 2534 | .availability(ControlSurface::Cli, ctx) |
| 2535 | .is_available() |
| 2536 | ); |
| 2537 | { |
| 2538 | let surface = ControlSurface::Slash; |
| 2539 | let availability = descriptor.availability(surface, ctx); |
| 2540 | assert_eq!( |
| 2541 | availability.reason(), |
| 2542 | Some(UnavailableReason::SurfaceNotSupported) |
| 2543 | ); |
| 2544 | assert!( |
| 2545 | availability |
| 2546 | .hint() |
| 2547 | .is_some_and(|hint| hint.contains("codewhale fleet restart")), |
| 2548 | "an unavailable surface must point at the one that works" |
| 2549 | ); |
| 2550 | } |
| 2551 | } |
| 2552 | |
| 2553 | #[test] |
| 2554 | fn missing_durable_stores_are_typed_unavailability_not_silence() { |
| 2555 | let empty = ControlContext::default(); |
| 2556 | let lane = ControlOperation::LaneList.descriptor(); |
| 2557 | let fleet = ControlOperation::FleetStatus.descriptor(); |
| 2558 | for surface in ControlSurface::ALL { |
| 2559 | assert_eq!( |
| 2560 | lane.availability(*surface, empty).reason(), |
| 2561 | Some(UnavailableReason::NoLaneRegistry) |
| 2562 | ); |
| 2563 | assert_eq!( |
| 2564 | fleet.availability(*surface, empty).reason(), |
| 2565 | Some(UnavailableReason::NoFleetLedger) |
| 2566 | ); |
| 2567 | } |
| 2568 | let ready = ControlContext::new(true, true); |
| 2569 | assert!( |
| 2570 | lane.availability(ControlSurface::Slash, ready) |
| 2571 | .is_available() |
| 2572 | ); |
| 2573 | assert!( |
| 2574 | fleet |
| 2575 | .availability(ControlSurface::Slash, ready) |
| 2576 | .is_available() |
| 2577 | ); |
| 2578 | } |
| 2579 | |
| 2580 | #[test] |
| 2581 | fn availability_is_identical_on_every_surface_for_implemented_verbs() { |
| 2582 | let ctx = ControlContext::new(true, true); |
| 2583 | for descriptor in OPERATIONS { |
| 2584 | if !matches!(descriptor.backend, BackendCapability::Implemented) { |
| 2585 | continue; |
| 2586 | } |
| 2587 | let reasons: BTreeSet<_> = ControlSurface::ALL |
| 2588 | .iter() |
| 2589 | .map(|surface| descriptor.availability(*surface, ctx).reason()) |
| 2590 | .collect(); |
| 2591 | assert_eq!( |
| 2592 | reasons.len(), |
| 2593 | 1, |
| 2594 | "{} drifted across surfaces", |
| 2595 | descriptor.id |
| 2596 | ); |
| 2597 | } |
| 2598 | } |
| 2599 | |
| 2600 | // -- target selection ------------------------------------------------ |
| 2601 | |
| 2602 | #[test] |
| 2603 | fn target_selection_is_exact_and_shared() { |
| 2604 | let status = ControlOperation::LaneStatus.descriptor(); |
| 2605 | let target = parse_target(status, Some(" lane-a1b2c3d4 ")) |
| 2606 | .expect("valid id") |
| 2607 | .expect("target present"); |
| 2608 | assert_eq!(target.kind, TargetKind::LaneRun); |
| 2609 | assert_eq!(target.id, "lane-a1b2c3d4"); |
| 2610 | assert_eq!(target.expected_lifecycle_seq, None); |
| 2611 | |
| 2612 | // Same parser, same result, whichever surface calls it. |
| 2613 | for raw in ["lane-a1b2c3d4", " lane-a1b2c3d4"] { |
| 2614 | assert_eq!( |
| 2615 | parse_target(status, Some(raw)).unwrap().unwrap().id, |
| 2616 | "lane-a1b2c3d4" |
| 2617 | ); |
| 2618 | } |
| 2619 | } |
| 2620 | |
| 2621 | #[test] |
| 2622 | fn target_selection_rejects_prefixes_paths_and_extra_tokens() { |
| 2623 | let interrupt = ControlOperation::LaneInterrupt.descriptor(); |
| 2624 | for bad in ["", " "] { |
| 2625 | let failure = parse_target(interrupt, Some(bad)).unwrap_err(); |
| 2626 | assert_eq!(failure.kind, ControlFailureKind::InvalidTarget); |
| 2627 | } |
| 2628 | for bad in [ |
| 2629 | "lane-a1b2 lane-c3d4", |
| 2630 | "../../etc/passwd", |
| 2631 | "lane/a1b2", |
| 2632 | "lane a1b2", |
| 2633 | ] { |
| 2634 | let failure = parse_target(interrupt, Some(bad)).unwrap_err(); |
| 2635 | assert_eq!( |
| 2636 | failure.kind, |
| 2637 | ControlFailureKind::InvalidTarget, |
| 2638 | "{bad} must be rejected" |
| 2639 | ); |
| 2640 | } |
| 2641 | assert_eq!( |
| 2642 | parse_target(interrupt, None).unwrap_err().kind, |
| 2643 | ControlFailureKind::InvalidTarget |
| 2644 | ); |
| 2645 | } |
| 2646 | |
| 2647 | #[test] |
| 2648 | fn targetless_verbs_reject_stray_arguments() { |
| 2649 | let list = ControlOperation::LaneList.descriptor(); |
| 2650 | assert_eq!(parse_target(list, None).unwrap(), None); |
| 2651 | assert_eq!(parse_target(list, Some(" ")).unwrap(), None); |
| 2652 | assert_eq!( |
| 2653 | parse_target(list, Some("lane-a1b2")).unwrap_err().kind, |
| 2654 | ControlFailureKind::InvalidTarget |
| 2655 | ); |
| 2656 | } |
| 2657 | |
| 2658 | #[test] |
| 2659 | fn lifecycle_fence_pins_exact_run_identity() { |
| 2660 | let interrupt = ControlOperation::LaneInterrupt.descriptor(); |
| 2661 | let target = parse_target(interrupt, Some("lane-a1b2c3d4@7")) |
| 2662 | .unwrap() |
| 2663 | .unwrap(); |
| 2664 | assert_eq!(target.id, "lane-a1b2c3d4"); |
| 2665 | assert_eq!(target.expected_lifecycle_seq, Some(7)); |
| 2666 | assert!(target.matches_lifecycle(7)); |
| 2667 | assert!(!target.matches_lifecycle(8)); |
| 2668 | assert_eq!(target.to_string(), "lane-a1b2c3d4@7"); |
| 2669 | |
| 2670 | let unfenced = parse_target(interrupt, Some("lane-a1b2c3d4")) |
| 2671 | .unwrap() |
| 2672 | .unwrap(); |
| 2673 | assert!(unfenced.matches_lifecycle(1)); |
| 2674 | assert!(unfenced.matches_lifecycle(99)); |
| 2675 | |
| 2676 | assert_eq!( |
| 2677 | parse_target(interrupt, Some("lane-a1b2c3d4@later")) |
| 2678 | .unwrap_err() |
| 2679 | .kind, |
| 2680 | ControlFailureKind::InvalidTarget |
| 2681 | ); |
| 2682 | } |
| 2683 | |
| 2684 | // -- receipts -------------------------------------------------------- |
| 2685 | |
| 2686 | #[test] |
| 2687 | fn receipts_carry_the_descriptor_contract_and_round_trip() { |
| 2688 | let descriptor = ControlOperation::LaneInterrupt.descriptor(); |
| 2689 | let target = parse_target(descriptor, Some("lane-a1b2c3d4@3")) |
| 2690 | .unwrap() |
| 2691 | .unwrap(); |
| 2692 | let receipt = ControlReceipt::transitioned(descriptor, ControlSurface::Slash, Some(target)) |
| 2693 | .with_lifecycle_seq(4) |
| 2694 | .with_detail(["stopped tmux session"]); |
| 2695 | assert_eq!(receipt.operation_id, "lane.interrupt"); |
| 2696 | assert_eq!(receipt.authority, ControlAuthority::Write); |
| 2697 | assert_eq!(receipt.persistence, PersistenceScope::LaneRegistry); |
| 2698 | assert_eq!(receipt.outcome, LifecycleOutcome::Transitioned); |
| 2699 | assert!(receipt.retryable, "interrupt is idempotent"); |
| 2700 | assert!(!receipt.is_error()); |
| 2701 | |
| 2702 | let json = serde_json::to_string(&receipt).unwrap(); |
| 2703 | let back: ControlReceipt = serde_json::from_str(&json).unwrap(); |
| 2704 | assert_eq!(back, receipt); |
| 2705 | let rendered = receipt.render(); |
| 2706 | assert!(rendered.contains("lane.interrupt")); |
| 2707 | assert!(rendered.contains("lifecycle_seq=4")); |
| 2708 | } |
| 2709 | |
| 2710 | #[test] |
| 2711 | fn conflict_and_unavailable_receipts_are_not_retryable() { |
| 2712 | let descriptor = ControlOperation::LaneInterrupt.descriptor(); |
| 2713 | let conflict = ControlReceipt::rejected( |
| 2714 | descriptor, |
| 2715 | ControlSurface::Cli, |
| 2716 | None, |
| 2717 | ControlFailure::conflict("lane moved to stopped"), |
| 2718 | ); |
| 2719 | assert!(!conflict.retryable); |
| 2720 | assert!(conflict.is_error()); |
| 2721 | |
| 2722 | let availability = ControlOperation::LaneRestart |
| 2723 | .descriptor() |
| 2724 | .availability(ControlSurface::Cli, ControlContext::new(true, true)); |
| 2725 | let unavailable = ControlReceipt::unavailable( |
| 2726 | ControlOperation::LaneRestart.descriptor(), |
| 2727 | ControlSurface::Cli, |
| 2728 | availability, |
| 2729 | ); |
| 2730 | assert!(!unavailable.retryable); |
| 2731 | assert_eq!( |
| 2732 | unavailable.availability.reason(), |
| 2733 | Some(UnavailableReason::BackendNotImplemented) |
| 2734 | ); |
| 2735 | assert!(unavailable.render().contains("backend_not_implemented")); |
| 2736 | } |
| 2737 | |
| 2738 | #[test] |
| 2739 | fn backend_failures_are_retryable_and_sanitized() { |
| 2740 | let descriptor = ControlOperation::FleetInterrupt.descriptor(); |
| 2741 | let receipt = ControlReceipt::failed( |
| 2742 | descriptor, |
| 2743 | ControlSurface::Cli, |
| 2744 | None, |
| 2745 | ControlFailure::backend("ledger append failed token=abcd1234"), |
| 2746 | ); |
| 2747 | assert!(receipt.retryable); |
| 2748 | let message = &receipt.failure.as_ref().unwrap().message; |
| 2749 | assert!(message.contains(REDACTED), "{message}"); |
| 2750 | assert!(!message.contains("abcd1234")); |
| 2751 | } |
| 2752 | |
| 2753 | #[test] |
| 2754 | fn receipt_detail_is_bounded() { |
| 2755 | let descriptor = ControlOperation::LaneList.descriptor(); |
| 2756 | let receipt = ControlReceipt::inspected(descriptor, ControlSurface::Cli, None) |
| 2757 | .with_detail((0..MAX_DETAIL_LINES * 2).map(|index| format!("line {index}"))); |
| 2758 | assert_eq!(receipt.detail.len(), MAX_DETAIL_LINES + 1); |
| 2759 | assert!(receipt.detail.last().unwrap().contains("truncated")); |
| 2760 | } |
| 2761 | |
| 2762 | // -- typed unknown ---------------------------------------------------- |
| 2763 | |
| 2764 | #[test] |
| 2765 | fn unknown_values_render_their_typed_reason() { |
| 2766 | let known: Known<u64> = Known::Known(12); |
| 2767 | assert_eq!(known.render(), "12"); |
| 2768 | assert!(known.is_known()); |
| 2769 | let unknown: Known<u64> = Known::unknown(); |
| 2770 | assert_eq!(unknown.render(), "<not_recorded>"); |
| 2771 | assert_eq!(unknown.unknown_reason(), Some(UnknownReason::NotRecorded)); |
| 2772 | let na: Known<String> = Known::not_applicable(); |
| 2773 | assert_eq!(na.render(), "<not_applicable>"); |
| 2774 | let json = serde_json::to_string(&na).unwrap(); |
| 2775 | assert_eq!(json, r#"{"unknown":"not_applicable"}"#); |
| 2776 | let back: Known<String> = serde_json::from_str(&json).unwrap(); |
| 2777 | assert_eq!(back, na); |
| 2778 | } |
| 2779 | |
| 2780 | #[test] |
| 2781 | fn reasoning_downgrade_is_never_inferred_from_missing_data() { |
| 2782 | let mut route = RunRouteDto::all_unknown(UnknownReason::NotRecorded); |
| 2783 | assert_eq!(route.reasoning_downgraded(), None); |
| 2784 | route.requested_reasoning = Known::Known("high".into()); |
| 2785 | assert_eq!( |
| 2786 | route.reasoning_downgraded(), |
| 2787 | None, |
| 2788 | "one side is still unknown" |
| 2789 | ); |
| 2790 | route.effective_reasoning = Known::Known("high".into()); |
| 2791 | assert_eq!(route.reasoning_downgraded(), Some(false)); |
| 2792 | route.effective_reasoning = Known::Known("medium".into()); |
| 2793 | assert_eq!(route.reasoning_downgraded(), Some(true)); |
| 2794 | assert!(route.render_line().contains("high -> medium")); |
| 2795 | } |
| 2796 | |
| 2797 | // -- DTOs and bounding ------------------------------------------------- |
| 2798 | |
| 2799 | #[test] |
| 2800 | fn lane_summary_keeps_exact_identity_and_types_its_unknowns() { |
| 2801 | let summary = lane_run_summary(&lane_record("lane-a1b2c3d4")); |
| 2802 | assert_eq!(summary.run_id, "lane-a1b2c3d4"); |
| 2803 | assert_eq!(summary.domain, ControlDomain::Lane); |
| 2804 | assert_eq!(summary.lifecycle_seq, Known::Known(2)); |
| 2805 | assert_eq!(summary.runtime, Known::Known("tmux".to_string())); |
| 2806 | assert_eq!(summary.workflow, Known::Known("stopship".to_string())); |
| 2807 | assert_eq!(summary.fleet, Known::Known("stopship".to_string())); |
| 2808 | // The Lane registry does not record route or usage; say so in types. |
| 2809 | assert_eq!( |
| 2810 | summary.route.provider_id.unknown_reason(), |
| 2811 | Some(UnknownReason::NotRecorded) |
| 2812 | ); |
| 2813 | assert_eq!( |
| 2814 | summary.usage.total_tokens.unknown_reason(), |
| 2815 | Some(UnknownReason::NotRecorded) |
| 2816 | ); |
| 2817 | assert_eq!( |
| 2818 | summary.goal.unknown_reason(), |
| 2819 | Some(UnknownReason::NotRecorded) |
| 2820 | ); |
| 2821 | let detail = summary.render_detail(); |
| 2822 | assert!(detail.contains("<not_recorded>")); |
| 2823 | assert!(detail.contains("lane-a1b2c3d4")); |
| 2824 | } |
| 2825 | |
| 2826 | #[test] |
| 2827 | fn run_list_pages_are_bounded_and_report_what_they_dropped() { |
| 2828 | let records: Vec<LaneRecord> = (0..10) |
| 2829 | .map(|index| lane_record(&format!("lane-{index:08}"))) |
| 2830 | .collect(); |
| 2831 | let page = lane_run_page(&records, 4); |
| 2832 | assert_eq!(page.runs.len(), 4); |
| 2833 | assert_eq!(page.total, 10); |
| 2834 | assert_eq!(page.truncated, 6); |
| 2835 | let rendered = render_run_table(&page); |
| 2836 | assert!(rendered.contains("6 omitted")); |
| 2837 | |
| 2838 | // A caller cannot opt out of the ceiling. |
| 2839 | let page = lane_run_page(&records, usize::MAX); |
| 2840 | assert_eq!(page.limit, MAX_RUN_LIST_LIMIT); |
| 2841 | assert_eq!(page.truncated, 0); |
| 2842 | |
| 2843 | let empty = RunListPage::bounded(Vec::new(), DEFAULT_RUN_LIST_LIMIT); |
| 2844 | assert!(empty.is_empty()); |
| 2845 | assert_eq!(render_run_table(&empty), "no durable runs"); |
| 2846 | } |
| 2847 | |
| 2848 | #[test] |
| 2849 | fn run_dtos_round_trip_as_json() { |
| 2850 | let page = lane_run_page(&[lane_record("lane-a1b2c3d4")], DEFAULT_RUN_LIST_LIMIT); |
| 2851 | let json = serde_json::to_string(&page).unwrap(); |
| 2852 | let back: RunListPage = serde_json::from_str(&json).unwrap(); |
| 2853 | assert_eq!(back, page); |
| 2854 | } |
| 2855 | |
| 2856 | // -- redaction --------------------------------------------------------- |
| 2857 | |
| 2858 | #[test] |
| 2859 | fn sanitize_redacts_secret_shaped_tokens() { |
| 2860 | for raw in [ |
| 2861 | "authorization=Bearer-xyz", |
| 2862 | "api_key=abcdef", |
| 2863 | "SLACK_WEBHOOK=https://hooks.example/abc", |
| 2864 | "password=hunter2", |
| 2865 | ] { |
| 2866 | let sanitized = sanitize_line(raw); |
| 2867 | assert!(sanitized.contains(REDACTED), "{raw} -> {sanitized}"); |
| 2868 | } |
| 2869 | for raw in [ |
| 2870 | "sk-livekey123", |
| 2871 | "ghp_abcdefghij", |
| 2872 | "xoxb-1-2-3", |
| 2873 | "AKIAEXAMPLE1", |
| 2874 | ] { |
| 2875 | assert_eq!(sanitize_line(raw), REDACTED, "{raw}"); |
| 2876 | } |
| 2877 | // Ordinary text survives, and leading indentation is structure: it is |
| 2878 | // preserved (bounded) while interior runs are still collapsed. |
| 2879 | assert_eq!( |
| 2880 | sanitize_line(" lane stopped cleanly "), |
| 2881 | " lane stopped cleanly" |
| 2882 | ); |
| 2883 | assert_eq!(sanitize_line("lane stopped"), "lane stopped"); |
| 2884 | assert_eq!(sanitize_line(" "), ""); |
| 2885 | assert_eq!( |
| 2886 | sanitize_line(&format!("{}deep", " ".repeat(40))), |
| 2887 | format!("{}deep", " ".repeat(MAX_PRESERVED_INDENT)), |
| 2888 | "indent is bounded so it cannot pad a receipt" |
| 2889 | ); |
| 2890 | } |
| 2891 | |
| 2892 | #[test] |
| 2893 | fn sanitize_bounds_line_length_and_line_count() { |
| 2894 | let long = "x".repeat(MAX_DETAIL_LINE_CHARS * 3); |
| 2895 | let sanitized = sanitize_line(&long); |
| 2896 | assert_eq!(sanitized.chars().count(), MAX_DETAIL_LINE_CHARS); |
| 2897 | assert!(sanitized.ends_with('…')); |
| 2898 | |
| 2899 | let blob = (0..MAX_DETAIL_LINES * 2) |
| 2900 | .map(|index| format!("line {index}")) |
| 2901 | .collect::<Vec<_>>() |
| 2902 | .join("\n"); |
| 2903 | let lines = sanitize_lines(&blob); |
| 2904 | assert_eq!(lines.len(), MAX_DETAIL_LINES + 1); |
| 2905 | assert!(lines.last().unwrap().contains("truncated")); |
| 2906 | } |
| 2907 | |
| 2908 | // -- shared executor: one code path, three surfaces -------------------- |
| 2909 | |
| 2910 | fn seeded_registry() -> (tempfile::TempDir, String) { |
| 2911 | let dir = tempfile::tempdir().unwrap(); |
| 2912 | let registry = crate::registry::LaneRegistry::open(dir.path()).unwrap(); |
| 2913 | let record = registry |
| 2914 | .create_pending( |
| 2915 | Some("stopship".into()), |
| 2916 | Some("stopship".into()), |
| 2917 | Some("4022".into()), |
| 2918 | None, |
| 2919 | RuntimeBackendKind::Inline, |
| 2920 | None, |
| 2921 | ) |
| 2922 | .unwrap(); |
| 2923 | let id = record.id.clone(); |
| 2924 | (dir, id) |
| 2925 | } |
| 2926 | |
| 2927 | #[test] |
| 2928 | fn every_surface_gets_the_same_receipt_for_the_same_lane_verb() { |
| 2929 | // #1888/#4022: the CLI, a slash command, and a hotbar dispatch must |
| 2930 | // observe the same durable Lane through the same contract. |
| 2931 | let (dir, id) = seeded_registry(); |
| 2932 | let mut payloads = BTreeSet::new(); |
| 2933 | for surface in ControlSurface::ALL { |
| 2934 | let receipt = execute_lane_control_in( |
| 2935 | *surface, |
| 2936 | ControlOperation::LaneStatus, |
| 2937 | Some(id.as_str()), |
| 2938 | Some(dir.path()), |
| 2939 | ); |
| 2940 | assert_eq!(receipt.surface, *surface); |
| 2941 | assert_eq!(receipt.operation_id, "lane.status"); |
| 2942 | assert_eq!(receipt.authority, ControlAuthority::Read); |
| 2943 | assert_eq!(receipt.persistence, PersistenceScope::LaneRegistry); |
| 2944 | assert_eq!(receipt.outcome, LifecycleOutcome::Inspected); |
| 2945 | assert_eq!(receipt.observed_lifecycle_seq, Known::Known(1)); |
| 2946 | let page = receipt.runs.as_ref().expect("status carries the run DTO"); |
| 2947 | assert_eq!(page.runs.len(), 1); |
| 2948 | assert_eq!(page.runs[0].run_id, id); |
| 2949 | assert_eq!(page.runs[0].runtime, Known::Known("inline".to_string())); |
| 2950 | // The observed durable payload must be identical. The detail lines |
| 2951 | // deliberately are not: the slash surface discloses that it skipped |
| 2952 | // reconciliation, which is a truthful difference, not drift. |
| 2953 | payloads.insert(serde_json::to_string(page).unwrap()); |
| 2954 | } |
| 2955 | assert_eq!( |
| 2956 | payloads.len(), |
| 2957 | 1, |
| 2958 | "surfaces observed different durable state" |
| 2959 | ); |
| 2960 | } |
| 2961 | |
| 2962 | #[test] |
| 2963 | fn lane_list_is_bounded_and_identical_across_surfaces() { |
| 2964 | let (dir, id) = seeded_registry(); |
| 2965 | let mut payloads = BTreeSet::new(); |
| 2966 | for surface in ControlSurface::ALL { |
| 2967 | let receipt = execute_lane_control_in( |
| 2968 | *surface, |
| 2969 | ControlOperation::LaneList, |
| 2970 | None, |
| 2971 | Some(dir.path()), |
| 2972 | ); |
| 2973 | assert_eq!(receipt.outcome, LifecycleOutcome::Inspected); |
| 2974 | let page = receipt.runs.as_ref().expect("list carries a bounded page"); |
| 2975 | assert_eq!(page.limit, DEFAULT_RUN_LIST_LIMIT); |
| 2976 | assert_eq!(page.total, 1); |
| 2977 | assert_eq!(page.truncated, 0); |
| 2978 | assert!(page.runs.iter().any(|run| run.run_id == id)); |
| 2979 | payloads.insert(serde_json::to_string(page).unwrap()); |
| 2980 | } |
| 2981 | assert_eq!(payloads.len(), 1); |
| 2982 | } |
| 2983 | |
| 2984 | #[test] |
| 2985 | fn interrupt_acts_on_exact_run_identity_and_is_idempotent() { |
| 2986 | let (dir, id) = seeded_registry(); |
| 2987 | let stale_fence = format!("{id}@99"); |
| 2988 | let exact_fence = format!("{id}@1"); |
| 2989 | |
| 2990 | // A stale fence must not act on a record that moved on. |
| 2991 | let stale = execute_lane_control_in( |
| 2992 | ControlSurface::Slash, |
| 2993 | ControlOperation::LaneInterrupt, |
| 2994 | Some(stale_fence.as_str()), |
| 2995 | Some(dir.path()), |
| 2996 | ); |
| 2997 | assert_eq!(stale.outcome, LifecycleOutcome::Rejected); |
| 2998 | assert_eq!( |
| 2999 | stale.failure.as_ref().map(|failure| failure.kind), |
| 3000 | Some(ControlFailureKind::Conflict) |
| 3001 | ); |
| 3002 | assert_eq!(stale.observed_lifecycle_seq, Known::Known(1)); |
| 3003 | |
| 3004 | // The exact fence transitions it once. |
| 3005 | let first = execute_lane_control_in( |
| 3006 | ControlSurface::Cli, |
| 3007 | ControlOperation::LaneInterrupt, |
| 3008 | Some(exact_fence.as_str()), |
| 3009 | Some(dir.path()), |
| 3010 | ); |
| 3011 | assert_eq!(first.outcome, LifecycleOutcome::Transitioned); |
| 3012 | assert!(first.retryable, "interrupt is declared idempotent"); |
| 3013 | |
| 3014 | // Re-issuing converges rather than repeating the transition. |
| 3015 | let second = execute_lane_control_in( |
| 3016 | ControlSurface::Cli, |
| 3017 | ControlOperation::LaneInterrupt, |
| 3018 | Some(id.as_str()), |
| 3019 | Some(dir.path()), |
| 3020 | ); |
| 3021 | assert_eq!(second.outcome, LifecycleOutcome::NoChange); |
| 3022 | assert!( |
| 3023 | second |
| 3024 | .detail |
| 3025 | .iter() |
| 3026 | .any(|line| line.contains("already stopped")) |
| 3027 | ); |
| 3028 | } |
| 3029 | |
| 3030 | /// #4022: a no-op stop must not be reported as a transition. The backend |
| 3031 | /// distinguishes the three cases; the receipt must carry that through. |
| 3032 | #[test] |
| 3033 | fn an_already_terminal_lane_reports_no_change_not_transitioned() { |
| 3034 | let (dir, id) = seeded_registry(); |
| 3035 | let first = execute_lane_control_in( |
| 3036 | ControlSurface::Cli, |
| 3037 | ControlOperation::LaneInterrupt, |
| 3038 | Some(id.as_str()), |
| 3039 | Some(dir.path()), |
| 3040 | ); |
| 3041 | assert_eq!(first.outcome, LifecycleOutcome::Transitioned); |
| 3042 | let observed = first.observed_lifecycle_seq.clone(); |
| 3043 | |
| 3044 | // Whoever stopped it, this call changed nothing and says so — and it |
| 3045 | // does not claim credit by advancing the lifecycle sequence. |
| 3046 | let second = execute_lane_control_in( |
| 3047 | ControlSurface::Cli, |
| 3048 | ControlOperation::LaneInterrupt, |
| 3049 | Some(id.as_str()), |
| 3050 | Some(dir.path()), |
| 3051 | ); |
| 3052 | assert_eq!(second.outcome, LifecycleOutcome::NoChange); |
| 3053 | assert_eq!(second.observed_lifecycle_seq, observed); |
| 3054 | } |
| 3055 | |
| 3056 | /// #1888: the lifecycle fence is enforced by the registry under the same |
| 3057 | /// lock that mutates, so a stale fence refuses *and leaves the record |
| 3058 | /// untouched* rather than being pre-checked and then racing. |
| 3059 | #[test] |
| 3060 | fn a_stale_fence_refuses_under_the_lock_and_changes_nothing() { |
| 3061 | let (dir, id) = seeded_registry(); |
| 3062 | let before = crate::registry::LaneRegistry::open(dir.path()) |
| 3063 | .unwrap() |
| 3064 | .load(&id) |
| 3065 | .unwrap(); |
| 3066 | |
| 3067 | let receipt = execute_lane_control_in( |
| 3068 | ControlSurface::Cli, |
| 3069 | ControlOperation::LaneInterrupt, |
| 3070 | Some(format!("{id}@{}", before.lifecycle_seq + 41).as_str()), |
| 3071 | Some(dir.path()), |
| 3072 | ); |
| 3073 | assert_eq!(receipt.outcome, LifecycleOutcome::Rejected); |
| 3074 | assert_eq!( |
| 3075 | receipt.failure.as_ref().map(|failure| failure.kind), |
| 3076 | Some(ControlFailureKind::Conflict) |
| 3077 | ); |
| 3078 | assert_eq!( |
| 3079 | receipt.observed_lifecycle_seq, |
| 3080 | Known::Known(before.lifecycle_seq), |
| 3081 | "the receipt reports the generation the registry actually saw" |
| 3082 | ); |
| 3083 | |
| 3084 | let after = crate::registry::LaneRegistry::open(dir.path()) |
| 3085 | .unwrap() |
| 3086 | .load(&id) |
| 3087 | .unwrap(); |
| 3088 | assert_eq!(after, before, "a refused fence must not mutate the record"); |
| 3089 | } |
| 3090 | |
| 3091 | /// #1888: two concurrent interrupts of the same Lane produce exactly one |
| 3092 | /// transition. The loser reports `no_change`, never a second transition. |
| 3093 | #[test] |
| 3094 | fn concurrent_interrupts_produce_exactly_one_transition() { |
| 3095 | use std::sync::mpsc; |
| 3096 | |
| 3097 | let (dir, id) = seeded_registry(); |
| 3098 | let root = dir.path().to_path_buf(); |
| 3099 | let (tx, rx) = mpsc::channel(); |
| 3100 | let handles: Vec<_> = (0..2) |
| 3101 | .map(|_| { |
| 3102 | let root = root.clone(); |
| 3103 | let id = id.clone(); |
| 3104 | let tx = tx.clone(); |
| 3105 | std::thread::spawn(move || { |
| 3106 | let receipt = execute_lane_control_in( |
| 3107 | ControlSurface::Cli, |
| 3108 | ControlOperation::LaneInterrupt, |
| 3109 | Some(id.as_str()), |
| 3110 | Some(&root), |
| 3111 | ); |
| 3112 | tx.send(receipt.outcome).unwrap(); |
| 3113 | }) |
| 3114 | }) |
| 3115 | .collect(); |
| 3116 | drop(tx); |
| 3117 | for handle in handles { |
| 3118 | handle.join().unwrap(); |
| 3119 | } |
| 3120 | let outcomes: Vec<_> = rx.iter().collect(); |
| 3121 | assert_eq!(outcomes.len(), 2); |
| 3122 | assert_eq!( |
| 3123 | outcomes |
| 3124 | .iter() |
| 3125 | .filter(|outcome| **outcome == LifecycleOutcome::Transitioned) |
| 3126 | .count(), |
| 3127 | 1, |
| 3128 | "exactly one caller may claim the transition: {outcomes:?}" |
| 3129 | ); |
| 3130 | assert_eq!( |
| 3131 | outcomes |
| 3132 | .iter() |
| 3133 | .filter(|outcome| **outcome == LifecycleOutcome::NoChange) |
| 3134 | .count(), |
| 3135 | 1, |
| 3136 | "the loser reports no_change: {outcomes:?}" |
| 3137 | ); |
| 3138 | } |
| 3139 | |
| 3140 | /// #4022: `lane.interrupt` is a real write on every surface, including the |
| 3141 | /// composer. Runtime teardown must not run on the composer thread, but the |
| 3142 | /// answer is the off-loop executor in `codewhale-tui::lane_control` — not a |
| 3143 | /// surface refusal. This executor is the shared, blocking body that both the |
| 3144 | /// CLI and that worker thread call, so the slash surface must transition |
| 3145 | /// durable state exactly like the CLI does. |
| 3146 | #[test] |
| 3147 | fn lane_interrupt_is_a_real_write_on_the_slash_surface() { |
| 3148 | let (dir, id) = seeded_registry(); |
| 3149 | let descriptor = ControlOperation::LaneInterrupt.descriptor(); |
| 3150 | assert!( |
| 3151 | descriptor.offers(ControlSurface::Slash), |
| 3152 | "interrupt must stay offered on the composer surface" |
| 3153 | ); |
| 3154 | assert!( |
| 3155 | descriptor |
| 3156 | .availability(ControlSurface::Slash, ControlContext::new(true, true)) |
| 3157 | .is_available(), |
| 3158 | "interrupt must stay available, not surface-limited" |
| 3159 | ); |
| 3160 | |
| 3161 | let receipt = execute_lane_control_in( |
| 3162 | ControlSurface::Slash, |
| 3163 | ControlOperation::LaneInterrupt, |
| 3164 | Some(id.as_str()), |
| 3165 | Some(dir.path()), |
| 3166 | ); |
| 3167 | assert_eq!(receipt.outcome, LifecycleOutcome::Transitioned); |
| 3168 | assert!(receipt.availability.is_available()); |
| 3169 | assert!(receipt.failure.is_none()); |
| 3170 | |
| 3171 | // The write reached durable state rather than being deferred away. |
| 3172 | let record = crate::registry::LaneRegistry::open(dir.path()) |
| 3173 | .unwrap() |
| 3174 | .load(&id) |
| 3175 | .unwrap(); |
| 3176 | assert_ne!(record.status, LaneStatus::Pending); |
| 3177 | } |
| 3178 | |
| 3179 | /// #4022: a read on the slash surface does no reconciliation (no tmux |
| 3180 | /// subprocess, no lock) and says so instead of implying freshness. |
| 3181 | #[test] |
| 3182 | fn slash_reads_skip_reconciliation_and_disclose_it() { |
| 3183 | let (dir, id) = seeded_registry(); |
| 3184 | for operation in [ControlOperation::LaneList, ControlOperation::LaneStatus] { |
| 3185 | let target = (operation == ControlOperation::LaneStatus).then_some(id.as_str()); |
| 3186 | let receipt = |
| 3187 | execute_lane_control_in(ControlSurface::Slash, operation, target, Some(dir.path())); |
| 3188 | assert_eq!(receipt.outcome, LifecycleOutcome::Inspected); |
| 3189 | assert!(!receipt.reconciled); |
| 3190 | assert!( |
| 3191 | receipt |
| 3192 | .detail |
| 3193 | .iter() |
| 3194 | .any(|line| line.contains("reconciliation skipped")), |
| 3195 | "{} must disclose the skipped reconciliation", |
| 3196 | receipt.operation_id |
| 3197 | ); |
| 3198 | } |
| 3199 | } |
| 3200 | |
| 3201 | /// #4022: `lane status` must keep reporting the fields operators use to |
| 3202 | /// attach to and tail a Lane. Dropping them silently was a regression. |
| 3203 | #[test] |
| 3204 | fn lane_status_preserves_attach_branch_session_and_log_fields() { |
| 3205 | let record = lane_record("lane-a1b2c3d4"); |
| 3206 | let summary = lane_run_summary(&record); |
| 3207 | assert_eq!(summary.branch, Known::Known("lane/x".to_string())); |
| 3208 | assert_eq!(summary.runtime_session, Known::Known("cw-x".to_string())); |
| 3209 | assert!(summary.log.is_known(), "the log path must survive"); |
| 3210 | let detail = summary.render_detail(); |
| 3211 | for field in ["branch:", "session:", "socket:", "attach:", "log:"] { |
| 3212 | assert!(detail.contains(field), "{field} missing from {detail}"); |
| 3213 | } |
| 3214 | } |
| 3215 | |
| 3216 | #[test] |
| 3217 | fn unknown_lane_ids_fail_identically_on_every_surface() { |
| 3218 | let (dir, _id) = seeded_registry(); |
| 3219 | for (surface, operation) in [ |
| 3220 | (ControlSurface::Cli, ControlOperation::LaneStatus), |
| 3221 | (ControlSurface::Slash, ControlOperation::LaneStatus), |
| 3222 | (ControlSurface::Cli, ControlOperation::LaneInterrupt), |
| 3223 | (ControlSurface::Slash, ControlOperation::LaneInterrupt), |
| 3224 | ] { |
| 3225 | { |
| 3226 | let receipt = execute_lane_control_in( |
| 3227 | surface, |
| 3228 | operation, |
| 3229 | Some("lane-doesnotexist"), |
| 3230 | Some(dir.path()), |
| 3231 | ); |
| 3232 | assert_eq!(receipt.outcome, LifecycleOutcome::Rejected); |
| 3233 | assert_eq!( |
| 3234 | receipt.failure.as_ref().map(|failure| failure.kind), |
| 3235 | Some(ControlFailureKind::NotFound) |
| 3236 | ); |
| 3237 | assert!(!receipt.retryable); |
| 3238 | } |
| 3239 | } |
| 3240 | } |
| 3241 | |
| 3242 | #[test] |
| 3243 | fn corrupt_lane_records_are_retryable_backend_failures() { |
| 3244 | let (dir, id) = seeded_registry(); |
| 3245 | let registry = crate::registry::LaneRegistry::open(dir.path()).unwrap(); |
| 3246 | std::fs::write(registry.record_path(&id), b"{not-json").unwrap(); |
| 3247 | |
| 3248 | let receipt = execute_lane_control_in( |
| 3249 | ControlSurface::Cli, |
| 3250 | ControlOperation::LaneStatus, |
| 3251 | Some(&id), |
| 3252 | Some(dir.path()), |
| 3253 | ); |
| 3254 | |
| 3255 | assert_eq!(receipt.outcome, LifecycleOutcome::Failed); |
| 3256 | assert_eq!( |
| 3257 | receipt.failure.as_ref().map(|failure| failure.kind), |
| 3258 | Some(ControlFailureKind::Backend) |
| 3259 | ); |
| 3260 | assert!(receipt.retryable); |
| 3261 | assert!( |
| 3262 | receipt |
| 3263 | .failure |
| 3264 | .as_ref() |
| 3265 | .is_some_and(|failure| failure.message.contains("parse lane record")) |
| 3266 | ); |
| 3267 | } |
| 3268 | |
| 3269 | #[test] |
| 3270 | fn unimplemented_lane_verbs_are_refused_before_touching_the_registry() { |
| 3271 | let (dir, id) = seeded_registry(); |
| 3272 | for operation in [ControlOperation::LaneRestart, ControlOperation::LaneResume] { |
| 3273 | for surface in ControlSurface::ALL { |
| 3274 | let receipt = execute_lane_control_in( |
| 3275 | *surface, |
| 3276 | operation, |
| 3277 | Some(id.as_str()), |
| 3278 | Some(dir.path()), |
| 3279 | ); |
| 3280 | assert_eq!(receipt.outcome, LifecycleOutcome::Rejected); |
| 3281 | assert_eq!( |
| 3282 | receipt.availability.reason(), |
| 3283 | Some(UnavailableReason::BackendNotImplemented) |
| 3284 | ); |
| 3285 | assert!(!receipt.retryable); |
| 3286 | } |
| 3287 | } |
| 3288 | // The refusal did not mutate durable state. |
| 3289 | let registry = crate::registry::LaneRegistry::open(dir.path()).unwrap(); |
| 3290 | assert_eq!(registry.load(&id).unwrap().status, LaneStatus::Pending); |
| 3291 | } |
| 3292 | |
| 3293 | #[test] |
| 3294 | fn a_missing_registry_is_reported_not_created() { |
| 3295 | let dir = tempfile::tempdir().unwrap(); |
| 3296 | let absent = dir.path().join("never-created"); |
| 3297 | let receipt = execute_lane_control_in( |
| 3298 | ControlSurface::Slash, |
| 3299 | ControlOperation::LaneList, |
| 3300 | None, |
| 3301 | Some(&absent), |
| 3302 | ); |
| 3303 | assert_eq!( |
| 3304 | receipt.availability.reason(), |
| 3305 | Some(UnavailableReason::NoLaneRegistry) |
| 3306 | ); |
| 3307 | assert!(!absent.exists(), "a read verb must not create the registry"); |
| 3308 | } |
| 3309 | |
| 3310 | #[test] |
| 3311 | fn home_rooted_paths_are_collapsed() { |
| 3312 | // `redact_path` is a no-op outside $HOME and never panics on either. |
| 3313 | let outside = redact_path(Path::new("/tmp/lanes/logs/x.ndjson")); |
| 3314 | assert_eq!(outside, "/tmp/lanes/logs/x.ndjson"); |
| 3315 | if let Some(home) = home_prefix() { |
| 3316 | let inside = redact_path(&PathBuf::from(home).join("lanes").join("x")); |
| 3317 | assert!(inside.starts_with("~/"), "{inside}"); |
| 3318 | assert!(!inside.contains(home)); |
| 3319 | assert_eq!(redact_path(Path::new(home)), "~"); |
| 3320 | |
| 3321 | // Prefix confusion: a sibling directory that merely *starts with* |
| 3322 | // $HOME's text is not inside $HOME and must not be abbreviated. |
| 3323 | let sibling = format!("{home}-backup/secrets"); |
| 3324 | assert_eq!( |
| 3325 | redact_path_str(&sibling), |
| 3326 | sibling, |
| 3327 | "a path boundary is a separator, not a string prefix" |
| 3328 | ); |
| 3329 | } |
| 3330 | } |
| 3331 | |
| 3332 | /// 2026-08-04 audit: `Authorization: Bearer <jwt>` leaked the JWT in |
| 3333 | /// full. The bare `Bearer` token failed the `len() > prefix.len()` guard |
| 3334 | /// (it IS the prefix) and the JWT after it matched nothing. Every |
| 3335 | /// operator-visible ControlReceipt string goes through this sanitizer. |
| 3336 | #[test] |
| 3337 | fn bearer_and_case_variant_secrets_do_not_survive_sanitization() { |
| 3338 | let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.c2lnbmF0dXJl"; |
| 3339 | |
| 3340 | let line = sanitize_line(&format!("request failed: Authorization: Bearer {jwt}")); |
| 3341 | assert!(!line.contains(jwt), "bearer JWT leaked: {line}"); |
| 3342 | |
| 3343 | // Lowercase scheme, and a trailing comma after the scheme word. |
| 3344 | let line = sanitize_line(&format!("hdr bearer {jwt}")); |
| 3345 | assert!(!line.contains(jwt), "lowercase bearer leaked: {line}"); |
| 3346 | let line = sanitize_line(&format!("token, {jwt}")); |
| 3347 | assert!( |
| 3348 | !line.contains(jwt), |
| 3349 | "scheme word with punctuation leaked: {line}" |
| 3350 | ); |
| 3351 | |
| 3352 | // Case-insensitive value prefixes. |
| 3353 | for secret in [ |
| 3354 | "SK-live-abc123def456", |
| 3355 | "sk-live-abc123def456", |
| 3356 | "GHP_abcdef123456", |
| 3357 | ] { |
| 3358 | let line = sanitize_line(&format!("using {secret} now")); |
| 3359 | assert!(!line.contains(secret), "prefixed secret leaked: {line}"); |
| 3360 | } |
| 3361 | |
| 3362 | // Ordinary prose must survive: the scheme word only arms the NEXT |
| 3363 | // token, and only when it is a bare scheme word. |
| 3364 | let line = sanitize_line("the bearer of this token is unknown"); |
| 3365 | assert!(line.contains("the bearer"), "over-redacted prose: {line}"); |
| 3366 | assert!(line.contains("unknown"), "over-redacted prose: {line}"); |
| 3367 | } |
| 3368 | } |
| 3369 |