Skip to main content

claudectl_core/
runtime.rs

1//! UI ↔ runtime contract.
2//!
3//! Tracking: issue #274 of the workspace-refactor epic (#279).
4//!
5//! The TUI today reaches deep into brain / coord / bus / rules internals. This
6//! module defines the **read-only** boundary it should reach through instead,
7//! so a future `claudectl-tui` crate (#275) can be extracted and iterated on
8//! without recompiling brain or the bus.
9//!
10//! ## Why traits, why core-owned DTOs
11//!
12//! Each view is a trait, not a concrete struct, so:
13//!
14//! - The binary crate can hand the TUI a real implementation backed by SQLite
15//!   / the engine / the bus DB. A future remote frontend can hand it an HTTP
16//!   client. Tests hand it a fixture.
17//! - Adding a method to a trait is a contract change reviewable in one PR;
18//!   adding a method to a concrete struct ripples through every caller.
19//!
20//! Each DTO (`SessionSnapshot`, `LeaseSummary`, …) is owned by `core` so the
21//! traits don't drag `brain::DecisionRecord` / `coord::Lease` upward into the
22//! TUI's dependency surface. Conversion happens once, in the wrapper.
23//!
24//! ## What's in scope here
25//!
26//! Read-only views only. Side-effecting paths (`terminate_session`,
27//! `inject_prompt`, `log_decision`) deserve a separate `Actions` trait once
28//! the TUI's write surface is mapped. Adding it speculatively now would
29//! violate the "only add methods existing call sites need" rule the epic
30//! committed to.
31//!
32//! ## What's NOT in scope
33//!
34//! - The TUI doesn't yet call through these traits — that's #275.
35//! - Brain's review/scorecard surface — it's heavier and worth its own
36//!   trait once #275 stabilizes the basic shape.
37//! - Pub/sub claim protocol — #283 builds on top of these traits, not the
38//!   other way around.
39
40use serde::{Deserialize, Serialize};
41use std::sync::Arc;
42
43// ============================================================================
44// Sessions
45// ============================================================================
46
47/// One running Claude Code session, as observed by the runtime. Minimal
48/// projection of the binary-crate `ClaudeSession`; only fields the TUI
49/// renders.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SessionSnapshot {
52    pub session_id: String,
53    pub pid: u32,
54    pub cwd: String,
55    pub project_name: String,
56    pub status: String,
57    pub cost_usd: f64,
58    pub context_tokens: u64,
59    pub context_max: u64,
60    pub last_message_ts: u64,
61}
62
63/// Read access to the live session roster.
64pub trait SessionSource: Send + Sync {
65    /// Snapshot of every running Claude Code session. Order is the
66    /// implementor's choice; the TUI sorts again client-side.
67    fn list(&self) -> Vec<SessionSnapshot>;
68
69    /// Fetch a specific session by its ID. `None` when the session has
70    /// exited or never existed.
71    fn detail(&self, session_id: &str) -> Option<SessionSnapshot> {
72        self.list().into_iter().find(|s| s.session_id == session_id)
73    }
74}
75
76// ============================================================================
77// Brain
78// ============================================================================
79
80/// Mirrors the binary's `brain::GateMode` without depending on the brain
81/// crate. Persisted as the lowercased label.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "snake_case")]
84pub enum BrainGateMode {
85    On,
86    Off,
87    Auto,
88}
89
90impl BrainGateMode {
91    /// Canonical lowercase label — the form persisted to
92    /// `~/.claudectl/brain/gate-mode` and emitted by the TUI status messages.
93    pub fn as_str(&self) -> &'static str {
94        match self {
95            Self::On => "on",
96            Self::Off => "off",
97            Self::Auto => "auto",
98        }
99    }
100}
101
102impl std::fmt::Display for BrainGateMode {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.write_str(self.as_str())
105    }
106}
107
108/// A single past brain decision, projected for display.
109///
110/// The first six fields are the common shape used by `BrainView::recent_decisions`.
111/// The remaining fields support the Brain Review surface (`BrainReviewView`); they
112/// are `Option`-wrapped + `#[serde(default)]` so older `BrainView` callers can
113/// keep treating them as opaque.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct DecisionSummary {
116    pub id: String,
117    pub timestamp: String,
118    pub action: String,
119    pub confidence: Option<f64>,
120    pub project: Option<String>,
121    pub tool: Option<String>,
122    /// PID of the session this decision belongs to. Used by counterfactual
123    /// analysis to pair decisions with their subsequent outcome from the
124    /// same session.
125    #[serde(default)]
126    pub pid: u32,
127
128    /// Tool input string when the decision was about a specific command.
129    #[serde(default)]
130    pub command: Option<String>,
131    /// Brain's free-form rationale for the suggestion.
132    #[serde(default)]
133    pub reasoning: Option<String>,
134    /// What the user did with the suggestion — `"accept"`, `"reject"`,
135    /// `"deny_rule_override"`, etc.
136    #[serde(default)]
137    pub user_action: Option<String>,
138    /// Why the user overrode the brain (if applicable).
139    #[serde(default)]
140    pub override_reason: Option<String>,
141    /// Wall-clock latency of the brain decision in milliseconds.
142    #[serde(default)]
143    pub brain_decision_ms: Option<u64>,
144    /// Whether the operator has marked this decision as canonical (teaching
145    /// material). `None` for records written before the field existed.
146    #[serde(default)]
147    pub canonical: Option<bool>,
148    /// Cache hit flag — served from the few-shot store without an LLM call.
149    /// `None` before instrumentation.
150    #[serde(default)]
151    pub cache_hit: Option<bool>,
152    /// Cost in USD when this decision was made (context snapshot).
153    #[serde(default)]
154    pub cost_usd: Option<f64>,
155    /// Model that produced the suggestion.
156    #[serde(default)]
157    pub model: Option<String>,
158    /// Resolved outcome category, when known. `"success" | "error" |
159    /// "test_failed" | "skipped"` etc. Mirrors the variants of the binary's
160    /// `brain::decisions::DecisionOutcome` enum, flattened to a string so
161    /// the contract doesn't pull the enum upward.
162    #[serde(default)]
163    pub outcome_kind: Option<String>,
164    /// Free-form detail for failure outcomes (the failing command for
165    /// `test_failed`, the error message for `error`).
166    #[serde(default)]
167    pub outcome_detail: Option<String>,
168    /// Epoch seconds when the brain suggestion was first surfaced. Used by
169    /// time-to-correct analysis. `None` for records pre-instrumentation or
170    /// passive observations.
171    #[serde(default)]
172    pub suggested_at: Option<u64>,
173    /// Epoch seconds when the user acted on the suggestion. `None` for
174    /// passive observations or records still in flight.
175    #[serde(default)]
176    pub resolved_at: Option<u64>,
177    /// One-line, human-readable explanation of *why* the brain reached this
178    /// decision (#372): source, confidence, rule/few-shot provenance. Rendered
179    /// inline in the review detail panel. Empty for records that carry no
180    /// derivable cause.
181    #[serde(default)]
182    pub why: String,
183    /// Where the decision originated (#372): `"llm"`, `"rule"`, `"few_shot"`.
184    #[serde(default)]
185    pub decision_source: Option<String>,
186    /// The auto-rule that fired, when this was a rule decision (#372).
187    #[serde(default)]
188    pub rule_name: Option<String>,
189}
190
191impl DecisionSummary {
192    /// Whether the user agreed with the brain (or the call was auto-executed).
193    /// Mirrors `brain::decisions::DecisionRecord::is_positive`.
194    pub fn is_positive(&self) -> bool {
195        matches!(
196            self.user_action.as_deref(),
197            Some("accept" | "auto" | "user_approve" | "rule_approve")
198        )
199    }
200
201    /// Whether the user disagreed with the brain. Mirrors
202    /// `brain::decisions::DecisionRecord::is_negative`.
203    pub fn is_negative(&self) -> bool {
204        matches!(
205            self.user_action.as_deref(),
206            Some("reject" | "deny_rule_override" | "rule_deny" | "conflict_deny")
207        )
208    }
209}
210
211/// Read access to the brain's decision history and current mode.
212pub trait BrainView: Send + Sync {
213    fn gate_mode(&self) -> BrainGateMode;
214
215    /// Most recent `n` decisions, newest first.
216    fn recent_decisions(&self, n: usize) -> Vec<DecisionSummary>;
217
218    /// Total count of brain decisions on disk. Drives the "decisions: N"
219    /// status line.
220    fn decision_count(&self) -> usize;
221}
222
223/// One entry in the Brain Review queue — a decision worth showing the operator
224/// for canonical-marking review, with a reason and a priority score.
225#[derive(Debug, Clone, Serialize, Deserialize)]
226pub struct ReviewItemSummary {
227    pub decision: DecisionSummary,
228    /// Free-form rationale for why this decision was queued for review.
229    pub reason: String,
230    /// Priority score (higher = more important to review first).
231    pub score: f64,
232}
233
234/// Read access to the Brain Review surface — the full decision log plus the
235/// review-queue projection. Separate trait from `BrainView` because:
236///
237/// - The Brain Review screen is the only TUI consumer; the dashboard's status
238///   bar only needs `BrainView::recent_decisions(n)`.
239/// - `all_decisions()` returns the whole log (can be thousands of records);
240///   it's a heavier surface than the lightweight `BrainView` methods and
241///   worth gating behind its own trait so callers don't accidentally invoke it.
242pub trait BrainReviewView: Send + Sync {
243    /// Every decision on disk, newest first. Used by the Brain Review screen
244    /// to render the full history list.
245    fn all_decisions(&self) -> Vec<DecisionSummary>;
246
247    /// Priority-ordered review queue. Built from `all_decisions()` plus the
248    /// brain's queue-building heuristics (counterfactual hits, Critical-tier
249    /// safety cases, high-confidence misses).
250    fn review_queue(&self) -> Vec<ReviewItemSummary>;
251}
252
253// ============================================================================
254// Coordination layer
255// ============================================================================
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct LeaseSummary {
259    pub id: String,
260    pub owner_session_id: String,
261    pub resource_kind: String,
262    pub resource_value: String,
263    pub mode: String,
264    pub acquired_at: String,
265    /// ISO timestamp when the lease expires. `None` for leases held without
266    /// an explicit deadline.
267    #[serde(default)]
268    pub expires_at: Option<String>,
269}
270
271#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct HandoffSummary {
273    pub id: String,
274    pub from_session_id: String,
275    pub to_session_id: Option<String>,
276    pub task_id: String,
277    pub summary: String,
278    pub priority: String,
279    pub created_at: String,
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
283pub struct InterruptSummary {
284    pub id: String,
285    pub interrupt_type: String,
286    pub priority: String,
287    pub target_session_id: String,
288    pub reason: String,
289    pub created_at: String,
290}
291
292/// A supervisor task lifecycle, projected for the TUI. One row per task in the
293/// coord `tasks` table. `state` is the wire label (`TaskState::as_str`), kept as
294/// a string so the contract doesn't drag the coord state enum upward.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct TaskSummary {
297    pub id: String,
298    pub name: String,
299    pub state: String,
300    pub role: Option<String>,
301    /// Number of recorded attempts so far.
302    pub attempts: u32,
303    pub max_retries: u32,
304    /// Session id of the most recent attempt, when one has run.
305    pub last_session_id: Option<String>,
306    /// Most recent verifier verdict (e.g. "PASS"/"FAIL"), or `None` if no
307    /// verifier has run for this task yet.
308    #[serde(default)]
309    pub last_verdict: Option<String>,
310    /// Total cost in USD across all attempts.
311    #[serde(default)]
312    pub cost_usd: f64,
313    pub created_at: String,
314    pub updated_at: String,
315}
316
317/// Read access to coordination state (leases, handoffs, interrupts, tasks).
318/// Backed today by the `coord` SQLite store; in tests, by a fixture.
319pub trait CoordView: Send + Sync {
320    fn active_leases(&self) -> Vec<LeaseSummary>;
321    fn pending_handoffs(&self) -> Vec<HandoffSummary>;
322    fn pending_interrupts(&self) -> Vec<InterruptSummary>;
323    /// Supervisor task lifecycles, newest first. Empty when the `coord` feature
324    /// is off so the TUI can render a "no supervisor" state without cfg.
325    fn tasks(&self) -> Vec<TaskSummary>;
326}
327
328// ============================================================================
329// Agent bus
330// ============================================================================
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct AgentDirectoryEntry {
334    pub session_id: String,
335    pub pid: u32,
336    pub cwd: String,
337    pub project: String,
338    pub status: String,
339    pub role: Option<String>,
340}
341
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct RoleBinding {
344    pub name: String,
345    pub cwd_selector: String,
346    pub last_session_id: Option<String>,
347    pub last_seen: String,
348    /// PID this role is bound to (#307). `None` for cwd-only bindings.
349    pub pid: Option<u32>,
350}
351
352/// Read access to the agent-bus roster + role table. Disabled implementations
353/// (`bus` feature off in the binary) return empty vectors so the TUI can
354/// render the panel as "no bus" without conditional compilation.
355pub trait BusView: Send + Sync {
356    fn list_agents(&self) -> Vec<AgentDirectoryEntry>;
357    fn list_roles(&self) -> Vec<RoleBinding>;
358}
359
360// ============================================================================
361// Actions (write surface)
362// ============================================================================
363
364/// What the TUI needs to record alongside a user action. Core-owned so the
365/// trait doesn't drag `brain::decisions::log_observation`'s argument list
366/// upward into the TUI surface.
367#[derive(Debug, Clone, Serialize, Deserialize)]
368pub struct ObservationInput {
369    /// PID of the session the observation belongs to.
370    pub session_pid: u32,
371    /// Project label (usually the cwd basename).
372    pub project: String,
373    /// Tool the action targeted, when applicable ("Bash", "Write", …).
374    pub tool: Option<String>,
375    /// The command or input string the user ran/sent.
376    pub command: Option<String>,
377    /// Classification — `"user_approve"`, `"user_input"`, `"rule_approve"`,
378    /// `"rule_deny"`, and friends. Kept as a string so callers can introduce
379    /// new categories without a trait change.
380    pub observed_action: String,
381}
382
383/// Whether a decision was about a session or about an orchestration task.
384/// Mirrors `brain::decisions::DecisionType` without depending on it.
385#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(rename_all = "snake_case")]
387pub enum DecisionScope {
388    Session,
389    Orchestration,
390}
391
392impl DecisionScope {
393    /// Wire label. Matches what `brain::decisions::DecisionType::as_label`
394    /// writes to disk so the round-trip is stable.
395    pub fn as_str(&self) -> &'static str {
396        match self {
397            Self::Session => "session",
398            Self::Orchestration => "orchestration",
399        }
400    }
401}
402
403/// What the TUI needs to record when the user resolves a brain suggestion.
404/// Carries the suggestion that was on screen at the time, plus what the user
405/// did and (optionally) why they overrode the brain.
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct LogDecisionInput {
408    /// PID of the session the decision applies to.
409    pub session_pid: u32,
410    /// Project label.
411    pub project: String,
412    /// Tool name the suggestion targeted.
413    pub tool: Option<String>,
414    /// Command / input string the suggestion targeted.
415    pub command: Option<String>,
416    /// The brain's suggestion the user is resolving.
417    pub suggestion: PendingSuggestion,
418    /// What the user did — `"accept"`, `"reject"`, `"deny_rule_override"`, etc.
419    pub user_action: String,
420    /// Whether this was a session decision or an orchestration decision.
421    pub decision_type: DecisionScope,
422    /// Why the user overrode a brain denial (if applicable).
423    pub override_reason: Option<String>,
424}
425
426/// Side-effecting paths the TUI invokes when the user takes an action
427/// (terminating a session, sending a prompt, flipping the brain mode,
428/// recording an observation, marking a decision canonical for teaching).
429///
430/// Each method is fallible — implementations return a `Result` rather than
431/// silently swallowing errors so the TUI can surface failures in the status
432/// bar instead of pretending an action succeeded.
433///
434/// **Not modeled here (intentionally):**
435///
436/// - `launch::*` — launching a new Claude Code session is wider than a single
437///   call (cwd selection, model defaults, plugin propagation). Will get its
438///   own trait once the TUI's launcher surface is refactored alongside #275.
439/// - `mailbox::deliver_pending` — orchestrator-style mailbox delivery; should
440///   eventually move to an `Orchestrator` trait, not `Actions`.
441/// - `log_decision` (vs. `log_observation`) — currently only called from the
442///   plugin gate path, not the TUI. Stays a direct call until a TUI site
443///   needs it.
444pub trait Actions: Send + Sync {
445    /// Terminate the OS process with the given PID. The TUI uses this for
446    /// "kill session" hotkeys.
447    fn terminate_session(&self, pid: u32) -> Result<(), String>;
448
449    /// Inject text into the session's terminal. Implementations pick the
450    /// right backend (tmux, Kitty, etc.) from the session metadata.
451    ///
452    /// The `text` is sent verbatim — the caller is responsible for any
453    /// trailing newline they want. Sanitization (e.g. neutralizing a leading
454    /// `/`) belongs to the caller; this trait is a transport.
455    fn inject_text(&self, session_id: &str, text: &str) -> Result<(), String>;
456
457    /// Update the persisted brain gate mode. Reflected back through
458    /// `BrainView::gate_mode` on the next call.
459    fn set_gate_mode(&self, mode: BrainGateMode) -> Result<(), String>;
460
461    /// Record an observation about a user action — non-LLM "the user did X."
462    /// Drives the brain's outcome telemetry and preference distillation.
463    fn log_observation(&self, observation: ObservationInput) -> Result<(), String>;
464
465    /// Record a user's accept/reject decision on a brain suggestion. The
466    /// distinction from `log_observation` is that `log_decision` carries
467    /// the actual suggestion that was on screen — the brain pairs it with
468    /// the user's response for outcome telemetry, counterfactual analysis,
469    /// and few-shot retrieval.
470    fn log_decision(&self, input: LogDecisionInput) -> Result<(), String>;
471
472    /// Mark a past brain decision as canonical for teaching. Optional `note`
473    /// is the operator's annotation. Used by the Brain Review surface.
474    fn mark_canonical(&self, decision_id: &str, note: Option<String>) -> Result<(), String>;
475
476    /// Bind an agent-bus role to a `(cwd, pid)` pair. The TUI calls this from
477    /// the new role-bind key (Ctrl+R) so the operator can attach a role to a
478    /// specific running Claude session without dropping to another terminal.
479    /// Returns `Err` when the bus feature is compiled out or the DB write
480    /// fails. See issue #307.
481    fn bind_bus_role(&self, name: &str, cwd: &str, pid: u32) -> Result<(), String>;
482
483    /// Cancel a supervisor task (move it to CANCELLED). Used by the Supervisor
484    /// panel's one-key cancel (#368). `Err` when the `coord` feature is compiled
485    /// out, the task is already terminal, or the store write fails.
486    fn cancel_task(&self, task_id: &str) -> Result<(), String>;
487
488    /// Set or clear the supervisor drain marker. When draining, the reconciler
489    /// stops issuing new task assignments while letting running tasks finish.
490    /// `Err` when the `coord` feature is compiled out.
491    fn set_supervisor_drain(&self, draining: bool) -> Result<(), String>;
492
493    /// Re-queue a failed/cancelled task (move it back to PENDING so the
494    /// reconciler re-assigns it). `Err` for a DONE task (nothing to retry), a
495    /// still-active task, or when the `coord` feature is compiled out.
496    fn retry_task(&self, task_id: &str) -> Result<(), String>;
497
498    /// Approve a NEEDS_HUMAN task — accept the work as-is and move it to DONE,
499    /// overriding the verifier that escalated it. `Err` when the task isn't in
500    /// NEEDS_HUMAN, or the `coord` feature is compiled out.
501    fn approve_task(&self, task_id: &str) -> Result<(), String>;
502
503    /// Move a conversation to a paired peer, so it can be resumed there
504    /// (#510). The TUI calls this from the send hotkey; `transcript` is the
505    /// path session discovery already resolved, which is more reliable than
506    /// re-deriving it from `cwd` and the id.
507    ///
508    /// Returns the session id the transcript actually travels under, which is
509    /// not always `session_id`: discovery can hand back a transcript belonging
510    /// to a different id, and the bytes decide.
511    ///
512    /// Everything checkable without the network — transcript readable, peer
513    /// paired, an address to dial — is checked before returning `Ok`. The
514    /// megabytes themselves go in a detached child, because a median
515    /// transcript is 5.45 MB and the caller is a render loop. So `Ok` means
516    /// "accepted and started", not "delivered"; the ledger behind
517    /// `claudectl relay sessions` is where delivery shows up.
518    ///
519    /// `Err` when the `relay` feature is compiled out.
520    fn send_session_to_peer(
521        &self,
522        session_id: &str,
523        transcript: Option<&std::path::Path>,
524        cwd: &str,
525        peer: &str,
526    ) -> Result<String, String>;
527}
528
529// ============================================================================
530// Hive actions — knowledge-store (`hive`) + transport (`relay`) reads/writes
531// ============================================================================
532
533/// Snapshot of local hive state the overlay reads to render the Hive tab.
534///
535/// Mirrors the binary's same-named struct, lifted here so the TUI can hold
536/// it without depending on the binary's app module.
537#[derive(Debug, Clone, Default)]
538pub struct HiveViewSnapshot {
539    /// Local relay identity (peer ID string). `None` when the `relay`
540    /// feature is compiled out.
541    pub identity: Option<String>,
542    /// Known peers, paired with last-seen address when available.
543    pub peers: Vec<(String, Option<String>)>,
544}
545
546/// The hive a nearby machine advertises in its LAN announcement.
547///
548/// Mirrors the binary's `relay::lan::HiveAd` with locally redefined fields,
549/// the way `HiveViewSnapshot` mirrors its binary-side twin, so the TUI can
550/// render a discovery listing without depending on the relay module.
551#[derive(Debug, Clone, PartialEq, Eq)]
552pub struct DiscoveredHive {
553    pub id: String,
554    pub name: String,
555    /// `invite`, `ask` or `open` — the effective policy the machine advertises.
556    pub join_policy: String,
557    /// Connected peers and knowledge units, as the machine last announced them.
558    pub peers: u32,
559    pub units: u32,
560}
561
562/// One machine heard announcing on the LAN.
563///
564/// An announcement is unauthenticated and carries no key, so a listing is
565/// informational: it tells the operator a relay is there and which hive it
566/// names, and pairing still goes through an invite from that machine.
567#[derive(Debug, Clone, PartialEq, Eq)]
568pub struct DiscoveredMachine {
569    /// The announcing machine's relay identity.
570    pub identity: String,
571    /// `ip:port` of its relay listener.
572    pub addr: String,
573    pub version: String,
574    /// `None` when the machine has not named its hive.
575    pub hive: Option<DiscoveredHive>,
576    /// Whether this machine is already paired with us (a PSK is on disk).
577    pub paired: bool,
578}
579
580/// Where a discovery snapshot came from, shown so an empty list is diagnosable.
581#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
582pub enum DiscoverySource {
583    /// Another claudectl process on this machine — a relay, or another TUI —
584    /// holds the discovery port and publishes what it hears; the listing is
585    /// read from that rather than from a second socket beside it.
586    Published,
587    /// Nobody else is listening here, so the handle holds the port itself
588    /// (and publishes in turn, for the next process that asks).
589    Listener,
590    /// The port could not be bound: something that is not claudectl holds
591    /// UDP 9848. Retried on the next snapshot.
592    BindFailed,
593    /// Nothing can listen — the `relay` feature is compiled out, or the mock.
594    #[default]
595    Unavailable,
596}
597
598/// What the Hive tab shows as "nearby": the machines currently announcing.
599#[derive(Debug, Clone, Default)]
600pub struct DiscoverySnapshot {
601    pub source: DiscoverySource,
602    pub machines: Vec<DiscoveredMachine>,
603}
604
605/// Group a listing by hive: the named hives in first-seen order, each with
606/// the machines announcing it, then the machines that named none. Shared by
607/// the TUI's Hive tab and `hive discover`, so the two cannot disagree about a
608/// hive's counts.
609///
610/// When two machines in one hive disagree on peers or units, the higher
611/// value is kept: the counts are snapshots taken at different instants, and
612/// the larger is the more recently true.
613pub fn group_by_hive(
614    machines: &[DiscoveredMachine],
615) -> (
616    Vec<(DiscoveredHive, Vec<&DiscoveredMachine>)>,
617    Vec<&DiscoveredMachine>,
618) {
619    let mut hives: Vec<(DiscoveredHive, Vec<&DiscoveredMachine>)> = Vec::new();
620    let mut unnamed = Vec::new();
621    for m in machines {
622        let Some(h) = &m.hive else {
623            unnamed.push(m);
624            continue;
625        };
626        match hives.iter_mut().find(|(known, _)| known.id == h.id) {
627            Some((known, members)) => {
628                known.peers = known.peers.max(h.peers);
629                known.units = known.units.max(h.units);
630                members.push(m);
631            }
632            None => hives.push((h.clone(), vec![m])),
633        }
634    }
635    (hives, unnamed)
636}
637
638#[cfg(test)]
639mod group_by_hive_tests {
640    use super::*;
641
642    fn machine(identity: &str, hive: Option<(&str, u32)>) -> DiscoveredMachine {
643        DiscoveredMachine {
644            identity: identity.into(),
645            addr: "10.0.0.2:9847".into(),
646            version: "0.90.2".into(),
647            hive: hive.map(|(name, units)| DiscoveredHive {
648                id: format!("hv_{name}"),
649                name: name.into(),
650                join_policy: "ask".into(),
651                peers: 1,
652                units,
653            }),
654            paired: false,
655        }
656    }
657
658    #[test]
659    fn merges_a_hive_across_machines_and_keeps_highest_counts() {
660        let machines = vec![
661            machine("a", Some(("ops", 10))),
662            machine("b", None),
663            machine("c", Some(("ops", 40))),
664            machine("d", Some(("dev", 5))),
665        ];
666        let (hives, unnamed) = group_by_hive(&machines);
667        assert_eq!(hives.len(), 2);
668        assert_eq!(hives[0].0.name, "ops");
669        assert_eq!(hives[0].0.units, 40);
670        assert_eq!(hives[0].1.len(), 2);
671        assert_eq!(hives[1].0.name, "dev");
672        assert_eq!(unnamed.len(), 1);
673        assert_eq!(unnamed[0].identity, "b");
674    }
675}
676
677/// A running LAN discovery, alive for as long as the Hive tab is showing.
678///
679/// Handed out by [`HiveActions::start_discovery`] and owned by the TUI like
680/// `BrainDriver`, so the listener's lifetime is tied to the tab: dropping the
681/// handle stops listening. `snapshot` is cheap and non-blocking — the TUI
682/// calls it on every tick while the tab is open.
683pub trait DiscoveryListener: Send {
684    fn snapshot(&mut self) -> DiscoverySnapshot;
685}
686
687/// Hive + relay surface the TUI needs to render the Skills and Hive panels.
688///
689/// Separate trait from `Actions` because the underlying subsystems are
690/// feature-gated (`hive`, `relay`) — when those features are off the
691/// implementation returns empty/no-op values rather than failing.
692pub trait HiveActions: Send + Sync {
693    /// Semantic keys (`skill:<lowered-name>`) of skills already shared into
694    /// the local hive store. Used by the Skills overlay to mark "already
695    /// shared" rows.
696    fn shared_skill_keys(&self) -> std::collections::HashSet<String>;
697
698    /// Share a discovered skill into the local hive store. Returns the new
699    /// unit ID on success.
700    fn share_skill(&self, skill: &crate::skills::DiscoveredSkill) -> Result<String, String>;
701
702    /// Current view of the local relay identity + known peers for the Hive
703    /// tab.
704    fn hive_view_snapshot(&self) -> HiveViewSnapshot;
705
706    /// Begin listening for nearby relays and the hives they name.
707    ///
708    /// Called when the Hive tab comes into view; the TUI drops the handle when
709    /// the tab goes away. `None` when nothing on this build can listen (the
710    /// `relay` feature is off), so the tab can say so instead of showing an
711    /// empty list forever.
712    fn start_discovery(&self) -> Option<Box<dyn DiscoveryListener>>;
713}
714
715// ============================================================================
716// Brain driver (stateful)
717// ============================================================================
718
719/// One pending brain suggestion the user can accept or reject. Mirrors the
720/// binary's `brain::client::BrainSuggestion` without exposing brain types.
721#[derive(Debug, Clone, Serialize, Deserialize)]
722pub struct PendingSuggestion {
723    /// PID of the session this suggestion applies to.
724    pub pid: u32,
725    /// String label of the suggested action — `"approve"`, `"deny"`,
726    /// `"send"`, `"terminate"`. String to avoid pulling `RuleAction`'s enum
727    /// shape into the contract (it's already in `claudectl-core::rules` but
728    /// callers may want to add new categories without bumping the trait).
729    pub action: String,
730    /// Suggested message body (a prompt to inject, a rationale to surface).
731    pub message: Option<String>,
732    /// Why the brain suggested this — used for the detail panel and the
733    /// rejection log.
734    pub reasoning: String,
735    /// 0.0–1.0 confidence the brain assigned.
736    pub confidence: f64,
737    /// Epoch seconds when the suggestion was created. Drives time-to-correct
738    /// telemetry.
739    pub suggested_at: u64,
740}
741
742/// Stateful brain orchestration. Unlike the read-only views, this needs
743/// `&mut` per tick — the engine accumulates pending suggestions, cleans up
744/// after exited sessions, and applies user accept/reject decisions.
745///
746/// Boxed as `Box<dyn BrainDriver>` rather than `Arc<dyn BrainDriver>`
747/// because there is exactly one owner (the TUI's `App`) and every method
748/// needs `&mut self`. Sharing across threads is not a goal here.
749///
750/// Implementations may be `None` — the brain is opt-in (`--brain` flag).
751/// When the field is `None` the TUI renders without brain interaction.
752pub trait BrainDriver: Send {
753    /// Run one tick of the brain loop against the current session set and
754    /// the operator's deny rules. Returns `(pid, status_message)` pairs
755    /// for any actions the brain decided on this tick — the TUI surfaces
756    /// the messages in the status bar.
757    fn tick(
758        &mut self,
759        sessions: &[SessionSnapshot],
760        deny_rules: &[crate::rules::AutoRule],
761    ) -> Vec<(u32, String)>;
762
763    /// Drop pending suggestions for sessions that have exited. Called every
764    /// refresh so the pending map stays bounded by the live session count.
765    fn cleanup(&mut self, sessions: &[SessionSnapshot]);
766
767    /// User accepted the pending suggestion for `pid`. Implementations
768    /// return the log message the TUI should surface (`None` when there's
769    /// no suggestion to accept).
770    fn accept(&mut self, pid: u32) -> Option<String>;
771
772    /// User rejected the pending suggestion for `pid`. Returns the
773    /// suggestion that was rejected — the TUI logs it for replay /
774    /// counterfactual analysis.
775    fn reject(&mut self, pid: u32) -> Option<PendingSuggestion>;
776
777    /// Lookup the pending suggestion for `pid`, if any. Used by detail
778    /// panels and the status bar.
779    fn pending_for(&self, pid: u32) -> Option<PendingSuggestion>;
780
781    /// Total pending suggestion count. Used by the status bar.
782    fn pending_count(&self) -> usize;
783
784    /// Drop every pending suggestion. Called when the brain mode flips
785    /// off or the operator resets state.
786    fn clear_pending(&mut self);
787
788    /// Inject a pending suggestion for `pid`. Used by demo mode to fake
789    /// brain activity for screenshots / recordings. Implementations may
790    /// drop suggestions whose `action` string doesn't map to a known
791    /// engine action (rather than erroring out) — demo is best-effort.
792    fn set_pending(&mut self, suggestion: PendingSuggestion);
793}
794
795// ============================================================================
796// Orchestrator
797// ============================================================================
798
799/// Tick the orchestration layer once per refresh: deliver pending mailbox
800/// messages, deliver pending coordination interrupts, and bookkeep stale
801/// rows in the underlying stores.
802///
803/// Each `deliver_*` method returns `(id, status_message)` tuples the TUI
804/// surfaces in the status bar. `id` is a `u32` for mailbox (matching the
805/// brain's per-PID queue) and a `String` for interrupts (matching the
806/// coord interrupt_id). They differ deliberately — keeping the trait honest
807/// to the underlying surfaces rather than papering over the distinction.
808///
809/// Unlike `CoordView` / `BrainView` which are read-only, this trait is
810/// stateful: each call writes to the brain/coord SQLite stores. It still
811/// boxes as `Arc<dyn>` because no method needs `&mut self` — implementations
812/// open a connection per call.
813///
814/// Implementations may be no-ops when the relevant feature is off (e.g. the
815/// `coord` feature is disabled at compile time), so call sites don't need
816/// `#[cfg(feature = "...")]` guards.
817pub trait Orchestrator: Send + Sync {
818    /// Drain pending mailbox messages addressed to running sessions and
819    /// deliver them to the live terminals. Used by `App::tick` to surface
820    /// `"Brain → sess_X: 'go ahead'"` style messages.
821    fn deliver_mailbox(&self, sessions: &[SessionSnapshot]) -> Vec<(u32, String)>;
822
823    /// Deliver pending coordination interrupts (cross-agent signals) to
824    /// running sessions. Returns `(interrupt_id, status_message)` tuples.
825    fn deliver_interrupts(&self, sessions: &[SessionSnapshot]) -> Vec<(String, String)>;
826
827    /// Expire stale leases and interrupts that have passed their deadline.
828    /// Bookkeeping side-effect; best-effort, no return. Implementations
829    /// log failures rather than propagating them.
830    ///
831    /// `live_session_ids` are the sessions running now. Their leases are
832    /// renewed before anything expires (#569), so the caller passes what it
833    /// already discovered rather than the runtime scanning again.
834    fn expire_stale(&self, live_session_ids: &[String]);
835}
836
837// ============================================================================
838// Runtime aggregate
839// ============================================================================
840
841/// Single struct the binary builds at startup and hands to the TUI.
842///
843/// All fields are `Arc<dyn ...>` so the TUI doesn't care whether an impl is
844/// a thin SQLite wrapper, a remote HTTP client, or an in-memory mock — they
845/// all share the same shape.
846#[derive(Clone)]
847pub struct Runtime {
848    pub sessions: Arc<dyn SessionSource>,
849    pub brain: Arc<dyn BrainView>,
850    pub coord: Arc<dyn CoordView>,
851    pub bus: Arc<dyn BusView>,
852    pub actions: Arc<dyn Actions>,
853    pub review: Arc<dyn BrainReviewView>,
854    pub orchestrator: Arc<dyn Orchestrator>,
855    pub hive: Arc<dyn HiveActions>,
856}
857
858impl Runtime {
859    // 8-trait composition root; each view is its own arc. Splitting this into
860    // sub-records would just trade one wide ctor for several narrow ones with
861    // the same total fan-out.
862    #[allow(clippy::too_many_arguments)]
863    pub fn new(
864        sessions: Arc<dyn SessionSource>,
865        brain: Arc<dyn BrainView>,
866        coord: Arc<dyn CoordView>,
867        bus: Arc<dyn BusView>,
868        actions: Arc<dyn Actions>,
869        review: Arc<dyn BrainReviewView>,
870        orchestrator: Arc<dyn Orchestrator>,
871        hive: Arc<dyn HiveActions>,
872    ) -> Self {
873        Self {
874            sessions,
875            brain,
876            coord,
877            bus,
878            actions,
879            review,
880            orchestrator,
881            hive,
882        }
883    }
884}
885
886// ============================================================================
887// MockRuntime — for tests in this crate and in claudectl-tui
888// ============================================================================
889
890/// In-memory runtime backed by `Vec`s and interior-mutable counters. Used by
891/// tests in this crate to verify the trait shapes compile and roundtrip
892/// cleanly, and by the future `claudectl-tui` crate's tests to render the
893/// TUI against fixtures without dragging in brain / coord / bus.
894///
895/// `Actions` impls record their calls in `actions_log` so tests can assert
896/// "the TUI invoked the right side-effect" without spying on real I/O.
897#[derive(Default)]
898pub struct MockRuntime {
899    pub sessions: Vec<SessionSnapshot>,
900    pub gate_mode: std::sync::Mutex<Option<BrainGateMode>>,
901    pub decisions: Vec<DecisionSummary>,
902    pub review_queue: Vec<ReviewItemSummary>,
903    pub leases: Vec<LeaseSummary>,
904    pub handoffs: Vec<HandoffSummary>,
905    pub interrupts: Vec<InterruptSummary>,
906    pub tasks: Vec<TaskSummary>,
907    pub agents: Vec<AgentDirectoryEntry>,
908    pub roles: Vec<RoleBinding>,
909    /// Paired peers `hive_view_snapshot` reports, so a test can exercise the
910    /// peer picker without a relay on the machine running the suite.
911    pub peers: Vec<(String, Option<String>)>,
912    /// Machines `start_discovery` reports, so a test can drive the Hive tab's
913    /// nearby listing without a network.
914    pub discovered: Vec<DiscoveredMachine>,
915    /// When set, `start_discovery` answers `None`, the way a build without
916    /// the `relay` feature does, so the tab's "cannot listen" path has a test.
917    pub discovery_unavailable: bool,
918    pub actions_log: std::sync::Mutex<Vec<MockAction>>,
919}
920
921/// Recorded `Actions` calls. Tests use this to assert side-effect ordering
922/// without needing a real terminal or filesystem.
923#[derive(Debug, Clone, PartialEq, Eq)]
924pub enum MockAction {
925    Terminate {
926        pid: u32,
927    },
928    InjectText {
929        session_id: String,
930        text: String,
931    },
932    SetGateMode(BrainGateMode),
933    LogObservation(ObservationInput),
934    LogDecision(LogDecisionInput),
935    MarkCanonical {
936        decision_id: String,
937        note: Option<String>,
938    },
939    BindBusRole {
940        name: String,
941        cwd: String,
942        pid: u32,
943    },
944    CancelTask {
945        task_id: String,
946    },
947    SetSupervisorDrain {
948        draining: bool,
949    },
950    RetryTask {
951        task_id: String,
952    },
953    ApproveTask {
954        task_id: String,
955    },
956    SendSessionToPeer {
957        session_id: String,
958        transcript: Option<std::path::PathBuf>,
959        cwd: String,
960        peer: String,
961    },
962}
963
964impl PartialEq for ObservationInput {
965    fn eq(&self, other: &Self) -> bool {
966        self.session_pid == other.session_pid
967            && self.project == other.project
968            && self.tool == other.tool
969            && self.command == other.command
970            && self.observed_action == other.observed_action
971    }
972}
973
974impl Eq for ObservationInput {}
975
976impl PartialEq for LogDecisionInput {
977    fn eq(&self, other: &Self) -> bool {
978        self.session_pid == other.session_pid
979            && self.project == other.project
980            && self.tool == other.tool
981            && self.command == other.command
982            && self.user_action == other.user_action
983            && self.decision_type == other.decision_type
984            && self.override_reason == other.override_reason
985            // PendingSuggestion's field-by-field equality
986            && self.suggestion.pid == other.suggestion.pid
987            && self.suggestion.action == other.suggestion.action
988            && self.suggestion.message == other.suggestion.message
989            && self.suggestion.reasoning == other.suggestion.reasoning
990    }
991}
992
993impl Eq for LogDecisionInput {}
994
995impl MockRuntime {
996    pub fn into_runtime(self) -> Runtime {
997        let arc = Arc::new(self);
998        Runtime::new(
999            arc.clone(),
1000            arc.clone(),
1001            arc.clone(),
1002            arc.clone(),
1003            arc.clone(),
1004            arc.clone(),
1005            arc.clone(),
1006            arc,
1007        )
1008    }
1009
1010    /// Like `into_runtime`, but hands back the `Arc` as well so a test can
1011    /// read `actions()` after the TUI has driven the runtime. `into_runtime`
1012    /// alone moves the mock out of reach.
1013    pub fn into_runtime_shared(self) -> (Arc<Self>, Runtime) {
1014        let arc = Arc::new(self);
1015        let runtime = Runtime::new(
1016            arc.clone(),
1017            arc.clone(),
1018            arc.clone(),
1019            arc.clone(),
1020            arc.clone(),
1021            arc.clone(),
1022            arc.clone(),
1023            arc.clone(),
1024        );
1025        (arc, runtime)
1026    }
1027
1028    pub fn actions(&self) -> Vec<MockAction> {
1029        self.actions_log
1030            .lock()
1031            .expect("actions_log poisoned")
1032            .clone()
1033    }
1034}
1035
1036impl SessionSource for MockRuntime {
1037    fn list(&self) -> Vec<SessionSnapshot> {
1038        self.sessions.clone()
1039    }
1040}
1041
1042impl BrainView for MockRuntime {
1043    fn gate_mode(&self) -> BrainGateMode {
1044        self.gate_mode
1045            .lock()
1046            .expect("gate_mode poisoned")
1047            .unwrap_or(BrainGateMode::On)
1048    }
1049    fn recent_decisions(&self, n: usize) -> Vec<DecisionSummary> {
1050        self.decisions.iter().take(n).cloned().collect()
1051    }
1052    fn decision_count(&self) -> usize {
1053        self.decisions.len()
1054    }
1055}
1056
1057impl CoordView for MockRuntime {
1058    fn active_leases(&self) -> Vec<LeaseSummary> {
1059        self.leases.clone()
1060    }
1061    fn pending_handoffs(&self) -> Vec<HandoffSummary> {
1062        self.handoffs.clone()
1063    }
1064    fn pending_interrupts(&self) -> Vec<InterruptSummary> {
1065        self.interrupts.clone()
1066    }
1067    fn tasks(&self) -> Vec<TaskSummary> {
1068        self.tasks.clone()
1069    }
1070}
1071
1072impl BusView for MockRuntime {
1073    fn list_agents(&self) -> Vec<AgentDirectoryEntry> {
1074        self.agents.clone()
1075    }
1076    fn list_roles(&self) -> Vec<RoleBinding> {
1077        self.roles.clone()
1078    }
1079}
1080
1081impl BrainReviewView for MockRuntime {
1082    fn all_decisions(&self) -> Vec<DecisionSummary> {
1083        self.decisions.clone()
1084    }
1085    fn review_queue(&self) -> Vec<ReviewItemSummary> {
1086        self.review_queue.clone()
1087    }
1088}
1089
1090impl Orchestrator for MockRuntime {
1091    fn deliver_mailbox(&self, _sessions: &[SessionSnapshot]) -> Vec<(u32, String)> {
1092        Vec::new()
1093    }
1094    fn deliver_interrupts(&self, _sessions: &[SessionSnapshot]) -> Vec<(String, String)> {
1095        Vec::new()
1096    }
1097    fn expire_stale(&self, _live_session_ids: &[String]) {}
1098}
1099
1100impl HiveActions for MockRuntime {
1101    fn shared_skill_keys(&self) -> std::collections::HashSet<String> {
1102        std::collections::HashSet::new()
1103    }
1104    fn share_skill(&self, _skill: &crate::skills::DiscoveredSkill) -> Result<String, String> {
1105        Err("MockRuntime does not implement skill sharing".into())
1106    }
1107    fn hive_view_snapshot(&self) -> HiveViewSnapshot {
1108        HiveViewSnapshot {
1109            identity: Some("mock-local".into()),
1110            peers: self.peers.clone(),
1111        }
1112    }
1113    fn start_discovery(&self) -> Option<Box<dyn DiscoveryListener>> {
1114        if self.discovery_unavailable {
1115            return None;
1116        }
1117        Some(Box::new(MockDiscovery {
1118            machines: self.discovered.clone(),
1119        }))
1120    }
1121}
1122
1123/// The handle `MockRuntime::start_discovery` returns: a fixed listing, with
1124/// `Listener` as its source so the tab renders the real-listener wording.
1125pub struct MockDiscovery {
1126    pub machines: Vec<DiscoveredMachine>,
1127}
1128
1129impl DiscoveryListener for MockDiscovery {
1130    fn snapshot(&mut self) -> DiscoverySnapshot {
1131        DiscoverySnapshot {
1132            source: DiscoverySource::Listener,
1133            machines: self.machines.clone(),
1134        }
1135    }
1136}
1137
1138impl Actions for MockRuntime {
1139    fn terminate_session(&self, pid: u32) -> Result<(), String> {
1140        self.actions_log
1141            .lock()
1142            .expect("actions_log poisoned")
1143            .push(MockAction::Terminate { pid });
1144        Ok(())
1145    }
1146    fn inject_text(&self, session_id: &str, text: &str) -> Result<(), String> {
1147        self.actions_log
1148            .lock()
1149            .expect("actions_log poisoned")
1150            .push(MockAction::InjectText {
1151                session_id: session_id.into(),
1152                text: text.into(),
1153            });
1154        Ok(())
1155    }
1156    fn set_gate_mode(&self, mode: BrainGateMode) -> Result<(), String> {
1157        *self.gate_mode.lock().expect("gate_mode poisoned") = Some(mode);
1158        self.actions_log
1159            .lock()
1160            .expect("actions_log poisoned")
1161            .push(MockAction::SetGateMode(mode));
1162        Ok(())
1163    }
1164    fn log_observation(&self, observation: ObservationInput) -> Result<(), String> {
1165        self.actions_log
1166            .lock()
1167            .expect("actions_log poisoned")
1168            .push(MockAction::LogObservation(observation));
1169        Ok(())
1170    }
1171    fn log_decision(&self, input: LogDecisionInput) -> Result<(), String> {
1172        self.actions_log
1173            .lock()
1174            .expect("actions_log poisoned")
1175            .push(MockAction::LogDecision(input));
1176        Ok(())
1177    }
1178    fn mark_canonical(&self, decision_id: &str, note: Option<String>) -> Result<(), String> {
1179        self.actions_log
1180            .lock()
1181            .expect("actions_log poisoned")
1182            .push(MockAction::MarkCanonical {
1183                decision_id: decision_id.into(),
1184                note,
1185            });
1186        Ok(())
1187    }
1188    fn bind_bus_role(&self, name: &str, cwd: &str, pid: u32) -> Result<(), String> {
1189        self.actions_log
1190            .lock()
1191            .expect("actions_log poisoned")
1192            .push(MockAction::BindBusRole {
1193                name: name.into(),
1194                cwd: cwd.into(),
1195                pid,
1196            });
1197        Ok(())
1198    }
1199    fn cancel_task(&self, task_id: &str) -> Result<(), String> {
1200        self.actions_log
1201            .lock()
1202            .expect("actions_log poisoned")
1203            .push(MockAction::CancelTask {
1204                task_id: task_id.into(),
1205            });
1206        Ok(())
1207    }
1208    fn set_supervisor_drain(&self, draining: bool) -> Result<(), String> {
1209        self.actions_log
1210            .lock()
1211            .expect("actions_log poisoned")
1212            .push(MockAction::SetSupervisorDrain { draining });
1213        Ok(())
1214    }
1215    fn retry_task(&self, task_id: &str) -> Result<(), String> {
1216        self.actions_log
1217            .lock()
1218            .expect("actions_log poisoned")
1219            .push(MockAction::RetryTask {
1220                task_id: task_id.into(),
1221            });
1222        Ok(())
1223    }
1224    fn approve_task(&self, task_id: &str) -> Result<(), String> {
1225        self.actions_log
1226            .lock()
1227            .expect("actions_log poisoned")
1228            .push(MockAction::ApproveTask {
1229                task_id: task_id.into(),
1230            });
1231        Ok(())
1232    }
1233    fn send_session_to_peer(
1234        &self,
1235        session_id: &str,
1236        transcript: Option<&std::path::Path>,
1237        cwd: &str,
1238        peer: &str,
1239    ) -> Result<String, String> {
1240        self.actions_log.lock().expect("actions_log poisoned").push(
1241            MockAction::SendSessionToPeer {
1242                session_id: session_id.into(),
1243                transcript: transcript.map(|p| p.to_path_buf()),
1244                cwd: cwd.into(),
1245                peer: peer.into(),
1246            },
1247        );
1248        Ok(session_id.to_string())
1249    }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255
1256    fn sample_session(id: &str) -> SessionSnapshot {
1257        SessionSnapshot {
1258            session_id: id.into(),
1259            pid: 12345,
1260            cwd: "/work/proj".into(),
1261            project_name: "proj".into(),
1262            status: "Processing".into(),
1263            cost_usd: 1.23,
1264            context_tokens: 4000,
1265            context_max: 200_000,
1266            last_message_ts: 1_780_000_000,
1267        }
1268    }
1269
1270    #[test]
1271    fn mock_runtime_assembles_and_lists_sessions() {
1272        let mock = MockRuntime {
1273            sessions: vec![sample_session("sess_a"), sample_session("sess_b")],
1274            ..Default::default()
1275        };
1276        let rt = mock.into_runtime();
1277        let listed = rt.sessions.list();
1278        assert_eq!(listed.len(), 2);
1279        assert_eq!(listed[0].session_id, "sess_a");
1280    }
1281
1282    #[test]
1283    fn session_detail_falls_back_to_list_scan() {
1284        let mock = MockRuntime {
1285            sessions: vec![sample_session("sess_a"), sample_session("sess_b")],
1286            ..Default::default()
1287        };
1288        let rt = mock.into_runtime();
1289        assert!(rt.sessions.detail("sess_a").is_some());
1290        assert!(rt.sessions.detail("sess_missing").is_none());
1291    }
1292
1293    #[test]
1294    fn brain_view_returns_recent_decisions_with_cap() {
1295        let mock = MockRuntime {
1296            decisions: (0..5)
1297                .map(|i| DecisionSummary {
1298                    id: format!("dec_{i}"),
1299                    timestamp: format!("2026-06-06T00:00:0{i}Z"),
1300                    action: "approve".into(),
1301                    confidence: Some(0.9),
1302                    project: None,
1303                    tool: None,
1304                    pid: 0,
1305                    command: None,
1306                    reasoning: None,
1307                    user_action: None,
1308                    override_reason: None,
1309                    brain_decision_ms: None,
1310                    canonical: None,
1311                    cache_hit: None,
1312                    cost_usd: None,
1313                    model: None,
1314                    outcome_kind: None,
1315                    outcome_detail: None,
1316                    suggested_at: None,
1317                    resolved_at: None,
1318                    why: String::new(),
1319                    decision_source: None,
1320                    rule_name: None,
1321                })
1322                .collect(),
1323            ..Default::default()
1324        };
1325        let rt = mock.into_runtime();
1326        assert_eq!(rt.brain.recent_decisions(3).len(), 3);
1327        assert_eq!(rt.brain.decision_count(), 5);
1328        assert_eq!(rt.brain.gate_mode(), BrainGateMode::On);
1329    }
1330
1331    #[test]
1332    fn coord_view_reports_empty_state_cleanly() {
1333        let rt = MockRuntime::default().into_runtime();
1334        assert!(rt.coord.active_leases().is_empty());
1335        assert!(rt.coord.pending_handoffs().is_empty());
1336        assert!(rt.coord.pending_interrupts().is_empty());
1337        assert!(rt.coord.tasks().is_empty());
1338    }
1339
1340    #[test]
1341    fn coord_view_returns_task_fixture() {
1342        let mock = MockRuntime {
1343            tasks: vec![TaskSummary {
1344                id: "t1".into(),
1345                name: "ship the thing".into(),
1346                state: "running".into(),
1347                role: Some("backend".into()),
1348                attempts: 2,
1349                max_retries: 3,
1350                last_session_id: Some("sess-abcdef12".into()),
1351                last_verdict: Some("PASS".into()),
1352                cost_usd: 1.25,
1353                created_at: "2026-06-25T10:00:00Z".into(),
1354                updated_at: "2026-06-25T10:05:00Z".into(),
1355            }],
1356            ..Default::default()
1357        };
1358        let rt = mock.into_runtime();
1359        let tasks = rt.coord.tasks();
1360        assert_eq!(tasks.len(), 1);
1361        assert_eq!(tasks[0].state, "running");
1362        assert_eq!(tasks[0].attempts, 2);
1363    }
1364
1365    #[test]
1366    fn bus_view_reports_empty_state_cleanly() {
1367        let rt = MockRuntime::default().into_runtime();
1368        assert!(rt.bus.list_agents().is_empty());
1369        assert!(rt.bus.list_roles().is_empty());
1370    }
1371
1372    /// Smoke test that the trait shapes are usable behind `dyn`. If this
1373    /// compiles, the boxed-trait composition works.
1374    #[test]
1375    fn runtime_implements_clone_and_send() {
1376        fn assert_send<T: Send>() {}
1377        assert_send::<Runtime>();
1378
1379        let rt = MockRuntime::default().into_runtime();
1380        let _rt2 = rt.clone();
1381    }
1382
1383    #[test]
1384    fn actions_terminate_records_in_log() {
1385        let rt = MockRuntime::default().into_runtime();
1386        rt.actions.terminate_session(42).unwrap();
1387        rt.actions.terminate_session(43).unwrap();
1388        // No public mock accessor on Arc<dyn Actions>, but we can downcast
1389        // through the trait by going via the *original* Arc through inspecting
1390        // the trait's behavior — the integration test in the binary covers
1391        // that surface. Here we just assert the calls succeeded.
1392    }
1393
1394    #[test]
1395    fn actions_task_writes_record_in_log() {
1396        let mock = MockRuntime::default();
1397        // MockRuntime itself implements Actions; call directly so we can read
1398        // the recorded log via the public `actions()` accessor.
1399        mock.cancel_task("t-1").unwrap();
1400        mock.set_supervisor_drain(true).unwrap();
1401        mock.retry_task("t-2").unwrap();
1402        mock.approve_task("t-3").unwrap();
1403        let actions = mock.actions();
1404        assert!(matches!(&actions[0], MockAction::CancelTask { task_id } if task_id == "t-1"));
1405        assert!(matches!(
1406            actions[1],
1407            MockAction::SetSupervisorDrain { draining: true }
1408        ));
1409        assert!(matches!(&actions[2], MockAction::RetryTask { task_id } if task_id == "t-2"));
1410        assert!(matches!(&actions[3], MockAction::ApproveTask { task_id } if task_id == "t-3"));
1411    }
1412
1413    #[test]
1414    fn actions_set_gate_mode_round_trips_through_brain_view() {
1415        let mock = MockRuntime::default();
1416        let arc = Arc::new(mock);
1417        let rt = Runtime::new(
1418            arc.clone(),
1419            arc.clone(),
1420            arc.clone(),
1421            arc.clone(),
1422            arc.clone(),
1423            arc.clone(),
1424            arc.clone(),
1425            arc.clone(),
1426        );
1427        // Initial: default On.
1428        assert_eq!(rt.brain.gate_mode(), BrainGateMode::On);
1429        // Flip via Actions; observe via BrainView.
1430        rt.actions.set_gate_mode(BrainGateMode::Off).unwrap();
1431        assert_eq!(rt.brain.gate_mode(), BrainGateMode::Off);
1432        rt.actions.set_gate_mode(BrainGateMode::Auto).unwrap();
1433        assert_eq!(rt.brain.gate_mode(), BrainGateMode::Auto);
1434        // And the log captured both transitions.
1435        let calls = arc.actions();
1436        assert_eq!(
1437            calls,
1438            vec![
1439                MockAction::SetGateMode(BrainGateMode::Off),
1440                MockAction::SetGateMode(BrainGateMode::Auto),
1441            ]
1442        );
1443    }
1444
1445    #[test]
1446    fn actions_log_observation_captures_full_input() {
1447        let mock = MockRuntime::default();
1448        let arc = Arc::new(mock);
1449        let rt = Runtime::new(
1450            arc.clone(),
1451            arc.clone(),
1452            arc.clone(),
1453            arc.clone(),
1454            arc.clone(),
1455            arc.clone(),
1456            arc.clone(),
1457            arc.clone(),
1458        );
1459        let obs = ObservationInput {
1460            session_pid: 12345,
1461            project: "claudectl".into(),
1462            tool: Some("Bash".into()),
1463            command: Some("cargo test".into()),
1464            observed_action: "user_approve".into(),
1465        };
1466        rt.actions.log_observation(obs.clone()).unwrap();
1467        let calls = arc.actions();
1468        assert_eq!(calls, vec![MockAction::LogObservation(obs)]);
1469    }
1470
1471    #[test]
1472    fn actions_inject_text_and_mark_canonical_record_inputs() {
1473        let mock = MockRuntime::default();
1474        let arc = Arc::new(mock);
1475        let rt = Runtime::new(
1476            arc.clone(),
1477            arc.clone(),
1478            arc.clone(),
1479            arc.clone(),
1480            arc.clone(),
1481            arc.clone(),
1482            arc.clone(),
1483            arc.clone(),
1484        );
1485        rt.actions.inject_text("sess_a", "/compact\n").unwrap();
1486        rt.actions
1487            .mark_canonical("dec_42", Some("nice catch".into()))
1488            .unwrap();
1489        let calls = arc.actions();
1490        assert_eq!(
1491            calls,
1492            vec![
1493                MockAction::InjectText {
1494                    session_id: "sess_a".into(),
1495                    text: "/compact\n".into(),
1496                },
1497                MockAction::MarkCanonical {
1498                    decision_id: "dec_42".into(),
1499                    note: Some("nice catch".into()),
1500                },
1501            ]
1502        );
1503    }
1504}