返回 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 mod lifecycle_outbox;
13
14 pub use lifecycle_outbox::{
15 LifecycleEvent, LifecycleOutbox, OUTBOX_DETAIL_MAX_CHARS, OUTBOX_HEADLINE_MAX_CHARS,
16 OUTBOX_PREVIEW_MAX_CHARS, OUTBOX_TRUNCATION_MARKER, bounded_text,
17 };
18
19 /// All events that can be emitted through the hook system.
20 ///
21 /// Each variant represents a distinct lifecycle or streaming event. The enum is
22 /// serialised with a `"type"` discriminator using `snake_case` naming (e.g.
23 /// `"response_start"`, `"tool_lifecycle"`), making it easy to consume from
24 /// JSON-based log files or webhook receivers.
25 #[allow(clippy::large_enum_variant)] // Keep the public HookEvent shape stable for 0.8.x.
26 #[derive(Debug, Clone, Serialize, Deserialize)]
27 #[serde(tag = "type", rename_all = "snake_case")]
28 pub enum HookEvent {
29 /// A new response stream has started.
30 ResponseStart {
31 /// Unique identifier for the response being streamed.
32 response_id: String,
33 },
34 /// A chunk of text has been received for an in-progress response.
35 ResponseDelta {
36 /// Unique identifier for the response being streamed.
37 response_id: String,
38 /// The incremental text content of this chunk.
39 delta: String,
40 },
41 /// A response stream has finished.
42 ResponseEnd {
43 /// Unique identifier for the response that completed.
44 response_id: String,
45 },
46 /// A tool invocation has transitioned to a new phase (e.g. start, end, error).
47 ToolLifecycle {
48 /// Identifier of the response under which the tool was invoked.
49 response_id: String,
50 /// Name of the tool (e.g. `"shell"`, `"read_file"`).
51 tool_name: String,
52 /// Current phase of the tool execution (e.g. `"start"`, `"end"`).
53 phase: String,
54 /// Arbitrary structured payload associated with this phase.
55 payload: Value,
56 },
57 /// A background job has transitioned to a new phase.
58 JobLifecycle {
59 /// Unique identifier of the job.
60 job_id: String,
61 /// Current phase of the job (e.g. `"queued"`, `"running"`, `"done"`).
62 phase: String,
63 /// Optional progress percentage (0-100).
64 progress: Option<u8>,
65 /// Optional human-readable detail about the current phase.
66 detail: Option<String>,
67 },
68 /// An approval request has transitioned to a new phase.
69 ApprovalLifecycle {
70 /// Unique identifier of the approval request.
71 approval_id: String,
72 /// Current phase (e.g. `"requested"`, `"approved"`, `"denied"`).
73 phase: String,
74 /// Optional reason explaining the current phase.
75 reason: Option<String>,
76 },
77 /// A catch-all variant that wraps an arbitrary [`EventFrame`].
78 ///
79 /// Use this when you need to forward a protocol-level event frame without
80 /// mapping it to a more specific variant.
81 GenericEventFrame {
82 /// The raw event frame to forward.
83 frame: Box<EventFrame>,
84 },
85 }
86
87 impl HookEvent {
88 /// Serialise this event into a [`serde_json::Value`].
89 ///
90 /// Returns a JSON object with the `"type"` discriminator and all variant
91 /// fields. If serialisation fails (which should be extremely rare), a
92 /// fallback `{"type":"serialization_error"}` value is returned instead of
93 /// panicking.
94 pub fn to_json(&self) -> Value {
95 serde_json::to_value(self).unwrap_or_else(|_| json!({"type":"serialization_error"}))
96 }
97 }
98
99 /// A destination that can receive [`HookEvent`]s.
100 ///
101 /// Implementors handle the transport-specific details of delivering events
102 /// (writing to stdout, appending to a file, POSTing to a webhook, etc.).
103 /// The [`HookDispatcher`] fans out every event to all registered sinks, so a
104 /// single process can log to multiple destinations simultaneously.
105 ///
106 /// Sinks are expected to be **best-effort**: implementations should avoid
107 /// panicking and should return an [`anyhow::Error`] only for truly unexpected
108 /// failures. [`HookDispatcher::emit`] discards individual sink errors so hook
109 /// delivery failures do not abort the application.
110 #[async_trait]
111 pub trait HookSink: Send + Sync {
112 /// Deliver a single event to this sink.
113 ///
114 /// Implementations should be resilient to transient failures (e.g. a
115 /// missing listener) and should not block the caller for extended periods.
116 async fn emit(&self, event: &HookEvent) -> Result<()>;
117 }
118
119 /// A [`HookSink`] that prints each event as a single JSON line to stdout.
120 ///
121 /// Useful for local development and debugging. Events are printed via
122 /// [`println!`] so they appear interleaved with other program output.
123 #[derive(Default)]
124 pub struct StdoutHookSink;
125
126 #[async_trait]
127 impl HookSink for StdoutHookSink {
128 async fn emit(&self, event: &HookEvent) -> Result<()> {
129 println!("{}", event.to_json());
130 Ok(())
131 }
132 }
133
134 /// A [`HookSink`] that appends each event as a JSON line to a file.
135 ///
136 /// The file is created (along with any missing parent directories) on the
137 /// first emitted event. Each line is a JSON object of the form
138 /// `{"at": "<ISO 8601 timestamp>", "event": {...}}`.
139 ///
140 /// Concurrent [`emit`](HookSink::emit) calls serialize on an internal mutex so
141 /// that each JSON event is written and flushed as a complete line; without that
142 /// lock, overlapping `write_all` calls can interleave partial lines and corrupt
143 /// the JSONL log (see issue #4739).
144 pub struct JsonlHookSink {
145 path: PathBuf,
146 /// Serializes open+append+flush so concurrent tool-call emits cannot
147 /// interleave bytes mid-line.
148 write_lock: tokio::sync::Mutex<()>,
149 }
150
151 impl JsonlHookSink {
152 /// Create a new sink that writes to the file at `path`.
153 ///
154 /// Parent directories are created lazily on the first [`HookSink::emit`]
155 /// call.
156 pub fn new(path: PathBuf) -> Self {
157 Self {
158 path,
159 write_lock: tokio::sync::Mutex::new(()),
160 }
161 }
162 }
163
164 #[async_trait]
165 impl HookSink for JsonlHookSink {
166 async fn emit(&self, event: &HookEvent) -> Result<()> {
167 if let Some(parent) = self.path.parent() {
168 tokio::fs::create_dir_all(parent).await.with_context(|| {
169 format!("failed to create hook log directory {}", parent.display())
170 })?;
171 }
172 // Encode outside the lock so only I/O is serialized.
173 let payload = json!({
174 "at": Utc::now().to_rfc3339(),
175 "event": event
176 });
177 let encoded = serde_json::to_string(&payload).context("failed to encode hook event")?;
178
179 let _guard = self.write_lock.lock().await;
180 let mut file = tokio::fs::OpenOptions::new()
181 .create(true)
182 .append(true)
183 .open(&self.path)
184 .await
185 .with_context(|| format!("failed to open hook log {}", self.path.display()))?;
186 file.write_all(encoded.as_bytes())
187 .await
188 .context("failed to write hook event")?;
189 file.write_all(b"\n")
190 .await
191 .context("failed to write hook event newline")?;
192 // Flush before drop so sequential emits (and tests that read the
193 // file immediately after) observe every completed line. Holding the
194 // mutex through flush guarantees concurrent writers never observe a
195 // partial line at EOF.
196 file.flush().await.context("failed to flush hook event")?;
197 Ok(())
198 }
199 }
200
201 /// A [`HookSink`] that POSTs each event as JSON to a remote HTTP endpoint.
202 ///
203 /// The request body is `{"at": "<ISO 8601 timestamp>", "event": {...}}`.
204 /// Failed requests are retried up to 2 times with exponential back-off
205 /// (200 ms, 400 ms). After exhausting retries the error is propagated.
206 #[derive(Clone)]
207 pub struct WebhookHookSink {
208 url: String,
209 /// Optional bearer token sent as `Authorization: Bearer <token>`.
210 bearer_token: Option<String>,
211 client: reqwest::Client,
212 }
213
214 impl WebhookHookSink {
215 /// Create a new sink that sends events to the given `url`.
216 pub fn new(url: String) -> Self {
217 Self::new_with_token(url, None)
218 }
219
220 /// Create a new sink that sends events to the given `url`, attaching
221 /// `Authorization: Bearer <token>` when a token is provided.
222 pub fn new_with_token(url: String, bearer_token: Option<String>) -> Self {
223 Self {
224 url,
225 bearer_token,
226 client: codewhale_release::platform_http_client_builder()
227 .timeout(std::time::Duration::from_secs(10))
228 .build()
229 .unwrap_or_else(|_| {
230 codewhale_release::platform_http_client_builder()
231 .build()
232 .unwrap_or_else(|_| codewhale_release::tls::reqwest_client())
233 }),
234 }
235 }
236
237 /// POST an arbitrary JSON payload to the configured endpoint.
238 ///
239 /// This is the shared delivery path behind both [`HookSink::emit`] and
240 /// the lifecycle outbox fan-out. It is deliberately not part of the
241 /// [`HookSink`] trait: outbox events are runtime event envelopes, not
242 /// [`HookEvent`]s, and only the transport needs to be shared.
243 pub async fn post_payload(&self, payload: serde_json::Value) -> Result<()> {
244 let mut retries = 0usize;
245 loop {
246 let mut request = self.client.post(&self.url).json(&payload);
247 if let Some(token) = self.bearer_token.as_deref().filter(|t| !t.is_empty()) {
248 request = request.bearer_auth(token);
249 }
250 let resp = request.send().await;
251 match resp {
252 Ok(response) if response.status().is_success() => return Ok(()),
253 Ok(response) => {
254 if retries >= 2 {
255 anyhow::bail!("webhook returned non-success status {}", response.status());
256 }
257 }
258 Err(err) => {
259 if retries >= 2 {
260 return Err(err).context("webhook request failed");
261 }
262 }
263 }
264 retries += 1;
265 tokio::time::sleep(std::time::Duration::from_millis(200 * retries as u64)).await;
266 }
267 }
268 }
269
270 #[async_trait]
271 impl HookSink for WebhookHookSink {
272 async fn emit(&self, event: &HookEvent) -> Result<()> {
273 self.post_payload(json!({
274 "at": Utc::now().to_rfc3339(),
275 "event": event,
276 }))
277 .await
278 }
279 }
280
281 /// A [`HookSink`] that sends events over a Unix domain socket.
282 ///
283 /// Each event is serialized as a single JSON line (`{"at": "...", "event": {...}}\n`)
284 /// and written to the socket. If the socket is not available (listener not running),
285 /// the event is silently dropped - hook sinks are best-effort observability, not
286 /// control flow.
287 ///
288 /// On non-Unix platforms this struct exists but its [`HookSink::emit`] is a no-op.
289 #[derive(Debug, Clone)]
290 pub struct UnixSocketHookSink {
291 #[cfg(unix)]
292 path: PathBuf,
293 }
294
295 impl UnixSocketHookSink {
296 /// Create a sink that connects to the Unix domain socket at `path`.
297 pub fn new(path: PathBuf) -> Self {
298 #[cfg(unix)]
299 {
300 Self { path }
301 }
302 #[cfg(not(unix))]
303 {
304 let _ = path;
305 Self {}
306 }
307 }
308 }
309
310 #[async_trait]
311 impl HookSink for UnixSocketHookSink {
312 #[cfg(unix)]
313 async fn emit(&self, event: &HookEvent) -> Result<()> {
314 let mut stream = match tokio::net::UnixStream::connect(&self.path).await {
315 Ok(s) => s,
316 Err(_) => return Ok(()), // listener not running, skip silently
317 };
318 let payload = json!({
319 "at": Utc::now().to_rfc3339(),
320 "event": event
321 });
322 let mut line = serde_json::to_string(&payload).context("failed to encode hook event")?;
323 line.push('\n');
324 stream
325 .write_all(line.as_bytes())
326 .await
327 .context("failed to write to unix socket")?;
328 Ok(())
329 }
330
331 #[cfg(not(unix))]
332 async fn emit(&self, _event: &HookEvent) -> Result<()> {
333 // Unix sockets are not available on this platform.
334 Ok(())
335 }
336 }
337
338 /// Fans out [`HookEvent`]s to a collection of [`HookSink`]s.
339 ///
340 /// Register one or more sinks via [`add_sink`](HookDispatcher::add_sink),
341 /// then call [`emit`](HookDispatcher::emit) to broadcast an event to all of
342 /// them. If a sink returns an error it is silently ignored so that a failing
343 /// sink does not prevent remaining sinks from receiving the event.
344 #[derive(Default, Clone)]
345 pub struct HookDispatcher {
346 sinks: Vec<Arc<dyn HookSink>>,
347 }
348
349 impl HookDispatcher {
350 /// Register a new sink that will receive all subsequently emitted events.
351 pub fn add_sink(&mut self, sink: Arc<dyn HookSink>) {
352 self.sinks.push(sink);
353 }
354
355 /// Number of registered sinks. Exposed so transport setup can assert
356 /// exactly which sinks were wired (e.g. no stdout sink in stdio mode).
357 #[must_use]
358 pub fn sink_count(&self) -> usize {
359 self.sinks.len()
360 }
361
362 /// Broadcast an event to every registered sink.
363 ///
364 /// Errors from individual sinks are silently discarded so that one failing
365 /// sink does not block the others.
366 pub async fn emit(&self, event: HookEvent) {
367 for sink in &self.sinks {
368 let _ = sink.emit(&event).await;
369 }
370 }
371 }
372
373 #[cfg(test)]
374 mod tests {
375 use super::*;
376 use std::sync::Mutex;
377 #[cfg(unix)]
378 use std::sync::atomic::{AtomicU64, Ordering};
379 #[cfg(unix)]
380 use std::time::Duration;
381 use std::time::{SystemTime, UNIX_EPOCH};
382
383 #[cfg(unix)]
384 static SOCKET_PATH_NONCE: AtomicU64 = AtomicU64::new(0);
385
386 #[test]
387 fn hook_event_serializes_with_snake_case_type_and_payload() {
388 let event = HookEvent::ToolLifecycle {
389 response_id: "resp-1".to_string(),
390 tool_name: "shell".to_string(),
391 phase: "end".to_string(),
392 payload: json!({ "exit_code": 0 }),
393 };
394
395 let encoded = event.to_json();
396
397 assert_eq!(encoded["type"], "tool_lifecycle");
398 assert_eq!(encoded["response_id"], "resp-1");
399 assert_eq!(encoded["tool_name"], "shell");
400 assert_eq!(encoded["phase"], "end");
401 assert_eq!(encoded["payload"]["exit_code"], 0);
402 }
403
404 #[test]
405 fn generic_event_frame_serialization_is_unchanged_by_boxing() {
406 let event = HookEvent::GenericEventFrame {
407 frame: Box::new(EventFrame::ResponseStart {
408 response_id: "resp-1".to_string(),
409 }),
410 };
411
412 let encoded = event.to_json();
413
414 assert_eq!(encoded["type"], "generic_event_frame");
415 assert_eq!(encoded["frame"]["event"], "response_start");
416 assert_eq!(encoded["frame"]["response_id"], "resp-1");
417 }
418
419 #[tokio::test]
420 async fn jsonl_sink_creates_parent_dir_and_appends_events() {
421 let root = unique_temp_dir("jsonl_sink");
422 let path = root.join("nested").join("hooks.jsonl");
423 let sink = JsonlHookSink::new(path.clone());
424
425 sink.emit(&HookEvent::ResponseStart {
426 response_id: "resp-1".to_string(),
427 })
428 .await
429 .unwrap();
430 sink.emit(&HookEvent::ResponseEnd {
431 response_id: "resp-1".to_string(),
432 })
433 .await
434 .unwrap();
435
436 let raw = std::fs::read_to_string(&path).unwrap();
437 let lines = raw.lines().collect::<Vec<_>>();
438 assert_eq!(lines.len(), 2);
439
440 let first: Value = serde_json::from_str(lines[0]).unwrap();
441 let second: Value = serde_json::from_str(lines[1]).unwrap();
442 assert!(first["at"].as_str().is_some());
443 assert_eq!(first["event"]["type"], "response_start");
444 assert_eq!(first["event"]["response_id"], "resp-1");
445 assert_eq!(second["event"]["type"], "response_end");
446 assert_eq!(second["event"]["response_id"], "resp-1");
447
448 let _ = std::fs::remove_dir_all(root);
449 }
450
451 /// Concurrent emits must not interleave partial lines (#4739).
452 ///
453 /// Spawns many tasks writing through one shared [`JsonlHookSink`] and
454 /// asserts every line in the resulting file is complete, parseable JSON.
455 #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
456 async fn jsonl_sink_concurrent_emits_write_atomic_json_lines() {
457 let root = unique_temp_dir("jsonl_sink_concurrent");
458 let path = root.join("hooks.jsonl");
459 let sink = Arc::new(JsonlHookSink::new(path.clone()));
460
461 const TASKS: usize = 32;
462 const EVENTS_PER_TASK: usize = 20;
463 let expected = TASKS * EVENTS_PER_TASK;
464
465 let mut handles = Vec::with_capacity(TASKS);
466 for task_id in 0..TASKS {
467 let sink = Arc::clone(&sink);
468 handles.push(tokio::spawn(async move {
469 for n in 0..EVENTS_PER_TASK {
470 let event = HookEvent::ToolLifecycle {
471 response_id: format!("resp-{task_id}"),
472 tool_name: "shell".to_string(),
473 phase: "end".to_string(),
474 payload: json!({ "n": n, "task": task_id }),
475 };
476 sink.emit(&event).await.expect("concurrent emit");
477 }
478 }));
479 }
480 for handle in handles {
481 handle.await.expect("join concurrent writer");
482 }
483
484 let raw = std::fs::read_to_string(&path).expect("read concurrent jsonl");
485 // Trailing newline yields an empty final split; keep non-empty lines only.
486 let lines: Vec<&str> = raw.lines().filter(|l| !l.is_empty()).collect();
487 assert_eq!(
488 lines.len(),
489 expected,
490 "expected {expected} complete lines, got {}; sample: {:?}",
491 lines.len(),
492 lines
493 .first()
494 .map(|s| s.chars().take(80).collect::<String>())
495 );
496
497 for (idx, line) in lines.iter().enumerate() {
498 let parsed: Value = serde_json::from_str(line).unwrap_or_else(|err| {
499 panic!("line {idx} is not complete JSON ({err}): {line:?}");
500 });
501 assert!(
502 parsed.get("at").and_then(|v| v.as_str()).is_some(),
503 "line {idx} missing at: {parsed}"
504 );
505 assert_eq!(
506 parsed["event"]["type"], "tool_lifecycle",
507 "line {idx} unexpected event type"
508 );
509 }
510
511 let _ = std::fs::remove_dir_all(root);
512 }
513
514 #[tokio::test]
515 async fn dispatcher_continues_after_sink_error() {
516 let mut dispatcher = HookDispatcher::default();
517 let first = Arc::new(RecordingSink::default());
518 let second = Arc::new(RecordingSink::default());
519
520 dispatcher.add_sink(first.clone());
521 dispatcher.add_sink(Arc::new(FailingSink));
522 dispatcher.add_sink(second.clone());
523
524 dispatcher
525 .emit(HookEvent::ApprovalLifecycle {
526 approval_id: "approval-1".to_string(),
527 phase: "requested".to_string(),
528 reason: Some("needs review".to_string()),
529 })
530 .await;
531
532 assert_eq!(
533 first.events(),
534 vec![json!({
535 "type": "approval_lifecycle",
536 "approval_id": "approval-1",
537 "phase": "requested",
538 "reason": "needs review",
539 })]
540 );
541 assert_eq!(second.events(), first.events());
542 }
543
544 #[cfg(unix)]
545 #[tokio::test]
546 async fn unix_socket_sink_skips_when_listener_absent() {
547 let (_root, socket_path) = unique_short_socket_path("missing");
548 let sink = UnixSocketHookSink::new(socket_path);
549 let result = sink
550 .emit(&HookEvent::ResponseStart {
551 response_id: "resp-1".to_string(),
552 })
553 .await;
554 assert!(result.is_ok());
555 }
556
557 #[cfg(unix)]
558 #[tokio::test]
559 async fn unix_socket_sink_sends_event_to_listener() {
560 use tokio::io::AsyncBufReadExt;
561 use tokio::net::UnixListener;
562
563 let (root, socket_path) = unique_short_socket_path("send");
564 std::fs::create_dir_all(&root).expect("mkdir");
565 let _ = std::fs::remove_file(&socket_path);
566
567 let listener = UnixListener::bind(&socket_path).expect("bind");
568 let sink = UnixSocketHookSink::new(socket_path.clone());
569 let cleanup = || {
570 let _ = std::fs::remove_file(&socket_path);
571 let _ = std::fs::remove_dir_all(&root);
572 };
573
574 let mut handle = tokio::spawn(async move {
575 let (stream, _) = listener.accept().await.expect("accept");
576 let mut reader = tokio::io::BufReader::new(stream);
577 let mut line = String::new();
578 reader.read_line(&mut line).await.expect("read_line");
579 line
580 });
581
582 let event = HookEvent::ResponseStart {
583 response_id: "resp-42".to_string(),
584 };
585 let emit = sink.emit(&event);
586 match tokio::time::timeout(Duration::from_secs(5), emit).await {
587 Ok(result) => result.expect("emit"),
588 Err(_) => {
589 handle.abort();
590 let _ = (&mut handle).await;
591 cleanup();
592 panic!("unix socket emit timed out");
593 }
594 }
595
596 let received = match tokio::time::timeout(Duration::from_secs(5), &mut handle).await {
597 Ok(result) => result.expect("join"),
598 Err(_) => {
599 handle.abort();
600 let _ = (&mut handle).await;
601 cleanup();
602 panic!("unix socket exchange timed out");
603 }
604 };
605 let parsed: Value = serde_json::from_str(&received).expect("parse");
606 assert_eq!(parsed["event"]["type"], "response_start");
607 assert_eq!(parsed["event"]["response_id"], "resp-42");
608 assert!(parsed["at"].as_str().is_some());
609
610 cleanup();
611 }
612
613 #[derive(Default)]
614 struct RecordingSink {
615 events: Mutex<Vec<Value>>,
616 }
617
618 impl RecordingSink {
619 fn events(&self) -> Vec<Value> {
620 self.events.lock().unwrap().clone()
621 }
622 }
623
624 #[async_trait::async_trait]
625 impl HookSink for RecordingSink {
626 async fn emit(&self, event: &HookEvent) -> Result<()> {
627 self.events.lock().unwrap().push(event.to_json());
628 Ok(())
629 }
630 }
631
632 struct FailingSink;
633
634 #[async_trait::async_trait]
635 impl HookSink for FailingSink {
636 async fn emit(&self, _event: &HookEvent) -> Result<()> {
637 anyhow::bail!("sink failed")
638 }
639 }
640
641 fn unique_temp_dir(label: &str) -> PathBuf {
642 let nanos = SystemTime::now()
643 .duration_since(UNIX_EPOCH)
644 .unwrap()
645 .as_nanos();
646 std::env::temp_dir().join(format!(
647 "deepseek-hooks-{label}-{}-{nanos}",
648 std::process::id()
649 ))
650 }
651
652 #[cfg(unix)]
653 fn unique_short_socket_path(label: &str) -> (PathBuf, PathBuf) {
654 let nanos = SystemTime::now()
655 .duration_since(UNIX_EPOCH)
656 .unwrap()
657 .as_nanos();
658 let nonce = SOCKET_PATH_NONCE.fetch_add(1, Ordering::Relaxed);
659 let root = PathBuf::from("/tmp").join(format!(
660 "cw-hk-{label}-{}-{nanos}-{nonce}",
661 std::process::id()
662 ));
663 let path = root.join("hook.sock");
664 (root, path)
665 }
666
667 #[test]
668 fn webhook_sink_new_does_not_panic() {
669 // Construction must never panic: if the configured client builder
670 // fails, the code falls back to a default `reqwest::Client` instead of
671 // calling `.expect(...)`.
672 let sink = WebhookHookSink::new("https://example.invalid/webhook".to_string());
673 let _ = sink;
674 }
675 }
676
676 lines RUST