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#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(tag = "type", rename_all = "snake_case")]
20pub enum HookEvent {
21 ResponseStart {
23 response_id: String,
25 },
26 ResponseDelta {
28 response_id: String,
30 delta: String,
32 },
33 ResponseEnd {
35 response_id: String,
37 },
38 ToolLifecycle {
40 response_id: String,
42 tool_name: String,
44 phase: String,
46 payload: Value,
48 },
49 JobLifecycle {
51 job_id: String,
53 phase: String,
55 progress: Option<u8>,
57 detail: Option<String>,
59 },
60 ApprovalLifecycle {
62 approval_id: String,
64 phase: String,
66 reason: Option<String>,
68 },
69 GenericEventFrame {
74 frame: EventFrame,
76 },
77}
78
79impl HookEvent {
80 pub fn to_json(&self) -> Value {
87 serde_json::to_value(self).unwrap_or_else(|_| json!({"type":"serialization_error"}))
88 }
89}
90
91#[async_trait]
103pub trait HookSink: Send + Sync {
104 async fn emit(&self, event: &HookEvent) -> Result<()>;
109}
110
111#[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
126pub struct JsonlHookSink {
132 path: PathBuf,
133}
134
135impl JsonlHookSink {
136 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
174pub struct WebhookHookSink {
180 url: String,
181 client: reqwest::Client,
182}
183
184impl WebhookHookSink {
185 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#[derive(Debug, Clone)]
236pub struct UnixSocketHookSink {
237 #[cfg(unix)]
238 path: PathBuf,
239}
240
241impl UnixSocketHookSink {
242 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(()), };
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 Ok(())
281 }
282}
283
284#[derive(Default, Clone)]
291pub struct HookDispatcher {
292 sinks: Vec<Arc<dyn HookSink>>,
293}
294
295impl HookDispatcher {
296 pub fn add_sink(&mut self, sink: Arc<dyn HookSink>) {
298 self.sinks.push(sink);
299 }
300
301 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}