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