Skip to main content

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}