返回 CodeWhale
lib.rs
根目录 / crates / hooks / src / lib.rs
1 use std::path::PathBuf;
2 use std::sync::Arc;
3
4 use anyhow::{Context, Result};
5 use async_trait::async_trait;
6 use chrono::Utc;
7 use codewhale_protocol::EventFrame;
8 use serde::{Deserialize, Serialize};
9 use serde_json::{Value, json};
10 use tokio::io::AsyncWriteExt;
11
12 /// All events that can be emitted through the hook system.
13 ///
14 /// Each variant represents a distinct lifecycle or streaming event. The enum is
15 /// serialised with a `"type"` discriminator using `snake_case` naming (e.g.
16 /// `"response_start"`, `"tool_lifecycle"`), making it easy to consume from
17 /// JSON-based log files or webhook receivers.
18 #[allow(clippy::large_enum_variant)] // Keep the public HookEvent shape stable for 0.8.x.
19 #[derive(Debug, Clone, Serialize, Deserialize)]
20 #[serde(tag = "type", rename_all = "snake_case")]
21 pub enum HookEvent {
22 /// A new response stream has started.
23 ResponseStart {
24 /// Unique identifier for the response being streamed.
25 response_id: String,
26 },
27 /// A chunk of text has been received for an in-progress response.
28 ResponseDelta {
29 /// Unique identifier for the response being streamed.
30 response_id: String,
31 /// The incremental text content of this chunk.
32 delta: String,
33 },
34 /// A response stream has finished.
35 ResponseEnd {
36 /// Unique identifier for the response that completed.
37 response_id: String,
38 },
39 /// A tool invocation has transitioned to a new phase (e.g. start, end, error).
40 ToolLifecycle {
41 /// Identifier of the response under which the tool was invoked.
42 response_id: String,
43 /// Name of the tool (e.g. `"shell"`, `"read_file"`).
44 tool_name: String,
45 /// Current phase of the tool execution (e.g. `"start"`, `"end"`).
46 phase: String,
47 /// Arbitrary structured payload associated with this phase.
48 payload: Value,
49 },
50 /// A background job has transitioned to a new phase.
51 JobLifecycle {
52 /// Unique identifier of the job.
53 job_id: String,
54 /// Current phase of the job (e.g. `"queued"`, `"running"`, `"done"`).
55 phase: String,
56 /// Optional progress percentage (0-100).
57 progress: Option<u8>,
58 /// Optional human-readable detail about the current phase.
59 detail: Option<String>,
60 },
61 /// An approval request has transitioned to a new phase.
62 ApprovalLifecycle {
63 /// Unique identifier of the approval request.
64 approval_id: String,
65 /// Current phase (e.g. `"requested"`, `"approved"`, `"denied"`).
66 phase: String,
67 /// Optional reason explaining the current phase.
68 reason: Option<String>,
69 },
70 /// A catch-all variant that wraps an arbitrary [`EventFrame`].
71 ///
72 /// Use this when you need to forward a protocol-level event frame without
73 /// mapping it to a more specific variant.
74 GenericEventFrame {
75 /// The raw event frame to forward.
76 frame: Box<EventFrame>,
77 },
78 }
79
80 impl HookEvent {
81 /// Serialise this event into a [`serde_json::Value`].
82 ///
83 /// Returns a JSON object with the `"type"` discriminator and all variant
84 /// fields. If serialisation fails (which should be extremely rare), a
85 /// fallback `{"type":"serialization_error"}` value is returned instead of
86 /// panicking.
87 pub fn to_json(&self) -> Value {
88 serde_json::to_value(self).unwrap_or_else(|_| json!({"type":"serialization_error"}))
89 }
90 }
91
92 /// A destination that can receive [`HookEvent`]s.
93 ///
94 /// Implementors handle the transport-specific details of delivering events
95 /// (writing to stdout, appending to a file, POSTing to a webhook, etc.).
96 /// The [`HookDispatcher`] fans out every event to all registered sinks, so a
97 /// single process can log to multiple destinations simultaneously.
98 ///
99 /// Sinks are expected to be **best-effort**: implementations should avoid
100 /// panicking and should return an [`anyhow::Error`] only for truly unexpected
101 /// failures. [`HookDispatcher::emit`] discards individual sink errors so hook
102 /// delivery failures do not abort the application.
103 #[async_trait]
104 pub trait HookSink: Send + Sync {
105 /// Deliver a single event to this sink.
106 ///
107 /// Implementations should be resilient to transient failures (e.g. a
108 /// missing listener) and should not block the caller for extended periods.
109 async fn emit(&self, event: &HookEvent) -> Result<()>;
110 }
111
112 /// A [`HookSink`] that prints each event as a single JSON line to stdout.
113 ///
114 /// Useful for local development and debugging. Events are printed via
115 /// [`println!`] so they appear interleaved with other program output.
116 #[derive(Default)]
117 pub struct StdoutHookSink;
118
119 #[async_trait]
120 impl HookSink for StdoutHookSink {
121 async fn emit(&self, event: &HookEvent) -> Result<()> {
122 println!("{}", event.to_json());
123 Ok(())
124 }
125 }
126
127 /// A [`HookSink`] that appends each event as a JSON line to a file.
128 ///
129 /// The file is created (along with any missing parent directories) on the
130 /// first emitted event. Each line is a JSON object of the form
131 /// `{"at": "<ISO 8601 timestamp>", "event": {...}}`.
132 ///
133 /// Concurrent [`emit`](HookSink::emit) calls serialize on an internal mutex so
134 /// that each JSON event is written and flushed as a complete line; without that
135 /// lock, overlapping `write_all` calls can interleave partial lines and corrupt
136 /// the JSONL log (see issue #4739).
137 pub struct JsonlHookSink {
138 path: PathBuf,
139 /// Serializes open+append+flush so concurrent tool-call emits cannot
140 /// interleave bytes mid-line.
141 write_lock: tokio::sync::Mutex<()>,
142 }
143
144 impl JsonlHookSink {
145 /// Create a new sink that writes to the file at `path`.
146 ///
147 /// Parent directories are created lazily on the first [`HookSink::emit`]
148 /// call.
149 pub fn new(path: PathBuf) -> Self {
150 Self {
151 path,
152 write_lock: tokio::sync::Mutex::new(()),
153 }
154 }
155 }
156
157 #[async_trait]
158 impl 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 // Encode outside the lock so only I/O is serialized.
166 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 // Flush before drop so sequential emits (and tests that read the
186 // file immediately after) observe every completed line. Holding the
187 // mutex through flush guarantees concurrent writers never observe a
188 // partial line at EOF.
189 file.flush().await.context("failed to flush hook event")?;
190 Ok(())
191 }
192 }
193
194 /// A [`HookSink`] that POSTs each event as JSON to a remote HTTP endpoint.
195 ///
196 /// The request body is `{"at": "<ISO 8601 timestamp>", "event": {...}}`.
197 /// Failed requests are retried up to 2 times with exponential back-off
198 /// (200 ms, 400 ms). After exhausting retries the error is propagated.
199 pub struct WebhookHookSink {
200 url: String,
201 client: reqwest::Client,
202 }
203
204 impl WebhookHookSink {
205 /// Create a new sink that sends events to the given `url`.
206 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]
222 impl 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 /// A [`HookSink`] that sends events over a Unix domain socket.
255 ///
256 /// Each event is serialized as a single JSON line (`{"at": "...", "event": {...}}\n`)
257 /// and written to the socket. If the socket is not available (listener not running),
258 /// the event is silently dropped - hook sinks are best-effort observability, not
259 /// control flow.
260 ///
261 /// On non-Unix platforms this struct exists but its [`HookSink::emit`] is a no-op.
262 #[derive(Debug, Clone)]
263 pub struct UnixSocketHookSink {
264 #[cfg(unix)]
265 path: PathBuf,
266 }
267
268 impl UnixSocketHookSink {
269 /// Create a sink that connects to the Unix domain socket at `path`.
270 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]
284 impl 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(()), // listener not running, skip silently
290 };
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 // Unix sockets are not available on this platform.
307 Ok(())
308 }
309 }
310
311 /// Fans out [`HookEvent`]s to a collection of [`HookSink`]s.
312 ///
313 /// Register one or more sinks via [`add_sink`](HookDispatcher::add_sink),
314 /// then call [`emit`](HookDispatcher::emit) to broadcast an event to all of
315 /// them. If a sink returns an error it is silently ignored so that a failing
316 /// sink does not prevent remaining sinks from receiving the event.
317 #[derive(Default, Clone)]
318 pub struct HookDispatcher {
319 sinks: Vec<Arc<dyn HookSink>>,
320 }
321
322 impl HookDispatcher {
323 /// Register a new sink that will receive all subsequently emitted events.
324 pub fn add_sink(&mut self, sink: Arc<dyn HookSink>) {
325 self.sinks.push(sink);
326 }
327
328 /// Number of registered sinks. Exposed so transport setup can assert
329 /// exactly which sinks were wired (e.g. no stdout sink in stdio mode).
330 #[must_use]
331 pub fn sink_count(&self) -> usize {
332 self.sinks.len()
333 }
334
335 /// Broadcast an event to every registered sink.
336 ///
337 /// Errors from individual sinks are silently discarded so that one failing
338 /// sink does not block the others.
339 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)]
347 mod 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 /// Concurrent emits must not interleave partial lines (#4739).
418 ///
419 /// Spawns many tasks writing through one shared [`JsonlHookSink`] and
420 /// asserts every line in the resulting file is complete, parseable JSON.
421 #[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 // Trailing newline yields an empty final split; keep non-empty lines only.
452 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
611 lines RUST