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    /// Number of registered sinks. Exposed so transport setup can assert
329    /// exactly which sinks were wired (e.g. no stdout sink in stdio mode).
330    #[must_use]
331    pub fn sink_count(&self) -> usize {
332        self.sinks.len()
333    }
334
335    /// Broadcast an event to every registered sink.
336    ///
337    /// Errors from individual sinks are silently discarded so that one failing
338    /// sink does not block the others.
339    pub async fn emit(&self, event: HookEvent) {
340        for sink in &self.sinks {
341            let _ = sink.emit(&event).await;
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use std::sync::Mutex;
350    use std::time::{SystemTime, UNIX_EPOCH};
351
352    #[test]
353    fn hook_event_serializes_with_snake_case_type_and_payload() {
354        let event = HookEvent::ToolLifecycle {
355            response_id: "resp-1".to_string(),
356            tool_name: "shell".to_string(),
357            phase: "end".to_string(),
358            payload: json!({ "exit_code": 0 }),
359        };
360
361        let encoded = event.to_json();
362
363        assert_eq!(encoded["type"], "tool_lifecycle");
364        assert_eq!(encoded["response_id"], "resp-1");
365        assert_eq!(encoded["tool_name"], "shell");
366        assert_eq!(encoded["phase"], "end");
367        assert_eq!(encoded["payload"]["exit_code"], 0);
368    }
369
370    #[test]
371    fn generic_event_frame_serialization_is_unchanged_by_boxing() {
372        let event = HookEvent::GenericEventFrame {
373            frame: Box::new(EventFrame::ResponseStart {
374                response_id: "resp-1".to_string(),
375            }),
376        };
377
378        let encoded = event.to_json();
379
380        assert_eq!(encoded["type"], "generic_event_frame");
381        assert_eq!(encoded["frame"]["event"], "response_start");
382        assert_eq!(encoded["frame"]["response_id"], "resp-1");
383    }
384
385    #[tokio::test]
386    async fn jsonl_sink_creates_parent_dir_and_appends_events() {
387        let root = unique_temp_dir("jsonl_sink");
388        let path = root.join("nested").join("hooks.jsonl");
389        let sink = JsonlHookSink::new(path.clone());
390
391        sink.emit(&HookEvent::ResponseStart {
392            response_id: "resp-1".to_string(),
393        })
394        .await
395        .unwrap();
396        sink.emit(&HookEvent::ResponseEnd {
397            response_id: "resp-1".to_string(),
398        })
399        .await
400        .unwrap();
401
402        let raw = std::fs::read_to_string(&path).unwrap();
403        let lines = raw.lines().collect::<Vec<_>>();
404        assert_eq!(lines.len(), 2);
405
406        let first: Value = serde_json::from_str(lines[0]).unwrap();
407        let second: Value = serde_json::from_str(lines[1]).unwrap();
408        assert!(first["at"].as_str().is_some());
409        assert_eq!(first["event"]["type"], "response_start");
410        assert_eq!(first["event"]["response_id"], "resp-1");
411        assert_eq!(second["event"]["type"], "response_end");
412        assert_eq!(second["event"]["response_id"], "resp-1");
413
414        let _ = std::fs::remove_dir_all(root);
415    }
416
417    /// Concurrent emits must not interleave partial lines (#4739).
418    ///
419    /// Spawns many tasks writing through one shared [`JsonlHookSink`] and
420    /// asserts every line in the resulting file is complete, parseable JSON.
421    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
422    async fn jsonl_sink_concurrent_emits_write_atomic_json_lines() {
423        let root = unique_temp_dir("jsonl_sink_concurrent");
424        let path = root.join("hooks.jsonl");
425        let sink = Arc::new(JsonlHookSink::new(path.clone()));
426
427        const TASKS: usize = 32;
428        const EVENTS_PER_TASK: usize = 20;
429        let expected = TASKS * EVENTS_PER_TASK;
430
431        let mut handles = Vec::with_capacity(TASKS);
432        for task_id in 0..TASKS {
433            let sink = Arc::clone(&sink);
434            handles.push(tokio::spawn(async move {
435                for n in 0..EVENTS_PER_TASK {
436                    let event = HookEvent::ToolLifecycle {
437                        response_id: format!("resp-{task_id}"),
438                        tool_name: "shell".to_string(),
439                        phase: "end".to_string(),
440                        payload: json!({ "n": n, "task": task_id }),
441                    };
442                    sink.emit(&event).await.expect("concurrent emit");
443                }
444            }));
445        }
446        for handle in handles {
447            handle.await.expect("join concurrent writer");
448        }
449
450        let raw = std::fs::read_to_string(&path).expect("read concurrent jsonl");
451        // Trailing newline yields an empty final split; keep non-empty lines only.
452        let lines: Vec<&str> = raw.lines().filter(|l| !l.is_empty()).collect();
453        assert_eq!(
454            lines.len(),
455            expected,
456            "expected {expected} complete lines, got {}; sample: {:?}",
457            lines.len(),
458            lines
459                .first()
460                .map(|s| s.chars().take(80).collect::<String>())
461        );
462
463        for (idx, line) in lines.iter().enumerate() {
464            let parsed: Value = serde_json::from_str(line).unwrap_or_else(|err| {
465                panic!("line {idx} is not complete JSON ({err}): {line:?}");
466            });
467            assert!(
468                parsed.get("at").and_then(|v| v.as_str()).is_some(),
469                "line {idx} missing at: {parsed}"
470            );
471            assert_eq!(
472                parsed["event"]["type"], "tool_lifecycle",
473                "line {idx} unexpected event type"
474            );
475        }
476
477        let _ = std::fs::remove_dir_all(root);
478    }
479
480    #[tokio::test]
481    async fn dispatcher_continues_after_sink_error() {
482        let mut dispatcher = HookDispatcher::default();
483        let first = Arc::new(RecordingSink::default());
484        let second = Arc::new(RecordingSink::default());
485
486        dispatcher.add_sink(first.clone());
487        dispatcher.add_sink(Arc::new(FailingSink));
488        dispatcher.add_sink(second.clone());
489
490        dispatcher
491            .emit(HookEvent::ApprovalLifecycle {
492                approval_id: "approval-1".to_string(),
493                phase: "requested".to_string(),
494                reason: Some("needs review".to_string()),
495            })
496            .await;
497
498        assert_eq!(
499            first.events(),
500            vec![json!({
501                "type": "approval_lifecycle",
502                "approval_id": "approval-1",
503                "phase": "requested",
504                "reason": "needs review",
505            })]
506        );
507        assert_eq!(second.events(), first.events());
508    }
509
510    #[cfg(unix)]
511    #[tokio::test]
512    async fn unix_socket_sink_skips_when_listener_absent() {
513        let (root, socket_path) = unique_short_socket_path("missing");
514        let sink = UnixSocketHookSink::new(socket_path);
515        let result = sink
516            .emit(&HookEvent::ResponseStart {
517                response_id: "resp-1".to_string(),
518            })
519            .await;
520        assert!(result.is_ok());
521        let _ = std::fs::remove_dir_all(root);
522    }
523
524    #[cfg(unix)]
525    #[tokio::test]
526    async fn unix_socket_sink_sends_event_to_listener() {
527        use tokio::io::AsyncBufReadExt;
528        use tokio::net::UnixListener;
529
530        let (root, socket_path) = unique_short_socket_path("send");
531        std::fs::create_dir_all(&root).expect("mkdir");
532        let _ = std::fs::remove_file(&socket_path);
533
534        let listener = UnixListener::bind(&socket_path).expect("bind");
535        let sink = UnixSocketHookSink::new(socket_path.clone());
536
537        let handle = tokio::spawn(async move {
538            let (stream, _) = listener.accept().await.expect("accept");
539            let mut reader = tokio::io::BufReader::new(stream);
540            let mut line = String::new();
541            reader.read_line(&mut line).await.expect("read_line");
542            line
543        });
544
545        sink.emit(&HookEvent::ResponseStart {
546            response_id: "resp-42".to_string(),
547        })
548        .await
549        .expect("emit");
550
551        let received = handle.await.expect("join");
552        let parsed: Value = serde_json::from_str(&received).expect("parse");
553        assert_eq!(parsed["event"]["type"], "response_start");
554        assert_eq!(parsed["event"]["response_id"], "resp-42");
555        assert!(parsed["at"].as_str().is_some());
556
557        let _ = std::fs::remove_file(&socket_path);
558        let _ = std::fs::remove_dir_all(root);
559    }
560
561    #[derive(Default)]
562    struct RecordingSink {
563        events: Mutex<Vec<Value>>,
564    }
565
566    impl RecordingSink {
567        fn events(&self) -> Vec<Value> {
568            self.events.lock().unwrap().clone()
569        }
570    }
571
572    #[async_trait::async_trait]
573    impl HookSink for RecordingSink {
574        async fn emit(&self, event: &HookEvent) -> Result<()> {
575            self.events.lock().unwrap().push(event.to_json());
576            Ok(())
577        }
578    }
579
580    struct FailingSink;
581
582    #[async_trait::async_trait]
583    impl HookSink for FailingSink {
584        async fn emit(&self, _event: &HookEvent) -> Result<()> {
585            anyhow::bail!("sink failed")
586        }
587    }
588
589    fn unique_temp_dir(label: &str) -> PathBuf {
590        let nanos = SystemTime::now()
591            .duration_since(UNIX_EPOCH)
592            .unwrap()
593            .as_nanos();
594        std::env::temp_dir().join(format!(
595            "deepseek-hooks-{label}-{}-{nanos}",
596            std::process::id()
597        ))
598    }
599
600    #[cfg(unix)]
601    fn unique_short_socket_path(label: &str) -> (PathBuf, PathBuf) {
602        let nanos = SystemTime::now()
603            .duration_since(UNIX_EPOCH)
604            .unwrap()
605            .as_nanos();
606        let root = PathBuf::from("/tmp").join(format!("cw-hk-{}-{nanos}", std::process::id()));
607        let path = root.join(format!("{label}.sock"));
608        (root, path)
609    }
610}