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 {
138 path: PathBuf,
139 write_lock: tokio::sync::Mutex<()>,
142}
143
144impl JsonlHookSink {
145 pub fn new(path: PathBuf) -> Self {
150 Self {
151 path,
152 write_lock: tokio::sync::Mutex::new(()),
153 }
154 }
155}
156
157#[async_trait]
158impl HookSink for JsonlHookSink {
159 async fn emit(&self, event: &HookEvent) -> Result<()> {
160 if let Some(parent) = self.path.parent() {
161 tokio::fs::create_dir_all(parent).await.with_context(|| {
162 format!("failed to create hook log directory {}", parent.display())
163 })?;
164 }
165 let payload = json!({
167 "at": Utc::now().to_rfc3339(),
168 "event": event
169 });
170 let encoded = serde_json::to_string(&payload).context("failed to encode hook event")?;
171
172 let _guard = self.write_lock.lock().await;
173 let mut file = tokio::fs::OpenOptions::new()
174 .create(true)
175 .append(true)
176 .open(&self.path)
177 .await
178 .with_context(|| format!("failed to open hook log {}", self.path.display()))?;
179 file.write_all(encoded.as_bytes())
180 .await
181 .context("failed to write hook event")?;
182 file.write_all(b"\n")
183 .await
184 .context("failed to write hook event newline")?;
185 file.flush().await.context("failed to flush hook event")?;
190 Ok(())
191 }
192}
193
194pub struct WebhookHookSink {
200 url: String,
201 client: reqwest::Client,
202}
203
204impl WebhookHookSink {
205 pub fn new(url: String) -> Self {
207 Self {
208 url,
209 client: codewhale_release::platform_http_client_builder()
210 .timeout(std::time::Duration::from_secs(10))
211 .build()
212 .unwrap_or_else(|_| {
213 codewhale_release::platform_http_client_builder()
214 .build()
215 .expect("build fallback HTTP client")
216 }),
217 }
218 }
219}
220
221#[async_trait]
222impl HookSink for WebhookHookSink {
223 async fn emit(&self, event: &HookEvent) -> Result<()> {
224 let mut retries = 0usize;
225 loop {
226 let resp = self
227 .client
228 .post(&self.url)
229 .json(&json!({
230 "at": Utc::now().to_rfc3339(),
231 "event": event,
232 }))
233 .send()
234 .await;
235 match resp {
236 Ok(response) if response.status().is_success() => return Ok(()),
237 Ok(response) => {
238 if retries >= 2 {
239 anyhow::bail!("webhook returned non-success status {}", response.status());
240 }
241 }
242 Err(err) => {
243 if retries >= 2 {
244 return Err(err).context("webhook request failed");
245 }
246 }
247 }
248 retries += 1;
249 tokio::time::sleep(std::time::Duration::from_millis(200 * retries as u64)).await;
250 }
251 }
252}
253
254#[derive(Debug, Clone)]
263pub struct UnixSocketHookSink {
264 #[cfg(unix)]
265 path: PathBuf,
266}
267
268impl UnixSocketHookSink {
269 pub fn new(path: PathBuf) -> Self {
271 #[cfg(unix)]
272 {
273 Self { path }
274 }
275 #[cfg(not(unix))]
276 {
277 let _ = path;
278 Self {}
279 }
280 }
281}
282
283#[async_trait]
284impl HookSink for UnixSocketHookSink {
285 #[cfg(unix)]
286 async fn emit(&self, event: &HookEvent) -> Result<()> {
287 let mut stream = match tokio::net::UnixStream::connect(&self.path).await {
288 Ok(s) => s,
289 Err(_) => return Ok(()), };
291 let payload = json!({
292 "at": Utc::now().to_rfc3339(),
293 "event": event
294 });
295 let mut line = serde_json::to_string(&payload).context("failed to encode hook event")?;
296 line.push('\n');
297 stream
298 .write_all(line.as_bytes())
299 .await
300 .context("failed to write to unix socket")?;
301 Ok(())
302 }
303
304 #[cfg(not(unix))]
305 async fn emit(&self, _event: &HookEvent) -> Result<()> {
306 Ok(())
308 }
309}
310
311#[derive(Default, Clone)]
318pub struct HookDispatcher {
319 sinks: Vec<Arc<dyn HookSink>>,
320}
321
322impl HookDispatcher {
323 pub fn add_sink(&mut self, sink: Arc<dyn HookSink>) {
325 self.sinks.push(sink);
326 }
327
328 pub async fn emit(&self, event: HookEvent) {
333 for sink in &self.sinks {
334 let _ = sink.emit(&event).await;
335 }
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use std::sync::Mutex;
343 use std::time::{SystemTime, UNIX_EPOCH};
344
345 #[test]
346 fn hook_event_serializes_with_snake_case_type_and_payload() {
347 let event = HookEvent::ToolLifecycle {
348 response_id: "resp-1".to_string(),
349 tool_name: "shell".to_string(),
350 phase: "end".to_string(),
351 payload: json!({ "exit_code": 0 }),
352 };
353
354 let encoded = event.to_json();
355
356 assert_eq!(encoded["type"], "tool_lifecycle");
357 assert_eq!(encoded["response_id"], "resp-1");
358 assert_eq!(encoded["tool_name"], "shell");
359 assert_eq!(encoded["phase"], "end");
360 assert_eq!(encoded["payload"]["exit_code"], 0);
361 }
362
363 #[test]
364 fn generic_event_frame_serialization_is_unchanged_by_boxing() {
365 let event = HookEvent::GenericEventFrame {
366 frame: Box::new(EventFrame::ResponseStart {
367 response_id: "resp-1".to_string(),
368 }),
369 };
370
371 let encoded = event.to_json();
372
373 assert_eq!(encoded["type"], "generic_event_frame");
374 assert_eq!(encoded["frame"]["event"], "response_start");
375 assert_eq!(encoded["frame"]["response_id"], "resp-1");
376 }
377
378 #[tokio::test]
379 async fn jsonl_sink_creates_parent_dir_and_appends_events() {
380 let root = unique_temp_dir("jsonl_sink");
381 let path = root.join("nested").join("hooks.jsonl");
382 let sink = JsonlHookSink::new(path.clone());
383
384 sink.emit(&HookEvent::ResponseStart {
385 response_id: "resp-1".to_string(),
386 })
387 .await
388 .unwrap();
389 sink.emit(&HookEvent::ResponseEnd {
390 response_id: "resp-1".to_string(),
391 })
392 .await
393 .unwrap();
394
395 let raw = std::fs::read_to_string(&path).unwrap();
396 let lines = raw.lines().collect::<Vec<_>>();
397 assert_eq!(lines.len(), 2);
398
399 let first: Value = serde_json::from_str(lines[0]).unwrap();
400 let second: Value = serde_json::from_str(lines[1]).unwrap();
401 assert!(first["at"].as_str().is_some());
402 assert_eq!(first["event"]["type"], "response_start");
403 assert_eq!(first["event"]["response_id"], "resp-1");
404 assert_eq!(second["event"]["type"], "response_end");
405 assert_eq!(second["event"]["response_id"], "resp-1");
406
407 let _ = std::fs::remove_dir_all(root);
408 }
409
410 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
415 async fn jsonl_sink_concurrent_emits_write_atomic_json_lines() {
416 let root = unique_temp_dir("jsonl_sink_concurrent");
417 let path = root.join("hooks.jsonl");
418 let sink = Arc::new(JsonlHookSink::new(path.clone()));
419
420 const TASKS: usize = 32;
421 const EVENTS_PER_TASK: usize = 20;
422 let expected = TASKS * EVENTS_PER_TASK;
423
424 let mut handles = Vec::with_capacity(TASKS);
425 for task_id in 0..TASKS {
426 let sink = Arc::clone(&sink);
427 handles.push(tokio::spawn(async move {
428 for n in 0..EVENTS_PER_TASK {
429 let event = HookEvent::ToolLifecycle {
430 response_id: format!("resp-{task_id}"),
431 tool_name: "shell".to_string(),
432 phase: "end".to_string(),
433 payload: json!({ "n": n, "task": task_id }),
434 };
435 sink.emit(&event).await.expect("concurrent emit");
436 }
437 }));
438 }
439 for handle in handles {
440 handle.await.expect("join concurrent writer");
441 }
442
443 let raw = std::fs::read_to_string(&path).expect("read concurrent jsonl");
444 let lines: Vec<&str> = raw.lines().filter(|l| !l.is_empty()).collect();
446 assert_eq!(
447 lines.len(),
448 expected,
449 "expected {expected} complete lines, got {}; sample: {:?}",
450 lines.len(),
451 lines
452 .first()
453 .map(|s| s.chars().take(80).collect::<String>())
454 );
455
456 for (idx, line) in lines.iter().enumerate() {
457 let parsed: Value = serde_json::from_str(line).unwrap_or_else(|err| {
458 panic!("line {idx} is not complete JSON ({err}): {line:?}");
459 });
460 assert!(
461 parsed.get("at").and_then(|v| v.as_str()).is_some(),
462 "line {idx} missing at: {parsed}"
463 );
464 assert_eq!(
465 parsed["event"]["type"], "tool_lifecycle",
466 "line {idx} unexpected event type"
467 );
468 }
469
470 let _ = std::fs::remove_dir_all(root);
471 }
472
473 #[tokio::test]
474 async fn dispatcher_continues_after_sink_error() {
475 let mut dispatcher = HookDispatcher::default();
476 let first = Arc::new(RecordingSink::default());
477 let second = Arc::new(RecordingSink::default());
478
479 dispatcher.add_sink(first.clone());
480 dispatcher.add_sink(Arc::new(FailingSink));
481 dispatcher.add_sink(second.clone());
482
483 dispatcher
484 .emit(HookEvent::ApprovalLifecycle {
485 approval_id: "approval-1".to_string(),
486 phase: "requested".to_string(),
487 reason: Some("needs review".to_string()),
488 })
489 .await;
490
491 assert_eq!(
492 first.events(),
493 vec![json!({
494 "type": "approval_lifecycle",
495 "approval_id": "approval-1",
496 "phase": "requested",
497 "reason": "needs review",
498 })]
499 );
500 assert_eq!(second.events(), first.events());
501 }
502
503 #[cfg(unix)]
504 #[tokio::test]
505 async fn unix_socket_sink_skips_when_listener_absent() {
506 let (root, socket_path) = unique_short_socket_path("missing");
507 let sink = UnixSocketHookSink::new(socket_path);
508 let result = sink
509 .emit(&HookEvent::ResponseStart {
510 response_id: "resp-1".to_string(),
511 })
512 .await;
513 assert!(result.is_ok());
514 let _ = std::fs::remove_dir_all(root);
515 }
516
517 #[cfg(unix)]
518 #[tokio::test]
519 async fn unix_socket_sink_sends_event_to_listener() {
520 use tokio::io::AsyncBufReadExt;
521 use tokio::net::UnixListener;
522
523 let (root, socket_path) = unique_short_socket_path("send");
524 std::fs::create_dir_all(&root).expect("mkdir");
525 let _ = std::fs::remove_file(&socket_path);
526
527 let listener = UnixListener::bind(&socket_path).expect("bind");
528 let sink = UnixSocketHookSink::new(socket_path.clone());
529
530 let handle = tokio::spawn(async move {
531 let (stream, _) = listener.accept().await.expect("accept");
532 let mut reader = tokio::io::BufReader::new(stream);
533 let mut line = String::new();
534 reader.read_line(&mut line).await.expect("read_line");
535 line
536 });
537
538 sink.emit(&HookEvent::ResponseStart {
539 response_id: "resp-42".to_string(),
540 })
541 .await
542 .expect("emit");
543
544 let received = handle.await.expect("join");
545 let parsed: Value = serde_json::from_str(&received).expect("parse");
546 assert_eq!(parsed["event"]["type"], "response_start");
547 assert_eq!(parsed["event"]["response_id"], "resp-42");
548 assert!(parsed["at"].as_str().is_some());
549
550 let _ = std::fs::remove_file(&socket_path);
551 let _ = std::fs::remove_dir_all(root);
552 }
553
554 #[derive(Default)]
555 struct RecordingSink {
556 events: Mutex<Vec<Value>>,
557 }
558
559 impl RecordingSink {
560 fn events(&self) -> Vec<Value> {
561 self.events.lock().unwrap().clone()
562 }
563 }
564
565 #[async_trait::async_trait]
566 impl HookSink for RecordingSink {
567 async fn emit(&self, event: &HookEvent) -> Result<()> {
568 self.events.lock().unwrap().push(event.to_json());
569 Ok(())
570 }
571 }
572
573 struct FailingSink;
574
575 #[async_trait::async_trait]
576 impl HookSink for FailingSink {
577 async fn emit(&self, _event: &HookEvent) -> Result<()> {
578 anyhow::bail!("sink failed")
579 }
580 }
581
582 fn unique_temp_dir(label: &str) -> PathBuf {
583 let nanos = SystemTime::now()
584 .duration_since(UNIX_EPOCH)
585 .unwrap()
586 .as_nanos();
587 std::env::temp_dir().join(format!(
588 "deepseek-hooks-{label}-{}-{nanos}",
589 std::process::id()
590 ))
591 }
592
593 #[cfg(unix)]
594 fn unique_short_socket_path(label: &str) -> (PathBuf, PathBuf) {
595 let nanos = SystemTime::now()
596 .duration_since(UNIX_EPOCH)
597 .unwrap()
598 .as_nanos();
599 let root = PathBuf::from("/tmp").join(format!("cw-hk-{}-{nanos}", std::process::id()));
600 let path = root.join(format!("{label}.sock"));
601 (root, path)
602 }
603}