trusty_memory/events.rs
1//! Daemon activity events and their persistence fallback.
2//!
3//! Why: the SSE dashboard and the persistent activity log both need a single
4//! typed vocabulary for "something happened" (palace created, drawer added,
5//! dream completed, hook fired). Keeping the event enum, its hook/injection
6//! labels, and the best-effort log-open fallback together — separate from the
7//! `AppState` plumbing in `lib.rs` — keeps each file focused and under the
8//! SLOC cap (#1195).
9//! What: exports `DaemonEvent`, `HookType`, `InjectionKind` — re-exported at
10//! the crate root, so existing `trusty_memory::DaemonEvent` paths are
11//! unchanged — and the crate-internal `open_activity_log_with_fallback` helper.
12//! Test: `lib_tests` covers `type_str`/`palace_id`/`source` extraction, the
13//! serde round-trips, and the discard fallback branch.
14
15use crate::{ActivityLog, ActivitySource};
16use std::path::Path;
17use std::sync::Arc;
18
19/// Hook type — labels the Claude Code hook that triggered a submission.
20///
21/// Why: every hook firing produces an activity-feed entry tagged with the
22/// originating hook so operators can tell whether activity came from a user
23/// prompt (`UserPromptSubmit`), a new session (`SessionStart`), or a future
24/// hook variant. Threading this through `DaemonEvent::HookFired` lets the
25/// dashboard badge each row with the hook label.
26/// What: serde-serialised in PascalCase so the wire format matches Claude
27/// Code's own hook-name strings exactly (e.g. `"UserPromptSubmit"`).
28/// Test: `hook_type_serde_round_trips`.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
30pub enum HookType {
31 /// Claude Code's `UserPromptSubmit` hook — fires on every user prompt.
32 UserPromptSubmit,
33 /// Claude Code's `SessionStart` hook — fires once at session open.
34 SessionStart,
35}
36
37impl HookType {
38 /// Stable string label used for the wire format.
39 pub fn as_str(&self) -> &'static str {
40 match self {
41 Self::UserPromptSubmit => "UserPromptSubmit",
42 Self::SessionStart => "SessionStart",
43 }
44 }
45}
46
47/// Injection kind — labels what the hook actually injected (or attempted).
48///
49/// Why: distinct from `HookType` because one hook could in principle render
50/// more than one kind of injection (e.g. SessionStart can deliver both an
51/// inbox check and bootstrap context). Tagging the rendered kind explicitly
52/// keeps the activity log searchable when that fan-out lands.
53/// What: serde-serialised as kebab-case so it matches the labels already
54/// used in the JSONL prompt log (`prompt-context-facts`,
55/// `inbox-check-messages`).
56/// Test: `injection_kind_serde_round_trips`.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
58#[serde(rename_all = "kebab-case")]
59pub enum InjectionKind {
60 /// `prompt-context` hook rendered the prompt-facts block.
61 PromptContext,
62 /// `inbox-check` hook delivered unread messages.
63 InboxCheck,
64}
65
66impl InjectionKind {
67 /// Stable string label used for the wire format.
68 pub fn as_str(&self) -> &'static str {
69 match self {
70 Self::PromptContext => "prompt-context",
71 Self::InboxCheck => "inbox-check",
72 }
73 }
74}
75
76/// Live daemon events broadcast to connected SSE subscribers.
77///
78/// Why: The dashboard needs push-driven updates so palace creation, drawer
79/// add/delete, dream cycles, and aggregate status changes are visible without
80/// polling. A single broadcast channel fans out to every connected browser.
81/// What: Tagged enum serialized as `{"type": "...", ...fields}` over SSE.
82/// Test: `web::tests::sse_stream_emits_events` subscribes, triggers a
83/// mutation, and asserts the frame arrives.
84#[derive(Clone, Debug, serde::Serialize)]
85#[serde(tag = "type", rename_all = "snake_case")]
86pub enum DaemonEvent {
87 PalaceCreated {
88 id: String,
89 name: String,
90 /// Originating subsystem (HTTP, MCP, Hook). Why (issue #96): the
91 /// UI badges each row with its source so operators can tell at a
92 /// glance whether a write came from the dashboard form, an MCP
93 /// tool call, or a hook-driven path. The wire-format key is
94 /// `source` (lower-case strings via serde rename_all on
95 /// `ActivitySource`).
96 source: ActivitySource,
97 },
98 DrawerAdded {
99 palace_id: String,
100 /// Friendly palace name (Palace.name) at write time. Why: lets SSE
101 /// consumers (the dashboard activity feed) render the human-readable
102 /// label without a separate id→name lookup. Empty string if the
103 /// emitter could not resolve the name.
104 #[serde(default)]
105 palace_name: String,
106 drawer_count: usize,
107 /// Wall-clock timestamp when the drawer was added. Why: SSE
108 /// receivers want to render "just now / 2m ago" relative to the
109 /// daemon's clock, not the time the SSE frame happens to arrive.
110 timestamp: chrono::DateTime<chrono::Utc>,
111 /// Short preview of the drawer's content (whitespace-collapsed,
112 /// truncated to ~80 chars with an ellipsis when cut). Why: the TUI
113 /// activity feed and dashboard ticker want to show *what* was
114 /// stored, not just the running drawer count. Empty when the
115 /// emitter could not resolve the content (legacy clients tolerate
116 /// the missing field via `#[serde(default)]`).
117 #[serde(default)]
118 content_preview: String,
119 /// Originating subsystem (issue #96).
120 source: ActivitySource,
121 },
122 DrawerDeleted {
123 palace_id: String,
124 drawer_count: usize,
125 /// Originating subsystem (issue #96).
126 source: ActivitySource,
127 },
128 DreamCompleted {
129 palace_id: Option<String>,
130 merged: usize,
131 pruned: usize,
132 compacted: usize,
133 closets_updated: usize,
134 duration_ms: u64,
135 /// Originating subsystem (issue #96).
136 source: ActivitySource,
137 },
138 StatusChanged {
139 total_drawers: usize,
140 total_vectors: usize,
141 total_kg_triples: usize,
142 },
143 /// A Claude Code hook completed and rendered (or attempted to render) an
144 /// injection block.
145 ///
146 /// Why: pre-#XXX the activity feed only fired on drawer / palace / dream
147 /// writes, which meant a normal Claude Code session — whose only daemon
148 /// traffic is hook invocations — left the feed empty. Surfacing every
149 /// hook firing answers the user complaint "no activity in the TUI" and
150 /// gives operators a way to see how often each project palace is
151 /// actually picking up prompt-context / inbox-check work.
152 /// What: carries the resolved palace (or `None` if cwd resolution
153 /// failed), the [`HookType`] label, the [`InjectionKind`] label, the
154 /// rendered injection byte length, a short excerpt of the triggering
155 /// prompt (capped at ~80 chars; the full content stays in the JSONL
156 /// prompt log only), the timestamp, the hook's wall-clock duration,
157 /// and the [`ActivitySource`] tag (always `Hook` for this variant).
158 /// Backwards-compatible: SSE clients that do not recognise the
159 /// `hook_fired` `type` tag can safely ignore the frame.
160 HookFired {
161 /// Resolved palace id (slug) — `None` if cwd resolution failed.
162 #[serde(default)]
163 palace_id: Option<String>,
164 /// Friendly palace name at hook time — `None` if the registry
165 /// could not be consulted (HTTP path uses `palace_id` here when
166 /// no separate name is known).
167 #[serde(default)]
168 palace_name: Option<String>,
169 hook_type: HookType,
170 injection_kind: InjectionKind,
171 /// Rendered injection size in bytes (`0` when no injection was
172 /// emitted, e.g. SessionStart with an empty inbox).
173 injection_length: u64,
174 /// Short excerpt of the triggering prompt for the activity feed
175 /// display. Capped at ~80 chars with a trailing `…` when cut.
176 /// Why: the activity feed renders this directly; full prompt
177 /// content (which may be sensitive) stays in the JSONL log.
178 #[serde(default)]
179 trigger_prompt_excerpt: String,
180 timestamp: chrono::DateTime<chrono::Utc>,
181 /// Hook wall-clock duration in milliseconds.
182 duration_ms: u64,
183 /// Always `ActivitySource::Hook` for this variant; encoded explicitly
184 /// so the same dispatch path (`emit`) can persist + broadcast it.
185 source: ActivitySource,
186 },
187}
188
189impl DaemonEvent {
190 /// Short discriminant label matching the SSE `type` field.
191 ///
192 /// Why: the persisted activity log stores `event_type` as a string so
193 /// the UI can render the row without re-parsing the payload. Sharing
194 /// the same labels the SSE serializer uses keeps the wire and the
195 /// stored history consistent.
196 /// What: returns one of `palace_created`, `drawer_added`,
197 /// `drawer_deleted`, `dream_completed`, `status_changed`.
198 /// Test: `daemon_event_type_str_matches_sse_tag` in the lib tests.
199 pub fn type_str(&self) -> &'static str {
200 match self {
201 Self::PalaceCreated { .. } => "palace_created",
202 Self::DrawerAdded { .. } => "drawer_added",
203 Self::DrawerDeleted { .. } => "drawer_deleted",
204 Self::DreamCompleted { .. } => "dream_completed",
205 Self::StatusChanged { .. } => "status_changed",
206 Self::HookFired { .. } => "hook_fired",
207 }
208 }
209
210 /// `palace_id` if the event is scoped to a single palace.
211 ///
212 /// Why: the activity log indexes entries by palace id so the UI can
213 /// filter by palace; daemon-wide events (`status_changed`,
214 /// dream-across-all-palaces) return `None`.
215 /// What: returns a borrowed string when the variant carries a palace
216 /// id, otherwise `None`.
217 /// Test: `daemon_event_palace_id_extraction`.
218 pub fn palace_id(&self) -> Option<&str> {
219 match self {
220 Self::PalaceCreated { id, .. } => Some(id),
221 Self::DrawerAdded { palace_id, .. } | Self::DrawerDeleted { palace_id, .. } => {
222 Some(palace_id)
223 }
224 Self::DreamCompleted { palace_id, .. } => palace_id.as_deref(),
225 Self::HookFired { palace_id, .. } => palace_id.as_deref(),
226 Self::StatusChanged { .. } => None,
227 }
228 }
229
230 /// Originating subsystem if the event carries one.
231 ///
232 /// Why: only mutation events carry a `source`; the aggregate
233 /// `StatusChanged` is recomputed by the daemon and has no caller, so
234 /// it returns `None`.
235 /// What: returns the variant's `source` field where present.
236 /// Test: `daemon_event_source_extraction`.
237 pub fn source(&self) -> Option<ActivitySource> {
238 match self {
239 Self::PalaceCreated { source, .. }
240 | Self::DrawerAdded { source, .. }
241 | Self::DrawerDeleted { source, .. }
242 | Self::DreamCompleted { source, .. }
243 | Self::HookFired { source, .. } => Some(*source),
244 Self::StatusChanged { .. } => None,
245 }
246 }
247}
248
249/// Open the activity log under `data_root`, falling back to a per-process
250/// tempdir and finally to a no-op `Discard` variant when no writable
251/// directory is available.
252///
253/// Why (issues #96, #225): the activity log is a best-effort feature — if
254/// the data root is on a read-only mount, missing, or locked by another
255/// process, the daemon should still come up and serve every other endpoint.
256/// The first fallback is a `std::env::temp_dir()`-anchored subdirectory
257/// keyed by the daemon's process id. Issue #225: a previous version called
258/// `expect()` on the tempdir fallback, which crashed the daemon on hosts
259/// where neither `data_root` nor `std::env::temp_dir()` is writable
260/// (read-only containers, locked-down sandboxes). The contract is
261/// "best-effort", so the final fallback is now `ActivityLog::discard()` —
262/// a no-op variant that drops every append and returns empty reads. The
263/// dashboard's activity feed simply shows up empty in that degraded state.
264/// What: tries `ActivityLog::open(data_root)`; on error logs a warning and
265/// retries against `<temp>/trusty-memory-activity-<pid>/`. If both fail,
266/// emits a final warning and returns `ActivityLog::discard()`.
267/// Test: `open_activity_log_with_fallback_returns_discard_when_unwritable`
268/// covers the discard branch; existing `AppState` construction tests cover
269/// the happy and tempdir-fallback paths.
270pub(crate) fn open_activity_log_with_fallback(data_root: &Path) -> Arc<ActivityLog> {
271 open_activity_log_with_fallback_in(data_root, &std::env::temp_dir())
272}
273
274/// Same as [`open_activity_log_with_fallback`], but the tempdir-fallback
275/// ROOT is an explicit parameter rather than always `std::env::temp_dir()`
276/// (issue #3434).
277///
278/// Why: the discard-path test needs to force BOTH the primary data root and
279/// the tempdir fallback to be unwritable. The previous version of that test
280/// did this by mutating the process-global `TMPDIR` env var for the
281/// duration of the test — but `cargo test` runs every test in this crate's
282/// lib binary as threads of ONE process, so any OTHER test that calls
283/// `tempfile::tempdir()` (which reads `$TMPDIR`) while the mutation was live
284/// would itself fail with `PermissionDenied`, for a reason entirely
285/// unrelated to its own code. Splitting the fallback root out into a
286/// parameter removes the shared mutable global from this path entirely —
287/// mirroring the same "thread it through instead of mutating the env var"
288/// fix already applied to `trusty-code`'s `catchup::pm_catchup_context`
289/// (#3003) and `session::memory_sink::TurnMemorySink` for the identical
290/// class of bug — so the test needs no lock, no restore-on-panic guard, and
291/// cannot leak state into any concurrently-running test.
292/// What: tries `ActivityLog::open(data_root)`; on error, retries against
293/// `<fallback_root>/trusty-memory-activity-<pid>/`; if that also fails,
294/// returns `ActivityLog::discard()`. Identical behaviour to
295/// [`open_activity_log_with_fallback`], which is now a thin wrapper passing
296/// `std::env::temp_dir()` as `fallback_root`.
297/// Test: `open_activity_log_with_fallback_returns_discard_when_unwritable`.
298pub(crate) fn open_activity_log_with_fallback_in(
299 data_root: &Path,
300 fallback_root: &Path,
301) -> Arc<ActivityLog> {
302 match ActivityLog::open(data_root) {
303 Ok(log) => Arc::new(log),
304 Err(primary_err) => {
305 tracing::warn!(
306 "could not open activity log at {}: {primary_err:#}; falling back to per-process tempdir",
307 data_root.display()
308 );
309 let fallback =
310 fallback_root.join(format!("trusty-memory-activity-{}", std::process::id()));
311 match ActivityLog::open(&fallback) {
312 Ok(log) => Arc::new(log),
313 Err(fallback_err) => {
314 tracing::warn!(
315 "activity log tempdir fallback at {} also failed: {fallback_err:#}; \
316 activity feed disabled for this process (no-op log)",
317 fallback.display()
318 );
319 Arc::new(ActivityLog::discard())
320 }
321 }
322 }
323 }
324}