Skip to main content

lean_ctx/core/
events.rs

1// SPDX-License-Identifier: Apache-2.0
2
3use serde::{Deserialize, Serialize};
4use std::collections::VecDeque;
5use std::io::Write;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Mutex, OnceLock};
8
9const RING_CAPACITY: usize = 1000;
10#[cfg(test)]
11const JSONL_MAX_LINES: usize = 10_000;
12const EVENT_ID_BLOCK_SIZE: u64 = 1_024;
13
14#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
15pub struct LeanCtxEvent {
16    pub id: u64,
17    pub timestamp: String,
18    pub kind: EventKind,
19}
20
21#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
22#[serde(tag = "type")]
23pub enum EventKind {
24    ToolCall {
25        tool: String,
26        tokens_original: u64,
27        tokens_saved: u64,
28        mode: Option<String>,
29        duration_ms: u64,
30        path: Option<String>,
31    },
32    CacheHit {
33        path: String,
34        saved_tokens: u64,
35    },
36    Compression {
37        path: String,
38        before_lines: u32,
39        after_lines: u32,
40        strategy: String,
41        kept_line_count: u32,
42        removed_line_count: u32,
43    },
44    AgentAction {
45        agent_id: String,
46        action: String,
47        tool: Option<String>,
48    },
49    KnowledgeUpdate {
50        category: String,
51        key: String,
52        action: String,
53    },
54    ThresholdShift {
55        language: String,
56        old_entropy: f64,
57        new_entropy: f64,
58        old_jaccard: f64,
59        new_jaccard: f64,
60    },
61    PolicyViolation {
62        role: String,
63        tool: String,
64        reason: String,
65    },
66    RoleChanged {
67        from: String,
68        to: String,
69    },
70    ProfileChanged {
71        from: String,
72        to: String,
73    },
74    VerificationWarning {
75        warning_kind: String,
76        detail: String,
77        severity: String,
78    },
79    ThresholdAdapted {
80        language: String,
81        arm: String,
82        old_threshold: f64,
83        new_threshold: f64,
84    },
85}
86
87/// Event kinds written by older releases whose feature was removed (agent
88/// budget limits, SLO evaluation, anomaly detection). Persisted records of
89/// these kinds are skipped on read so existing journals and observation stores
90/// stay loadable; every other record is still validated strictly.
91const RETIRED_EVENT_KINDS: &[&str] = &[
92    "BudgetWarning",
93    "BudgetExhausted",
94    "SloViolation",
95    "Anomaly",
96];
97
98/// True when `json` is a serialized [`LeanCtxEvent`] of a retired kind.
99pub(crate) fn is_retired_event_record(json: &str) -> bool {
100    #[derive(Deserialize)]
101    struct KindTag {
102        #[serde(rename = "type")]
103        tag: String,
104    }
105    #[derive(Deserialize)]
106    struct Record {
107        kind: KindTag,
108    }
109    serde_json::from_str::<Record>(json)
110        .is_ok_and(|record| RETIRED_EVENT_KINDS.contains(&record.kind.tag.as_str()))
111}
112
113struct EventBus {
114    ring: Mutex<VecDeque<LeanCtxEvent>>,
115}
116
117impl EventBus {
118    fn new() -> Self {
119        Self {
120            ring: Mutex::new(VecDeque::with_capacity(RING_CAPACITY)),
121        }
122    }
123
124    fn record(&self, event: LeanCtxEvent) {
125        let Ok(mut ring) = crate::core::context_os::observation::lock_observation(&self.ring)
126        else {
127            tracing::warn!(
128                "legacy projection unavailable; committed observation retained in ContextBus"
129            );
130            return;
131        };
132        if ring.len() >= RING_CAPACITY {
133            ring.pop_front();
134        }
135        ring.push_back(event);
136    }
137
138    fn events_since(&self, after_id: u64) -> Vec<LeanCtxEvent> {
139        let ring = self
140            .ring
141            .lock()
142            .unwrap_or_else(std::sync::PoisonError::into_inner);
143        ring.iter().filter(|e| e.id > after_id).cloned().collect()
144    }
145
146    fn latest_events(&self, n: usize) -> Vec<LeanCtxEvent> {
147        let ring = self
148            .ring
149            .lock()
150            .unwrap_or_else(std::sync::PoisonError::into_inner);
151        let len = ring.len();
152        let start = len.saturating_sub(n);
153        ring.iter().skip(start).cloned().collect()
154    }
155}
156
157fn bus() -> &'static EventBus {
158    static INSTANCE: OnceLock<EventBus> = OnceLock::new();
159    INSTANCE.get_or_init(EventBus::new)
160}
161
162fn jsonl_path() -> Option<std::path::PathBuf> {
163    crate::core::paths::state_dir()
164        .ok()
165        .map(|d| d.join("events.jsonl"))
166}
167
168fn event_sequence_path() -> Option<std::path::PathBuf> {
169    crate::core::paths::state_dir()
170        .ok()
171        .map(|d| d.join("events.seq"))
172}
173
174/// Identity-only view of a journal line: retired event kinds still reserve
175/// their IDs, so the next allocated ID never collides with old history.
176#[derive(Deserialize)]
177struct EventIdOnly {
178    id: u64,
179}
180
181fn max_event_id_in_journal(path: &std::path::Path) -> u64 {
182    let Ok(content) = std::fs::read_to_string(path) else {
183        return 0;
184    };
185    content
186        .lines()
187        .filter_map(|line| serde_json::from_str::<EventIdOnly>(line).ok())
188        .map(|event| event.id)
189        .max()
190        .unwrap_or(0)
191}
192
193fn parse_sequence_record(record: &str) -> Option<u64> {
194    let (value, checksum) = record.trim().split_once(':')?;
195    let value = value.parse::<u64>().ok()?;
196    let checksum = checksum.parse::<u64>().ok()?;
197    (checksum == !value).then_some(value)
198}
199
200fn read_persisted_sequence(path: &std::path::Path) -> Option<u64> {
201    let content = std::fs::read_to_string(path).ok()?;
202    content.lines().rev().find_map(parse_sequence_record)
203}
204
205fn sequence_record(value: u64) -> String {
206    format!("{value}:{}", !value)
207}
208
209fn reserve_event_id_block_at(
210    sequence_path: &std::path::Path,
211    journal_path: &std::path::Path,
212    block_size: u64,
213) -> std::io::Result<(u64, u64)> {
214    use fs2::FileExt;
215
216    if block_size == 0 {
217        return Err(std::io::Error::other("event id block must not be empty"));
218    }
219    if let Some(parent) = sequence_path.parent() {
220        std::fs::create_dir_all(parent)?;
221    }
222    // Lock a stable companion inode. The append-only sequence journal can be
223    // repaired without allowing another process to bypass the lock.
224    let lock_path = sequence_path.with_extension("seq.lock");
225    let lock = std::fs::OpenOptions::new()
226        .create(true)
227        .truncate(false)
228        .read(true)
229        .write(true)
230        .open(lock_path)?;
231    lock.lock_exclusive()?;
232
233    let result = (|| {
234        let persisted = read_persisted_sequence(sequence_path);
235        let baseline = persisted.unwrap_or_else(|| {
236            max_event_id_in_journal(journal_path).max(max_event_id_in_journal(
237                &journal_path.with_extension("jsonl.old"),
238            ))
239        });
240        let first = baseline
241            .checked_add(1)
242            .ok_or_else(|| std::io::Error::other("event id space exhausted"))?;
243        let last = baseline
244            .checked_add(block_size)
245            .ok_or_else(|| std::io::Error::other("event id space exhausted"))?;
246
247        // A prior writer's record (or corruption) may leave the file without
248        // a trailing newline; appending directly would fuse onto that line
249        // and make both records unparsable. Start a fresh line first.
250        let needs_leading_newline = std::fs::read(sequence_path)
251            .is_ok_and(|bytes| !bytes.is_empty() && bytes.last() != Some(&b'\n'));
252
253        let mut sequence = std::fs::OpenOptions::new()
254            .create(true)
255            .append(true)
256            .open(sequence_path)?;
257        let mut record = Vec::new();
258        if needs_leading_newline {
259            record.push(b'\n');
260        }
261        record.extend_from_slice(sequence_record(last).as_bytes());
262        record.push(b'\n');
263        sequence.write_all(&record)?;
264        sequence.sync_data()?;
265        Ok((first, last))
266    })();
267
268    let _ = FileExt::unlock(&lock);
269    result
270}
271
272#[cfg(test)]
273fn next_event_id_at(
274    sequence_path: &std::path::Path,
275    journal_path: &std::path::Path,
276) -> std::io::Result<u64> {
277    reserve_event_id_block_at(sequence_path, journal_path, 1).map(|(first, _)| first)
278}
279
280fn next_event_id() -> u64 {
281    #[derive(Default)]
282    struct LocalBlock {
283        next: u64,
284        last: u64,
285    }
286
287    static BLOCK: OnceLock<Mutex<LocalBlock>> = OnceLock::new();
288    static FALLBACK: AtomicU64 = AtomicU64::new(0);
289    if !is_test_environment()
290        && let Some((sequence, journal)) = event_sequence_path().zip(jsonl_path())
291    {
292        let mut block = BLOCK
293            .get_or_init(|| Mutex::new(LocalBlock::default()))
294            .lock()
295            .unwrap_or_else(std::sync::PoisonError::into_inner);
296        if block.next <= block.last && block.next != 0 {
297            let id = block.next;
298            block.next = block.next.saturating_add(1);
299            return id;
300        }
301        if let Ok((first, last)) =
302            reserve_event_id_block_at(&sequence, &journal, EVENT_ID_BLOCK_SIZE)
303        {
304            block.next = first.saturating_add(1);
305            block.last = last;
306            return first;
307        }
308    }
309
310    fallback_event_id(&FALLBACK, chrono::Utc::now().timestamp_millis())
311}
312
313/// Clock-derived id (milliseconds × 1000) that is strictly increasing within
314/// the process even when the wall clock steps back (an NTP correction on a
315/// CI runner): `max(last + 1, now)`. The former `now + counter` could repeat
316/// an id after such a step — the canonical store then rejects the event as an
317/// identity conflict — or order a later event before an earlier one.
318fn fallback_event_id(last: &AtomicU64, now_millis: i64) -> u64 {
319    let now = u64::try_from(now_millis)
320        .unwrap_or_default()
321        .saturating_mul(1_000);
322    let mut current = last.load(Ordering::Relaxed);
323    loop {
324        let next = now.max(current.saturating_add(1));
325        match last.compare_exchange_weak(current, next, Ordering::Relaxed, Ordering::Relaxed) {
326            Ok(_) => return next,
327            Err(observed) => current = observed,
328        }
329    }
330}
331
332fn is_test_environment() -> bool {
333    use std::sync::OnceLock;
334    static CACHED: OnceLock<bool> = OnceLock::new();
335    *CACHED.get_or_init(|| {
336        if cfg!(test) {
337            return true;
338        }
339        if std::env::var_os("__LEAN_CTX_SKIP_EVENTS").is_some() {
340            return true;
341        }
342        std::env::current_exe().is_ok_and(|p| {
343            let s = p.to_string_lossy();
344            s.contains("/deps/") || s.contains("\\deps\\")
345        })
346    })
347}
348
349#[cfg(test)]
350fn append_jsonl_at(path: &std::path::Path, event: &LeanCtxEvent) -> std::io::Result<()> {
351    use fs2::FileExt;
352    use std::io::Write;
353
354    if let Some(parent) = path.parent() {
355        std::fs::create_dir_all(parent)?;
356    }
357
358    // Lock a stable companion inode rather than events.jsonl itself: rotation
359    // renames the data file, so locking that inode would let another process
360    // open the replacement and bypass the lock.
361    let lock_path = path.with_extension("jsonl.lock");
362    let lock = std::fs::OpenOptions::new()
363        .create(true)
364        .truncate(false)
365        .read(true)
366        .write(true)
367        .open(lock_path)?;
368    lock.lock_exclusive()?;
369
370    let result = (|| {
371        if let Ok(content) = std::fs::read_to_string(path)
372            && content.lines().count() >= JSONL_MAX_LINES
373        {
374            let old = path.with_extension("jsonl.old");
375            let _ = std::fs::remove_file(&old);
376            std::fs::rename(path, old)?;
377        }
378
379        let json = serde_json::to_string(event).map_err(std::io::Error::other)?;
380        let mut line = json.into_bytes();
381        line.push(b'\n');
382        let mut file = std::fs::OpenOptions::new()
383            .create(true)
384            .append(true)
385            .open(path)?;
386        // One logical write while holding the interprocess lock prevents JSON
387        // objects and their trailing newlines from interleaving.
388        file.write_all(&line)
389    })();
390
391    let _ = FileExt::unlock(&lock);
392    result
393}
394
395// --- Public API ---
396
397/// Persist a legacy event as a canonical local observation.
398///
399/// This compatibility adapter deliberately never appends to `events.jsonl`;
400/// that journal remains an immutable historical read source.
401pub fn emit_compatibility(kind: EventKind) -> u64 {
402    match try_emit(kind) {
403        Ok(id) => id,
404        Err(error) => {
405            tracing::warn!("canonical legacy observation persistence failed: {error}");
406            0
407        }
408    }
409}
410
411/// [`emit_compatibility`] that reports why persistence failed instead of
412/// collapsing the failure into id `0`.
413pub(crate) fn try_emit(
414    kind: EventKind,
415) -> Result<u64, crate::core::context_os::ObservationPersistenceError> {
416    let id = next_event_id();
417    let event = LeanCtxEvent {
418        id,
419        timestamp: chrono::Local::now()
420            .format("%Y-%m-%dT%H:%M:%S%.3f")
421            .to_string(),
422        kind,
423    };
424    crate::core::context_os::try_runtime().and_then(|runtime| {
425        runtime.bus.append_legacy_observation(
426            crate::core::context_os::observation::producer_instance(),
427            &event,
428        )
429    })?;
430    bus().record(event);
431    Ok(id)
432}
433
434/// Backward-compatible spelling for the explicit legacy compatibility adapter.
435pub fn emit(kind: EventKind) -> u64 {
436    emit_compatibility(kind)
437}
438
439pub fn events_since(after_id: u64) -> Vec<LeanCtxEvent> {
440    bus().events_since(after_id)
441}
442
443pub fn latest_events(n: usize) -> Vec<LeanCtxEvent> {
444    bus().latest_events(n)
445}
446
447#[derive(Default)]
448struct FileEventCache {
449    path: Option<std::path::PathBuf>,
450    mtime: Option<std::time::SystemTime>,
451    len: u64,
452    events: Vec<LeanCtxEvent>,
453}
454
455fn load_historical_events(
456    path: &std::path::Path,
457) -> Result<Vec<LeanCtxEvent>, crate::core::context_os::ObservationPersistenceError> {
458    static CACHE: OnceLock<Mutex<FileEventCache>> = OnceLock::new();
459    let metadata = match std::fs::metadata(path) {
460        Ok(metadata) => metadata,
461        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
462        Err(error) => return Err(error.into()),
463    };
464    let (mtime, len) = (metadata.modified().ok(), metadata.len());
465    let cache = CACHE.get_or_init(|| Mutex::new(FileEventCache::default()));
466    let mut guard = crate::core::context_os::observation::lock_observation(cache)?;
467    let fresh = guard.path.as_deref() == Some(path) && guard.mtime == mtime && guard.len == len;
468    if !fresh {
469        let content = std::fs::read_to_string(path)?;
470        let events = content
471            .lines()
472            .filter(|line| !line.trim().is_empty() && !is_retired_event_record(line))
473            .map(serde_json::from_str)
474            .collect::<Result<Vec<_>, _>>()?;
475        // Replace the cache only after every nonempty record validates.
476        guard.events = events;
477        guard.path = Some(path.to_path_buf());
478        guard.mtime = mtime;
479        guard.len = len;
480    }
481    Ok(guard.events.clone())
482}
483
484/// Compatibility reader: an unavailable canonical store returns no events,
485/// never apparently current history. Use the fallible API to inspect the error.
486pub fn load_events_from_file(n: usize) -> Vec<LeanCtxEvent> {
487    match try_load_events_from_file(n) {
488        Ok(events) => events,
489        Err(error) => {
490            tracing::warn!("canonical event history unavailable: {error}");
491            Vec::new()
492        }
493    }
494}
495
496/// [`load_events_from_file`] for a surface that SHOWS events — paths, names,
497/// free text — to a host or a model, for a reader working in `project` (the
498/// project of the running request when `None`). Every event passes the
499/// serve-time rule of the observation store (`ObservationGate`): an event
500/// that names a file is shown only while that file is admitted now; one
501/// without a source on record follows the originless rule. Historical JSONL
502/// records and rows written before admission existed are asked the same way.
503/// Counts and timestamps need no gate: use [`load_events_from_file`].
504///
505/// With a reader project the whole call — the admission snapshot, every
506/// payload check and every source read — runs inside ONE pinned policy view
507/// of that project (`policy::runtime::with_project_source_view`): every read
508/// sees the same project policy and role, and the view compares both again
509/// when it ends. A view that cannot be opened, that is not the view of the
510/// reader's project, or that reports a change shows NOTHING for this call.
511/// A reader without a project has no view; only the originless rule applies.
512pub fn load_served_events(n: usize, project: Option<&std::path::Path>) -> Vec<LeanCtxEvent> {
513    use crate::core::context_os::observation::ObservationGate;
514
515    let request = crate::core::admitted_sources::request_root();
516    let reader = project
517        .map(|root| root.to_string_lossy().into_owned())
518        .or_else(|| (!request.is_empty()).then_some(request));
519    let Some(reader) = reader else {
520        return served_through(ObservationGate::for_project(None), n);
521    };
522    crate::core::policy::runtime::with_project_source_view(&reader, || {
523        // The view pins the project the request is already bound to, if any.
524        // The gate reads under that project; a reader that asked for another
525        // one is not served from it.
526        // Compared in the view's own spelling: it pins `Path::canonicalize`
527        // of the project (`policy::runtime::canonical_project_root`).
528        let pinned = crate::core::admitted_sources::request_root();
529        let same = std::path::Path::new(&reader)
530            .canonicalize()
531            .is_ok_and(|asked| asked == std::path::Path::new(&pinned));
532        if same {
533            served_through(ObservationGate::for_project(None), n)
534        } else {
535            Vec::new()
536        }
537    })
538    .unwrap_or_else(|error| {
539        tracing::debug!("[events] nothing shown: {error}");
540        Vec::new()
541    })
542}
543
544fn served_through(
545    mut gate: crate::core::context_os::observation::ObservationGate,
546    n: usize,
547) -> Vec<LeanCtxEvent> {
548    let mut events = load_events_from_file(n);
549    events.retain(|event| gate.serves(event));
550    // The view pins project policy and role, not the gateway rules: those are
551    // compared once more before anything is returned. Changed mid-call:
552    // nothing is shown.
553    if !gate.gateway_unchanged() {
554        tracing::debug!("[events] nothing shown: gateway rules changed during the call");
555        return Vec::new();
556    }
557    if gate.withheld() > 0 {
558        tracing::debug!(
559            "[events] {} observation(s) withheld: no current source authority",
560            gate.withheld()
561        );
562    }
563    events
564}
565
566/// Merge immutable historical JSONL with validated canonical observations.
567pub fn try_load_events_from_file(
568    n: usize,
569) -> Result<Vec<LeanCtxEvent>, crate::core::context_os::ObservationPersistenceError> {
570    let mut events = jsonl_path()
571        .map(|path| load_historical_events(&path))
572        .transpose()?
573        .unwrap_or_default();
574
575    let compatibility = crate::core::context_os::try_runtime()?
576        .bus
577        .read_legacy_observations(n.max(1))?;
578    events.extend(compatibility);
579    // Historical file order predates the canonical cursor. Block-allocated
580    // IDs from concurrent producers do not encode append order.
581    // Never discard distinct records just because historical/fallback IDs
582    // collide. The legacy projection omits producer identity, so even equal
583    // projected payloads cannot establish that two observations are duplicates.
584    let start = events.len().saturating_sub(n);
585    Ok(events[start..].to_vec())
586}
587
588pub fn emit_tool_call(
589    tool: &str,
590    tokens_original: u64,
591    tokens_saved: u64,
592    mode: Option<String>,
593    duration_ms: u64,
594    path: Option<String>,
595) {
596    emit(EventKind::ToolCall {
597        tool: tool.to_string(),
598        tokens_original,
599        tokens_saved,
600        mode,
601        duration_ms,
602        path,
603    });
604}
605
606pub fn emit_cache_hit(path: &str, saved_tokens: u64) {
607    emit(EventKind::CacheHit {
608        path: path.to_string(),
609        saved_tokens,
610    });
611}
612
613pub fn emit_agent_action(agent_id: &str, action: &str, tool: Option<&str>) {
614    emit(EventKind::AgentAction {
615        agent_id: agent_id.to_string(),
616        action: action.to_string(),
617        tool: tool.map(std::string::ToString::to_string),
618    });
619}
620
621pub fn emit_policy_violation(role: &str, tool: &str, reason: &str) {
622    emit(EventKind::PolicyViolation {
623        role: role.to_string(),
624        tool: tool.to_string(),
625        reason: reason.to_string(),
626    });
627}
628
629pub fn emit_role_changed(from: &str, to: &str) {
630    emit(EventKind::RoleChanged {
631        from: from.to_string(),
632        to: to.to_string(),
633    });
634}
635
636pub fn emit_profile_changed(from: &str, to: &str) {
637    emit(EventKind::ProfileChanged {
638        from: from.to_string(),
639        to: to.to_string(),
640    });
641}
642
643pub fn emit_verification_warning(warning_kind: &str, detail: &str, severity: &str) {
644    emit(EventKind::VerificationWarning {
645        warning_kind: warning_kind.to_string(),
646        detail: detail.to_string(),
647        severity: severity.to_string(),
648    });
649}
650
651pub fn emit_threshold_adapted(language: &str, arm: &str, old_threshold: f64, new_threshold: f64) {
652    emit(EventKind::ThresholdAdapted {
653        language: language.to_string(),
654        arm: arm.to_string(),
655        old_threshold,
656        new_threshold,
657    });
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    /// Every parallel test in this process emits into one shared store, and
665    /// the write lock wait is bounded on purpose (fire-and-forget emitters).
666    /// On a loaded Windows runner that queue can exceed the bound, so only a
667    /// `LockTimeout` is retried; any other persistence failure still fails.
668    fn emit_persisted(kind: &EventKind) -> u64 {
669        use crate::core::context_os::ObservationPersistenceError;
670        // Five backoff attempts (~3 s) still lost the queue on a loaded
671        // windows-latest lib run (2026-10-07, 12k tests in parallel); bound
672        // the retry by wall clock instead.
673        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
674        let mut attempt = 0u32;
675        loop {
676            match try_emit(kind.clone()) {
677                Ok(id) => return id,
678                Err(ObservationPersistenceError::LockTimeout)
679                    if std::time::Instant::now() < deadline =>
680                {
681                    std::thread::sleep(std::time::Duration::from_millis(100 << attempt.min(4)));
682                    attempt += 1;
683                }
684                Err(ObservationPersistenceError::LockTimeout) => {
685                    panic!("canonical persistence: write lock still contended after 30 s");
686                }
687                Err(error) => panic!("canonical persistence: {error:?}"),
688            }
689        }
690    }
691
692    #[test]
693    fn emit_returns_positive_id() {
694        // Keep real persistence, without queueing behind the whole suite's emitters.
695        if crate::test_env::run_in_own_process("core::events::tests::emit_returns_positive_id", &[])
696        {
697            return;
698        }
699        let id = emit_persisted(&EventKind::ToolCall {
700            tool: "ctx_read".to_string(),
701            tokens_original: 1000,
702            tokens_saved: 800,
703            mode: Some("map".to_string()),
704            duration_ms: 5,
705            path: Some("src/main.rs".to_string()),
706        });
707        assert!(id > 0);
708        let events = latest_events(100);
709        assert!(events.iter().any(|e| e.id == id));
710    }
711
712    /// Project an event into the in-memory ring only. The filter tests are
713    /// about `events_since`, not persistence: routing them through the shared
714    /// SQLite observation store made them queue behind every other emitting
715    /// test, and on windows-latest that queue outlasted even a 30 s retry.
716    fn record_in_ring(kind: EventKind) -> u64 {
717        let id = next_event_id();
718        bus().record(LeanCtxEvent {
719            id,
720            timestamp: chrono::Local::now()
721                .format("%Y-%m-%dT%H:%M:%S%.3f")
722                .to_string(),
723            kind,
724        });
725        id
726    }
727
728    #[test]
729    fn events_since_filters_correctly() {
730        let id1 = record_in_ring(EventKind::CacheHit {
731            path: "filter_test_a.rs".to_string(),
732            saved_tokens: 100,
733        });
734        let id2 = record_in_ring(EventKind::CacheHit {
735            path: "filter_test_b.rs".to_string(),
736            saved_tokens: 200,
737        });
738
739        let after = events_since(id1);
740        assert!(after.iter().any(|e| e.id == id2));
741        assert!(after.iter().all(|e| e.id > id1));
742    }
743
744    #[test]
745    fn fallback_ids_stay_unique_and_increasing_when_the_clock_steps_back() {
746        let last = AtomicU64::new(0);
747        let first = fallback_event_id(&last, 1_000);
748        let same_ms = fallback_event_id(&last, 1_000);
749        let stepped_back = fallback_event_id(&last, 990);
750        let later = fallback_event_id(&last, 2_000);
751        assert_eq!(first, 1_000_000);
752        assert!(same_ms > first);
753        assert!(
754            stepped_back > same_ms,
755            "a backwards clock step must not reuse or reorder ids"
756        );
757        assert_eq!(later, 2_000_000);
758    }
759
760    /// The (path, mtime, len) cache must never serve stale events: appending a
761    /// line changes the file length, which has nanosecond-independent
762    /// granularity (unlike mtime), so new events show up on the next poll.
763    /// An isolated journal keeps unrelated canonical emissions from evicting
764    /// these historical fixtures from the merged public reader's result window.
765    #[test]
766    fn load_events_from_file_sees_appended_events() {
767        let directory = tempfile::tempdir().expect("isolated historical journal");
768        let path = directory.path().join("events.jsonl");
769
770        let line_a = r#"{"id":900001,"timestamp":"2026-06-12T08:00:00.000","kind":{"type":"CacheHit","path":"cached_a.rs","saved_tokens":42}}"#;
771        std::fs::write(&path, format!("{line_a}\n")).expect("write events.jsonl");
772
773        let first = load_historical_events(&path).expect("read initial historical events");
774        assert!(
775            first.iter().any(|e| e.id == 900_001),
776            "initial load should parse the seeded event"
777        );
778
779        // Second call with unchanged file exercises the cached branch.
780        let cached = load_historical_events(&path).expect("read cached historical events");
781        assert_eq!(cached.len(), first.len());
782
783        let line_b = r#"{"id":900002,"timestamp":"2026-06-12T08:00:01.000","kind":{"type":"CacheHit","path":"cached_b.rs","saved_tokens":7}}"#;
784        {
785            use std::io::Write;
786            let mut f = std::fs::OpenOptions::new()
787                .append(true)
788                .open(&path)
789                .expect("append events.jsonl");
790            writeln!(f, "{line_b}").expect("append line");
791        }
792
793        let second = load_historical_events(&path).expect("read appended historical events");
794        assert!(
795            second.iter().any(|e| e.id == 900_002),
796            "append must invalidate the cache and surface the new event"
797        );
798    }
799
800    #[test]
801    fn events_jsonl_writer_child() {
802        let Some(path) = std::env::var_os("__LEAN_CTX_EVENTS_TEST_PATH") else {
803            return;
804        };
805        let writer = std::env::var("__LEAN_CTX_EVENTS_TEST_WRITER")
806            .expect("writer id")
807            .parse::<u64>()
808            .expect("numeric writer id");
809        for sequence in 0..100 {
810            let event = LeanCtxEvent {
811                id: writer * 100 + sequence,
812                timestamp: "2026-07-15T12:00:00.000".to_string(),
813                kind: EventKind::CacheHit {
814                    path: format!("writer-{writer}.rs"),
815                    saved_tokens: sequence,
816                },
817            };
818            append_jsonl_at(std::path::Path::new(&path), &event).expect("append event");
819        }
820    }
821
822    fn test_event(id: u64) -> LeanCtxEvent {
823        LeanCtxEvent {
824            id,
825            timestamp: "2026-07-15T12:00:00.000".to_string(),
826            kind: EventKind::CacheHit {
827                path: "event-id-test.rs".to_string(),
828                saved_tokens: 1,
829            },
830        }
831    }
832
833    #[test]
834    fn retired_event_kinds_are_skipped_and_still_reserve_ids() {
835        let dir = tempfile::tempdir().expect("temp dir");
836        let journal = dir.path().join("events.jsonl");
837        let retired = r#"{"id":90,"timestamp":"2026-07-15T12:00:00.000","kind":{"type":"BudgetWarning","role":"coder","dimension":"tokens","used":"1","limit":"2","percent":50}}"#;
838        assert!(is_retired_event_record(retired));
839        let current = serde_json::to_string(&test_event(42)).unwrap();
840        assert!(!is_retired_event_record(&current));
841        std::fs::write(&journal, format!("{current}\n{retired}\n")).unwrap();
842
843        let events = load_historical_events(&journal).expect("retired kinds must not fail reads");
844        assert_eq!(events, vec![test_event(42)]);
845        assert_eq!(max_event_id_in_journal(&journal), 90);
846    }
847
848    #[test]
849    fn concurrent_processes_append_complete_json_lines() {
850        let dir = tempfile::tempdir().expect("temp dir");
851        let path = dir.path().join("events.jsonl");
852        let executable = std::env::current_exe().expect("test executable");
853
854        let mut children = Vec::new();
855        for writer in 0..4 {
856            children.push(
857                std::process::Command::new(&executable)
858                    .args([
859                        "--exact",
860                        "core::events::tests::events_jsonl_writer_child",
861                        "--nocapture",
862                    ])
863                    .env("__LEAN_CTX_EVENTS_TEST_PATH", &path)
864                    .env("__LEAN_CTX_EVENTS_TEST_WRITER", writer.to_string())
865                    .spawn()
866                    .expect("spawn writer"),
867            );
868        }
869        for mut child in children {
870            assert!(child.wait().expect("wait for writer").success());
871        }
872
873        let content = std::fs::read_to_string(&path).expect("read events");
874        let lines: Vec<_> = content.lines().collect();
875        assert_eq!(lines.len(), 400, "every append must produce one line");
876        for line in lines {
877            serde_json::from_str::<LeanCtxEvent>(line)
878                .unwrap_or_else(|error| panic!("invalid JSONL line: {error}: {line}"));
879        }
880    }
881
882    #[test]
883    fn concurrent_processes_serialize_rotation_and_append() {
884        let dir = tempfile::tempdir().expect("temp dir");
885        let path = dir.path().join("events.jsonl");
886        let seed = serde_json::to_string(&LeanCtxEvent {
887            id: 1,
888            timestamp: "2026-07-15T12:00:00.000".to_string(),
889            kind: EventKind::CacheHit {
890                path: "seed.rs".to_string(),
891                saved_tokens: 1,
892            },
893        })
894        .expect("serialize seed");
895        std::fs::write(&path, format!("{seed}\n").repeat(JSONL_MAX_LINES))
896            .expect("seed rotation threshold");
897
898        let executable = std::env::current_exe().expect("test executable");
899        let mut children = Vec::new();
900        for writer in 0..4 {
901            children.push(
902                std::process::Command::new(&executable)
903                    .args(["--exact", "core::events::tests::events_jsonl_writer_child"])
904                    .env("__LEAN_CTX_EVENTS_TEST_PATH", &path)
905                    .env("__LEAN_CTX_EVENTS_TEST_WRITER", writer.to_string())
906                    .spawn()
907                    .expect("spawn writer"),
908            );
909        }
910        for mut child in children {
911            assert!(child.wait().expect("wait for writer").success());
912        }
913
914        let old =
915            std::fs::read_to_string(path.with_extension("jsonl.old")).expect("rotated journal");
916        assert_eq!(old.lines().count(), JSONL_MAX_LINES);
917        assert!(
918            old.lines()
919                .all(|line| serde_json::from_str::<LeanCtxEvent>(line).is_ok()),
920            "rotated journal must contain complete JSON lines"
921        );
922
923        let current = std::fs::read_to_string(&path).expect("current journal");
924        assert_eq!(current.lines().count(), 400);
925        assert!(
926            current
927                .lines()
928                .all(|line| serde_json::from_str::<LeanCtxEvent>(line).is_ok()),
929            "replacement journal must contain complete JSON lines"
930        );
931    }
932
933    #[test]
934    fn persistent_event_id_writer_child() {
935        let Some(dir) = std::env::var_os("__LEAN_CTX_EVENT_ID_TEST_DIR") else {
936            return;
937        };
938        let writer = std::env::var("__LEAN_CTX_EVENT_ID_TEST_WRITER").expect("writer id");
939        let dir = std::path::PathBuf::from(dir);
940        let sequence = dir.join("events.seq");
941        let journal = dir.join("events.jsonl");
942        let (first, last) =
943            reserve_event_id_block_at(&sequence, &journal, 100).expect("reserve event id block");
944        let ids: Vec<String> = (first..=last).map(|id| id.to_string()).collect();
945        std::fs::write(dir.join(format!("writer-{writer}.ids")), ids.join("\n"))
946            .expect("write allocated ids");
947    }
948
949    #[test]
950    fn persistent_event_id_bootstraps_above_existing_journals() {
951        let dir = tempfile::tempdir().expect("temp dir");
952        let sequence = dir.path().join("events.seq");
953        let journal = dir.path().join("events.jsonl");
954        let old = journal.with_extension("jsonl.old");
955        std::fs::write(
956            &journal,
957            format!("{}\n", serde_json::to_string(&test_event(41)).unwrap()),
958        )
959        .unwrap();
960        std::fs::write(
961            &old,
962            format!("{}\n", serde_json::to_string(&test_event(73)).unwrap()),
963        )
964        .unwrap();
965
966        assert_eq!(next_event_id_at(&sequence, &journal).unwrap(), 74);
967        assert_eq!(next_event_id_at(&sequence, &journal).unwrap(), 75);
968    }
969
970    #[test]
971    fn corrupt_sequence_recovers_above_existing_journal() {
972        let dir = tempfile::tempdir().expect("temp dir");
973        let sequence = dir.path().join("events.seq");
974        let journal = dir.path().join("events.jsonl");
975        std::fs::write(&sequence, "76:broken").unwrap();
976        std::fs::write(
977            &journal,
978            format!("{}\n", serde_json::to_string(&test_event(80)).unwrap()),
979        )
980        .unwrap();
981
982        assert_eq!(next_event_id_at(&sequence, &journal).unwrap(), 81);
983        assert_eq!(read_persisted_sequence(&sequence), Some(81));
984    }
985
986    #[test]
987    fn concurrent_processes_allocate_unique_event_ids() {
988        let dir = tempfile::tempdir().expect("temp dir");
989        let executable = std::env::current_exe().expect("test executable");
990        let mut children = Vec::new();
991        for writer in 0..4 {
992            children.push(
993                std::process::Command::new(&executable)
994                    .args([
995                        "--exact",
996                        "core::events::tests::persistent_event_id_writer_child",
997                    ])
998                    .env("__LEAN_CTX_EVENT_ID_TEST_DIR", dir.path())
999                    .env("__LEAN_CTX_EVENT_ID_TEST_WRITER", writer.to_string())
1000                    .spawn()
1001                    .expect("spawn event id writer"),
1002            );
1003        }
1004        for mut child in children {
1005            assert!(child.wait().expect("wait for event id writer").success());
1006        }
1007
1008        let mut ids = Vec::new();
1009        for writer in 0..4 {
1010            let content = std::fs::read_to_string(dir.path().join(format!("writer-{writer}.ids")))
1011                .expect("read allocated ids");
1012            ids.extend(content.lines().map(|line| line.parse::<u64>().unwrap()));
1013        }
1014        ids.sort_unstable();
1015        ids.dedup();
1016        assert_eq!(ids.len(), 400, "every process must receive unique ids");
1017        assert_eq!(ids.first(), Some(&1));
1018        assert_eq!(ids.last(), Some(&400));
1019        let sequence_path = dir.path().join("events.seq");
1020        assert_eq!(read_persisted_sequence(&sequence_path), Some(400));
1021        assert_eq!(
1022            std::fs::read_to_string(sequence_path)
1023                .expect("read sequence journal")
1024                .lines()
1025                .count(),
1026            4,
1027            "four writers should persist four block reservations, not 400 ids"
1028        );
1029    }
1030}