Skip to main content

codewhale_hooks/
lib.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use anyhow::{Context, Result};
5use async_trait::async_trait;
6use chrono::Utc;
7use codewhale_protocol::EventFrame;
8use serde::{Deserialize, Serialize};
9use serde_json::{Value, json};
10use tokio::io::AsyncWriteExt;
11
12/// All events that can be emitted through the hook system.
13///
14/// Each variant represents a distinct lifecycle or streaming event. The enum is
15/// serialised with a `"type"` discriminator using `snake_case` naming (e.g.
16/// `"response_start"`, `"tool_lifecycle"`), making it easy to consume from
17/// JSON-based log files or webhook receivers.
18#[allow(clippy::large_enum_variant)] // Keep the public HookEvent shape stable for 0.8.x.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(tag = "type", rename_all = "snake_case")]
21pub enum HookEvent {
22    /// A new response stream has started.
23    ResponseStart {
24        /// Unique identifier for the response being streamed.
25        response_id: String,
26    },
27    /// A chunk of text has been received for an in-progress response.
28    ResponseDelta {
29        /// Unique identifier for the response being streamed.
30        response_id: String,
31        /// The incremental text content of this chunk.
32        delta: String,
33    },
34    /// A response stream has finished.
35    ResponseEnd {
36        /// Unique identifier for the response that completed.
37        response_id: String,
38    },
39    /// A tool invocation has transitioned to a new phase (e.g. start, end, error).
40    ToolLifecycle {
41        /// Identifier of the response under which the tool was invoked.
42        response_id: String,
43        /// Name of the tool (e.g. `"shell"`, `"read_file"`).
44        tool_name: String,
45        /// Current phase of the tool execution (e.g. `"start"`, `"end"`).
46        phase: String,
47        /// Arbitrary structured payload associated with this phase.
48        payload: Value,
49    },
50    /// A background job has transitioned to a new phase.
51    JobLifecycle {
52        /// Unique identifier of the job.
53        job_id: String,
54        /// Current phase of the job (e.g. `"queued"`, `"running"`, `"done"`).
55        phase: String,
56        /// Optional progress percentage (0-100).
57        progress: Option<u8>,
58        /// Optional human-readable detail about the current phase.
59        detail: Option<String>,
60    },
61    /// An approval request has transitioned to a new phase.
62    ApprovalLifecycle {
63        /// Unique identifier of the approval request.
64        approval_id: String,
65        /// Current phase (e.g. `"requested"`, `"approved"`, `"denied"`).
66        phase: String,
67        /// Optional reason explaining the current phase.
68        reason: Option<String>,
69    },
70    /// A catch-all variant that wraps an arbitrary [`EventFrame`].
71    ///
72    /// Use this when you need to forward a protocol-level event frame without
73    /// mapping it to a more specific variant.
74    GenericEventFrame {
75        /// The raw event frame to forward.
76        frame: Box<EventFrame>,
77    },
78}
79
80impl HookEvent {
81    /// Serialise this event into a [`serde_json::Value`].
82    ///
83    /// Returns a JSON object with the `"type"` discriminator and all variant
84    /// fields. If serialisation fails (which should be extremely rare), a
85    /// fallback `{"type":"serialization_error"}` value is returned instead of
86    /// panicking.
87    pub fn to_json(&self) -> Value {
88        serde_json::to_value(self).unwrap_or_else(|_| json!({"type":"serialization_error"}))
89    }
90}
91
92/// A destination that can receive [`HookEvent`]s.
93///
94/// Implementors handle the transport-specific details of delivering events
95/// (writing to stdout, appending to a file, POSTing to a webhook, etc.).
96/// The [`HookDispatcher`] fans out every event to all registered sinks, so a
97/// single process can log to multiple destinations simultaneously.
98///
99/// Sinks are expected to be **best-effort**: implementations should avoid
100/// panicking and should return an [`anyhow::Error`] only for truly unexpected
101/// failures. [`HookDispatcher::emit`] discards individual sink errors so hook
102/// delivery failures do not abort the application.
103#[async_trait]
104pub trait HookSink: Send + Sync {
105    /// Deliver a single event to this sink.
106    ///
107    /// Implementations should be resilient to transient failures (e.g. a
108    /// missing listener) and should not block the caller for extended periods.
109    async fn emit(&self, event: &HookEvent) -> Result<()>;
110}
111
112/// A [`HookSink`] that prints each event as a single JSON line to stdout.
113///
114/// Useful for local development and debugging. Events are printed via
115/// [`println!`] so they appear interleaved with other program output.
116#[derive(Default)]
117pub struct StdoutHookSink;
118
119#[async_trait]
120impl HookSink for StdoutHookSink {
121    async fn emit(&self, event: &HookEvent) -> Result<()> {
122        println!("{}", event.to_json());
123        Ok(())
124    }
125}
126
127/// A [`HookSink`] that appends each event as a JSON line to a file.
128///
129/// The file is created (along with any missing parent directories) on the
130/// first emitted event. Each line is a JSON object of the form
131/// `{"at": "<ISO 8601 timestamp>", "event": {...}}`.
132///
133/// Concurrent [`emit`](HookSink::emit) calls serialize on an internal mutex so
134/// that each JSON event is written and flushed as a complete line; without that
135/// lock, overlapping `write_all` calls can interleave partial lines and corrupt
136/// the JSONL log (see issue #4739).
137pub struct JsonlHookSink {
138    path: PathBuf,
139    /// Serializes open+append+flush so concurrent tool-call emits cannot
140    /// interleave bytes mid-line.
141    write_lock: tokio::sync::Mutex<()>,
142}
143
144impl JsonlHookSink {
145    /// Create a new sink that writes to the file at `path`.
146    ///
147    /// Parent directories are created lazily on the first [`HookSink::emit`]
148    /// call.
149    pub fn new(path: PathBuf) -> Self {
150        Self {
151            path,
152            write_lock: tokio::sync::Mutex::new(()),
153        }
154    }
155}
156
157#[async_trait]
158impl HookSink for JsonlHookSink {
159    async fn emit(&self, event: &HookEvent) -> Result<()> {
160        if let Some(parent) = self.path.parent() {
161            tokio::fs::create_dir_all(parent).await.with_context(|| {
162                format!("failed to create hook log directory {}", parent.display())
163            })?;
164        }
165        // Encode outside the lock so only I/O is serialized.
166        let payload = json!({
167            "at": Utc::now().to_rfc3339(),
168            "event": event
169        });
170        let encoded = serde_json::to_string(&payload).context("failed to encode hook event")?;
171
172        let _guard = self.write_lock.lock().await;
173        let mut file = tokio::fs::OpenOptions::new()
174            .create(true)
175            .append(true)
176            .open(&self.path)
177            .await
178            .with_context(|| format!("failed to open hook log {}", self.path.display()))?;
179        file.write_all(encoded.as_bytes())
180            .await
181            .context("failed to write hook event")?;
182        file.write_all(b"\n")
183            .await
184            .context("failed to write hook event newline")?;
185        // Flush before drop so sequential emits (and tests that read the
186        // file immediately after) observe every completed line. Holding the
187        // mutex through flush guarantees concurrent writers never observe a
188        // partial line at EOF.
189        file.flush().await.context("failed to flush hook event")?;
190        Ok(())
191    }
192}
193
194/// A [`HookSink`] that POSTs each event as JSON to a remote HTTP endpoint.
195///
196/// The request body is `{"at": "<ISO 8601 timestamp>", "event": {...}}`.
197/// Failed requests are retried up to 2 times with exponential back-off
198/// (200 ms, 400 ms). After exhausting retries the error is propagated.
199pub struct WebhookHookSink {
200    url: String,
201    client: reqwest::Client,
202}
203
204impl WebhookHookSink {
205    /// Create a new sink that sends events to the given `url`.
206    pub fn new(url: String) -> Self {
207        Self {
208            url,
209            client: codewhale_release::platform_http_client_builder()
210                .timeout(std::time::Duration::from_secs(10))
211                .build()
212                .unwrap_or_else(|_| {
213                    codewhale_release::platform_http_client_builder()
214                        .build()
215                        .expect("build fallback HTTP client")
216                }),
217        }
218    }
219}
220
221#[async_trait]
222impl HookSink for WebhookHookSink {
223    async fn emit(&self, event: &HookEvent) -> Result<()> {
224        let mut retries = 0usize;
225        loop {
226            let resp = self
227                .client
228                .post(&self.url)
229                .json(&json!({
230                    "at": Utc::now().to_rfc3339(),
231                    "event": event,
232                }))
233                .send()
234                .await;
235            match resp {
236                Ok(response) if response.status().is_success() => return Ok(()),
237                Ok(response) => {
238                    if retries >= 2 {
239                        anyhow::bail!("webhook returned non-success status {}", response.status());
240                    }
241                }
242                Err(err) => {
243                    if retries >= 2 {
244                        return Err(err).context("webhook request failed");
245                    }
246                }
247            }
248            retries += 1;
249            tokio::time::sleep(std::time::Duration::from_millis(200 * retries as u64)).await;
250        }
251    }
252}
253
254/// A [`HookSink`] that sends events over a Unix domain socket.
255///
256/// Each event is serialized as a single JSON line (`{"at": "...", "event": {...}}\n`)
257/// and written to the socket. If the socket is not available (listener not running),
258/// the event is silently dropped - hook sinks are best-effort observability, not
259/// control flow.
260///
261/// On non-Unix platforms this struct exists but its [`HookSink::emit`] is a no-op.
262#[derive(Debug, Clone)]
263pub struct UnixSocketHookSink {
264    #[cfg(unix)]
265    path: PathBuf,
266}
267
268impl UnixSocketHookSink {
269    /// Create a sink that connects to the Unix domain socket at `path`.
270    pub fn new(path: PathBuf) -> Self {
271        #[cfg(unix)]
272        {
273            Self { path }
274        }
275        #[cfg(not(unix))]
276        {
277            let _ = path;
278            Self {}
279        }
280    }
281}
282
283#[async_trait]
284impl HookSink for UnixSocketHookSink {
285    #[cfg(unix)]
286    async fn emit(&self, event: &HookEvent) -> Result<()> {
287        let mut stream = match tokio::net::UnixStream::connect(&self.path).await {
288            Ok(s) => s,
289            Err(_) => return Ok(()), // listener not running, skip silently
290        };
291        let payload = json!({
292            "at": Utc::now().to_rfc3339(),
293            "event": event
294        });
295        let mut line = serde_json::to_string(&payload).context("failed to encode hook event")?;
296        line.push('\n');
297        stream
298            .write_all(line.as_bytes())
299            .await
300            .context("failed to write to unix socket")?;
301        Ok(())
302    }
303
304    #[cfg(not(unix))]
305    async fn emit(&self, _event: &HookEvent) -> Result<()> {
306        // Unix sockets are not available on this platform.
307        Ok(())
308    }
309}
310
311/// Fans out [`HookEvent`]s to a collection of [`HookSink`]s.
312///
313/// Register one or more sinks via [`add_sink`](HookDispatcher::add_sink),
314/// then call [`emit`](HookDispatcher::emit) to broadcast an event to all of
315/// them. If a sink returns an error it is silently ignored so that a failing
316/// sink does not prevent remaining sinks from receiving the event.
317#[derive(Default, Clone)]
318pub struct HookDispatcher {
319    sinks: Vec<Arc<dyn HookSink>>,
320}
321
322impl HookDispatcher {
323    /// Register a new sink that will receive all subsequently emitted events.
324    pub fn add_sink(&mut self, sink: Arc<dyn HookSink>) {
325        self.sinks.push(sink);
326    }
327
328    /// Broadcast an event to every registered sink.
329    ///
330    /// Errors from individual sinks are silently discarded so that one failing
331    /// sink does not block the others.
332    pub async fn emit(&self, event: HookEvent) {
333        for sink in &self.sinks {
334            let _ = sink.emit(&event).await;
335        }
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use std::sync::Mutex;
343    use std::time::{SystemTime, UNIX_EPOCH};
344
345    #[test]
346    fn hook_event_serializes_with_snake_case_type_and_payload() {
347        let event = HookEvent::ToolLifecycle {
348            response_id: "resp-1".to_string(),
349            tool_name: "shell".to_string(),
350            phase: "end".to_string(),
351            payload: json!({ "exit_code": 0 }),
352        };
353
354        let encoded = event.to_json();
355
356        assert_eq!(encoded["type"], "tool_lifecycle");
357        assert_eq!(encoded["response_id"], "resp-1");
358        assert_eq!(encoded["tool_name"], "shell");
359        assert_eq!(encoded["phase"], "end");
360        assert_eq!(encoded["payload"]["exit_code"], 0);
361    }
362
363    #[test]
364    fn generic_event_frame_serialization_is_unchanged_by_boxing() {
365        let event = HookEvent::GenericEventFrame {
366            frame: Box::new(EventFrame::ResponseStart {
367                response_id: "resp-1".to_string(),
368            }),
369        };
370
371        let encoded = event.to_json();
372
373        assert_eq!(encoded["type"], "generic_event_frame");
374        assert_eq!(encoded["frame"]["event"], "response_start");
375        assert_eq!(encoded["frame"]["response_id"], "resp-1");
376    }
377
378    #[tokio::test]
379    async fn jsonl_sink_creates_parent_dir_and_appends_events() {
380        let root = unique_temp_dir("jsonl_sink");
381        let path = root.join("nested").join("hooks.jsonl");
382        let sink = JsonlHookSink::new(path.clone());
383
384        sink.emit(&HookEvent::ResponseStart {
385            response_id: "resp-1".to_string(),
386        })
387        .await
388        .unwrap();
389        sink.emit(&HookEvent::ResponseEnd {
390            response_id: "resp-1".to_string(),
391        })
392        .await
393        .unwrap();
394
395        let raw = std::fs::read_to_string(&path).unwrap();
396        let lines = raw.lines().collect::<Vec<_>>();
397        assert_eq!(lines.len(), 2);
398
399        let first: Value = serde_json::from_str(lines[0]).unwrap();
400        let second: Value = serde_json::from_str(lines[1]).unwrap();
401        assert!(first["at"].as_str().is_some());
402        assert_eq!(first["event"]["type"], "response_start");
403        assert_eq!(first["event"]["response_id"], "resp-1");
404        assert_eq!(second["event"]["type"], "response_end");
405        assert_eq!(second["event"]["response_id"], "resp-1");
406
407        let _ = std::fs::remove_dir_all(root);
408    }
409
410    /// Concurrent emits must not interleave partial lines (#4739).
411    ///
412    /// Spawns many tasks writing through one shared [`JsonlHookSink`] and
413    /// asserts every line in the resulting file is complete, parseable JSON.
414    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
415    async fn jsonl_sink_concurrent_emits_write_atomic_json_lines() {
416        let root = unique_temp_dir("jsonl_sink_concurrent");
417        let path = root.join("hooks.jsonl");
418        let sink = Arc::new(JsonlHookSink::new(path.clone()));
419
420        const TASKS: usize = 32;
421        const EVENTS_PER_TASK: usize = 20;
422        let expected = TASKS * EVENTS_PER_TASK;
423
424        let mut handles = Vec::with_capacity(TASKS);
425        for task_id in 0..TASKS {
426            let sink = Arc::clone(&sink);
427            handles.push(tokio::spawn(async move {
428                for n in 0..EVENTS_PER_TASK {
429                    let event = HookEvent::ToolLifecycle {
430                        response_id: format!("resp-{task_id}"),
431                        tool_name: "shell".to_string(),
432                        phase: "end".to_string(),
433                        payload: json!({ "n": n, "task": task_id }),
434                    };
435                    sink.emit(&event).await.expect("concurrent emit");
436                }
437            }));
438        }
439        for handle in handles {
440            handle.await.expect("join concurrent writer");
441        }
442
443        let raw = std::fs::read_to_string(&path).expect("read concurrent jsonl");
444        // Trailing newline yields an empty final split; keep non-empty lines only.
445        let lines: Vec<&str> = raw.lines().filter(|l| !l.is_empty()).collect();
446        assert_eq!(
447            lines.len(),
448            expected,
449            "expected {expected} complete lines, got {}; sample: {:?}",
450            lines.len(),
451            lines
452                .first()
453                .map(|s| s.chars().take(80).collect::<String>())
454        );
455
456        for (idx, line) in lines.iter().enumerate() {
457            let parsed: Value = serde_json::from_str(line).unwrap_or_else(|err| {
458                panic!("line {idx} is not complete JSON ({err}): {line:?}");
459            });
460            assert!(
461                parsed.get("at").and_then(|v| v.as_str()).is_some(),
462                "line {idx} missing at: {parsed}"
463            );
464            assert_eq!(
465                parsed["event"]["type"], "tool_lifecycle",
466                "line {idx} unexpected event type"
467            );
468        }
469
470        let _ = std::fs::remove_dir_all(root);
471    }
472
473    #[tokio::test]
474    async fn dispatcher_continues_after_sink_error() {
475        let mut dispatcher = HookDispatcher::default();
476        let first = Arc::new(RecordingSink::default());
477        let second = Arc::new(RecordingSink::default());
478
479        dispatcher.add_sink(first.clone());
480        dispatcher.add_sink(Arc::new(FailingSink));
481        dispatcher.add_sink(second.clone());
482
483        dispatcher
484            .emit(HookEvent::ApprovalLifecycle {
485                approval_id: "approval-1".to_string(),
486                phase: "requested".to_string(),
487                reason: Some("needs review".to_string()),
488            })
489            .await;
490
491        assert_eq!(
492            first.events(),
493            vec![json!({
494                "type": "approval_lifecycle",
495                "approval_id": "approval-1",
496                "phase": "requested",
497                "reason": "needs review",
498            })]
499        );
500        assert_eq!(second.events(), first.events());
501    }
502
503    #[cfg(unix)]
504    #[tokio::test]
505    async fn unix_socket_sink_skips_when_listener_absent() {
506        let (root, socket_path) = unique_short_socket_path("missing");
507        let sink = UnixSocketHookSink::new(socket_path);
508        let result = sink
509            .emit(&HookEvent::ResponseStart {
510                response_id: "resp-1".to_string(),
511            })
512            .await;
513        assert!(result.is_ok());
514        let _ = std::fs::remove_dir_all(root);
515    }
516
517    #[cfg(unix)]
518    #[tokio::test]
519    async fn unix_socket_sink_sends_event_to_listener() {
520        use tokio::io::AsyncBufReadExt;
521        use tokio::net::UnixListener;
522
523        let (root, socket_path) = unique_short_socket_path("send");
524        std::fs::create_dir_all(&root).expect("mkdir");
525        let _ = std::fs::remove_file(&socket_path);
526
527        let listener = UnixListener::bind(&socket_path).expect("bind");
528        let sink = UnixSocketHookSink::new(socket_path.clone());
529
530        let handle = tokio::spawn(async move {
531            let (stream, _) = listener.accept().await.expect("accept");
532            let mut reader = tokio::io::BufReader::new(stream);
533            let mut line = String::new();
534            reader.read_line(&mut line).await.expect("read_line");
535            line
536        });
537
538        sink.emit(&HookEvent::ResponseStart {
539            response_id: "resp-42".to_string(),
540        })
541        .await
542        .expect("emit");
543
544        let received = handle.await.expect("join");
545        let parsed: Value = serde_json::from_str(&received).expect("parse");
546        assert_eq!(parsed["event"]["type"], "response_start");
547        assert_eq!(parsed["event"]["response_id"], "resp-42");
548        assert!(parsed["at"].as_str().is_some());
549
550        let _ = std::fs::remove_file(&socket_path);
551        let _ = std::fs::remove_dir_all(root);
552    }
553
554    #[derive(Default)]
555    struct RecordingSink {
556        events: Mutex<Vec<Value>>,
557    }
558
559    impl RecordingSink {
560        fn events(&self) -> Vec<Value> {
561            self.events.lock().unwrap().clone()
562        }
563    }
564
565    #[async_trait::async_trait]
566    impl HookSink for RecordingSink {
567        async fn emit(&self, event: &HookEvent) -> Result<()> {
568            self.events.lock().unwrap().push(event.to_json());
569            Ok(())
570        }
571    }
572
573    struct FailingSink;
574
575    #[async_trait::async_trait]
576    impl HookSink for FailingSink {
577        async fn emit(&self, _event: &HookEvent) -> Result<()> {
578            anyhow::bail!("sink failed")
579        }
580    }
581
582    fn unique_temp_dir(label: &str) -> PathBuf {
583        let nanos = SystemTime::now()
584            .duration_since(UNIX_EPOCH)
585            .unwrap()
586            .as_nanos();
587        std::env::temp_dir().join(format!(
588            "deepseek-hooks-{label}-{}-{nanos}",
589            std::process::id()
590        ))
591    }
592
593    #[cfg(unix)]
594    fn unique_short_socket_path(label: &str) -> (PathBuf, PathBuf) {
595        let nanos = SystemTime::now()
596            .duration_since(UNIX_EPOCH)
597            .unwrap()
598            .as_nanos();
599        let root = PathBuf::from("/tmp").join(format!("cw-hk-{}-{nanos}", std::process::id()));
600        let path = root.join(format!("{label}.sock"));
601        (root, path)
602    }
603}