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 Ok(())
172 }
173}
174
175pub struct WebhookHookSink {
181 url: String,
182 client: reqwest::Client,
183}
184
185impl WebhookHookSink {
186 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#[derive(Debug, Clone)]
237pub struct UnixSocketHookSink {
238 #[cfg(unix)]
239 path: PathBuf,
240}
241
242impl UnixSocketHookSink {
243 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(()), };
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 Ok(())
282 }
283}
284
285#[derive(Default, Clone)]
292pub struct HookDispatcher {
293 sinks: Vec<Arc<dyn HookSink>>,
294}
295
296impl HookDispatcher {
297 pub fn add_sink(&mut self, sink: Arc<dyn HookSink>) {
299 self.sinks.push(sink);
300 }
301
302 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}