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": {...}}`.
132pub struct JsonlHookSink {
133    path: PathBuf,
134}
135
136impl JsonlHookSink {
137    /// Create a new sink that writes to the file at `path`.
138    ///
139    /// Parent directories are created lazily on the first [`HookSink::emit`]
140    /// call.
141    pub fn new(path: PathBuf) -> Self {
142        Self { path }
143    }
144}
145
146#[async_trait]
147impl HookSink for JsonlHookSink {
148    async fn emit(&self, event: &HookEvent) -> Result<()> {
149        if let Some(parent) = self.path.parent() {
150            tokio::fs::create_dir_all(parent).await.with_context(|| {
151                format!("failed to create hook log directory {}", parent.display())
152            })?;
153        }
154        let mut file = tokio::fs::OpenOptions::new()
155            .create(true)
156            .append(true)
157            .open(&self.path)
158            .await
159            .with_context(|| format!("failed to open hook log {}", self.path.display()))?;
160        let payload = json!({
161            "at": Utc::now().to_rfc3339(),
162            "event": event
163        });
164        let encoded = serde_json::to_string(&payload).context("failed to encode hook event")?;
165        file.write_all(encoded.as_bytes())
166            .await
167            .context("failed to write hook event")?;
168        file.write_all(b"\n")
169            .await
170            .context("failed to write hook event newline")?;
171        // Flush before drop so sequential emits (and tests that read the
172        // file immediately after) observe every completed line.
173        file.flush().await.context("failed to flush hook event")?;
174        Ok(())
175    }
176}
177
178/// A [`HookSink`] that POSTs each event as JSON to a remote HTTP endpoint.
179///
180/// The request body is `{"at": "<ISO 8601 timestamp>", "event": {...}}`.
181/// Failed requests are retried up to 2 times with exponential back-off
182/// (200 ms, 400 ms). After exhausting retries the error is propagated.
183pub struct WebhookHookSink {
184    url: String,
185    client: reqwest::Client,
186}
187
188impl WebhookHookSink {
189    /// Create a new sink that sends events to the given `url`.
190    pub fn new(url: String) -> Self {
191        Self {
192            url,
193            client: codewhale_release::platform_http_client_builder()
194                .timeout(std::time::Duration::from_secs(10))
195                .build()
196                .unwrap_or_else(|_| {
197                    codewhale_release::platform_http_client_builder()
198                        .build()
199                        .expect("build fallback HTTP client")
200                }),
201        }
202    }
203}
204
205#[async_trait]
206impl HookSink for WebhookHookSink {
207    async fn emit(&self, event: &HookEvent) -> Result<()> {
208        let mut retries = 0usize;
209        loop {
210            let resp = self
211                .client
212                .post(&self.url)
213                .json(&json!({
214                    "at": Utc::now().to_rfc3339(),
215                    "event": event,
216                }))
217                .send()
218                .await;
219            match resp {
220                Ok(response) if response.status().is_success() => return Ok(()),
221                Ok(response) => {
222                    if retries >= 2 {
223                        anyhow::bail!("webhook returned non-success status {}", response.status());
224                    }
225                }
226                Err(err) => {
227                    if retries >= 2 {
228                        return Err(err).context("webhook request failed");
229                    }
230                }
231            }
232            retries += 1;
233            tokio::time::sleep(std::time::Duration::from_millis(200 * retries as u64)).await;
234        }
235    }
236}
237
238/// A [`HookSink`] that sends events over a Unix domain socket.
239///
240/// Each event is serialized as a single JSON line (`{"at": "...", "event": {...}}\n`)
241/// and written to the socket. If the socket is not available (listener not running),
242/// the event is silently dropped - hook sinks are best-effort observability, not
243/// control flow.
244///
245/// On non-Unix platforms this struct exists but its [`HookSink::emit`] is a no-op.
246#[derive(Debug, Clone)]
247pub struct UnixSocketHookSink {
248    #[cfg(unix)]
249    path: PathBuf,
250}
251
252impl UnixSocketHookSink {
253    /// Create a sink that connects to the Unix domain socket at `path`.
254    pub fn new(path: PathBuf) -> Self {
255        #[cfg(unix)]
256        {
257            Self { path }
258        }
259        #[cfg(not(unix))]
260        {
261            let _ = path;
262            Self {}
263        }
264    }
265}
266
267#[async_trait]
268impl HookSink for UnixSocketHookSink {
269    #[cfg(unix)]
270    async fn emit(&self, event: &HookEvent) -> Result<()> {
271        let mut stream = match tokio::net::UnixStream::connect(&self.path).await {
272            Ok(s) => s,
273            Err(_) => return Ok(()), // listener not running, skip silently
274        };
275        let payload = json!({
276            "at": Utc::now().to_rfc3339(),
277            "event": event
278        });
279        let mut line = serde_json::to_string(&payload).context("failed to encode hook event")?;
280        line.push('\n');
281        stream
282            .write_all(line.as_bytes())
283            .await
284            .context("failed to write to unix socket")?;
285        Ok(())
286    }
287
288    #[cfg(not(unix))]
289    async fn emit(&self, _event: &HookEvent) -> Result<()> {
290        // Unix sockets are not available on this platform.
291        Ok(())
292    }
293}
294
295/// Fans out [`HookEvent`]s to a collection of [`HookSink`]s.
296///
297/// Register one or more sinks via [`add_sink`](HookDispatcher::add_sink),
298/// then call [`emit`](HookDispatcher::emit) to broadcast an event to all of
299/// them. If a sink returns an error it is silently ignored so that a failing
300/// sink does not prevent remaining sinks from receiving the event.
301#[derive(Default, Clone)]
302pub struct HookDispatcher {
303    sinks: Vec<Arc<dyn HookSink>>,
304}
305
306impl HookDispatcher {
307    /// Register a new sink that will receive all subsequently emitted events.
308    pub fn add_sink(&mut self, sink: Arc<dyn HookSink>) {
309        self.sinks.push(sink);
310    }
311
312    /// Broadcast an event to every registered sink.
313    ///
314    /// Errors from individual sinks are silently discarded so that one failing
315    /// sink does not block the others.
316    pub async fn emit(&self, event: HookEvent) {
317        for sink in &self.sinks {
318            let _ = sink.emit(&event).await;
319        }
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use std::sync::Mutex;
327    use std::time::{SystemTime, UNIX_EPOCH};
328
329    #[test]
330    fn hook_event_serializes_with_snake_case_type_and_payload() {
331        let event = HookEvent::ToolLifecycle {
332            response_id: "resp-1".to_string(),
333            tool_name: "shell".to_string(),
334            phase: "end".to_string(),
335            payload: json!({ "exit_code": 0 }),
336        };
337
338        let encoded = event.to_json();
339
340        assert_eq!(encoded["type"], "tool_lifecycle");
341        assert_eq!(encoded["response_id"], "resp-1");
342        assert_eq!(encoded["tool_name"], "shell");
343        assert_eq!(encoded["phase"], "end");
344        assert_eq!(encoded["payload"]["exit_code"], 0);
345    }
346
347    #[test]
348    fn generic_event_frame_serialization_is_unchanged_by_boxing() {
349        let event = HookEvent::GenericEventFrame {
350            frame: Box::new(EventFrame::ResponseStart {
351                response_id: "resp-1".to_string(),
352            }),
353        };
354
355        let encoded = event.to_json();
356
357        assert_eq!(encoded["type"], "generic_event_frame");
358        assert_eq!(encoded["frame"]["event"], "response_start");
359        assert_eq!(encoded["frame"]["response_id"], "resp-1");
360    }
361
362    #[tokio::test]
363    async fn jsonl_sink_creates_parent_dir_and_appends_events() {
364        let root = unique_temp_dir("jsonl_sink");
365        let path = root.join("nested").join("hooks.jsonl");
366        let sink = JsonlHookSink::new(path.clone());
367
368        sink.emit(&HookEvent::ResponseStart {
369            response_id: "resp-1".to_string(),
370        })
371        .await
372        .unwrap();
373        sink.emit(&HookEvent::ResponseEnd {
374            response_id: "resp-1".to_string(),
375        })
376        .await
377        .unwrap();
378
379        let raw = std::fs::read_to_string(&path).unwrap();
380        let lines = raw.lines().collect::<Vec<_>>();
381        assert_eq!(lines.len(), 2);
382
383        let first: Value = serde_json::from_str(lines[0]).unwrap();
384        let second: Value = serde_json::from_str(lines[1]).unwrap();
385        assert!(first["at"].as_str().is_some());
386        assert_eq!(first["event"]["type"], "response_start");
387        assert_eq!(first["event"]["response_id"], "resp-1");
388        assert_eq!(second["event"]["type"], "response_end");
389        assert_eq!(second["event"]["response_id"], "resp-1");
390
391        let _ = std::fs::remove_dir_all(root);
392    }
393
394    #[tokio::test]
395    async fn dispatcher_continues_after_sink_error() {
396        let mut dispatcher = HookDispatcher::default();
397        let first = Arc::new(RecordingSink::default());
398        let second = Arc::new(RecordingSink::default());
399
400        dispatcher.add_sink(first.clone());
401        dispatcher.add_sink(Arc::new(FailingSink));
402        dispatcher.add_sink(second.clone());
403
404        dispatcher
405            .emit(HookEvent::ApprovalLifecycle {
406                approval_id: "approval-1".to_string(),
407                phase: "requested".to_string(),
408                reason: Some("needs review".to_string()),
409            })
410            .await;
411
412        assert_eq!(
413            first.events(),
414            vec![json!({
415                "type": "approval_lifecycle",
416                "approval_id": "approval-1",
417                "phase": "requested",
418                "reason": "needs review",
419            })]
420        );
421        assert_eq!(second.events(), first.events());
422    }
423
424    #[cfg(unix)]
425    #[tokio::test]
426    async fn unix_socket_sink_skips_when_listener_absent() {
427        let (root, socket_path) = unique_short_socket_path("missing");
428        let sink = UnixSocketHookSink::new(socket_path);
429        let result = sink
430            .emit(&HookEvent::ResponseStart {
431                response_id: "resp-1".to_string(),
432            })
433            .await;
434        assert!(result.is_ok());
435        let _ = std::fs::remove_dir_all(root);
436    }
437
438    #[cfg(unix)]
439    #[tokio::test]
440    async fn unix_socket_sink_sends_event_to_listener() {
441        use tokio::io::AsyncBufReadExt;
442        use tokio::net::UnixListener;
443
444        let (root, socket_path) = unique_short_socket_path("send");
445        std::fs::create_dir_all(&root).expect("mkdir");
446        let _ = std::fs::remove_file(&socket_path);
447
448        let listener = UnixListener::bind(&socket_path).expect("bind");
449        let sink = UnixSocketHookSink::new(socket_path.clone());
450
451        let handle = tokio::spawn(async move {
452            let (stream, _) = listener.accept().await.expect("accept");
453            let mut reader = tokio::io::BufReader::new(stream);
454            let mut line = String::new();
455            reader.read_line(&mut line).await.expect("read_line");
456            line
457        });
458
459        sink.emit(&HookEvent::ResponseStart {
460            response_id: "resp-42".to_string(),
461        })
462        .await
463        .expect("emit");
464
465        let received = handle.await.expect("join");
466        let parsed: Value = serde_json::from_str(&received).expect("parse");
467        assert_eq!(parsed["event"]["type"], "response_start");
468        assert_eq!(parsed["event"]["response_id"], "resp-42");
469        assert!(parsed["at"].as_str().is_some());
470
471        let _ = std::fs::remove_file(&socket_path);
472        let _ = std::fs::remove_dir_all(root);
473    }
474
475    #[derive(Default)]
476    struct RecordingSink {
477        events: Mutex<Vec<Value>>,
478    }
479
480    impl RecordingSink {
481        fn events(&self) -> Vec<Value> {
482            self.events.lock().unwrap().clone()
483        }
484    }
485
486    #[async_trait::async_trait]
487    impl HookSink for RecordingSink {
488        async fn emit(&self, event: &HookEvent) -> Result<()> {
489            self.events.lock().unwrap().push(event.to_json());
490            Ok(())
491        }
492    }
493
494    struct FailingSink;
495
496    #[async_trait::async_trait]
497    impl HookSink for FailingSink {
498        async fn emit(&self, _event: &HookEvent) -> Result<()> {
499            anyhow::bail!("sink failed")
500        }
501    }
502
503    fn unique_temp_dir(label: &str) -> PathBuf {
504        let nanos = SystemTime::now()
505            .duration_since(UNIX_EPOCH)
506            .unwrap()
507            .as_nanos();
508        std::env::temp_dir().join(format!(
509            "deepseek-hooks-{label}-{}-{nanos}",
510            std::process::id()
511        ))
512    }
513
514    #[cfg(unix)]
515    fn unique_short_socket_path(label: &str) -> (PathBuf, PathBuf) {
516        let nanos = SystemTime::now()
517            .duration_since(UNIX_EPOCH)
518            .unwrap()
519            .as_nanos();
520        let root = PathBuf::from("/tmp").join(format!("cw-hk-{}-{nanos}", std::process::id()));
521        let path = root.join(format!("{label}.sock"));
522        (root, path)
523    }
524}