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 .unwrap_or_else(|_| reqwest::Client::new())
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 #[must_use]
331 pub fn sink_count(&self) -> usize {
332 self.sinks.len()
333 }
334
335 pub async fn emit(&self, event: HookEvent) {
340 for sink in &self.sinks {
341 let _ = sink.emit(&event).await;
342 }
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349 use std::sync::Mutex;
350 use std::time::{SystemTime, UNIX_EPOCH};
351
352 #[test]
353 fn hook_event_serializes_with_snake_case_type_and_payload() {
354 let event = HookEvent::ToolLifecycle {
355 response_id: "resp-1".to_string(),
356 tool_name: "shell".to_string(),
357 phase: "end".to_string(),
358 payload: json!({ "exit_code": 0 }),
359 };
360
361 let encoded = event.to_json();
362
363 assert_eq!(encoded["type"], "tool_lifecycle");
364 assert_eq!(encoded["response_id"], "resp-1");
365 assert_eq!(encoded["tool_name"], "shell");
366 assert_eq!(encoded["phase"], "end");
367 assert_eq!(encoded["payload"]["exit_code"], 0);
368 }
369
370 #[test]
371 fn generic_event_frame_serialization_is_unchanged_by_boxing() {
372 let event = HookEvent::GenericEventFrame {
373 frame: Box::new(EventFrame::ResponseStart {
374 response_id: "resp-1".to_string(),
375 }),
376 };
377
378 let encoded = event.to_json();
379
380 assert_eq!(encoded["type"], "generic_event_frame");
381 assert_eq!(encoded["frame"]["event"], "response_start");
382 assert_eq!(encoded["frame"]["response_id"], "resp-1");
383 }
384
385 #[tokio::test]
386 async fn jsonl_sink_creates_parent_dir_and_appends_events() {
387 let root = unique_temp_dir("jsonl_sink");
388 let path = root.join("nested").join("hooks.jsonl");
389 let sink = JsonlHookSink::new(path.clone());
390
391 sink.emit(&HookEvent::ResponseStart {
392 response_id: "resp-1".to_string(),
393 })
394 .await
395 .unwrap();
396 sink.emit(&HookEvent::ResponseEnd {
397 response_id: "resp-1".to_string(),
398 })
399 .await
400 .unwrap();
401
402 let raw = std::fs::read_to_string(&path).unwrap();
403 let lines = raw.lines().collect::<Vec<_>>();
404 assert_eq!(lines.len(), 2);
405
406 let first: Value = serde_json::from_str(lines[0]).unwrap();
407 let second: Value = serde_json::from_str(lines[1]).unwrap();
408 assert!(first["at"].as_str().is_some());
409 assert_eq!(first["event"]["type"], "response_start");
410 assert_eq!(first["event"]["response_id"], "resp-1");
411 assert_eq!(second["event"]["type"], "response_end");
412 assert_eq!(second["event"]["response_id"], "resp-1");
413
414 let _ = std::fs::remove_dir_all(root);
415 }
416
417 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
422 async fn jsonl_sink_concurrent_emits_write_atomic_json_lines() {
423 let root = unique_temp_dir("jsonl_sink_concurrent");
424 let path = root.join("hooks.jsonl");
425 let sink = Arc::new(JsonlHookSink::new(path.clone()));
426
427 const TASKS: usize = 32;
428 const EVENTS_PER_TASK: usize = 20;
429 let expected = TASKS * EVENTS_PER_TASK;
430
431 let mut handles = Vec::with_capacity(TASKS);
432 for task_id in 0..TASKS {
433 let sink = Arc::clone(&sink);
434 handles.push(tokio::spawn(async move {
435 for n in 0..EVENTS_PER_TASK {
436 let event = HookEvent::ToolLifecycle {
437 response_id: format!("resp-{task_id}"),
438 tool_name: "shell".to_string(),
439 phase: "end".to_string(),
440 payload: json!({ "n": n, "task": task_id }),
441 };
442 sink.emit(&event).await.expect("concurrent emit");
443 }
444 }));
445 }
446 for handle in handles {
447 handle.await.expect("join concurrent writer");
448 }
449
450 let raw = std::fs::read_to_string(&path).expect("read concurrent jsonl");
451 let lines: Vec<&str> = raw.lines().filter(|l| !l.is_empty()).collect();
453 assert_eq!(
454 lines.len(),
455 expected,
456 "expected {expected} complete lines, got {}; sample: {:?}",
457 lines.len(),
458 lines
459 .first()
460 .map(|s| s.chars().take(80).collect::<String>())
461 );
462
463 for (idx, line) in lines.iter().enumerate() {
464 let parsed: Value = serde_json::from_str(line).unwrap_or_else(|err| {
465 panic!("line {idx} is not complete JSON ({err}): {line:?}");
466 });
467 assert!(
468 parsed.get("at").and_then(|v| v.as_str()).is_some(),
469 "line {idx} missing at: {parsed}"
470 );
471 assert_eq!(
472 parsed["event"]["type"], "tool_lifecycle",
473 "line {idx} unexpected event type"
474 );
475 }
476
477 let _ = std::fs::remove_dir_all(root);
478 }
479
480 #[tokio::test]
481 async fn dispatcher_continues_after_sink_error() {
482 let mut dispatcher = HookDispatcher::default();
483 let first = Arc::new(RecordingSink::default());
484 let second = Arc::new(RecordingSink::default());
485
486 dispatcher.add_sink(first.clone());
487 dispatcher.add_sink(Arc::new(FailingSink));
488 dispatcher.add_sink(second.clone());
489
490 dispatcher
491 .emit(HookEvent::ApprovalLifecycle {
492 approval_id: "approval-1".to_string(),
493 phase: "requested".to_string(),
494 reason: Some("needs review".to_string()),
495 })
496 .await;
497
498 assert_eq!(
499 first.events(),
500 vec![json!({
501 "type": "approval_lifecycle",
502 "approval_id": "approval-1",
503 "phase": "requested",
504 "reason": "needs review",
505 })]
506 );
507 assert_eq!(second.events(), first.events());
508 }
509
510 #[cfg(unix)]
511 #[tokio::test]
512 async fn unix_socket_sink_skips_when_listener_absent() {
513 let (root, socket_path) = unique_short_socket_path("missing");
514 let sink = UnixSocketHookSink::new(socket_path);
515 let result = sink
516 .emit(&HookEvent::ResponseStart {
517 response_id: "resp-1".to_string(),
518 })
519 .await;
520 assert!(result.is_ok());
521 let _ = std::fs::remove_dir_all(root);
522 }
523
524 #[cfg(unix)]
525 #[tokio::test]
526 async fn unix_socket_sink_sends_event_to_listener() {
527 use tokio::io::AsyncBufReadExt;
528 use tokio::net::UnixListener;
529
530 let (root, socket_path) = unique_short_socket_path("send");
531 std::fs::create_dir_all(&root).expect("mkdir");
532 let _ = std::fs::remove_file(&socket_path);
533
534 let listener = UnixListener::bind(&socket_path).expect("bind");
535 let sink = UnixSocketHookSink::new(socket_path.clone());
536
537 let handle = tokio::spawn(async move {
538 let (stream, _) = listener.accept().await.expect("accept");
539 let mut reader = tokio::io::BufReader::new(stream);
540 let mut line = String::new();
541 reader.read_line(&mut line).await.expect("read_line");
542 line
543 });
544
545 sink.emit(&HookEvent::ResponseStart {
546 response_id: "resp-42".to_string(),
547 })
548 .await
549 .expect("emit");
550
551 let received = handle.await.expect("join");
552 let parsed: Value = serde_json::from_str(&received).expect("parse");
553 assert_eq!(parsed["event"]["type"], "response_start");
554 assert_eq!(parsed["event"]["response_id"], "resp-42");
555 assert!(parsed["at"].as_str().is_some());
556
557 let _ = std::fs::remove_file(&socket_path);
558 let _ = std::fs::remove_dir_all(root);
559 }
560
561 #[derive(Default)]
562 struct RecordingSink {
563 events: Mutex<Vec<Value>>,
564 }
565
566 impl RecordingSink {
567 fn events(&self) -> Vec<Value> {
568 self.events.lock().unwrap().clone()
569 }
570 }
571
572 #[async_trait::async_trait]
573 impl HookSink for RecordingSink {
574 async fn emit(&self, event: &HookEvent) -> Result<()> {
575 self.events.lock().unwrap().push(event.to_json());
576 Ok(())
577 }
578 }
579
580 struct FailingSink;
581
582 #[async_trait::async_trait]
583 impl HookSink for FailingSink {
584 async fn emit(&self, _event: &HookEvent) -> Result<()> {
585 anyhow::bail!("sink failed")
586 }
587 }
588
589 fn unique_temp_dir(label: &str) -> PathBuf {
590 let nanos = SystemTime::now()
591 .duration_since(UNIX_EPOCH)
592 .unwrap()
593 .as_nanos();
594 std::env::temp_dir().join(format!(
595 "deepseek-hooks-{label}-{}-{nanos}",
596 std::process::id()
597 ))
598 }
599
600 #[cfg(unix)]
601 fn unique_short_socket_path(label: &str) -> (PathBuf, PathBuf) {
602 let nanos = SystemTime::now()
603 .duration_since(UNIX_EPOCH)
604 .unwrap()
605 .as_nanos();
606 let root = PathBuf::from("/tmp").join(format!("cw-hk-{}-{nanos}", std::process::id()));
607 let path = root.join(format!("{label}.sock"));
608 (root, path)
609 }
610
611 #[test]
612 fn webhook_sink_new_does_not_panic() {
613 let sink = WebhookHookSink::new("https://example.invalid/webhook".to_string());
617 let _ = sink;
618 }
619}