Skip to main content

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}