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}
178
179impl DecisionSummary {
180    /// Whether the user agreed with the brain (or the call was auto-executed).
181    /// Mirrors `brain::decisions::DecisionRecord::is_positive`.
182    pub fn is_positive(&self) -> bool {
183        matches!(
184            self.user_action.as_deref(),
185            Some("accept" | "auto" | "user_approve" | "rule_approve")
186        )
187    }
188
189    /// Whether the user disagreed with the brain. Mirrors
190    /// `brain::decisions::DecisionRecord::is_negative`.
191    pub fn is_negative(&self) -> bool {
192        matches!(
193            self.user_action.as_deref(),
194            Some("reject" | "deny_rule_override" | "rule_deny" | "conflict_deny")
195        )
196    }
197}
198
199/// Read access to the brain's decision history and current mode.
200pub trait BrainView: Send + Sync {
201    fn gate_mode(&self) -> BrainGateMode;
202
203    /// Most recent `n` decisions, newest first.
204    fn recent_decisions(&self, n: usize) -> Vec<DecisionSummary>;
205
206    /// Total count of brain decisions on disk. Drives the "decisions: N"
207    /// status line.
208    fn decision_count(&self) -> usize;
209}
210
211/// One entry in the Brain Review queue — a decision worth showing the operator
212/// for canonical-marking review, with a reason and a priority score.
213#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct ReviewItemSummary {
215    pub decision: DecisionSummary,
216    /// Free-form rationale for why this decision was queued for review.
217    pub reason: String,
218    /// Priority score (higher = more important to review first).
219    pub score: f64,
220}
221
222/// Read access to the Brain Review surface — the full decision log plus the
223/// review-queue projection. Separate trait from `BrainView` because:
224///
225/// - The Brain Review screen is the only TUI consumer; the dashboard's status
226///   bar only needs `BrainView::recent_decisions(n)`.
227/// - `all_decisions()` returns the whole log (can be thousands of records);
228///   it's a heavier surface than the lightweight `BrainView` methods and
229///   worth gating behind its own trait so callers don't accidentally invoke it.
230pub trait BrainReviewView: Send + Sync {
231    /// Every decision on disk, newest first. Used by the Brain Review screen
232    /// to render the full history list.
233    fn all_decisions(&self) -> Vec<DecisionSummary>;
234
235    /// Priority-ordered review queue. Built from `all_decisions()` plus the
236    /// brain's queue-building heuristics (counterfactual hits, Critical-tier
237    /// safety cases, high-confidence misses).
238    fn review_queue(&self) -> Vec<ReviewItemSummary>;
239}
240
241// ============================================================================
242// Coordination layer
243// ============================================================================
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct LeaseSummary {
247    pub id: String,
248    pub owner_session_id: String,
249    pub resource_kind: String,
250    pub resource_value: String,
251    pub mode: String,
252    pub acquired_at: String,
253    /// ISO timestamp when the lease expires. `None` for leases held without
254    /// an explicit deadline.
255    #[serde(default)]
256    pub expires_at: Option<String>,
257}
258
259#[derive(Debug, Clone, Serialize, Deserialize)]
260pub struct HandoffSummary {
261    pub id: String,
262    pub from_session_id: String,
263    pub to_session_id: Option<String>,
264    pub task_id: String,
265    pub summary: String,
266    pub priority: String,
267    pub created_at: String,
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct InterruptSummary {
272    pub id: String,
273    pub interrupt_type: String,
274    pub priority: String,
275    pub target_session_id: String,
276    pub reason: String,
277    pub created_at: String,
278}
279
280/// Read access to coordination state (leases, handoffs, interrupts).
281/// Backed today by the `coord` SQLite store; in tests, by a fixture.
282pub trait CoordView: Send + Sync {
283    fn active_leases(&self) -> Vec<LeaseSummary>;
284    fn pending_handoffs(&self) -> Vec<HandoffSummary>;
285    fn pending_interrupts(&self) -> Vec<InterruptSummary>;
286}
287
288// ============================================================================
289// Agent bus
290// ============================================================================
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct AgentDirectoryEntry {
294    pub session_id: String,
295    pub pid: u32,
296    pub cwd: String,
297    pub project: String,
298    pub status: String,
299    pub role: Option<String>,
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct RoleBinding {
304    pub name: String,
305    pub cwd_selector: String,
306    pub last_session_id: Option<String>,
307    pub last_seen: String,
308}
309
310/// Read access to the agent-bus roster + role table. Disabled implementations
311/// (`bus` feature off in the binary) return empty vectors so the TUI can
312/// render the panel as "no bus" without conditional compilation.
313pub trait BusView: Send + Sync {
314    fn list_agents(&self) -> Vec<AgentDirectoryEntry>;
315    fn list_roles(&self) -> Vec<RoleBinding>;
316}
317
318// ============================================================================
319// Actions (write surface)
320// ============================================================================
321
322/// What the TUI needs to record alongside a user action. Core-owned so the
323/// trait doesn't drag `brain::decisions::log_observation`'s argument list
324/// upward into the TUI surface.
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct ObservationInput {
327    /// PID of the session the observation belongs to.
328    pub session_pid: u32,
329    /// Project label (usually the cwd basename).
330    pub project: String,
331    /// Tool the action targeted, when applicable ("Bash", "Write", …).
332    pub tool: Option<String>,
333    /// The command or input string the user ran/sent.
334    pub command: Option<String>,
335    /// Classification — `"user_approve"`, `"user_input"`, `"rule_approve"`,
336    /// `"rule_deny"`, and friends. Kept as a string so callers can introduce
337    /// new categories without a trait change.
338    pub observed_action: String,
339}
340
341/// Whether a decision was about a session or about an orchestration task.
342/// Mirrors `brain::decisions::DecisionType` without depending on it.
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
344#[serde(rename_all = "snake_case")]
345pub enum DecisionScope {
346    Session,
347    Orchestration,
348}
349
350impl DecisionScope {
351    /// Wire label. Matches what `brain::decisions::DecisionType::as_label`
352    /// writes to disk so the round-trip is stable.
353    pub fn as_str(&self) -> &'static str {
354        match self {
355            Self::Session => "session",
356            Self::Orchestration => "orchestration",
357        }
358    }
359}
360
361/// What the TUI needs to record when the user resolves a brain suggestion.
362/// Carries the suggestion that was on screen at the time, plus what the user
363/// did and (optionally) why they overrode the brain.
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct LogDecisionInput {
366    /// PID of the session the decision applies to.
367    pub session_pid: u32,
368    /// Project label.
369    pub project: String,
370    /// Tool name the suggestion targeted.
371    pub tool: Option<String>,
372    /// Command / input string the suggestion targeted.
373    pub command: Option<String>,
374    /// The brain's suggestion the user is resolving.
375    pub suggestion: PendingSuggestion,
376    /// What the user did — `"accept"`, `"reject"`, `"deny_rule_override"`, etc.
377    pub user_action: String,
378    /// Whether this was a session decision or an orchestration decision.
379    pub decision_type: DecisionScope,
380    /// Why the user overrode a brain denial (if applicable).
381    pub override_reason: Option<String>,
382}
383
384/// Side-effecting paths the TUI invokes when the user takes an action
385/// (terminating a session, sending a prompt, flipping the brain mode,
386/// recording an observation, marking a decision canonical for teaching).
387///
388/// Each method is fallible — implementations return a `Result` rather than
389/// silently swallowing errors so the TUI can surface failures in the status
390/// bar instead of pretending an action succeeded.
391///
392/// **Not modeled here (intentionally):**
393///
394/// - `launch::*` — launching a new Claude Code session is wider than a single
395///   call (cwd selection, model defaults, plugin propagation). Will get its
396///   own trait once the TUI's launcher surface is refactored alongside #275.
397/// - `mailbox::deliver_pending` — orchestrator-style mailbox delivery; should
398///   eventually move to an `Orchestrator` trait, not `Actions`.
399/// - `log_decision` (vs. `log_observation`) — currently only called from the
400///   plugin gate path, not the TUI. Stays a direct call until a TUI site
401///   needs it.
402pub trait Actions: Send + Sync {
403    /// Terminate the OS process with the given PID. The TUI uses this for
404    /// "kill session" hotkeys.
405    fn terminate_session(&self, pid: u32) -> Result<(), String>;
406
407    /// Inject text into the session's terminal. Implementations pick the
408    /// right backend (tmux, Kitty, etc.) from the session metadata.
409    ///
410    /// The `text` is sent verbatim — the caller is responsible for any
411    /// trailing newline they want. Sanitization (e.g. neutralizing a leading
412    /// `/`) belongs to the caller; this trait is a transport.
413    fn inject_text(&self, session_id: &str, text: &str) -> Result<(), String>;
414
415    /// Update the persisted brain gate mode. Reflected back through
416    /// `BrainView::gate_mode` on the next call.
417    fn set_gate_mode(&self, mode: BrainGateMode) -> Result<(), String>;
418
419    /// Record an observation about a user action — non-LLM "the user did X."
420    /// Drives the brain's outcome telemetry and preference distillation.
421    fn log_observation(&self, observation: ObservationInput) -> Result<(), String>;
422
423    /// Record a user's accept/reject decision on a brain suggestion. The
424    /// distinction from `log_observation` is that `log_decision` carries
425    /// the actual suggestion that was on screen — the brain pairs it with
426    /// the user's response for outcome telemetry, counterfactual analysis,
427    /// and few-shot retrieval.
428    fn log_decision(&self, input: LogDecisionInput) -> Result<(), String>;
429
430    /// Mark a past brain decision as canonical for teaching. Optional `note`
431    /// is the operator's annotation. Used by the Brain Review surface.
432    fn mark_canonical(&self, decision_id: &str, note: Option<String>) -> Result<(), String>;
433}
434
435// ============================================================================
436// Hive actions — knowledge-store (`hive`) + transport (`relay`) reads/writes
437// ============================================================================
438
439/// Snapshot of local hive state the overlay reads to render the Hive tab.
440///
441/// Mirrors the binary's same-named struct, lifted here so the TUI can hold
442/// it without depending on the binary's app module.
443#[derive(Debug, Clone, Default)]
444pub struct HiveViewSnapshot {
445    /// Local relay identity (peer ID string). `None` when the `relay`
446    /// feature is compiled out.
447    pub identity: Option<String>,
448    /// Known peers, paired with last-seen address when available.
449    pub peers: Vec<(String, Option<String>)>,
450}
451
452/// Hive + relay surface the TUI needs to render the Skills and Hive panels.
453///
454/// Separate trait from `Actions` because the underlying subsystems are
455/// feature-gated (`hive`, `relay`) — when those features are off the
456/// implementation returns empty/no-op values rather than failing.
457pub trait HiveActions: Send + Sync {
458    /// Semantic keys (`skill:<lowered-name>`) of skills already shared into
459    /// the local hive store. Used by the Skills overlay to mark "already
460    /// shared" rows.
461    fn shared_skill_keys(&self) -> std::collections::HashSet<String>;
462
463    /// Share a discovered skill into the local hive store. Returns the new
464    /// unit ID on success.
465    fn share_skill(&self, skill: &crate::skills::DiscoveredSkill) -> Result<String, String>;
466
467    /// Current view of the local relay identity + known peers for the Hive
468    /// tab.
469    fn hive_view_snapshot(&self) -> HiveViewSnapshot;
470}
471
472// ============================================================================
473// Brain driver (stateful)
474// ============================================================================
475
476/// One pending brain suggestion the user can accept or reject. Mirrors the
477/// binary's `brain::client::BrainSuggestion` without exposing brain types.
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct PendingSuggestion {
480    /// PID of the session this suggestion applies to.
481    pub pid: u32,
482    /// String label of the suggested action — `"approve"`, `"deny"`,
483    /// `"send"`, `"terminate"`. String to avoid pulling `RuleAction`'s enum
484    /// shape into the contract (it's already in `claudectl-core::rules` but
485    /// callers may want to add new categories without bumping the trait).
486    pub action: String,
487    /// Suggested message body (a prompt to inject, a rationale to surface).
488    pub message: Option<String>,
489    /// Why the brain suggested this — used for the detail panel and the
490    /// rejection log.
491    pub reasoning: String,
492    /// 0.0–1.0 confidence the brain assigned.
493    pub confidence: f64,
494    /// Epoch seconds when the suggestion was created. Drives time-to-correct
495    /// telemetry.
496    pub suggested_at: u64,
497}
498
499/// Stateful brain orchestration. Unlike the read-only views, this needs
500/// `&mut` per tick — the engine accumulates pending suggestions, cleans up
501/// after exited sessions, and applies user accept/reject decisions.
502///
503/// Boxed as `Box<dyn BrainDriver>` rather than `Arc<dyn BrainDriver>`
504/// because there is exactly one owner (the TUI's `App`) and every method
505/// needs `&mut self`. Sharing across threads is not a goal here.
506///
507/// Implementations may be `None` — the brain is opt-in (`--brain` flag).
508/// When the field is `None` the TUI renders without brain interaction.
509pub trait BrainDriver: Send {
510    /// Run one tick of the brain loop against the current session set and
511    /// the operator's deny rules. Returns `(pid, status_message)` pairs
512    /// for any actions the brain decided on this tick — the TUI surfaces
513    /// the messages in the status bar.
514    fn tick(
515        &mut self,
516        sessions: &[SessionSnapshot],
517        deny_rules: &[crate::rules::AutoRule],
518    ) -> Vec<(u32, String)>;
519
520    /// Drop pending suggestions for sessions that have exited. Called every
521    /// refresh so the pending map stays bounded by the live session count.
522    fn cleanup(&mut self, sessions: &[SessionSnapshot]);
523
524    /// User accepted the pending suggestion for `pid`. Implementations
525    /// return the log message the TUI should surface (`None` when there's
526    /// no suggestion to accept).
527    fn accept(&mut self, pid: u32) -> Option<String>;
528
529    /// User rejected the pending suggestion for `pid`. Returns the
530    /// suggestion that was rejected — the TUI logs it for replay /
531    /// counterfactual analysis.
532    fn reject(&mut self, pid: u32) -> Option<PendingSuggestion>;
533
534    /// Lookup the pending suggestion for `pid`, if any. Used by detail
535    /// panels and the status bar.
536    fn pending_for(&self, pid: u32) -> Option<PendingSuggestion>;
537
538    /// Total pending suggestion count. Used by the status bar.
539    fn pending_count(&self) -> usize;
540
541    /// Drop every pending suggestion. Called when the brain mode flips
542    /// off or the operator resets state.
543    fn clear_pending(&mut self);
544
545    /// Inject a pending suggestion for `pid`. Used by demo mode to fake
546    /// brain activity for screenshots / recordings. Implementations may
547    /// drop suggestions whose `action` string doesn't map to a known
548    /// engine action (rather than erroring out) — demo is best-effort.
549    fn set_pending(&mut self, suggestion: PendingSuggestion);
550}
551
552// ============================================================================
553// Orchestrator
554// ============================================================================
555
556/// Tick the orchestration layer once per refresh: deliver pending mailbox
557/// messages, deliver pending coordination interrupts, and bookkeep stale
558/// rows in the underlying stores.
559///
560/// Each `deliver_*` method returns `(id, status_message)` tuples the TUI
561/// surfaces in the status bar. `id` is a `u32` for mailbox (matching the
562/// brain's per-PID queue) and a `String` for interrupts (matching the
563/// coord interrupt_id). They differ deliberately — keeping the trait honest
564/// to the underlying surfaces rather than papering over the distinction.
565///
566/// Unlike `CoordView` / `BrainView` which are read-only, this trait is
567/// stateful: each call writes to the brain/coord SQLite stores. It still
568/// boxes as `Arc<dyn>` because no method needs `&mut self` — implementations
569/// open a connection per call.
570///
571/// Implementations may be no-ops when the relevant feature is off (e.g. the
572/// `coord` feature is disabled at compile time), so call sites don't need
573/// `#[cfg(feature = "...")]` guards.
574pub trait Orchestrator: Send + Sync {
575    /// Drain pending mailbox messages addressed to running sessions and
576    /// deliver them to the live terminals. Used by `App::tick` to surface
577    /// `"Brain → sess_X: 'go ahead'"` style messages.
578    fn deliver_mailbox(&self, sessions: &[SessionSnapshot]) -> Vec<(u32, String)>;
579
580    /// Deliver pending coordination interrupts (cross-agent signals) to
581    /// running sessions. Returns `(interrupt_id, status_message)` tuples.
582    fn deliver_interrupts(&self, sessions: &[SessionSnapshot]) -> Vec<(String, String)>;
583
584    /// Expire stale leases and interrupts that have passed their deadline.
585    /// Bookkeeping side-effect; best-effort, no return. Implementations
586    /// log failures rather than propagating them.
587    fn expire_stale(&self);
588}
589
590// ============================================================================
591// Runtime aggregate
592// ============================================================================
593
594/// Single struct the binary builds at startup and hands to the TUI.
595///
596/// All fields are `Arc<dyn ...>` so the TUI doesn't care whether an impl is
597/// a thin SQLite wrapper, a remote HTTP client, or an in-memory mock — they
598/// all share the same shape.
599#[derive(Clone)]
600pub struct Runtime {
601    pub sessions: Arc<dyn SessionSource>,
602    pub brain: Arc<dyn BrainView>,
603    pub coord: Arc<dyn CoordView>,
604    pub bus: Arc<dyn BusView>,
605    pub actions: Arc<dyn Actions>,
606    pub review: Arc<dyn BrainReviewView>,
607    pub orchestrator: Arc<dyn Orchestrator>,
608    pub hive: Arc<dyn HiveActions>,
609}
610
611impl Runtime {
612    // 8-trait composition root; each view is its own arc. Splitting this into
613    // sub-records would just trade one wide ctor for several narrow ones with
614    // the same total fan-out.
615    #[allow(clippy::too_many_arguments)]
616    pub fn new(
617        sessions: Arc<dyn SessionSource>,
618        brain: Arc<dyn BrainView>,
619        coord: Arc<dyn CoordView>,
620        bus: Arc<dyn BusView>,
621        actions: Arc<dyn Actions>,
622        review: Arc<dyn BrainReviewView>,
623        orchestrator: Arc<dyn Orchestrator>,
624        hive: Arc<dyn HiveActions>,
625    ) -> Self {
626        Self {
627            sessions,
628            brain,
629            coord,
630            bus,
631            actions,
632            review,
633            orchestrator,
634            hive,
635        }
636    }
637}
638
639// ============================================================================
640// MockRuntime — for tests in this crate and in claudectl-tui
641// ============================================================================
642
643/// In-memory runtime backed by `Vec`s and interior-mutable counters. Used by
644/// tests in this crate to verify the trait shapes compile and roundtrip
645/// cleanly, and by the future `claudectl-tui` crate's tests to render the
646/// TUI against fixtures without dragging in brain / coord / bus.
647///
648/// `Actions` impls record their calls in `actions_log` so tests can assert
649/// "the TUI invoked the right side-effect" without spying on real I/O.
650#[derive(Default)]
651pub struct MockRuntime {
652    pub sessions: Vec<SessionSnapshot>,
653    pub gate_mode: std::sync::Mutex<Option<BrainGateMode>>,
654    pub decisions: Vec<DecisionSummary>,
655    pub review_queue: Vec<ReviewItemSummary>,
656    pub leases: Vec<LeaseSummary>,
657    pub handoffs: Vec<HandoffSummary>,
658    pub interrupts: Vec<InterruptSummary>,
659    pub agents: Vec<AgentDirectoryEntry>,
660    pub roles: Vec<RoleBinding>,
661    pub actions_log: std::sync::Mutex<Vec<MockAction>>,
662}
663
664/// Recorded `Actions` calls. Tests use this to assert side-effect ordering
665/// without needing a real terminal or filesystem.
666#[derive(Debug, Clone, PartialEq, Eq)]
667pub enum MockAction {
668    Terminate {
669        pid: u32,
670    },
671    InjectText {
672        session_id: String,
673        text: String,
674    },
675    SetGateMode(BrainGateMode),
676    LogObservation(ObservationInput),
677    LogDecision(LogDecisionInput),
678    MarkCanonical {
679        decision_id: String,
680        note: Option<String>,
681    },
682}
683
684impl PartialEq for ObservationInput {
685    fn eq(&self, other: &Self) -> bool {
686        self.session_pid == other.session_pid
687            && self.project == other.project
688            && self.tool == other.tool
689            && self.command == other.command
690            && self.observed_action == other.observed_action
691    }
692}
693
694impl Eq for ObservationInput {}
695
696impl PartialEq for LogDecisionInput {
697    fn eq(&self, other: &Self) -> bool {
698        self.session_pid == other.session_pid
699            && self.project == other.project
700            && self.tool == other.tool
701            && self.command == other.command
702            && self.user_action == other.user_action
703            && self.decision_type == other.decision_type
704            && self.override_reason == other.override_reason
705            // PendingSuggestion's field-by-field equality
706            && self.suggestion.pid == other.suggestion.pid
707            && self.suggestion.action == other.suggestion.action
708            && self.suggestion.message == other.suggestion.message
709            && self.suggestion.reasoning == other.suggestion.reasoning
710    }
711}
712
713impl Eq for LogDecisionInput {}
714
715impl MockRuntime {
716    pub fn into_runtime(self) -> Runtime {
717        let arc = Arc::new(self);
718        Runtime::new(
719            arc.clone(),
720            arc.clone(),
721            arc.clone(),
722            arc.clone(),
723            arc.clone(),
724            arc.clone(),
725            arc.clone(),
726            arc,
727        )
728    }
729
730    pub fn actions(&self) -> Vec<MockAction> {
731        self.actions_log
732            .lock()
733            .expect("actions_log poisoned")
734            .clone()
735    }
736}
737
738impl SessionSource for MockRuntime {
739    fn list(&self) -> Vec<SessionSnapshot> {
740        self.sessions.clone()
741    }
742}
743
744impl BrainView for MockRuntime {
745    fn gate_mode(&self) -> BrainGateMode {
746        self.gate_mode
747            .lock()
748            .expect("gate_mode poisoned")
749            .unwrap_or(BrainGateMode::On)
750    }
751    fn recent_decisions(&self, n: usize) -> Vec<DecisionSummary> {
752        self.decisions.iter().take(n).cloned().collect()
753    }
754    fn decision_count(&self) -> usize {
755        self.decisions.len()
756    }
757}
758
759impl CoordView for MockRuntime {
760    fn active_leases(&self) -> Vec<LeaseSummary> {
761        self.leases.clone()
762    }
763    fn pending_handoffs(&self) -> Vec<HandoffSummary> {
764        self.handoffs.clone()
765    }
766    fn pending_interrupts(&self) -> Vec<InterruptSummary> {
767        self.interrupts.clone()
768    }
769}
770
771impl BusView for MockRuntime {
772    fn list_agents(&self) -> Vec<AgentDirectoryEntry> {
773        self.agents.clone()
774    }
775    fn list_roles(&self) -> Vec<RoleBinding> {
776        self.roles.clone()
777    }
778}
779
780impl BrainReviewView for MockRuntime {
781    fn all_decisions(&self) -> Vec<DecisionSummary> {
782        self.decisions.clone()
783    }
784    fn review_queue(&self) -> Vec<ReviewItemSummary> {
785        self.review_queue.clone()
786    }
787}
788
789impl Orchestrator for MockRuntime {
790    fn deliver_mailbox(&self, _sessions: &[SessionSnapshot]) -> Vec<(u32, String)> {
791        Vec::new()
792    }
793    fn deliver_interrupts(&self, _sessions: &[SessionSnapshot]) -> Vec<(String, String)> {
794        Vec::new()
795    }
796    fn expire_stale(&self) {}
797}
798
799impl HiveActions for MockRuntime {
800    fn shared_skill_keys(&self) -> std::collections::HashSet<String> {
801        std::collections::HashSet::new()
802    }
803    fn share_skill(&self, _skill: &crate::skills::DiscoveredSkill) -> Result<String, String> {
804        Err("MockRuntime does not implement skill sharing".into())
805    }
806    fn hive_view_snapshot(&self) -> HiveViewSnapshot {
807        HiveViewSnapshot::default()
808    }
809}
810
811impl Actions for MockRuntime {
812    fn terminate_session(&self, pid: u32) -> Result<(), String> {
813        self.actions_log
814            .lock()
815            .expect("actions_log poisoned")
816            .push(MockAction::Terminate { pid });
817        Ok(())
818    }
819    fn inject_text(&self, session_id: &str, text: &str) -> Result<(), String> {
820        self.actions_log
821            .lock()
822            .expect("actions_log poisoned")
823            .push(MockAction::InjectText {
824                session_id: session_id.into(),
825                text: text.into(),
826            });
827        Ok(())
828    }
829    fn set_gate_mode(&self, mode: BrainGateMode) -> Result<(), String> {
830        *self.gate_mode.lock().expect("gate_mode poisoned") = Some(mode);
831        self.actions_log
832            .lock()
833            .expect("actions_log poisoned")
834            .push(MockAction::SetGateMode(mode));
835        Ok(())
836    }
837    fn log_observation(&self, observation: ObservationInput) -> Result<(), String> {
838        self.actions_log
839            .lock()
840            .expect("actions_log poisoned")
841            .push(MockAction::LogObservation(observation));
842        Ok(())
843    }
844    fn log_decision(&self, input: LogDecisionInput) -> Result<(), String> {
845        self.actions_log
846            .lock()
847            .expect("actions_log poisoned")
848            .push(MockAction::LogDecision(input));
849        Ok(())
850    }
851    fn mark_canonical(&self, decision_id: &str, note: Option<String>) -> Result<(), String> {
852        self.actions_log
853            .lock()
854            .expect("actions_log poisoned")
855            .push(MockAction::MarkCanonical {
856                decision_id: decision_id.into(),
857                note,
858            });
859        Ok(())
860    }
861}
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866
867    fn sample_session(id: &str) -> SessionSnapshot {
868        SessionSnapshot {
869            session_id: id.into(),
870            pid: 12345,
871            cwd: "/work/proj".into(),
872            project_name: "proj".into(),
873            status: "Processing".into(),
874            cost_usd: 1.23,
875            context_tokens: 4000,
876            context_max: 200_000,
877            last_message_ts: 1_780_000_000,
878        }
879    }
880
881    #[test]
882    fn mock_runtime_assembles_and_lists_sessions() {
883        let mock = MockRuntime {
884            sessions: vec![sample_session("sess_a"), sample_session("sess_b")],
885            ..Default::default()
886        };
887        let rt = mock.into_runtime();
888        let listed = rt.sessions.list();
889        assert_eq!(listed.len(), 2);
890        assert_eq!(listed[0].session_id, "sess_a");
891    }
892
893    #[test]
894    fn session_detail_falls_back_to_list_scan() {
895        let mock = MockRuntime {
896            sessions: vec![sample_session("sess_a"), sample_session("sess_b")],
897            ..Default::default()
898        };
899        let rt = mock.into_runtime();
900        assert!(rt.sessions.detail("sess_a").is_some());
901        assert!(rt.sessions.detail("sess_missing").is_none());
902    }
903
904    #[test]
905    fn brain_view_returns_recent_decisions_with_cap() {
906        let mock = MockRuntime {
907            decisions: (0..5)
908                .map(|i| DecisionSummary {
909                    id: format!("dec_{i}"),
910                    timestamp: format!("2026-06-06T00:00:0{i}Z"),
911                    action: "approve".into(),
912                    confidence: Some(0.9),
913                    project: None,
914                    tool: None,
915                    pid: 0,
916                    command: None,
917                    reasoning: None,
918                    user_action: None,
919                    override_reason: None,
920                    brain_decision_ms: None,
921                    canonical: None,
922                    cache_hit: None,
923                    cost_usd: None,
924                    model: None,
925                    outcome_kind: None,
926                    outcome_detail: None,
927                    suggested_at: None,
928                    resolved_at: None,
929                })
930                .collect(),
931            ..Default::default()
932        };
933        let rt = mock.into_runtime();
934        assert_eq!(rt.brain.recent_decisions(3).len(), 3);
935        assert_eq!(rt.brain.decision_count(), 5);
936        assert_eq!(rt.brain.gate_mode(), BrainGateMode::On);
937    }
938
939    #[test]
940    fn coord_view_reports_empty_state_cleanly() {
941        let rt = MockRuntime::default().into_runtime();
942        assert!(rt.coord.active_leases().is_empty());
943        assert!(rt.coord.pending_handoffs().is_empty());
944        assert!(rt.coord.pending_interrupts().is_empty());
945    }
946
947    #[test]
948    fn bus_view_reports_empty_state_cleanly() {
949        let rt = MockRuntime::default().into_runtime();
950        assert!(rt.bus.list_agents().is_empty());
951        assert!(rt.bus.list_roles().is_empty());
952    }
953
954    /// Smoke test that the trait shapes are usable behind `dyn`. If this
955    /// compiles, the boxed-trait composition works.
956    #[test]
957    fn runtime_implements_clone_and_send() {
958        fn assert_send<T: Send>() {}
959        assert_send::<Runtime>();
960
961        let rt = MockRuntime::default().into_runtime();
962        let _rt2 = rt.clone();
963    }
964
965    #[test]
966    fn actions_terminate_records_in_log() {
967        let rt = MockRuntime::default().into_runtime();
968        rt.actions.terminate_session(42).unwrap();
969        rt.actions.terminate_session(43).unwrap();
970        // No public mock accessor on Arc<dyn Actions>, but we can downcast
971        // through the trait by going via the *original* Arc through inspecting
972        // the trait's behavior — the integration test in the binary covers
973        // that surface. Here we just assert the calls succeeded.
974    }
975
976    #[test]
977    fn actions_set_gate_mode_round_trips_through_brain_view() {
978        let mock = MockRuntime::default();
979        let arc = Arc::new(mock);
980        let rt = Runtime::new(
981            arc.clone(),
982            arc.clone(),
983            arc.clone(),
984            arc.clone(),
985            arc.clone(),
986            arc.clone(),
987            arc.clone(),
988            arc.clone(),
989        );
990        // Initial: default On.
991        assert_eq!(rt.brain.gate_mode(), BrainGateMode::On);
992        // Flip via Actions; observe via BrainView.
993        rt.actions.set_gate_mode(BrainGateMode::Off).unwrap();
994        assert_eq!(rt.brain.gate_mode(), BrainGateMode::Off);
995        rt.actions.set_gate_mode(BrainGateMode::Auto).unwrap();
996        assert_eq!(rt.brain.gate_mode(), BrainGateMode::Auto);
997        // And the log captured both transitions.
998        let calls = arc.actions();
999        assert_eq!(
1000            calls,
1001            vec![
1002                MockAction::SetGateMode(BrainGateMode::Off),
1003                MockAction::SetGateMode(BrainGateMode::Auto),
1004            ]
1005        );
1006    }
1007
1008    #[test]
1009    fn actions_log_observation_captures_full_input() {
1010        let mock = MockRuntime::default();
1011        let arc = Arc::new(mock);
1012        let rt = Runtime::new(
1013            arc.clone(),
1014            arc.clone(),
1015            arc.clone(),
1016            arc.clone(),
1017            arc.clone(),
1018            arc.clone(),
1019            arc.clone(),
1020            arc.clone(),
1021        );
1022        let obs = ObservationInput {
1023            session_pid: 12345,
1024            project: "claudectl".into(),
1025            tool: Some("Bash".into()),
1026            command: Some("cargo test".into()),
1027            observed_action: "user_approve".into(),
1028        };
1029        rt.actions.log_observation(obs.clone()).unwrap();
1030        let calls = arc.actions();
1031        assert_eq!(calls, vec![MockAction::LogObservation(obs)]);
1032    }
1033
1034    #[test]
1035    fn actions_inject_text_and_mark_canonical_record_inputs() {
1036        let mock = MockRuntime::default();
1037        let arc = Arc::new(mock);
1038        let rt = Runtime::new(
1039            arc.clone(),
1040            arc.clone(),
1041            arc.clone(),
1042            arc.clone(),
1043            arc.clone(),
1044            arc.clone(),
1045            arc.clone(),
1046            arc.clone(),
1047        );
1048        rt.actions.inject_text("sess_a", "/compact\n").unwrap();
1049        rt.actions
1050            .mark_canonical("dec_42", Some("nice catch".into()))
1051            .unwrap();
1052        let calls = arc.actions();
1053        assert_eq!(
1054            calls,
1055            vec![
1056                MockAction::InjectText {
1057                    session_id: "sess_a".into(),
1058                    text: "/compact\n".into(),
1059                },
1060                MockAction::MarkCanonical {
1061                    decision_id: "dec_42".into(),
1062                    note: Some("nice catch".into()),
1063                },
1064            ]
1065        );
1066    }
1067}