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#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, Serialize, Deserialize)]
20#[serde(tag = "type", rename_all = "snake_case")]
21pub enum HookEvent {
22 ResponseStart {
24 response_id: String,
26 },
27 ResponseDelta {
29 response_id: String,
31 delta: String,
33 },
34 ResponseEnd {
36 response_id: String,
38 },
39 ToolLifecycle {
41 response_id: String,
43 tool_name: String,
45 phase: String,
47 payload: Value,
49 },
50 JobLifecycle {
52 job_id: String,
54 phase: String,
56 progress: Option<u8>,
58 detail: Option<String>,
60 },
61 ApprovalLifecycle {
63 approval_id: String,
65 phase: String,
67 reason: Option<String>,
69 },
70 GenericEventFrame {
75 frame: Box<EventFrame>,
77 },
78}
79
80impl HookEvent {
81 pub fn to_json(&self) -> Value {
88 serde_json::to_value(self).unwrap_or_else(|_| json!({"type":"serialization_error"}))
89 }
90}
91
92#[async_trait]
104pub trait HookSink: Send + Sync {
105 async fn emit(&self, event: &HookEvent) -> Result<()>;
110}
111
112#[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
127pub struct JsonlHookSink {
133 path: PathBuf,
134}
135
136impl JsonlHookSink {
137 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 file.flush().await.context("failed to flush hook event")?;
174 Ok(())
175 }
176}
177
178pub struct WebhookHookSink {
184 url: String,
185 client: reqwest::Client,
186}
187
188impl WebhookHookSink {
189 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#[derive(Debug, Clone)]
247pub struct UnixSocketHookSink {
248 #[cfg(unix)]
249 path: PathBuf,
250}
251
252impl UnixSocketHookSink {
253 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(()), };
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 Ok(())
292 }
293}
294
295#[derive(Default, Clone)]
302pub struct HookDispatcher {
303 sinks: Vec<Arc<dyn HookSink>>,
304}
305
306impl HookDispatcher {
307 pub fn add_sink(&mut self, sink: Arc<dyn HookSink>) {
309 self.sinks.push(sink);
310 }
311
312 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}