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