返回 CodeWhale
control.rs
根目录 / crates / lane / src / control.rs
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, &registry),
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, &registry, 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
3393 lines RUST