agentmux_common/event_log.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Reducer event log: an in-memory ring buffer of recent
5//! [`Event`]s for replay, plus an optional append-only JSON-lines disk
6//! stream for crash forensics.
7//!
8//! This module used to exist twice — `crates/launcher/src/event_log.rs`
9//! (Phase D.2) and a byte-for-byte mirror in `crates/srv/src/event_log.rs`
10//! (Phase E.1b), 415 lines each, whose only code-level difference was
11//! *which logging sink* a warning went to. The srv copy's own header
12//! carried the to-do: *"Phase E.7 cleanup: lift the shared parts into
13//! agentmux-common and unify launcher + srv event logs. (reagent P2
14//! #610.)"* This is that lift
15//! (`docs/reports/REPORT_DRY_AND_MODULARITY_AUDIT_2026_09_06.md` §2.2).
16//!
17//! The one real divergence is preserved as a parameter: [`run_disk_writer`]
18//! takes a [`WarnSink`], because the launcher logs to its own rotating
19//! file (`crate::log`) and has **no** `tracing` subscriber installed, while
20//! srv logs via `tracing::warn!`. Hard-coding either would silently drop
21//! the other crate's warnings.
22//!
23//! Two roles, kept clean:
24//!
25//! 1. **In-memory ring** is the source of truth for replay during the
26//! owning process's lifetime. `GetEvents { since: u64 }` reads from
27//! it. Bounded by `MAX_RING_EVENTS` to stop unbounded memory growth in
28//! long sessions; oldest events evict first. A subscriber that fell
29//! behind further than the ring's coverage gets an `EventList` of
30//! whatever's still in the ring + a flag indicating they may have
31//! missed some — it's their job to recover by treating subsequent
32//! events as authoritative.
33//!
34//! 2. **Disk file** is purely forensic. Append-only JSON-lines. Survives
35//! a crash so an operator can post-mortem "what was it doing right
36//! before it died." Fire-and-forget: write failures log a warning but
37//! never block the in-memory path. Rotated when it exceeds
38//! `MAX_DISK_BYTES` (renames current to `.old`, starts fresh).
39//!
40//! Why both: in-memory satisfies D.3's resync needs at zero I/O cost in
41//! the happy path. Disk satisfies the "what happened before the crash"
42//! debugging story without complicating the replay reader.
43
44use std::collections::VecDeque;
45use std::path::PathBuf;
46use std::sync::{Arc, Mutex};
47
48use tokio::io::AsyncWriteExt;
49
50use crate::ipc::Event;
51
52/// Where [`run_disk_writer`] sends its warnings. Each owning crate binds
53/// its own logging facility — see the module doc for why this is a
54/// parameter rather than a hard-coded `tracing::warn!`.
55pub type WarnSink = Arc<dyn Fn(&str) + Send + Sync>;
56
57/// Cap on in-memory ring size. 4096 events is comfortable for
58/// realistic resync windows (~minutes of activity at typical
59/// reducer event rates: 10–50 events per user action). Tunable
60/// upward if forensics in long sessions show truncation; downward
61/// would only be useful if we measured noticeable memory pressure
62/// from this (we don't — Event is small and there are 4096 of them).
63const MAX_RING_EVENTS: usize = 4096;
64
65/// Cap on disk file size before rotation. 8 MiB ≈ 4–8K events
66/// depending on event variant. Two-file rotation: when current
67/// exceeds this, rename to `.old` (overwriting any prior `.old`)
68/// and start fresh. Total worst-case footprint: 2 × MAX_DISK_BYTES.
69const MAX_DISK_BYTES: u64 = 8 * 1024 * 1024;
70
71/// Append-only ring + optional disk persistence.
72///
73/// Cloneable via `Arc<EventLog>` from the IPC server context;
74/// `append` and `events_since` are cheap (Mutex held for
75/// microseconds — Vec<Event> push / scan, no I/O on the
76/// in-memory path).
77///
78/// Disk writes happen on a dedicated tokio task that subscribes
79/// to the broadcast bus separately from the in-memory append.
80/// This means the in-memory ring is updated synchronously from
81/// the IPC server's dispatch path; disk persistence runs at its
82/// own pace and may lag.
83#[derive(Debug)]
84pub struct EventLog {
85 ring: Mutex<VecDeque<Event>>,
86 disk_path: Option<PathBuf>,
87}
88
89impl EventLog {
90 /// Construct an event log. `disk_path = None` disables disk
91 /// persistence (used in tests where filesystem state is
92 /// inconvenient). The on-disk file is created on first append;
93 /// no upfront I/O.
94 pub fn new(disk_path: Option<PathBuf>) -> Self {
95 Self {
96 ring: Mutex::new(VecDeque::with_capacity(MAX_RING_EVENTS)),
97 disk_path,
98 }
99 }
100
101 /// Append an event to the in-memory ring. Evicts the oldest
102 /// entry when the ring is at capacity. Synchronous,
103 /// O(1)-amortized.
104 pub fn append(&self, event: Event) {
105 let mut ring = self.ring.lock().expect("event-log ring mutex poisoned");
106 if ring.len() == MAX_RING_EVENTS {
107 ring.pop_front();
108 }
109 ring.push_back(event);
110 }
111
112 /// Snapshot of all events currently in the ring with version >
113 /// `since`. Returned in insertion order (oldest first), so the
114 /// caller applies them sequentially.
115 ///
116 /// Phase D.3 — used by `Command::GetEvents { since }` to
117 /// produce the replay slice. The snapshot is taken at-call-time;
118 /// events arriving after this returns are NOT included (the
119 /// subscriber sees them on the live broadcast stream).
120 pub fn events_since(&self, since: u64) -> Vec<Event> {
121 let ring = self.ring.lock().expect("event-log ring mutex poisoned");
122 ring.iter()
123 .filter(|e| event_version(e) > since)
124 .cloned()
125 .collect()
126 }
127
128 /// True if the requested `since` version is older than the
129 /// oldest event in the ring (i.e. the subscriber missed events
130 /// that have already been evicted). Caller should treat the
131 /// resulting `events_since` slice as best-effort and may need
132 /// to re-fetch a snapshot to recover canonical state.
133 pub fn replay_truncated(&self, since: u64) -> bool {
134 let ring = self.ring.lock().expect("event-log ring mutex poisoned");
135 match ring.front() {
136 // Phase E.1a (codex P2 #608) — saturating_add guards
137 // against `since: u64::MAX` overflow. Wire input is
138 // externally reachable; debug builds would panic, release
139 // would wrap. Saturating means "since == u64::MAX" trivially
140 // returns false (oldest can't exceed u64::MAX), which is
141 // the correct semantic — there's no possible event newer
142 // than that, so there can't be a gap.
143 Some(oldest) => event_version(oldest) > since.saturating_add(1),
144 None => false,
145 }
146 }
147
148 /// Disk path for the writer task to flush to. None when disk
149 /// persistence is disabled.
150 pub fn disk_path(&self) -> Option<&PathBuf> {
151 self.disk_path.as_ref()
152 }
153}
154
155/// Background task: write events to the disk file as they arrive
156/// on the broadcast bus. Rotates when the file exceeds
157/// `MAX_DISK_BYTES`.
158///
159/// Spawned once per process run. Holds a tokio broadcast receiver
160/// and the EventLog's disk path. Failures are reported through
161/// `warn` and the event is dropped from the disk stream (the
162/// in-memory ring is unaffected — disk is forensics-only).
163pub async fn run_disk_writer(
164 log: Arc<EventLog>,
165 mut events_rx: tokio::sync::broadcast::Receiver<Event>,
166 warn: WarnSink,
167) {
168 let path = match log.disk_path() {
169 Some(p) => p.clone(),
170 None => return, // disk persistence disabled — exit task
171 };
172 let rotated_path = path.with_extension("log.old");
173
174 let mut file = match open_for_append(&path).await {
175 Ok(f) => f,
176 Err(e) => {
177 warn(&format!(
178 "[event-log] cannot open {} for append: {} — disk persistence disabled",
179 path.display(),
180 e
181 ));
182 return;
183 }
184 };
185 let mut bytes_written = file.metadata().await.map(|m| m.len()).unwrap_or(0);
186
187 loop {
188 match events_rx.recv().await {
189 Ok(event) => {
190 let mut buf = match serde_json::to_vec(&event) {
191 Ok(b) => b,
192 Err(e) => {
193 warn(&format!("[event-log] serialize failed: {}", e));
194 continue;
195 }
196 };
197 buf.push(b'\n');
198 if bytes_written + buf.len() as u64 > MAX_DISK_BYTES {
199 // Rotate: drop the writer, rename current →
200 // .old (overwriting), reopen fresh. Failures
201 // here are non-fatal; we log and keep writing
202 // to the existing file (it'll just exceed cap).
203 drop(file);
204 if let Err(e) = tokio::fs::rename(&path, &rotated_path).await {
205 warn(&format!(
206 "[event-log] rotation rename failed: {} — continuing without rotation",
207 e
208 ));
209 }
210 file = match open_for_append(&path).await {
211 Ok(f) => f,
212 Err(e) => {
213 warn(&format!(
214 "[event-log] post-rotation open failed: {} — disk persistence stopping",
215 e
216 ));
217 return;
218 }
219 };
220 bytes_written = 0;
221 }
222 if let Err(e) = file.write_all(&buf).await {
223 warn(&format!(
224 "[event-log] write failed: {} — dropping event from disk stream",
225 e
226 ));
227 continue;
228 }
229 // Phase E.1a — durable: fsync per append so events
230 // written before a crash survive it. Required by
231 // Phase E §6.4 for srv's bootstrap-replay correctness.
232 // Latency cost: ~ms per event vs microseconds without
233 // sync. Acceptable for our event volume (~10–50 per
234 // user action). Trade-off considered: batched fsync
235 // (group commit). Skipping for E.1a — premature
236 // optimization; revisit if profiling shows a hot path.
237 if let Err(e) = file.sync_data().await {
238 warn(&format!(
239 "[event-log] sync_data failed: {} — event written but not fsynced",
240 e
241 ));
242 }
243 bytes_written += buf.len() as u64;
244 }
245 Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
246 warn(&format!("[event-log] disk writer lagged, missed {} events", n));
247 }
248 Err(tokio::sync::broadcast::error::RecvError::Closed) => {
249 let _ = file.flush().await;
250 return;
251 }
252 }
253 }
254}
255
256async fn open_for_append(path: &std::path::Path) -> std::io::Result<tokio::fs::File> {
257 tokio::fs::OpenOptions::new()
258 .create(true)
259 .append(true)
260 .open(path)
261 .await
262}
263
264/// Extract the `version` field from any Event variant. When a new
265/// variant is added that carries a version, add it here too — the
266/// exhaustive match catches future-variant compile errors.
267pub fn event_version(e: &Event) -> u64 {
268 match e {
269 Event::ProcessSpawned { version, .. }
270 | Event::ProcessExited { version, .. }
271 | Event::LifecyclePhaseChanged { version, .. }
272 | Event::Registered { version, .. }
273 | Event::Pong { version, .. }
274 | Event::WindowOpened { version, .. }
275 | Event::WindowClosed { version, .. }
276 | Event::PoolWindowAdded { version, .. }
277 | Event::PoolWindowRemoved { version, .. }
278 | Event::PoolWindowPromoted { version, .. }
279 | Event::PanesReaped { version, .. }
280 | Event::PoolDrained { version, .. }
281 | Event::PoolNotLast { version, .. }
282 | Event::WindowInstanceAssigned { version, .. }
283 | Event::WindowInstanceReleased { version, .. }
284 | Event::BackendWindowIdRegistered { version, .. }
285 | Event::BackendWindowIdUnregistered { version, .. }
286 | Event::DriftDetected { version, .. }
287 | Event::HwndDriftDetected { version, .. }
288 | Event::CorrectiveWindowMove { version, .. }
289 | Event::HostShouldQuit { version, .. }
290 | Event::Snapshot { version, .. }
291 | Event::EventList { version, .. }
292 | Event::SagaStarted { version, .. }
293 | Event::SagaCompleted { version, .. }
294 | Event::SagaFailed { version, .. }
295 | Event::SrvSnapshot { version, .. }
296 | Event::WorkspaceCreated { version, .. }
297 | Event::WorkspaceDeleted { version, .. }
298 | Event::TabCreated { version, .. }
299 | Event::TabDeleted { version, .. }
300 | Event::ActiveTabChanged { version, .. }
301 | Event::TabReordered { version, .. }
302 | Event::SrvWindowOpened { version, .. }
303 | Event::SrvWindowClosed { version, .. }
304 | Event::SrvWindowWorkspaceChanged { version, .. }
305 | Event::TabsReorderedBulk { version, .. }
306 | Event::WorkspaceRenamed { version, .. }
307 | Event::TabRenamed { version, .. }
308 | Event::WorkspaceMetaUpdated { version, .. }
309 | Event::WindowMetaUpdated { version, .. }
310 | Event::TabMetaUpdated { version, .. }
311 | Event::BlockMetaUpdated { version, .. }
312 | Event::TabMoved { version, .. }
313 | Event::BlockMoved { version, .. }
314 | Event::BlockCreated { version, .. }
315 | Event::BlockDeleted { version, .. }
316 | Event::FocusedNodeChanged { version, .. }
317 | Event::MagnifiedNodeChanged { version, .. }
318 | Event::SagaActionFailed { version, .. }
319 | Event::Error { version, .. }
320 // Phase E.4.B — layout tree events.
321 | Event::LayoutNodeInserted { version, .. }
322 | Event::LayoutNodeInsertedAtIndex { version, .. }
323 | Event::LayoutNodeDeleted { version, .. }
324 | Event::LayoutNodeMoved { version, .. }
325 | Event::LayoutNodesSwapped { version, .. }
326 | Event::LayoutNodesResized { version, .. }
327 | Event::LayoutNodeReplaced { version, .. }
328 | Event::LayoutSplitHorizontalApplied { version, .. }
329 | Event::LayoutSplitVerticalApplied { version, .. }
330 | Event::LayoutCleared { version, .. }
331 | Event::LayoutBackendActionsQueued { version, .. }
332 | Event::LayoutTreeReplaced { version, .. } => *version,
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use crate::ipc::{ClientKind, LifecyclePhase};
340
341 fn lifecycle_event(v: u64) -> Event {
342 Event::LifecyclePhaseChanged {
343 from: LifecyclePhase::Starting,
344 to: LifecyclePhase::Running,
345 version: v,
346 }
347 }
348
349 fn registered_event(v: u64) -> Event {
350 Event::Registered {
351 client_id: 1,
352 launcher_pid: 1,
353 launcher_version: "test".into(),
354 version: v,
355 }
356 }
357
358 fn process_spawned_event(v: u64) -> Event {
359 Event::ProcessSpawned {
360 pid: 100,
361 kind: ClientKind::Host,
362 client_version: "0.0.0".into(),
363 version: v,
364 }
365 }
366
367 #[test]
368 fn append_grows_ring_until_cap_then_evicts_oldest() {
369 let log = EventLog::new(None);
370 for v in 1..=(MAX_RING_EVENTS as u64 + 5) {
371 log.append(lifecycle_event(v));
372 }
373 let ring = log.ring.lock().unwrap();
374 assert_eq!(ring.len(), MAX_RING_EVENTS);
375 // Oldest 5 should have been evicted; first should be v=6.
376 assert_eq!(event_version(ring.front().unwrap()), 6);
377 // Newest is v = MAX_RING_EVENTS + 5.
378 assert_eq!(
379 event_version(ring.back().unwrap()),
380 MAX_RING_EVENTS as u64 + 5
381 );
382 }
383
384 #[test]
385 fn events_since_returns_only_versions_strictly_greater() {
386 let log = EventLog::new(None);
387 for v in [1u64, 2, 3, 4, 5] {
388 log.append(lifecycle_event(v));
389 }
390 let replay = log.events_since(2);
391 assert_eq!(replay.len(), 3, "expected v=3,4,5; got {:?}", replay);
392 assert_eq!(event_version(&replay[0]), 3);
393 assert_eq!(event_version(&replay[2]), 5);
394 }
395
396 #[test]
397 fn events_since_zero_returns_all() {
398 let log = EventLog::new(None);
399 for v in [1u64, 2, 3] {
400 log.append(registered_event(v));
401 }
402 let replay = log.events_since(0);
403 assert_eq!(replay.len(), 3);
404 }
405
406 #[test]
407 fn events_since_at_or_above_max_returns_empty() {
408 let log = EventLog::new(None);
409 log.append(process_spawned_event(5));
410 log.append(process_spawned_event(6));
411 let replay = log.events_since(10);
412 assert!(replay.is_empty());
413 }
414
415 #[test]
416 fn replay_truncated_detects_missed_events() {
417 let log = EventLog::new(None);
418 // Fill past capacity to force eviction.
419 for v in 1..=(MAX_RING_EVENTS as u64 + 100) {
420 log.append(lifecycle_event(v));
421 }
422 // Subscriber asks for events since v=5; ring's oldest is now v=101.
423 // The subscriber missed v=6..=100 — the replay slice covers v=101..,
424 // and `replay_truncated(5)` reports the gap.
425 assert!(log.replay_truncated(5));
426 // Subscriber asking from a version newer than ring's oldest sees
427 // no truncation.
428 let oldest = MAX_RING_EVENTS as u64 + 100 - MAX_RING_EVENTS as u64 + 1;
429 assert!(!log.replay_truncated(oldest));
430 }
431
432 #[test]
433 fn replay_truncated_on_empty_log_is_false() {
434 let log = EventLog::new(None);
435 assert!(!log.replay_truncated(0));
436 assert!(!log.replay_truncated(100));
437 }
438
439 /// The disk writer must route every warning through the injected
440 /// sink rather than a hard-coded logger — that sink is the whole
441 /// reason this module could be shared (see module doc). Exercise
442 /// the one warning path that needs no live broadcast bus: an
443 /// unopenable disk path.
444 #[tokio::test]
445 async fn disk_writer_reports_open_failure_through_the_injected_sink() {
446 let captured = Arc::new(Mutex::new(Vec::<String>::new()));
447 let sink: WarnSink = {
448 let c = captured.clone();
449 Arc::new(move |m: &str| c.lock().unwrap().push(m.to_string()))
450 };
451 // A path under a file (not a directory) cannot be created.
452 let dir = tempfile::tempdir().unwrap();
453 let blocker = dir.path().join("not-a-dir");
454 std::fs::write(&blocker, b"x").unwrap();
455 let log = Arc::new(EventLog::new(Some(blocker.join("events.log"))));
456 let (_tx, rx) = tokio::sync::broadcast::channel::<Event>(4);
457 run_disk_writer(log, rx, sink).await;
458 let msgs = captured.lock().unwrap();
459 assert_eq!(msgs.len(), 1, "exactly one warning expected, got {:?}", *msgs);
460 assert!(msgs[0].contains("cannot open"), "{}", msgs[0]);
461 }
462}