返回 CodeWhale
lifecycle_outbox.rs
根目录 / crates / hooks / src / lifecycle_outbox.rs
1 //! Lifecycle event outbox: a local JSONL log of session/turn/subagent
2 //! lifecycle events plus an optional webhook fan-out.
3 //!
4 //! This is the machine-readable sibling of the TUI shell-hook system. Hooks
5 //! fire shell commands per event and are TUI-only; the outbox appends one
6 //! JSON line per event to a config-gated file and needs no per-event
7 //! configuration. It is additive and opt-in: with no path configured,
8 //! [`LifecycleOutbox::emit`] is a no-op.
9 //!
10 //! # Line schema
11 //!
12 //! Every line is a `codewhale_protocol::runtime::RuntimeEventEnvelope`:
13 //!
14 //! ```json
15 //! {"schema_version": 1, "seq": 3, "event": "turn_start", "kind": "turn.started",
16 //! "thread_id": "…", "turn_id": "…", "item_id": null, "timestamp": "…",
17 //! "created_at": "…", "payload": {…}}
18 //! ```
19 //!
20 //! - `seq` is monotonic per outbox file. On the first write the writer
21 //! recovers the `seq` of the file's last complete line (bounded tail scan,
22 // so an outbox that grows unbounded is never re-read in full) and continues
23 //! from `last + 1`.
24 //! - `event` is the snake-case lifecycle name (`turn_start`, `turn_end`, …);
25 //! `kind` is the dotted kind (`turn.started`, `turn.failed`, …).
26 //! - Payloads are constructed by the emit sites from bounded, pre-redacted
27 //! fields only — never raw tool arguments, environment, or full transcript
28 //! text. [`bounded_text`] enforces the same ceilings as the desktop
29 //! notification payloads: headline ≤ 80, detail ≤ 120, preview ≤ 200
30 //! characters.
31 //!
32 //! # Delivery model
33 //!
34 //! [`LifecycleOutbox::emit`] never blocks the caller: it enqueues the event
35 //! on an internal channel and a single writer task appends lines in order.
36 //! If no tokio runtime is available the event is dropped with a warning.
37 //! Webhook POSTs (`{"at": …, "event": …}`) are attempted after the local
38 //! append; failures are logged and dropped, never retried into the agent
39 //! loop. Process owners call [`LifecycleOutbox::flush`] before runtime teardown
40 //! to give queued terminal events a bounded opportunity to finish delivery.
41
42 use std::path::{Path, PathBuf};
43 use std::sync::atomic::{AtomicBool, Ordering};
44 use std::sync::{Arc, Mutex};
45 use std::time::Duration;
46
47 use anyhow::{Context, Result};
48 use chrono::Utc;
49 use codewhale_protocol::runtime::{RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION, RuntimeEventEnvelope};
50 use serde_json::{Value, json};
51 use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
52 use tokio::sync::mpsc::error::TrySendError;
53 use tokio::sync::mpsc::{Receiver, Sender};
54 use tokio::sync::oneshot;
55
56 use crate::WebhookHookSink;
57
58 /// Text-length ceilings for outbox payload fields. Mirrors the desktop
59 /// notification payload limits so the outbox never carries more than the
60 /// lock-screen-capable surface already does.
61 pub const OUTBOX_HEADLINE_MAX_CHARS: usize = 80;
62 pub const OUTBOX_DETAIL_MAX_CHARS: usize = 120;
63 pub const OUTBOX_PREVIEW_MAX_CHARS: usize = 200;
64
65 /// Suffix appended when [`bounded_text`] truncates a field.
66 pub const OUTBOX_TRUNCATION_MARKER: &str = "…";
67
68 /// How far back from EOF the seq-recovery scan reads. Outbox lines are
69 /// bounded (payload ceilings above plus envelope overhead), so a line can
70 /// never approach this window and the last complete line is always inside it.
71 const SEQ_RECOVERY_TAIL_BYTES: u64 = 64 * 1024;
72
73 /// Queue capacity from emit sites to the writer (#6212). The outbox is
74 /// observability, not control flow: when a consumer is wedged longer than
75 /// this backlog, further events are dropped with a warning instead of
76 /// retaining unbounded snapshots in memory. Flush commands are never dropped
77 /// silently — a full queue fails the flush fast rather than timing it out.
78 const OUTBOX_QUEUE_CAPACITY: usize = 1024;
79
80 /// Events gathered per writer drain before one batched append. Batching only
81 /// affects how many queued events share a single file open/write/flush; each
82 /// line still lands as its own complete JSONL record.
83 const OUTBOX_MAX_BATCH: usize = 64;
84
85 /// Concurrent webhook posts the writer allows in flight. A stalled webhook
86 /// delays only its own event (plus the flush barrier, which the caller's
87 /// timeout bounds) — never the audit-log appends of later events.
88 const WEBHOOK_MAX_INFLIGHT: usize = 8;
89
90 /// One lifecycle event destined for the outbox.
91 ///
92 /// Construct one per emit site. `payload` must only contain bounded,
93 /// pre-redacted fields; apply [`bounded_text`] to anything free-form (error
94 /// messages, previews) before inserting it.
95 #[derive(Debug, Clone)]
96 pub struct LifecycleEvent {
97 /// Snake-case event name, e.g. `"turn_start"`.
98 pub event: String,
99 /// Dotted event kind, e.g. `"turn.started"` or `"turn.failed"`.
100 pub kind: String,
101 /// Owning session/thread id. Empty when the producer has none.
102 pub thread_id: String,
103 /// Current turn id, when known.
104 pub turn_id: Option<String>,
105 /// Current item id, when known.
106 pub item_id: Option<String>,
107 /// Bounded, redacted event payload.
108 pub payload: Value,
109 }
110
111 /// The lifecycle outbox handle.
112 ///
113 /// Cheap to clone (an `Arc`). When constructed without a path the outbox is
114 /// disabled and every `emit` is a no-op.
115 #[derive(Clone)]
116 pub struct LifecycleOutbox {
117 inner: Option<Arc<OutboxInner>>,
118 }
119
120 impl Default for LifecycleOutbox {
121 fn default() -> Self {
122 Self::disabled()
123 }
124 }
125
126 impl LifecycleOutbox {
127 /// Create an outbox writing to `path` when set and non-empty.
128 ///
129 /// `webhook_url` optionally adds a webhook fan-out (POST `{"at", "event"}`,
130 /// best-effort); `webhook_token` is its optional bearer token. Webhook
131 /// delivery is configured independently of the file: it only ever runs
132 /// when `webhook_url` is set, and it never replaces the local append.
133 pub fn new(
134 path: Option<PathBuf>,
135 webhook_url: Option<String>,
136 webhook_token: Option<String>,
137 ) -> Self {
138 let path = match path {
139 Some(path) if !path.as_os_str().is_empty() => path,
140 _ => return Self::disabled(),
141 };
142 let webhook = webhook_url
143 .as_deref()
144 .map(str::trim)
145 .filter(|url| !url.is_empty())
146 .map(|url| WebhookHookSink::new_with_token(url.to_string(), webhook_token));
147 let (sender, receiver) = tokio::sync::mpsc::channel(OUTBOX_QUEUE_CAPACITY);
148 Self {
149 inner: Some(Arc::new(OutboxInner {
150 path,
151 webhook,
152 sender,
153 receiver: Mutex::new(Some(receiver)),
154 writer_spawned: AtomicBool::new(false),
155 spawn_lock: Mutex::new(()),
156 })),
157 }
158 }
159
160 /// A disabled outbox that drops every event.
161 pub fn disabled() -> Self {
162 Self { inner: None }
163 }
164
165 /// True when a path was configured and events will be written.
166 pub fn is_enabled(&self) -> bool {
167 self.inner.is_some()
168 }
169
170 /// Emit one lifecycle event.
171 ///
172 /// Never blocks: the event is queued for the outbox's writer task (spawned
173 /// lazily on the current tokio runtime on first use). Events queued with
174 /// no runtime available — or after the writer task is gone — are dropped
175 /// with a warning. Delivery failures inside the writer are logged and
176 /// dropped as well; the outbox is observability, not control flow.
177 pub fn emit(&self, event: LifecycleEvent) {
178 let Some(inner) = self.inner.clone() else {
179 return;
180 };
181 if let Err(error) = inner.enqueue(OutboxCommand::Event(event)) {
182 tracing::warn!(target: "lifecycle_outbox", %error, "lifecycle event dropped");
183 }
184 }
185
186 /// Wait for events queued before this call to finish their delivery attempt.
187 ///
188 /// Local writes are flushed before the writer acknowledges this barrier.
189 /// Delivery errors remain logged and best effort; a stalled writer/webhook
190 /// or a stopped runtime returns an error within `timeout` instead of holding
191 /// process teardown indefinitely. This does not close other cloned handles.
192 pub async fn flush(&self, timeout: Duration) -> Result<()> {
193 let Some(inner) = self.inner.as_ref() else {
194 return Ok(());
195 };
196 let (reply, completed) = oneshot::channel();
197 inner.enqueue(OutboxCommand::Flush(reply))?;
198 tokio::time::timeout(timeout, completed)
199 .await
200 .context("lifecycle outbox flush timed out; queued events may be incomplete")?
201 .context("lifecycle outbox writer stopped before flush completed")
202 }
203 }
204
205 enum OutboxCommand {
206 Event(LifecycleEvent),
207 Flush(oneshot::Sender<()>),
208 }
209
210 struct OutboxInner {
211 path: PathBuf,
212 webhook: Option<WebhookHookSink>,
213 sender: Sender<OutboxCommand>,
214 /// The writer task's receive half. Taken exactly once by the writer task.
215 receiver: Mutex<Option<Receiver<OutboxCommand>>>,
216 writer_spawned: AtomicBool,
217 /// Serializes the lazy writer-task spawn so two racing first emits cannot
218 /// start two writers.
219 spawn_lock: Mutex<()>,
220 }
221
222 impl OutboxInner {
223 /// Queue an event and make sure the writer task exists to drain it.
224 ///
225 /// Ordering: `send` happens before the spawn so events queued before the
226 /// writer starts are drained first, preserving enqueue order. The queue
227 /// is bounded: a wedged consumer drops further events with a warning
228 /// (observability, not control flow) but fails a flush fast instead of
229 /// dropping its reply channel.
230 fn enqueue(self: &Arc<Self>, command: OutboxCommand) -> Result<()> {
231 let is_flush = matches!(command, OutboxCommand::Flush(_));
232 match self.sender.try_send(command) {
233 Ok(()) => {}
234 Err(TrySendError::Full(_)) if !is_flush => {
235 tracing::warn!(
236 target: "lifecycle_outbox",
237 queue_capacity = OUTBOX_QUEUE_CAPACITY,
238 "lifecycle event dropped: outbox queue is full"
239 );
240 }
241 Err(TrySendError::Full(_)) => {
242 anyhow::bail!("lifecycle outbox queue is full; flush rejected");
243 }
244 Err(TrySendError::Closed(_)) => {
245 anyhow::bail!("lifecycle outbox writer task is gone");
246 }
247 }
248 self.ensure_writer_spawned();
249 Ok(())
250 }
251
252 fn ensure_writer_spawned(self: &Arc<Self>) {
253 if self.writer_spawned.load(Ordering::Acquire) {
254 return;
255 }
256 let _guard = self
257 .spawn_lock
258 .lock()
259 .unwrap_or_else(|poisoned| poisoned.into_inner());
260 if self.writer_spawned.load(Ordering::Acquire) {
261 return;
262 }
263 let Ok(handle) = tokio::runtime::Handle::try_current() else {
264 tracing::warn!(
265 target: "lifecycle_outbox",
266 "no tokio runtime available; lifecycle events are queued but will not be written"
267 );
268 return;
269 };
270 let receiver = self
271 .receiver
272 .lock()
273 .unwrap_or_else(|poisoned| poisoned.into_inner())
274 .take();
275 let Some(receiver) = receiver else {
276 return;
277 };
278 let mut state = WriterState {
279 path: self.path.clone(),
280 webhook: self.webhook.clone(),
281 next_seq: 0,
282 recovered: false,
283 receiver,
284 };
285 self.writer_spawned.store(true, Ordering::Release);
286 handle.spawn(async move {
287 state.run().await;
288 });
289 }
290 }
291
292 /// The outbox writer: owns the file state and the event queue drain loop.
293 struct WriterState {
294 path: PathBuf,
295 webhook: Option<WebhookHookSink>,
296 /// Next seq to assign; filled in by [`Self::recover_seq`] on first use.
297 next_seq: u64,
298 recovered: bool,
299 receiver: Receiver<OutboxCommand>,
300 }
301
302 impl WriterState {
303 /// Drain the queue until every sender is dropped, then exit.
304 ///
305 /// Events are gathered into batches ([`OUTBOX_MAX_BATCH`]) so a burst
306 /// shares one file open/write/flush. Webhook posts fan out through a
307 /// bounded [`JoinSet`](tokio::task::JoinSet) and never delay the appends
308 /// of later batches — a stalled webhook only holds back its own event
309 /// plus the flush barrier, which the caller's timeout bounds (#6212).
310 async fn run(&mut self) {
311 let mut batch: Vec<OutboxCommand> = Vec::with_capacity(OUTBOX_MAX_BATCH);
312 let mut webhooks = tokio::task::JoinSet::new();
313 while self.receiver.recv_many(&mut batch, OUTBOX_MAX_BATCH).await > 0 {
314 let mut events = Vec::new();
315 let mut replies = Vec::new();
316 for command in batch.drain(..) {
317 match command {
318 OutboxCommand::Event(event) => events.push(event),
319 OutboxCommand::Flush(reply) => replies.push(reply),
320 }
321 }
322 if let Err(error) = self.deliver_batch(&events, &mut webhooks).await {
323 tracing::warn!(
324 target: "lifecycle_outbox",
325 %error,
326 path = %self.path.display(),
327 "lifecycle outbox write failed"
328 );
329 }
330 if !replies.is_empty() {
331 // The flush barrier covers every delivery attempt queued
332 // before it — appends above plus webhook attempts already
333 // spawned (mirroring the pre-batching serial writer, where
334 // a Flush was only reached after prior webhooks finished).
335 while webhooks.join_next().await.is_some() {}
336 for reply in replies {
337 let _ = reply.send(());
338 }
339 }
340 }
341 }
342
343 /// Assign a seq per event, build the envelopes, append the whole batch in
344 /// one file open/write/flush, then fan the batch's webhook posts out
345 /// concurrently (independently of the append result).
346 async fn deliver_batch(
347 &mut self,
348 events: &[LifecycleEvent],
349 webhooks: &mut tokio::task::JoinSet<()>,
350 ) -> Result<()> {
351 if events.is_empty() {
352 return Ok(());
353 }
354 if !self.recovered {
355 self.next_seq = recover_last_seq(&self.path).await?;
356 self.recovered = true;
357 }
358
359 let mut lines = Vec::with_capacity(events.len());
360 let mut webhook_posts = Vec::new();
361 for event in events {
362 let seq = self.next_seq;
363 self.next_seq = self.next_seq.saturating_add(1);
364
365 let envelope = RuntimeEventEnvelope {
366 schema_version: RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION,
367 seq,
368 event: event.event.clone(),
369 kind: event.kind.clone(),
370 thread_id: event.thread_id.clone(),
371 turn_id: event.turn_id.clone(),
372 item_id: event.item_id.clone(),
373 timestamp: Utc::now().to_rfc3339(),
374 created_at: Some(Utc::now().to_rfc3339()),
375 payload: event.payload.clone(),
376 extra: Default::default(),
377 };
378 if self.webhook.is_some() {
379 webhook_posts.push(json!({
380 "at": envelope.timestamp,
381 "event": envelope,
382 }));
383 }
384 lines.push(serde_json::to_string(&envelope).context("failed to encode outbox event")?);
385 }
386
387 // Appends land before any webhook work so the audit log never waits
388 // on a slow remote.
389 let append_result = self.append_lines(&lines).await;
390
391 if let Some(webhook) = &self.webhook {
392 for payload in webhook_posts {
393 while webhooks.len() >= WEBHOOK_MAX_INFLIGHT {
394 let _ = webhooks.join_next().await;
395 }
396 let sink = webhook.clone();
397 webhooks.spawn(async move {
398 if let Err(error) = sink.post_payload(payload).await {
399 tracing::warn!(
400 target: "lifecycle_outbox",
401 %error,
402 "lifecycle webhook delivery failed (dropped)"
403 );
404 }
405 });
406 }
407 }
408
409 append_result
410 }
411
412 /// Append complete JSONL lines, mirroring [`crate::JsonlHookSink`]:
413 /// lazy parent directories, append mode, flush before returning. The
414 /// writer task is the only appender for this outbox, so no extra lock is
415 /// needed here; the queue already serializes. The whole batch shares one
416 /// open and one flush; each line still lands as its own complete record.
417 async fn append_lines(&mut self, lines: &[String]) -> Result<()> {
418 if let Some(parent) = self.path.parent() {
419 tokio::fs::create_dir_all(parent).await.with_context(|| {
420 format!("failed to create outbox directory {}", parent.display())
421 })?;
422 }
423 let mut file = tokio::fs::OpenOptions::new()
424 .create(true)
425 .append(true)
426 .open(&self.path)
427 .await
428 .with_context(|| format!("failed to open outbox {}", self.path.display()))?;
429 // Line + newline in a single `write_all` per record: with O_APPEND
430 // each `write` lands contiguously, so even a second process appending
431 // to the same file can interleave lines but can never splice one
432 // mid-line.
433 let mut records = Vec::with_capacity(lines.len());
434 for line in lines {
435 let mut record = Vec::with_capacity(line.len() + 1);
436 record.extend_from_slice(line.as_bytes());
437 record.push(b'\n');
438 records.push(record);
439 }
440 for record in &records {
441 file.write_all(record)
442 .await
443 .context("failed to write outbox event")?;
444 }
445 file.flush().await.context("failed to flush outbox event")
446 }
447 }
448
449 /// Recover the seq to continue from: the `seq` of the outbox file's last
450 /// complete line, plus 1 — or 1 for a missing/empty file.
451 ///
452 /// Only the tail of the file is read (bounded by [`SEQ_RECOVERY_TAIL_BYTES`]);
453 /// outbox lines are bounded far below that window, so the last complete line
454 /// is always within it. A partial trailing line from a crash mid-write is
455 /// ignored (the previous newline-terminated line wins).
456 async fn recover_last_seq(path: &Path) -> Result<u64> {
457 let mut file = match tokio::fs::File::open(path).await {
458 Ok(file) => file,
459 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(1),
460 Err(error) => {
461 return Err(error).with_context(|| format!("failed to open outbox {}", path.display()));
462 }
463 };
464 let len = file
465 .metadata()
466 .await
467 .with_context(|| format!("failed to stat outbox {}", path.display()))?
468 .len();
469 if len == 0 {
470 return Ok(1);
471 }
472 let start = len.saturating_sub(SEQ_RECOVERY_TAIL_BYTES);
473 file.seek(std::io::SeekFrom::Start(start)).await?;
474 let mut tail = vec![0u8; (len - start) as usize];
475 file.read_exact(&mut tail).await?;
476
477 let line = match tail.iter().rposition(|byte| *byte == b'\n') {
478 // The bytes after the final newline are a torn trailing line from a
479 // crash mid-write; drop them. What remains ends at a newline, so the
480 // last complete line is the bytes after the previous newline.
481 Some(last_nl) => {
482 let body = &tail[..last_nl];
483 match body.iter().rposition(|byte| *byte == b'\n') {
484 Some(idx) => &body[idx + 1..],
485 None => body,
486 }
487 }
488 // No newline at all: no complete line inside this tail (a line can
489 // only exceed the tail window by violating the bounded-line
490 // invariant). Treat the file as not-yet-writable.
491 None => return Ok(1),
492 };
493 let line = std::str::from_utf8(line).context("outbox tail is not UTF-8")?;
494 if line.trim().is_empty() {
495 return Ok(1);
496 }
497 let envelope: RuntimeEventEnvelope =
498 serde_json::from_str(line).context("failed to parse last outbox line")?;
499 Ok(envelope.seq.saturating_add(1))
500 }
501
502 /// Bound free-form text to at most `max_chars` characters, stripping control
503 /// bytes and ANSI escape sequences and collapsing whitespace runs first.
504 ///
505 /// The limit counts Unicode scalar values, not bytes, so multi-byte text gets
506 /// the same ceiling as ASCII. The result is safe to embed in an outbox
507 /// payload. Callers remain responsible for only ever passing non-secret
508 /// fields (error messages, previews, model/provider labels — never raw tool
509 /// arguments, environment, or full transcript text), the same discipline the
510 /// desktop notification payloads enforce.
511 pub fn bounded_text(text: &str, max_chars: usize) -> String {
512 let cleaned: String = text
513 .chars()
514 .filter(|ch| !ch.is_control())
515 .collect::<String>()
516 .split_whitespace()
517 .collect::<Vec<_>>()
518 .join(" ");
519 let mut truncated = false;
520 let mut out = String::new();
521 let mut char_count = 0usize;
522 for ch in cleaned.chars() {
523 if char_count + 1 > max_chars {
524 truncated = true;
525 break;
526 }
527 out.push(ch);
528 char_count += 1;
529 }
530 if truncated {
531 // Make room for the marker while staying under the character ceiling.
532 let marker_chars = OUTBOX_TRUNCATION_MARKER.chars().count();
533 while char_count + marker_chars > max_chars {
534 out.pop();
535 char_count -= 1;
536 }
537 out.push_str(OUTBOX_TRUNCATION_MARKER);
538 }
539 out
540 }
541
542 #[cfg(test)]
543 mod tests {
544 use super::*;
545
546 fn temp_outbox_path(name: &str) -> (tempfile::TempDir, PathBuf) {
547 let dir = tempfile::tempdir().expect("tempdir");
548 let path = dir.path().join(name);
549 (dir, path)
550 }
551
552 fn event(name: &str, kind: &str) -> LifecycleEvent {
553 LifecycleEvent {
554 event: name.to_string(),
555 kind: kind.to_string(),
556 thread_id: "session-1".to_string(),
557 turn_id: Some("turn-1".to_string()),
558 item_id: None,
559 payload: json!({"status": "completed"}),
560 }
561 }
562
563 async fn deliver_all(state: &mut WriterState, events: Vec<LifecycleEvent>) {
564 let mut webhooks = tokio::task::JoinSet::new();
565 state
566 .deliver_batch(&events, &mut webhooks)
567 .await
568 .expect("deliver");
569 while webhooks.join_next().await.is_some() {}
570 }
571
572 async fn read_lines(path: &Path) -> Vec<Value> {
573 let text = tokio::fs::read_to_string(path).await.expect("read outbox");
574 text.lines()
575 .map(|line| serde_json::from_str::<Value>(line).expect("json line"))
576 .collect()
577 }
578
579 #[test]
580 fn flush_persists_queued_turn_boundaries_before_runtime_teardown() {
581 let (_dir, path) = temp_outbox_path("shutdown.jsonl");
582 let runtime = tokio::runtime::Builder::new_current_thread()
583 .enable_all()
584 .build()
585 .expect("runtime");
586 runtime.block_on(async {
587 let outbox = LifecycleOutbox::new(Some(path.clone()), None, None);
588 outbox.emit(event("turn_start", "turn.started"));
589 let clone = outbox.clone();
590 clone.emit(event("turn_end", "turn.completed"));
591 outbox.flush(Duration::from_secs(2)).await.expect("flush");
592 });
593 drop(runtime);
594
595 let contents = std::fs::read_to_string(path).expect("outbox persisted before exit");
596 let lines: Vec<Value> = contents
597 .lines()
598 .map(|line| serde_json::from_str(line).expect("complete JSONL record"))
599 .collect();
600 assert_eq!(lines.len(), 2);
601 assert_eq!(lines[0]["event"], "turn_start");
602 assert_eq!(lines[0]["seq"], 1);
603 assert_eq!(lines[1]["event"], "turn_end");
604 assert_eq!(lines[1]["seq"], 2);
605 }
606
607 #[tokio::test]
608 async fn flush_disabled_or_unused_outbox_creates_no_file() {
609 let (_dir, path) = temp_outbox_path("unused.jsonl");
610 LifecycleOutbox::disabled()
611 .flush(Duration::ZERO)
612 .await
613 .expect("disabled flush");
614 LifecycleOutbox::new(Some(path.clone()), None, None)
615 .flush(Duration::from_secs(2))
616 .await
617 .expect("unused flush");
618 assert!(!path.exists());
619 }
620
621 /// A stalled webhook must not head-of-line block the audit log: the
622 /// appends of later events land while the slow post is still in flight
623 /// (#6212). The pre-batching writer awaited each webhook inline, so the
624 /// second event's line could not appear until the first post resolved.
625 #[tokio::test]
626 async fn stalled_webhook_does_not_block_later_appends() {
627 let (_dir, path) = temp_outbox_path("webhook-head-of-line.jsonl");
628 let server = wiremock::MockServer::start().await;
629 wiremock::Mock::given(wiremock::matchers::method("POST"))
630 .respond_with(wiremock::ResponseTemplate::new(200).set_delay(Duration::from_secs(5)))
631 .mount(&server)
632 .await;
633 let outbox = LifecycleOutbox::new(Some(path.clone()), Some(server.uri()), None);
634
635 outbox.emit(event("turn_start", "turn.started"));
636 // Give the writer time to start the stalled webhook post for the
637 // first event before queueing the second.
638 tokio::time::sleep(Duration::from_millis(100)).await;
639 outbox.emit(event("turn_end", "turn.completed"));
640
641 for _ in 0..100 {
642 if read_lines(&path).await.len() >= 2 {
643 break;
644 }
645 tokio::time::sleep(Duration::from_millis(10)).await;
646 }
647 let lines = read_lines(&path).await;
648 assert_eq!(
649 lines.len(),
650 2,
651 "the second event must be appended while the first webhook is still stalled"
652 );
653 assert_eq!(lines[1]["event"], "turn_end");
654 }
655
656 #[tokio::test]
657 async fn flush_is_bounded_when_webhook_delivery_stalls() {
658 let (_dir, path) = temp_outbox_path("stalled.jsonl");
659 let server = wiremock::MockServer::start().await;
660 wiremock::Mock::given(wiremock::matchers::method("POST"))
661 .respond_with(wiremock::ResponseTemplate::new(200).set_delay(Duration::from_secs(5)))
662 .mount(&server)
663 .await;
664 let outbox = LifecycleOutbox::new(Some(path), Some(server.uri()), None);
665 outbox.emit(event("turn_start", "turn.started"));
666 let error = tokio::time::timeout(
667 Duration::from_secs(1),
668 outbox.flush(Duration::from_millis(20)),
669 )
670 .await
671 .expect("the flush deadline must bound a blocked writer")
672 .expect_err("stalled delivery must report incomplete drain");
673 assert!(error.to_string().contains("flush timed out"));
674 }
675
676 #[test]
677 fn flush_reports_a_writer_lost_with_its_runtime() {
678 let (_dir, path) = temp_outbox_path("stopped.jsonl");
679 let outbox = LifecycleOutbox::new(Some(path), None, None);
680 let runtime = tokio::runtime::Builder::new_current_thread()
681 .enable_all()
682 .build()
683 .expect("first runtime");
684 runtime.block_on(async {
685 outbox.emit(event("turn_start", "turn.started"));
686 outbox
687 .flush(Duration::from_secs(2))
688 .await
689 .expect("first flush");
690 });
691 drop(runtime);
692 let runtime = tokio::runtime::Builder::new_current_thread()
693 .enable_all()
694 .build()
695 .expect("second runtime");
696 let error = runtime
697 .block_on(outbox.flush(Duration::from_secs(2)))
698 .expect_err("a closed writer cannot acknowledge delivery");
699 assert!(error.to_string().contains("writer task is gone"));
700 }
701
702 #[tokio::test]
703 async fn appends_one_jsonl_line_per_event_with_envelope_schema() {
704 let (_dir, path) = temp_outbox_path("schema.jsonl");
705 let mut state = WriterState {
706 path: path.clone(),
707 webhook: None,
708 next_seq: 0,
709 recovered: false,
710 receiver: tokio::sync::mpsc::channel(OUTBOX_QUEUE_CAPACITY).1,
711 };
712 deliver_all(&mut state, vec![event("turn_start", "turn.started")]).await;
713
714 let lines = read_lines(&path).await;
715 assert_eq!(lines.len(), 1);
716 let line = &lines[0];
717 assert_eq!(line["schema_version"], 1);
718 assert_eq!(line["seq"], 1);
719 assert_eq!(line["event"], "turn_start");
720 assert_eq!(line["kind"], "turn.started");
721 assert_eq!(line["thread_id"], "session-1");
722 assert_eq!(line["turn_id"], "turn-1");
723 assert_eq!(line["item_id"], Value::Null);
724 assert!(line["timestamp"].as_str().is_some());
725 assert!(line["payload"]["status"].as_str() == Some("completed"));
726 }
727
728 /// Every emit site now carries `payload.workspace` (and subagent events
729 /// additionally `payload.subagent`) for consumer-side routing. The writer
730 /// must preserve those fields verbatim through the envelope round trip
731 /// for every event type.
732 #[tokio::test]
733 async fn payload_workspace_and_subagent_fields_survive_the_round_trip() {
734 let (_dir, path) = temp_outbox_path("routing-fields.jsonl");
735 let mut state = WriterState {
736 path: path.clone(),
737 webhook: None,
738 next_seq: 0,
739 recovered: false,
740 receiver: tokio::sync::mpsc::channel(OUTBOX_QUEUE_CAPACITY).1,
741 };
742 let workspace = "/home/cw/wt-lane";
743 let subagent = "explore-1";
744 let subagent_payload = json!({ "workspace": workspace, "subagent": subagent });
745 deliver_all(
746 &mut state,
747 vec![
748 LifecycleEvent {
749 event: "session_start".to_string(),
750 kind: "session.started".to_string(),
751 thread_id: "session-1".to_string(),
752 turn_id: None,
753 item_id: None,
754 payload: json!({ "workspace": workspace }),
755 },
756 LifecycleEvent {
757 event: "turn_start".to_string(),
758 kind: "turn.started".to_string(),
759 thread_id: "session-1".to_string(),
760 turn_id: Some("turn-1".to_string()),
761 item_id: None,
762 payload: json!({ "workspace": workspace }),
763 },
764 LifecycleEvent {
765 event: "turn_end".to_string(),
766 kind: "turn.completed".to_string(),
767 thread_id: "session-1".to_string(),
768 turn_id: Some("turn-1".to_string()),
769 item_id: None,
770 payload: json!({ "workspace": workspace }),
771 },
772 LifecycleEvent {
773 event: "turn_stalled".to_string(),
774 kind: "turn.stalled".to_string(),
775 thread_id: "session-1".to_string(),
776 turn_id: Some("turn-1".to_string()),
777 item_id: None,
778 payload: json!({ "workspace": workspace }),
779 },
780 LifecycleEvent {
781 event: "subagent_spawn".to_string(),
782 kind: "subagent.spawned".to_string(),
783 thread_id: "session-1".to_string(),
784 turn_id: Some("turn-1".to_string()),
785 item_id: None,
786 payload: subagent_payload.clone(),
787 },
788 LifecycleEvent {
789 event: "subagent_complete".to_string(),
790 kind: "subagent.completed".to_string(),
791 thread_id: "session-1".to_string(),
792 turn_id: Some("turn-1".to_string()),
793 item_id: None,
794 payload: subagent_payload.clone(),
795 },
796 LifecycleEvent {
797 event: "session_end".to_string(),
798 kind: "session.ended".to_string(),
799 thread_id: "session-1".to_string(),
800 turn_id: None,
801 item_id: None,
802 payload: json!({ "workspace": workspace }),
803 },
804 ],
805 )
806 .await;
807
808 let lines = read_lines(&path).await;
809 let events: Vec<&str> = lines
810 .iter()
811 .map(|line| line["event"].as_str().expect("event"))
812 .collect();
813 assert_eq!(
814 events,
815 vec![
816 "session_start",
817 "turn_start",
818 "turn_end",
819 "turn_stalled",
820 "subagent_spawn",
821 "subagent_complete",
822 "session_end",
823 ],
824 "the routing-field contract must cover every lifecycle event type"
825 );
826 for line in &lines {
827 assert_eq!(
828 line["payload"]["workspace"],
829 json!(workspace),
830 "workspace must survive the round trip for event {}",
831 line["event"]
832 );
833 }
834 for event in ["subagent_spawn", "subagent_complete"] {
835 let line = lines
836 .iter()
837 .find(|line| line["event"] == event)
838 .expect(event);
839 assert_eq!(
840 line["payload"]["subagent"],
841 json!(subagent),
842 "subagent must survive the round trip for event {event}"
843 );
844 }
845 }
846
847 #[tokio::test]
848 async fn seq_is_monotonic_and_recovers_across_reopen() {
849 let (_dir, path) = temp_outbox_path("seq.jsonl");
850 let mut state = WriterState {
851 path: path.clone(),
852 webhook: None,
853 next_seq: 0,
854 recovered: false,
855 receiver: tokio::sync::mpsc::channel(OUTBOX_QUEUE_CAPACITY).1,
856 };
857 deliver_all(
858 &mut state,
859 vec![
860 event("session_start", "session.started"),
861 event("turn_start", "turn.started"),
862 event("turn_end", "turn.completed"),
863 ],
864 )
865 .await;
866
867 // A fresh writer (new process, same file) continues the sequence.
868 let mut reopened = WriterState {
869 path: path.clone(),
870 webhook: None,
871 next_seq: 0,
872 recovered: false,
873 receiver: tokio::sync::mpsc::channel(OUTBOX_QUEUE_CAPACITY).1,
874 };
875 deliver_all(&mut reopened, vec![event("turn_start", "turn.started")]).await;
876
877 let lines = read_lines(&path).await;
878 let seqs: Vec<u64> = lines
879 .iter()
880 .map(|line| line["seq"].as_u64().expect("seq"))
881 .collect();
882 assert_eq!(seqs, vec![1, 2, 3, 4]);
883 }
884
885 #[tokio::test]
886 async fn missing_and_empty_files_start_at_seq_1() {
887 let (_dir, path) = temp_outbox_path("empty.jsonl");
888 assert_eq!(recover_last_seq(&path).await.expect("missing file"), 1);
889
890 tokio::fs::write(&path, "").await.expect("empty file");
891 assert_eq!(recover_last_seq(&path).await.expect("empty file"), 1);
892 }
893
894 #[tokio::test]
895 async fn partial_trailing_line_is_ignored_during_recovery() {
896 let (_dir, path) = temp_outbox_path("partial.jsonl");
897 tokio::fs::write(
898 &path,
899 format!(
900 "{}\n{}\n{{\"schema_version\":1,\"seq\":3,\"event\":\"turn_",
901 r#"{"schema_version":1,"seq":1,"event":"session_start","kind":"session.started","thread_id":"s","turn_id":null,"item_id":null,"timestamp":"t","payload":{}}"#,
902 r#"{"schema_version":1,"seq":2,"event":"turn_start","kind":"turn.started","thread_id":"s","turn_id":null,"item_id":null,"timestamp":"t","payload":{}}"#,
903 ),
904 )
905 .await
906 .expect("write partial outbox");
907 // The torn trailing line is not a complete record; recovery continues
908 // from the last complete line's seq (2) → next seq 3.
909 assert_eq!(recover_last_seq(&path).await.expect("recover"), 3);
910 }
911
912 #[tokio::test]
913 async fn emit_queues_and_writes_in_order_without_blocking() {
914 let (_dir, path) = temp_outbox_path("emit.jsonl");
915 let outbox = LifecycleOutbox::new(Some(path.clone()), None, None);
916 assert!(outbox.is_enabled());
917
918 outbox.emit(event("session_start", "session.started"));
919 outbox.emit(event("turn_start", "turn.started"));
920 outbox.emit(event("turn_end", "turn.completed"));
921
922 // The writer task drains asynchronously; wait for the lines to land.
923 for _ in 0..100 {
924 if tokio::fs::metadata(&path)
925 .await
926 .is_ok_and(|meta| meta.len() > 0)
927 && read_lines(&path).await.len() >= 3
928 {
929 break;
930 }
931 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
932 }
933 let lines = read_lines(&path).await;
934 assert_eq!(lines.len(), 3, "expected all queued events to be written");
935 let events: Vec<&str> = lines
936 .iter()
937 .map(|line| line["event"].as_str().expect("event"))
938 .collect();
939 assert_eq!(events, vec!["session_start", "turn_start", "turn_end"]);
940 let seqs: Vec<u64> = lines
941 .iter()
942 .map(|line| line["seq"].as_u64().expect("seq"))
943 .collect();
944 assert_eq!(seqs, vec![1, 2, 3], "seq must be assigned in emit order");
945 }
946
947 #[test]
948 fn disabled_outbox_drops_events_and_reports_disabled() {
949 let outbox = LifecycleOutbox::new(None, None, None);
950 assert!(!outbox.is_enabled());
951 outbox.emit(event("turn_start", "turn.started")); // must not panic
952
953 let empty_path = LifecycleOutbox::new(Some(PathBuf::new()), None, None);
954 assert!(!empty_path.is_enabled());
955
956 let default = LifecycleOutbox::default();
957 assert!(!default.is_enabled());
958 }
959
960 #[test]
961 fn webhook_only_configures_without_a_file_path() {
962 // `webhook_url` without `path` is stored losslessly in config; the
963 // outbox handle itself only activates on a path.
964 let outbox = LifecycleOutbox::new(
965 None,
966 Some("https://example.com/hook".to_string()),
967 Some("token".to_string()),
968 );
969 assert!(!outbox.is_enabled());
970 }
971
972 #[test]
973 fn bounded_text_truncates_to_limit_with_marker() {
974 assert_eq!(bounded_text("short", 80), "short");
975 let long = "x".repeat(200);
976 let bounded = bounded_text(&long, OUTBOX_DETAIL_MAX_CHARS);
977 assert_eq!(bounded.chars().count(), OUTBOX_DETAIL_MAX_CHARS);
978 assert!(bounded.ends_with(OUTBOX_TRUNCATION_MARKER));
979 assert!(bounded.starts_with('x'));
980 }
981
982 #[test]
983 fn bounded_text_strips_controls_and_collapses_whitespace() {
984 assert_eq!(
985 bounded_text("line\x1b[31m one\n\n two\t", 80),
986 "line[31m one two"
987 );
988 assert_eq!(bounded_text("", 80), "");
989 assert_eq!(bounded_text(" \n\t ", 80), "");
990 }
991
992 #[test]
993 fn bounded_text_respects_utf8_boundaries() {
994 // 30 multi-byte emoji (4 bytes each) = 120 bytes but only 30 chars.
995 let emoji = "🦈".repeat(30);
996 let bounded = bounded_text(&emoji, OUTBOX_DETAIL_MAX_CHARS);
997 assert!(bounded.chars().count() <= OUTBOX_DETAIL_MAX_CHARS);
998 assert!(bounded.starts_with('🦈'));
999 }
1000
1001 /// The webhook transport must POST `{"at", "event"}` JSON and, when a
1002 /// token is configured, send it as `Authorization: Bearer <token>`.
1003 #[tokio::test]
1004 async fn webhook_posts_at_event_payload_with_bearer_token() {
1005 let server = wiremock::MockServer::start().await;
1006 wiremock::Mock::given(wiremock::matchers::method("POST"))
1007 .and(wiremock::matchers::path("/hook"))
1008 .and(wiremock::matchers::header(
1009 "authorization",
1010 "Bearer secret-token",
1011 ))
1012 .and(wiremock::matchers::body_partial_json(json!({
1013 "event": {"kind": "turn.started"}
1014 })))
1015 .respond_with(wiremock::ResponseTemplate::new(200))
1016 .mount(&server)
1017 .await;
1018
1019 let webhook = WebhookHookSink::new_with_token(
1020 format!("{}/hook", server.uri()),
1021 Some("secret-token".to_string()),
1022 );
1023 webhook
1024 .post_payload(json!(
1025 {"at": "2026-08-19T00:00:00Z", "event": {"kind": "turn.started"}}
1026 ))
1027 .await
1028 .expect("webhook delivery");
1029
1030 let requests = server.received_requests().await.expect("requests");
1031 assert_eq!(requests.len(), 1, "exactly one webhook POST");
1032 }
1033
1034 /// A webhook that always fails must surface its error to the caller
1035 /// (which logs and drops it) — never panic, never retry forever.
1036 #[tokio::test]
1037 async fn webhook_failure_is_an_error_not_a_panic() {
1038 let server = wiremock::MockServer::start().await;
1039 wiremock::Mock::given(wiremock::matchers::method("POST"))
1040 .respond_with(wiremock::ResponseTemplate::new(500))
1041 .mount(&server)
1042 .await;
1043
1044 let webhook = WebhookHookSink::new_with_token(format!("{}/hook", server.uri()), None);
1045 let result = webhook.post_payload(json!({})).await;
1046 assert!(result.is_err(), "expected the failure to be reported");
1047 }
1048 }
1049
1049 lines RUST