Skip to main content

trusty_memory/
hook_emit.rs

1//! Cross-process hook activity emit.
2//!
3//! Why: Claude Code's hook commands (`UserPromptSubmit` → `prompt-context`,
4//! `SessionStart` → `inbox-check`) run as ephemeral CLI subprocesses, not
5//! inside the long-lived daemon. They cannot call `state.emit` directly because
6//! they hold no `AppState`. Before this module they had no way to populate the
7//! activity feed, which led directly to the user complaint "the TUI activity
8//! feed is always empty in a normal Claude Code session" — because in a normal
9//! session the only daemon traffic is hooks, and hooks emitted nothing.
10//!
11//! What: [`post_hook_event`] calls the daemon's `hook_fired` method over the
12//! socket, through [`crate::client`]. It used to resolve an HTTP address and
13//! POST `/api/v1/activity/hook`; ADR-0032 retired that listener, and the route
14//! was always a duplicate of the dispatcher method — `transport::rpc::dispatch`
15//! has routed `hook_fired` since #149, and it reaches the socket through the
16//! router's fallback.
17//!
18//! Failures stay swallowed (warn-logged to stderr) so the hook never fails
19//! because of a missing or unresponsive daemon — that contract matches the
20//! prompt-context handler's "always exit 0" rule. This differs deliberately
21//! from `trusty-memory note`, which now exits non-zero on a dropped write: a
22//! note is the caller's whole purpose, and an activity row is telemetry
23//! alongside output the user is waiting for.
24//!
25//! Test: `post_hook_event_no_daemon_is_noop` (the no-daemon branch);
26//! `hook_fired_activity_emit_smoke` (the live round trip, against a daemon on a
27//! temp socket); `hook_emit_failure_isolated` in `commands::prompt_context`
28//! (the hook still exits 0 when the emit fails).
29
30use crate::{HookType, InjectionKind};
31use std::path::Path;
32use std::time::Duration;
33
34/// The daemon method that ingests a hook firing.
35///
36/// Why a constant: this module is the only caller, and the name is what a
37/// rename has to change in one place. Routed by
38/// [`crate::transport::rpc::dispatch`], not by the folded-method table.
39pub const HOOK_EVENT_METHOD: &str = "hook_fired";
40
41/// Connect + total timeout for the hook emit.
42///
43/// Why: hooks run in front of every user prompt; the budget here must be
44/// tighter than the prompt-context fetch budget so a slow daemon never adds
45/// noticeable latency to the user's typing flow. 1.5 s is enough for a healthy
46/// local daemon plus a wide margin, and tight enough that a hung daemon does
47/// not block Claude Code by more than a moment.
48const HOOK_EMIT_TIMEOUT: Duration = Duration::from_millis(1500);
49
50/// The params sent with [`HOOK_EVENT_METHOD`].
51///
52/// Why: deliberately separate from `DaemonEvent` itself so the wire format can
53/// evolve (add fields, rename) without breaking the consumer schema. The daemon
54/// side (`transport::rpc::HookFiredParams`) maps this into the canonical
55/// `DaemonEvent::HookFired` variant. Forwards-compatible: `#[serde(default)]`
56/// on every optional field means a newer client can add fields without breaking
57/// an older daemon.
58/// What: serde-encoded as snake_case JSON.
59/// Test: `hook_fired_activity_emit_smoke` round-trips it against a real daemon.
60#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
61pub struct HookEventPayload {
62    #[serde(default)]
63    pub palace_id: Option<String>,
64    #[serde(default)]
65    pub palace_name: Option<String>,
66    pub hook_type: HookType,
67    pub injection_kind: InjectionKind,
68    #[serde(default)]
69    pub injection_length: u64,
70    #[serde(default)]
71    pub trigger_prompt_excerpt: String,
72    #[serde(default)]
73    pub duration_ms: u64,
74}
75
76/// Emit a hook event to the running daemon, best-effort.
77///
78/// Why: the contract for every hook handler is "never block the user's prompt
79/// because of a daemon problem". This function therefore swallows every error
80/// path — socket unresolvable, daemon not running, refused params — and
81/// warn-logs the failure to stderr so the hook command still prints whatever
82/// stdout the user expected.
83///
84/// What: derives the socket and calls [`HOOK_EVENT_METHOD`] through
85/// [`crate::client`]. Returns `()` regardless of outcome.
86///
87/// Test: `post_hook_event_no_daemon_is_noop`, `hook_fired_activity_emit_smoke`.
88pub async fn post_hook_event(payload: HookEventPayload) {
89    let params = match serde_json::to_value(&payload) {
90        Ok(v) => v,
91        Err(e) => {
92            tracing::warn!("hook_emit: encode payload failed: {e:#}");
93            return;
94        }
95    };
96    if let Err(e) =
97        crate::client::call_with_timeout(HOOK_EVENT_METHOD, params, HOOK_EMIT_TIMEOUT).await
98    {
99        // Operators chasing missing activity rows find the reason in
100        // `~/Library/Logs/trusty-memory/*.log` (or wherever the daemon routed
101        // stderr) without the hook itself blowing up.
102        tracing::warn!("hook_emit: {HOOK_EVENT_METHOD} failed: {e:#}");
103    }
104}
105
106/// [`post_hook_event`] against an explicit socket.
107///
108/// Why it is public: a test drives a daemon it bound on a temp path, and a
109/// fixture that had to mutate the real data directory to be testable would not
110/// be a test of this function.
111///
112/// Test: `hook_fired_activity_emit_smoke`.
113pub async fn post_hook_event_at(socket: &Path, payload: HookEventPayload) {
114    let params = match serde_json::to_value(&payload) {
115        Ok(v) => v,
116        Err(e) => {
117            tracing::warn!("hook_emit: encode payload failed: {e:#}");
118            return;
119        }
120    };
121    if let Err(e) =
122        crate::client::call_at(socket, HOOK_EVENT_METHOD, params, HOOK_EMIT_TIMEOUT).await
123    {
124        tracing::warn!("hook_emit: {HOOK_EVENT_METHOD} failed: {e:#}");
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn sample_payload() -> HookEventPayload {
133        HookEventPayload {
134            palace_id: Some("alpha".to_string()),
135            palace_name: Some("alpha".to_string()),
136            hook_type: HookType::UserPromptSubmit,
137            injection_kind: InjectionKind::PromptContext,
138            injection_length: 256,
139            trigger_prompt_excerpt: "test prompt".to_string(),
140            duration_ms: 12,
141        }
142    }
143
144    /// Why: the hook handlers rely on this function being a no-op when no
145    /// daemon is running. A panic or an error here would fail the hook and
146    /// break every Claude Code prompt on a host where the daemon was never
147    /// started.
148    /// What: pins a tempdir as the data dir so the derived socket path exists
149    /// nowhere and the dial is refused, then awaits `post_hook_event`. Must
150    /// return without panicking.
151    /// Test: itself.
152    #[tokio::test]
153    async fn post_hook_event_no_daemon_is_noop() {
154        let _guard = crate::commands::env_test_lock().lock().await;
155        let tmp = tempfile::tempdir().expect("tempdir");
156        // SAFETY: test serialised by env_test_lock.
157        unsafe {
158            std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
159        }
160        let mut payload = sample_payload();
161        payload.palace_id = None;
162        payload.palace_name = None;
163        // Must not panic / hang.
164        post_hook_event(payload).await;
165        unsafe {
166            std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
167        }
168    }
169
170    /// Why: every hook firing must produce an activity-feed row tagged
171    /// `source=hook`, so a normal Claude Code session — which triggers hooks
172    /// and nothing else — stops leaving the TUI feed empty. This test existed
173    /// against the retired `POST /api/v1/activity/hook` route and went with it;
174    /// nothing covered the emit path end to end afterwards, which is the gap
175    /// (#6286 review, finding 2). The unit tests on each side can both pass
176    /// while the two disagree about the method name or the params shape.
177    /// What: binds a daemon on a temp socket, emits one event through the real
178    /// [`post_hook_event_at`], flushes, and reads the row back through
179    /// `memory.activity` filtered to `source=hook`.
180    /// Test: itself.
181    #[cfg(feature = "daemon")]
182    #[tokio::test]
183    async fn hook_fired_activity_emit_smoke() {
184        let daemon = crate::test_daemon::TestDaemon::start().await;
185
186        post_hook_event_at(daemon.socket(), sample_payload()).await;
187        // #232: `emit` fire-and-forgets the redb append on the blocking pool.
188        daemon.state().flush_activity_writes().await;
189
190        let page = crate::client::call_at(
191            daemon.socket(),
192            "memory.activity",
193            serde_json::json!({ "source": "hook", "limit": 10 }),
194            Duration::from_secs(10),
195        )
196        .await
197        .expect("memory.activity answers");
198
199        let entries = page["entries"].as_array().expect("entries array");
200        assert!(
201            !entries.is_empty(),
202            "expected at least one hook activity row, got {page}"
203        );
204        let first = &entries[0];
205        assert_eq!(first["source"], "hook");
206        assert_eq!(first["event_type"], "hook_fired");
207        assert_eq!(first["palace_id"], "alpha");
208        assert_eq!(first["payload"]["hook_type"], "UserPromptSubmit");
209        assert_eq!(first["payload"]["injection_kind"], "prompt-context");
210    }
211
212    /// Why: a hook must complete even when the emit fails, and there are two
213    /// distinct ways it can. `post_hook_event_no_daemon_is_noop` covers the
214    /// transport half — nothing is listening. This covers the other: a daemon
215    /// that IS listening and REFUSES the call, which is what a params-shape
216    /// disagreement or an internal error looks like and which no test covered
217    /// after the retired route took `hook_emit_failure_isolated` with it (#6286
218    /// review, finding 2). A refusal arriving as a typed error rather than a
219    /// dial failure takes a different arm through this function.
220    /// What: serves a socket whose every method answers an error, emits against
221    /// it, and asserts the call returns rather than panicking or propagating.
222    /// Test: itself.
223    #[tokio::test]
224    async fn hook_emit_failure_isolated() {
225        use std::sync::Arc;
226        use trusty_common::uds::server::{
227            serve_until, RpcError, RpcFallback, RpcRouter, RpcServeOptions,
228        };
229
230        /// Refuses every method, the way a daemon that cannot accept the event
231        /// does.
232        struct AlwaysRefuses;
233
234        #[async_trait::async_trait]
235        impl RpcFallback for AlwaysRefuses {
236            async fn call(
237                &self,
238                method: &str,
239                _params: serde_json::Value,
240            ) -> Result<serde_json::Value, RpcError> {
241                Err(RpcError::internal(format!("{method} is refused")))
242            }
243        }
244
245        let dir = tempfile::tempdir().expect("tempdir");
246        let socket = dir.path().join("refusing.sock");
247        let listener = trusty_common::uds::bind_hardened(&socket).expect("bind");
248        let (stop, shutdown) = tokio::sync::oneshot::channel::<()>();
249        let router = Arc::new(RpcRouter::new().fallback(AlwaysRefuses));
250        tokio::spawn(async move {
251            serve_until(&listener, router, RpcServeOptions::default(), async {
252                let _ = shutdown.await;
253            })
254            .await;
255        });
256
257        // Returns `()` — the assertion is that it returns at all, having
258        // swallowed the daemon's refusal into a warn rather than propagating.
259        post_hook_event_at(&socket, sample_payload()).await;
260
261        let _ = stop.send(());
262    }
263}