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