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