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.
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}