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