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}