use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use async_trait::async_trait;
use chrono::Utc;
use codewhale_protocol::EventFrame;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tokio::io::AsyncWriteExt;
#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HookEvent {
ResponseStart {
response_id: String,
},
ResponseDelta {
response_id: String,
delta: String,
},
ResponseEnd {
response_id: String,
},
ToolLifecycle {
response_id: String,
tool_name: String,
phase: String,
payload: Value,
},
JobLifecycle {
job_id: String,
phase: String,
progress: Option<u8>,
detail: Option<String>,
},
ApprovalLifecycle {
approval_id: String,
phase: String,
reason: Option<String>,
},
GenericEventFrame {
frame: Box<EventFrame>,
},
}
impl HookEvent {
pub fn to_json(&self) -> Value {
serde_json::to_value(self).unwrap_or_else(|_| json!({"type":"serialization_error"}))
}
}
#[async_trait]
pub trait HookSink: Send + Sync {
async fn emit(&self, event: &HookEvent) -> Result<()>;
}
#[derive(Default)]
pub struct StdoutHookSink;
#[async_trait]
impl HookSink for StdoutHookSink {
async fn emit(&self, event: &HookEvent) -> Result<()> {
println!("{}", event.to_json());
Ok(())
}
}
pub struct JsonlHookSink {
path: PathBuf,
write_lock: tokio::sync::Mutex<()>,
}
impl JsonlHookSink {
pub fn new(path: PathBuf) -> Self {
Self {
path,
write_lock: tokio::sync::Mutex::new(()),
}
}
}
#[async_trait]
impl HookSink for JsonlHookSink {
async fn emit(&self, event: &HookEvent) -> Result<()> {
if let Some(parent) = self.path.parent() {
tokio::fs::create_dir_all(parent).await.with_context(|| {
format!("failed to create hook log directory {}", parent.display())
})?;
}
let payload = json!({
"at": Utc::now().to_rfc3339(),
"event": event
});
let encoded = serde_json::to_string(&payload).context("failed to encode hook event")?;
let _guard = self.write_lock.lock().await;
let mut file = tokio::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.await
.with_context(|| format!("failed to open hook log {}", self.path.display()))?;
file.write_all(encoded.as_bytes())
.await
.context("failed to write hook event")?;
file.write_all(b"\n")
.await
.context("failed to write hook event newline")?;
file.flush().await.context("failed to flush hook event")?;
Ok(())
}
}
pub struct WebhookHookSink {
url: String,
client: reqwest::Client,
}
impl WebhookHookSink {
pub fn new(url: String) -> Self {
Self {
url,
client: codewhale_release::platform_http_client_builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| {
codewhale_release::platform_http_client_builder()
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}),
}
}
}
#[async_trait]
impl HookSink for WebhookHookSink {
async fn emit(&self, event: &HookEvent) -> Result<()> {
let mut retries = 0usize;
loop {
let resp = self
.client
.post(&self.url)
.json(&json!({
"at": Utc::now().to_rfc3339(),
"event": event,
}))
.send()
.await;
match resp {
Ok(response) if response.status().is_success() => return Ok(()),
Ok(response) => {
if retries >= 2 {
anyhow::bail!("webhook returned non-success status {}", response.status());
}
}
Err(err) => {
if retries >= 2 {
return Err(err).context("webhook request failed");
}
}
}
retries += 1;
tokio::time::sleep(std::time::Duration::from_millis(200 * retries as u64)).await;
}
}
}
#[derive(Debug, Clone)]
pub struct UnixSocketHookSink {
#[cfg(unix)]
path: PathBuf,
}
impl UnixSocketHookSink {
pub fn new(path: PathBuf) -> Self {
#[cfg(unix)]
{
Self { path }
}
#[cfg(not(unix))]
{
let _ = path;
Self {}
}
}
}
#[async_trait]
impl HookSink for UnixSocketHookSink {
#[cfg(unix)]
async fn emit(&self, event: &HookEvent) -> Result<()> {
let mut stream = match tokio::net::UnixStream::connect(&self.path).await {
Ok(s) => s,
Err(_) => return Ok(()), };
let payload = json!({
"at": Utc::now().to_rfc3339(),
"event": event
});
let mut line = serde_json::to_string(&payload).context("failed to encode hook event")?;
line.push('\n');
stream
.write_all(line.as_bytes())
.await
.context("failed to write to unix socket")?;
Ok(())
}
#[cfg(not(unix))]
async fn emit(&self, _event: &HookEvent) -> Result<()> {
Ok(())
}
}
#[derive(Default, Clone)]
pub struct HookDispatcher {
sinks: Vec<Arc<dyn HookSink>>,
}
impl HookDispatcher {
pub fn add_sink(&mut self, sink: Arc<dyn HookSink>) {
self.sinks.push(sink);
}
#[must_use]
pub fn sink_count(&self) -> usize {
self.sinks.len()
}
pub async fn emit(&self, event: HookEvent) {
for sink in &self.sinks {
let _ = sink.emit(&event).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn hook_event_serializes_with_snake_case_type_and_payload() {
let event = HookEvent::ToolLifecycle {
response_id: "resp-1".to_string(),
tool_name: "shell".to_string(),
phase: "end".to_string(),
payload: json!({ "exit_code": 0 }),
};
let encoded = event.to_json();
assert_eq!(encoded["type"], "tool_lifecycle");
assert_eq!(encoded["response_id"], "resp-1");
assert_eq!(encoded["tool_name"], "shell");
assert_eq!(encoded["phase"], "end");
assert_eq!(encoded["payload"]["exit_code"], 0);
}
#[test]
fn generic_event_frame_serialization_is_unchanged_by_boxing() {
let event = HookEvent::GenericEventFrame {
frame: Box::new(EventFrame::ResponseStart {
response_id: "resp-1".to_string(),
}),
};
let encoded = event.to_json();
assert_eq!(encoded["type"], "generic_event_frame");
assert_eq!(encoded["frame"]["event"], "response_start");
assert_eq!(encoded["frame"]["response_id"], "resp-1");
}
#[tokio::test]
async fn jsonl_sink_creates_parent_dir_and_appends_events() {
let root = unique_temp_dir("jsonl_sink");
let path = root.join("nested").join("hooks.jsonl");
let sink = JsonlHookSink::new(path.clone());
sink.emit(&HookEvent::ResponseStart {
response_id: "resp-1".to_string(),
})
.await
.unwrap();
sink.emit(&HookEvent::ResponseEnd {
response_id: "resp-1".to_string(),
})
.await
.unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
let lines = raw.lines().collect::<Vec<_>>();
assert_eq!(lines.len(), 2);
let first: Value = serde_json::from_str(lines[0]).unwrap();
let second: Value = serde_json::from_str(lines[1]).unwrap();
assert!(first["at"].as_str().is_some());
assert_eq!(first["event"]["type"], "response_start");
assert_eq!(first["event"]["response_id"], "resp-1");
assert_eq!(second["event"]["type"], "response_end");
assert_eq!(second["event"]["response_id"], "resp-1");
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn jsonl_sink_concurrent_emits_write_atomic_json_lines() {
let root = unique_temp_dir("jsonl_sink_concurrent");
let path = root.join("hooks.jsonl");
let sink = Arc::new(JsonlHookSink::new(path.clone()));
const TASKS: usize = 32;
const EVENTS_PER_TASK: usize = 20;
let expected = TASKS * EVENTS_PER_TASK;
let mut handles = Vec::with_capacity(TASKS);
for task_id in 0..TASKS {
let sink = Arc::clone(&sink);
handles.push(tokio::spawn(async move {
for n in 0..EVENTS_PER_TASK {
let event = HookEvent::ToolLifecycle {
response_id: format!("resp-{task_id}"),
tool_name: "shell".to_string(),
phase: "end".to_string(),
payload: json!({ "n": n, "task": task_id }),
};
sink.emit(&event).await.expect("concurrent emit");
}
}));
}
for handle in handles {
handle.await.expect("join concurrent writer");
}
let raw = std::fs::read_to_string(&path).expect("read concurrent jsonl");
let lines: Vec<&str> = raw.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(
lines.len(),
expected,
"expected {expected} complete lines, got {}; sample: {:?}",
lines.len(),
lines
.first()
.map(|s| s.chars().take(80).collect::<String>())
);
for (idx, line) in lines.iter().enumerate() {
let parsed: Value = serde_json::from_str(line).unwrap_or_else(|err| {
panic!("line {idx} is not complete JSON ({err}): {line:?}");
});
assert!(
parsed.get("at").and_then(|v| v.as_str()).is_some(),
"line {idx} missing at: {parsed}"
);
assert_eq!(
parsed["event"]["type"], "tool_lifecycle",
"line {idx} unexpected event type"
);
}
let _ = std::fs::remove_dir_all(root);
}
#[tokio::test]
async fn dispatcher_continues_after_sink_error() {
let mut dispatcher = HookDispatcher::default();
let first = Arc::new(RecordingSink::default());
let second = Arc::new(RecordingSink::default());
dispatcher.add_sink(first.clone());
dispatcher.add_sink(Arc::new(FailingSink));
dispatcher.add_sink(second.clone());
dispatcher
.emit(HookEvent::ApprovalLifecycle {
approval_id: "approval-1".to_string(),
phase: "requested".to_string(),
reason: Some("needs review".to_string()),
})
.await;
assert_eq!(
first.events(),
vec![json!({
"type": "approval_lifecycle",
"approval_id": "approval-1",
"phase": "requested",
"reason": "needs review",
})]
);
assert_eq!(second.events(), first.events());
}
#[cfg(unix)]
#[tokio::test]
async fn unix_socket_sink_skips_when_listener_absent() {
let (root, socket_path) = unique_short_socket_path("missing");
let sink = UnixSocketHookSink::new(socket_path);
let result = sink
.emit(&HookEvent::ResponseStart {
response_id: "resp-1".to_string(),
})
.await;
assert!(result.is_ok());
let _ = std::fs::remove_dir_all(root);
}
#[cfg(unix)]
#[tokio::test]
async fn unix_socket_sink_sends_event_to_listener() {
use tokio::io::AsyncBufReadExt;
use tokio::net::UnixListener;
let (root, socket_path) = unique_short_socket_path("send");
std::fs::create_dir_all(&root).expect("mkdir");
let _ = std::fs::remove_file(&socket_path);
let listener = UnixListener::bind(&socket_path).expect("bind");
let sink = UnixSocketHookSink::new(socket_path.clone());
let handle = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept");
let mut reader = tokio::io::BufReader::new(stream);
let mut line = String::new();
reader.read_line(&mut line).await.expect("read_line");
line
});
sink.emit(&HookEvent::ResponseStart {
response_id: "resp-42".to_string(),
})
.await
.expect("emit");
let received = handle.await.expect("join");
let parsed: Value = serde_json::from_str(&received).expect("parse");
assert_eq!(parsed["event"]["type"], "response_start");
assert_eq!(parsed["event"]["response_id"], "resp-42");
assert!(parsed["at"].as_str().is_some());
let _ = std::fs::remove_file(&socket_path);
let _ = std::fs::remove_dir_all(root);
}
#[derive(Default)]
struct RecordingSink {
events: Mutex<Vec<Value>>,
}
impl RecordingSink {
fn events(&self) -> Vec<Value> {
self.events.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl HookSink for RecordingSink {
async fn emit(&self, event: &HookEvent) -> Result<()> {
self.events.lock().unwrap().push(event.to_json());
Ok(())
}
}
struct FailingSink;
#[async_trait::async_trait]
impl HookSink for FailingSink {
async fn emit(&self, _event: &HookEvent) -> Result<()> {
anyhow::bail!("sink failed")
}
}
fn unique_temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!(
"deepseek-hooks-{label}-{}-{nanos}",
std::process::id()
))
}
#[cfg(unix)]
fn unique_short_socket_path(label: &str) -> (PathBuf, PathBuf) {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = PathBuf::from("/tmp").join(format!("cw-hk-{}-{nanos}", std::process::id()));
let path = root.join(format!("{label}.sock"));
(root, path)
}
#[test]
fn webhook_sink_new_does_not_panic() {
let sink = WebhookHookSink::new("https://example.invalid/webhook".to_string());
let _ = sink;
}
}