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