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}