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.
401///
402/// In a serving process (after [`enable_detached_observations`]) the write is
403/// handed to a dedicated writer thread: a tool handler never waits on the
404/// observation lock or SQLite (#2196), and when the bounded queue is full the
405/// observation is dropped and counted instead of blocking dispatch. Storage
406/// admission still runs here, on the producer's thread and policy view.
407pub fn emit_compatibility(kind: EventKind) -> u64 {
408    let result = if DETACHED_ENABLED.load(Ordering::Acquire) {
409        emit_detached(kind)
410    } else {
411        try_emit(kind)
412    };
413    match result {
414        Ok(id) => id,
415        Err(error) => {
416            log_persistence_failure(&error);
417            0
418        }
419    }
420}
421
422/// [`emit_compatibility`] that reports why persistence failed instead of
423/// collapsing the failure into id `0`. Always synchronous.
424pub(crate) fn try_emit(
425    kind: EventKind,
426) -> Result<u64, crate::core::context_os::ObservationPersistenceError> {
427    persist_admitted(&admit_observation(kind)?)
428}
429
430fn admit_observation(
431    kind: EventKind,
432) -> Result<
433    crate::core::context_os::AdmittedLegacyObservation,
434    crate::core::context_os::ObservationPersistenceError,
435> {
436    let event = LeanCtxEvent {
437        id: next_event_id(),
438        timestamp: chrono::Local::now()
439            .format("%Y-%m-%dT%H:%M:%S%.3f")
440            .to_string(),
441        kind,
442    };
443    crate::core::context_os::ContextBus::admit_legacy_observation(&event)
444}
445
446fn persist_admitted(
447    admitted: &crate::core::context_os::AdmittedLegacyObservation,
448) -> Result<u64, crate::core::context_os::ObservationPersistenceError> {
449    crate::core::context_os::try_runtime().and_then(|runtime| {
450        runtime.bus.append_admitted_legacy_observation(
451            crate::core::context_os::observation::producer_instance(),
452            admitted,
453        )
454    })?;
455    bus().record(admitted.event.clone());
456    Ok(admitted.event.id)
457}
458
459/// A withheld observation is the gateway working as configured, not a fault:
460/// at warn level it flooded the worker log (765 lines in 5 minutes, #2196).
461fn log_persistence_failure(error: &crate::core::context_os::ObservationPersistenceError) {
462    if matches!(
463        error,
464        crate::core::context_os::ObservationPersistenceError::Withheld
465    ) {
466        tracing::debug!("canonical legacy observation persistence skipped: {error}");
467    } else {
468        tracing::warn!("canonical legacy observation persistence failed: {error}");
469    }
470}
471
472// --- Detached persistence for serving processes (#2196) ---
473
474/// Observations a serving process may have waiting for the writer thread.
475const DETACHED_QUEUE_CAPACITY: usize = 4_096;
476
477static DETACHED_ENABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
478static DETACHED_QUEUED: AtomicU64 = AtomicU64::new(0);
479static DETACHED_PERSISTED: AtomicU64 = AtomicU64::new(0);
480static DETACHED_FAILED: AtomicU64 = AtomicU64::new(0);
481static DETACHED_DROPPED: AtomicU64 = AtomicU64::new(0);
482
483enum DetachedMessage {
484    Persist(crate::core::context_os::AdmittedLegacyObservation),
485    Flush(std::sync::mpsc::SyncSender<()>),
486}
487
488/// Counters of the detached observation writer, for doctor and health.
489#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
490pub struct DetachedObservationStats {
491    pub enabled: bool,
492    pub queued: u64,
493    pub persisted: u64,
494    pub failed: u64,
495    pub dropped: u64,
496}
497
498pub fn detached_observation_stats() -> DetachedObservationStats {
499    DetachedObservationStats {
500        enabled: DETACHED_ENABLED.load(Ordering::Acquire),
501        queued: DETACHED_QUEUED.load(Ordering::Relaxed),
502        persisted: DETACHED_PERSISTED.load(Ordering::Relaxed),
503        failed: DETACHED_FAILED.load(Ordering::Relaxed),
504        dropped: DETACHED_DROPPED.load(Ordering::Relaxed),
505    }
506}
507
508/// Route [`emit_compatibility`] through the detached writer for the rest of
509/// this process. Called once by a serving process before it accepts requests;
510/// one-shot CLI commands keep synchronous persistence.
511pub fn enable_detached_observations() {
512    if detached_sender().is_some() {
513        DETACHED_ENABLED.store(true, Ordering::Release);
514    }
515}
516
517fn detached_sender() -> Option<&'static std::sync::mpsc::SyncSender<DetachedMessage>> {
518    static SENDER: OnceLock<Option<std::sync::mpsc::SyncSender<DetachedMessage>>> = OnceLock::new();
519    SENDER
520        .get_or_init(|| {
521            let (sender, receiver) =
522                std::sync::mpsc::sync_channel::<DetachedMessage>(DETACHED_QUEUE_CAPACITY);
523            std::thread::Builder::new()
524                .name("lean-ctx-observations".to_string())
525                .spawn(move || run_detached_writer(&receiver))
526                .map_err(|error| {
527                    tracing::warn!("detached observation writer unavailable: {error}");
528                })
529                .ok()
530                .map(|_| sender)
531        })
532        .as_ref()
533}
534
535fn run_detached_writer(receiver: &std::sync::mpsc::Receiver<DetachedMessage>) {
536    while let Ok(message) = receiver.recv() {
537        match message {
538            DetachedMessage::Persist(admitted) => {
539                // A panic in one write must not end the writer: the queue
540                // behind it would be lost and every later flush would wait
541                // for nothing.
542                let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
543                    persist_with_retry(&admitted)
544                }));
545                match outcome {
546                    Ok(Ok(_)) => {
547                        DETACHED_PERSISTED.fetch_add(1, Ordering::Relaxed);
548                    }
549                    Ok(Err(error)) => {
550                        DETACHED_FAILED.fetch_add(1, Ordering::Relaxed);
551                        log_persistence_failure(&error);
552                    }
553                    Err(_) => {
554                        DETACHED_FAILED.fetch_add(1, Ordering::Relaxed);
555                        tracing::warn!("canonical legacy observation persistence panicked");
556                    }
557                }
558            }
559            DetachedMessage::Flush(done) => {
560                let _ = done.send(());
561            }
562        }
563    }
564}
565
566/// The writer thread may wait where a request may not: one lock timeout
567/// there (another process holding the store) is retried once after a short
568/// pause before the observation is counted as failed. Each attempt already
569/// waits the full lock bound, so more retries would only stall the queue.
570fn persist_with_retry(
571    admitted: &crate::core::context_os::AdmittedLegacyObservation,
572) -> Result<u64, crate::core::context_os::ObservationPersistenceError> {
573    match persist_admitted(admitted) {
574        Err(crate::core::context_os::ObservationPersistenceError::LockTimeout) => {
575            std::thread::sleep(std::time::Duration::from_millis(250));
576            persist_admitted(admitted)
577        }
578        result => result,
579    }
580}
581
582fn emit_detached(
583    kind: EventKind,
584) -> Result<u64, crate::core::context_os::ObservationPersistenceError> {
585    let admitted = admit_observation(kind)?;
586    let id = admitted.event.id;
587    let Some(sender) = detached_sender() else {
588        return persist_admitted(&admitted);
589    };
590    match sender.try_send(DetachedMessage::Persist(admitted)) {
591        Ok(()) => {
592            DETACHED_QUEUED.fetch_add(1, Ordering::Relaxed);
593            Ok(id)
594        }
595        Err(std::sync::mpsc::TrySendError::Full(_)) => {
596            // Dropping one observation is the price of never stalling a
597            // request on a saturated store; the counter keeps it visible.
598            let dropped = DETACHED_DROPPED.fetch_add(1, Ordering::Relaxed) + 1;
599            if dropped.is_power_of_two() {
600                tracing::warn!(
601                    "observation writer saturated; {dropped} observations dropped so far"
602                );
603            }
604            Ok(0)
605        }
606        Err(std::sync::mpsc::TrySendError::Disconnected(DetachedMessage::Persist(admitted))) => {
607            persist_admitted(&admitted)
608        }
609        Err(std::sync::mpsc::TrySendError::Disconnected(DetachedMessage::Flush(_))) => Ok(0),
610    }
611}
612
613/// Wait until every observation queued before this call is persisted, or
614/// `timeout` passes. Returns whether the queue drained. Used at shutdown so a
615/// serving process does not lose its last observations.
616pub fn flush_detached_observations(timeout: std::time::Duration) -> bool {
617    if !DETACHED_ENABLED.load(Ordering::Acquire) {
618        return true;
619    }
620    let Some(sender) = detached_sender() else {
621        return true;
622    };
623    let deadline = std::time::Instant::now() + timeout;
624    let (done, drained) = std::sync::mpsc::sync_channel(1);
625    let mut message = DetachedMessage::Flush(done);
626    loop {
627        match sender.try_send(message) {
628            Ok(()) => break,
629            Err(std::sync::mpsc::TrySendError::Full(returned)) => {
630                if std::time::Instant::now() >= deadline {
631                    return false;
632                }
633                message = returned;
634                std::thread::sleep(std::time::Duration::from_millis(5));
635            }
636            // The writer is gone: whatever it still held is lost.
637            Err(std::sync::mpsc::TrySendError::Disconnected(_)) => return false,
638        }
639    }
640    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
641    drained.recv_timeout(remaining).is_ok()
642}
643
644/// Backward-compatible spelling for the explicit legacy compatibility adapter.
645pub fn emit(kind: EventKind) -> u64 {
646    emit_compatibility(kind)
647}
648
649pub fn events_since(after_id: u64) -> Vec<LeanCtxEvent> {
650    bus().events_since(after_id)
651}
652
653pub fn latest_events(n: usize) -> Vec<LeanCtxEvent> {
654    bus().latest_events(n)
655}
656
657#[derive(Default)]
658struct FileEventCache {
659    path: Option<std::path::PathBuf>,
660    mtime: Option<std::time::SystemTime>,
661    len: u64,
662    events: Vec<LeanCtxEvent>,
663}
664
665fn load_historical_events(
666    path: &std::path::Path,
667) -> Result<Vec<LeanCtxEvent>, crate::core::context_os::ObservationPersistenceError> {
668    static CACHE: OnceLock<Mutex<FileEventCache>> = OnceLock::new();
669    let metadata = match std::fs::metadata(path) {
670        Ok(metadata) => metadata,
671        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
672        Err(error) => return Err(error.into()),
673    };
674    let (mtime, len) = (metadata.modified().ok(), metadata.len());
675    let cache = CACHE.get_or_init(|| Mutex::new(FileEventCache::default()));
676    let mut guard = crate::core::context_os::observation::lock_observation(cache)?;
677    let fresh = guard.path.as_deref() == Some(path) && guard.mtime == mtime && guard.len == len;
678    if !fresh {
679        let content = std::fs::read_to_string(path)?;
680        let events = content
681            .lines()
682            .filter(|line| !line.trim().is_empty() && !is_retired_event_record(line))
683            .map(serde_json::from_str)
684            .collect::<Result<Vec<_>, _>>()?;
685        // Replace the cache only after every nonempty record validates.
686        guard.events = events;
687        guard.path = Some(path.to_path_buf());
688        guard.mtime = mtime;
689        guard.len = len;
690    }
691    Ok(guard.events.clone())
692}
693
694/// Compatibility reader: an unavailable canonical store returns no events,
695/// never apparently current history. Use the fallible API to inspect the error.
696pub fn load_events_from_file(n: usize) -> Vec<LeanCtxEvent> {
697    match try_load_events_from_file(n) {
698        Ok(events) => events,
699        Err(error) => {
700            tracing::warn!("canonical event history unavailable: {error}");
701            Vec::new()
702        }
703    }
704}
705
706/// [`load_events_from_file`] for a surface that SHOWS events — paths, names,
707/// free text — to a host or a model, for a reader working in `project` (the
708/// project of the running request when `None`). Every event passes the
709/// serve-time rule of the observation store (`ObservationGate`): an event
710/// that names a file is shown only while that file is admitted now; one
711/// without a source on record follows the originless rule. Historical JSONL
712/// records and rows written before admission existed are asked the same way.
713/// Counts and timestamps need no gate: use [`load_events_from_file`].
714///
715/// With a reader project the whole call — the admission snapshot, every
716/// payload check and every source read — runs inside ONE pinned policy view
717/// of that project (`policy::runtime::with_project_source_view`): every read
718/// sees the same project policy and role, and the view compares both again
719/// when it ends. A view that cannot be opened, that is not the view of the
720/// reader's project, or that reports a change shows NOTHING for this call.
721/// A reader without a project has no view; only the originless rule applies.
722pub fn load_served_events(n: usize, project: Option<&std::path::Path>) -> Vec<LeanCtxEvent> {
723    use crate::core::context_os::observation::ObservationGate;
724
725    let request = crate::core::admitted_sources::request_root();
726    let reader = project
727        .map(|root| root.to_string_lossy().into_owned())
728        .or_else(|| (!request.is_empty()).then_some(request));
729    let Some(reader) = reader else {
730        return served_through(ObservationGate::for_project(None), n);
731    };
732    crate::core::policy::runtime::with_project_source_view(&reader, || {
733        // The view pins the project the request is already bound to, if any.
734        // The gate reads under that project; a reader that asked for another
735        // one is not served from it.
736        // Compared in the view's own spelling: it pins `Path::canonicalize`
737        // of the project (`policy::runtime::canonical_project_root`).
738        let pinned = crate::core::admitted_sources::request_root();
739        let same = std::path::Path::new(&reader)
740            .canonicalize()
741            .is_ok_and(|asked| asked == std::path::Path::new(&pinned));
742        if same {
743            served_through(ObservationGate::for_project(None), n)
744        } else {
745            Vec::new()
746        }
747    })
748    .unwrap_or_else(|error| {
749        tracing::debug!("[events] nothing shown: {error}");
750        Vec::new()
751    })
752}
753
754fn served_through(
755    mut gate: crate::core::context_os::observation::ObservationGate,
756    n: usize,
757) -> Vec<LeanCtxEvent> {
758    let mut events = load_events_from_file(n);
759    events.retain(|event| gate.serves(event));
760    // The view pins project policy and role, not the gateway rules: those are
761    // compared once more before anything is returned. Changed mid-call:
762    // nothing is shown.
763    if !gate.gateway_unchanged() {
764        tracing::debug!("[events] nothing shown: gateway rules changed during the call");
765        return Vec::new();
766    }
767    if gate.withheld() > 0 {
768        tracing::debug!(
769            "[events] {} observation(s) withheld: no current source authority",
770            gate.withheld()
771        );
772    }
773    events
774}
775
776/// Merge immutable historical JSONL with validated canonical observations.
777pub fn try_load_events_from_file(
778    n: usize,
779) -> Result<Vec<LeanCtxEvent>, crate::core::context_os::ObservationPersistenceError> {
780    let mut events = jsonl_path()
781        .map(|path| load_historical_events(&path))
782        .transpose()?
783        .unwrap_or_default();
784
785    let compatibility = crate::core::context_os::try_runtime()?
786        .bus
787        .read_legacy_observations(n.max(1))?;
788    events.extend(compatibility);
789    // Historical file order predates the canonical cursor. Block-allocated
790    // IDs from concurrent producers do not encode append order.
791    // Never discard distinct records just because historical/fallback IDs
792    // collide. The legacy projection omits producer identity, so even equal
793    // projected payloads cannot establish that two observations are duplicates.
794    let start = events.len().saturating_sub(n);
795    Ok(events[start..].to_vec())
796}
797
798pub fn emit_tool_call(
799    tool: &str,
800    tokens_original: u64,
801    tokens_saved: u64,
802    mode: Option<String>,
803    duration_ms: u64,
804    path: Option<String>,
805) {
806    emit(EventKind::ToolCall {
807        tool: tool.to_string(),
808        tokens_original,
809        tokens_saved,
810        mode,
811        duration_ms,
812        path,
813    });
814}
815
816pub fn emit_cache_hit(path: &str, saved_tokens: u64) {
817    emit(EventKind::CacheHit {
818        path: path.to_string(),
819        saved_tokens,
820    });
821}
822
823pub fn emit_agent_action(agent_id: &str, action: &str, tool: Option<&str>) {
824    emit(EventKind::AgentAction {
825        agent_id: agent_id.to_string(),
826        action: action.to_string(),
827        tool: tool.map(std::string::ToString::to_string),
828    });
829}
830
831pub fn emit_policy_violation(role: &str, tool: &str, reason: &str) {
832    emit(EventKind::PolicyViolation {
833        role: role.to_string(),
834        tool: tool.to_string(),
835        reason: reason.to_string(),
836    });
837}
838
839pub fn emit_role_changed(from: &str, to: &str) {
840    emit(EventKind::RoleChanged {
841        from: from.to_string(),
842        to: to.to_string(),
843    });
844}
845
846pub fn emit_profile_changed(from: &str, to: &str) {
847    emit(EventKind::ProfileChanged {
848        from: from.to_string(),
849        to: to.to_string(),
850    });
851}
852
853pub fn emit_verification_warning(warning_kind: &str, detail: &str, severity: &str) {
854    emit(EventKind::VerificationWarning {
855        warning_kind: warning_kind.to_string(),
856        detail: detail.to_string(),
857        severity: severity.to_string(),
858    });
859}
860
861pub fn emit_threshold_adapted(language: &str, arm: &str, old_threshold: f64, new_threshold: f64) {
862    emit(EventKind::ThresholdAdapted {
863        language: language.to_string(),
864        arm: arm.to_string(),
865        old_threshold,
866        new_threshold,
867    });
868}
869
870#[cfg(test)]
871mod tests {
872    use super::*;
873
874    /// Every parallel test in this process emits into one shared store, and
875    /// the write lock wait is bounded on purpose (fire-and-forget emitters).
876    /// On a loaded Windows runner that queue can exceed the bound, so only a
877    /// `LockTimeout` is retried; any other persistence failure still fails.
878    fn emit_persisted(kind: &EventKind) -> u64 {
879        use crate::core::context_os::ObservationPersistenceError;
880        // Five backoff attempts (~3 s) still lost the queue on a loaded
881        // windows-latest lib run (2026-10-07, 12k tests in parallel); bound
882        // the retry by wall clock instead.
883        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
884        let mut attempt = 0u32;
885        loop {
886            match try_emit(kind.clone()) {
887                Ok(id) => return id,
888                Err(ObservationPersistenceError::LockTimeout)
889                    if std::time::Instant::now() < deadline =>
890                {
891                    std::thread::sleep(std::time::Duration::from_millis(100 << attempt.min(4)));
892                    attempt += 1;
893                }
894                Err(ObservationPersistenceError::LockTimeout) => {
895                    panic!("canonical persistence: write lock still contended after 30 s");
896                }
897                Err(error) => panic!("canonical persistence: {error:?}"),
898            }
899        }
900    }
901
902    #[test]
903    fn emit_returns_positive_id() {
904        // Keep real persistence, without queueing behind the whole suite's emitters.
905        if crate::test_env::run_in_own_process("core::events::tests::emit_returns_positive_id", &[])
906        {
907            return;
908        }
909        let id = emit_persisted(&EventKind::ToolCall {
910            tool: "ctx_read".to_string(),
911            tokens_original: 1000,
912            tokens_saved: 800,
913            mode: Some("map".to_string()),
914            duration_ms: 5,
915            path: Some("src/main.rs".to_string()),
916        });
917        assert!(id > 0);
918        let events = latest_events(100);
919        assert!(events.iter().any(|e| e.id == id));
920    }
921
922    /// #2196: a tool handler emitting an observation must not wait on the
923    /// observation writer. With the writer held (a long transaction or a
924    /// contended store), the synchronous path blocks for the full lock bound
925    /// and fails; a serving process returns at once and persists the
926    /// observation after the writer is released.
927    #[test]
928    fn serving_emit_does_not_wait_for_a_held_observation_lock() {
929        if crate::test_env::run_in_own_process(
930            "core::events::tests::serving_emit_does_not_wait_for_a_held_observation_lock",
931            &[],
932        ) {
933            return;
934        }
935        let kind = || EventKind::ToolCall {
936            tool: "ctx_read".to_string(),
937            tokens_original: 10,
938            tokens_saved: 5,
939            mode: Some("full".to_string()),
940            duration_ms: 1,
941            path: None,
942        };
943        let runtime = crate::core::context_os::try_runtime().expect("runtime");
944        let writer = runtime.bus.hold_observation_writer();
945
946        // The pre-#2196 dispatch path: the caller sleeps out the lock bound.
947        let started = std::time::Instant::now();
948        let blocked = try_emit(kind());
949        let synchronous_wait = started.elapsed();
950        assert!(
951            matches!(
952                blocked,
953                Err(crate::core::context_os::ObservationPersistenceError::LockTimeout)
954            ),
955            "synchronous emit with the writer held: {blocked:?}"
956        );
957        assert!(synchronous_wait >= std::time::Duration::from_secs(4));
958
959        enable_detached_observations();
960        let started = std::time::Instant::now();
961        let id = emit_compatibility(kind());
962        let detached_wait = started.elapsed();
963        assert!(id > 0, "the observation was admitted and queued");
964        assert!(
965            detached_wait < std::time::Duration::from_secs(1),
966            "dispatch waited {detached_wait:?} on a held observation lock"
967        );
968        assert!(!latest_events(1_000).iter().any(|e| e.id == id));
969
970        drop(writer);
971        assert!(flush_detached_observations(std::time::Duration::from_secs(
972            30
973        )));
974        assert!(latest_events(1_000).iter().any(|e| e.id == id));
975        let stats = detached_observation_stats();
976        assert_eq!((stats.queued, stats.persisted, stats.dropped), (1, 1, 0));
977    }
978
979    /// Project an event into the in-memory ring only. The filter tests are
980    /// about `events_since`, not persistence: routing them through the shared
981    /// SQLite observation store made them queue behind every other emitting
982    /// test, and on windows-latest that queue outlasted even a 30 s retry.
983    fn record_in_ring(kind: EventKind) -> u64 {
984        let id = next_event_id();
985        bus().record(LeanCtxEvent {
986            id,
987            timestamp: chrono::Local::now()
988                .format("%Y-%m-%dT%H:%M:%S%.3f")
989                .to_string(),
990            kind,
991        });
992        id
993    }
994
995    #[test]
996    fn events_since_filters_correctly() {
997        let id1 = record_in_ring(EventKind::CacheHit {
998            path: "filter_test_a.rs".to_string(),
999            saved_tokens: 100,
1000        });
1001        let id2 = record_in_ring(EventKind::CacheHit {
1002            path: "filter_test_b.rs".to_string(),
1003            saved_tokens: 200,
1004        });
1005
1006        let after = events_since(id1);
1007        assert!(after.iter().any(|e| e.id == id2));
1008        assert!(after.iter().all(|e| e.id > id1));
1009    }
1010
1011    #[test]
1012    fn fallback_ids_stay_unique_and_increasing_when_the_clock_steps_back() {
1013        let last = AtomicU64::new(0);
1014        let first = fallback_event_id(&last, 1_000);
1015        let same_ms = fallback_event_id(&last, 1_000);
1016        let stepped_back = fallback_event_id(&last, 990);
1017        let later = fallback_event_id(&last, 2_000);
1018        assert_eq!(first, 1_000_000);
1019        assert!(same_ms > first);
1020        assert!(
1021            stepped_back > same_ms,
1022            "a backwards clock step must not reuse or reorder ids"
1023        );
1024        assert_eq!(later, 2_000_000);
1025    }
1026
1027    /// The (path, mtime, len) cache must never serve stale events: appending a
1028    /// line changes the file length, which has nanosecond-independent
1029    /// granularity (unlike mtime), so new events show up on the next poll.
1030    /// An isolated journal keeps unrelated canonical emissions from evicting
1031    /// these historical fixtures from the merged public reader's result window.
1032    #[test]
1033    fn load_events_from_file_sees_appended_events() {
1034        let directory = tempfile::tempdir().expect("isolated historical journal");
1035        let path = directory.path().join("events.jsonl");
1036
1037        let line_a = r#"{"id":900001,"timestamp":"2026-06-12T08:00:00.000","kind":{"type":"CacheHit","path":"cached_a.rs","saved_tokens":42}}"#;
1038        std::fs::write(&path, format!("{line_a}\n")).expect("write events.jsonl");
1039
1040        let first = load_historical_events(&path).expect("read initial historical events");
1041        assert!(
1042            first.iter().any(|e| e.id == 900_001),
1043            "initial load should parse the seeded event"
1044        );
1045
1046        // Second call with unchanged file exercises the cached branch.
1047        let cached = load_historical_events(&path).expect("read cached historical events");
1048        assert_eq!(cached.len(), first.len());
1049
1050        let line_b = r#"{"id":900002,"timestamp":"2026-06-12T08:00:01.000","kind":{"type":"CacheHit","path":"cached_b.rs","saved_tokens":7}}"#;
1051        {
1052            use std::io::Write;
1053            let mut f = std::fs::OpenOptions::new()
1054                .append(true)
1055                .open(&path)
1056                .expect("append events.jsonl");
1057            writeln!(f, "{line_b}").expect("append line");
1058        }
1059
1060        let second = load_historical_events(&path).expect("read appended historical events");
1061        assert!(
1062            second.iter().any(|e| e.id == 900_002),
1063            "append must invalidate the cache and surface the new event"
1064        );
1065    }
1066
1067    #[test]
1068    fn events_jsonl_writer_child() {
1069        let Some(path) = std::env::var_os("__LEAN_CTX_EVENTS_TEST_PATH") else {
1070            return;
1071        };
1072        let writer = std::env::var("__LEAN_CTX_EVENTS_TEST_WRITER")
1073            .expect("writer id")
1074            .parse::<u64>()
1075            .expect("numeric writer id");
1076        for sequence in 0..100 {
1077            let event = LeanCtxEvent {
1078                id: writer * 100 + sequence,
1079                timestamp: "2026-07-15T12:00:00.000".to_string(),
1080                kind: EventKind::CacheHit {
1081                    path: format!("writer-{writer}.rs"),
1082                    saved_tokens: sequence,
1083                },
1084            };
1085            append_jsonl_at(std::path::Path::new(&path), &event).expect("append event");
1086        }
1087    }
1088
1089    fn test_event(id: u64) -> LeanCtxEvent {
1090        LeanCtxEvent {
1091            id,
1092            timestamp: "2026-07-15T12:00:00.000".to_string(),
1093            kind: EventKind::CacheHit {
1094                path: "event-id-test.rs".to_string(),
1095                saved_tokens: 1,
1096            },
1097        }
1098    }
1099
1100    #[test]
1101    fn retired_event_kinds_are_skipped_and_still_reserve_ids() {
1102        let dir = tempfile::tempdir().expect("temp dir");
1103        let journal = dir.path().join("events.jsonl");
1104        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}}"#;
1105        assert!(is_retired_event_record(retired));
1106        let current = serde_json::to_string(&test_event(42)).unwrap();
1107        assert!(!is_retired_event_record(&current));
1108        std::fs::write(&journal, format!("{current}\n{retired}\n")).unwrap();
1109
1110        let events = load_historical_events(&journal).expect("retired kinds must not fail reads");
1111        assert_eq!(events, vec![test_event(42)]);
1112        assert_eq!(max_event_id_in_journal(&journal), 90);
1113    }
1114
1115    #[test]
1116    fn concurrent_processes_append_complete_json_lines() {
1117        let dir = tempfile::tempdir().expect("temp dir");
1118        let path = dir.path().join("events.jsonl");
1119        let executable = std::env::current_exe().expect("test executable");
1120
1121        let mut children = Vec::new();
1122        for writer in 0..4 {
1123            children.push(
1124                std::process::Command::new(&executable)
1125                    .args([
1126                        "--exact",
1127                        "core::events::tests::events_jsonl_writer_child",
1128                        "--nocapture",
1129                    ])
1130                    .env("__LEAN_CTX_EVENTS_TEST_PATH", &path)
1131                    .env("__LEAN_CTX_EVENTS_TEST_WRITER", writer.to_string())
1132                    .spawn()
1133                    .expect("spawn writer"),
1134            );
1135        }
1136        for mut child in children {
1137            assert!(child.wait().expect("wait for writer").success());
1138        }
1139
1140        let content = std::fs::read_to_string(&path).expect("read events");
1141        let lines: Vec<_> = content.lines().collect();
1142        assert_eq!(lines.len(), 400, "every append must produce one line");
1143        for line in lines {
1144            serde_json::from_str::<LeanCtxEvent>(line)
1145                .unwrap_or_else(|error| panic!("invalid JSONL line: {error}: {line}"));
1146        }
1147    }
1148
1149    #[test]
1150    fn concurrent_processes_serialize_rotation_and_append() {
1151        let dir = tempfile::tempdir().expect("temp dir");
1152        let path = dir.path().join("events.jsonl");
1153        let seed = serde_json::to_string(&LeanCtxEvent {
1154            id: 1,
1155            timestamp: "2026-07-15T12:00:00.000".to_string(),
1156            kind: EventKind::CacheHit {
1157                path: "seed.rs".to_string(),
1158                saved_tokens: 1,
1159            },
1160        })
1161        .expect("serialize seed");
1162        std::fs::write(&path, format!("{seed}\n").repeat(JSONL_MAX_LINES))
1163            .expect("seed rotation threshold");
1164
1165        let executable = std::env::current_exe().expect("test executable");
1166        let mut children = Vec::new();
1167        for writer in 0..4 {
1168            children.push(
1169                std::process::Command::new(&executable)
1170                    .args(["--exact", "core::events::tests::events_jsonl_writer_child"])
1171                    .env("__LEAN_CTX_EVENTS_TEST_PATH", &path)
1172                    .env("__LEAN_CTX_EVENTS_TEST_WRITER", writer.to_string())
1173                    .spawn()
1174                    .expect("spawn writer"),
1175            );
1176        }
1177        for mut child in children {
1178            assert!(child.wait().expect("wait for writer").success());
1179        }
1180
1181        let old =
1182            std::fs::read_to_string(path.with_extension("jsonl.old")).expect("rotated journal");
1183        assert_eq!(old.lines().count(), JSONL_MAX_LINES);
1184        assert!(
1185            old.lines()
1186                .all(|line| serde_json::from_str::<LeanCtxEvent>(line).is_ok()),
1187            "rotated journal must contain complete JSON lines"
1188        );
1189
1190        let current = std::fs::read_to_string(&path).expect("current journal");
1191        assert_eq!(current.lines().count(), 400);
1192        assert!(
1193            current
1194                .lines()
1195                .all(|line| serde_json::from_str::<LeanCtxEvent>(line).is_ok()),
1196            "replacement journal must contain complete JSON lines"
1197        );
1198    }
1199
1200    #[test]
1201    fn persistent_event_id_writer_child() {
1202        let Some(dir) = std::env::var_os("__LEAN_CTX_EVENT_ID_TEST_DIR") else {
1203            return;
1204        };
1205        let writer = std::env::var("__LEAN_CTX_EVENT_ID_TEST_WRITER").expect("writer id");
1206        let dir = std::path::PathBuf::from(dir);
1207        let sequence = dir.join("events.seq");
1208        let journal = dir.join("events.jsonl");
1209        let (first, last) =
1210            reserve_event_id_block_at(&sequence, &journal, 100).expect("reserve event id block");
1211        let ids: Vec<String> = (first..=last).map(|id| id.to_string()).collect();
1212        std::fs::write(dir.join(format!("writer-{writer}.ids")), ids.join("\n"))
1213            .expect("write allocated ids");
1214    }
1215
1216    #[test]
1217    fn persistent_event_id_bootstraps_above_existing_journals() {
1218        let dir = tempfile::tempdir().expect("temp dir");
1219        let sequence = dir.path().join("events.seq");
1220        let journal = dir.path().join("events.jsonl");
1221        let old = journal.with_extension("jsonl.old");
1222        std::fs::write(
1223            &journal,
1224            format!("{}\n", serde_json::to_string(&test_event(41)).unwrap()),
1225        )
1226        .unwrap();
1227        std::fs::write(
1228            &old,
1229            format!("{}\n", serde_json::to_string(&test_event(73)).unwrap()),
1230        )
1231        .unwrap();
1232
1233        assert_eq!(next_event_id_at(&sequence, &journal).unwrap(), 74);
1234        assert_eq!(next_event_id_at(&sequence, &journal).unwrap(), 75);
1235    }
1236
1237    #[test]
1238    fn corrupt_sequence_recovers_above_existing_journal() {
1239        let dir = tempfile::tempdir().expect("temp dir");
1240        let sequence = dir.path().join("events.seq");
1241        let journal = dir.path().join("events.jsonl");
1242        std::fs::write(&sequence, "76:broken").unwrap();
1243        std::fs::write(
1244            &journal,
1245            format!("{}\n", serde_json::to_string(&test_event(80)).unwrap()),
1246        )
1247        .unwrap();
1248
1249        assert_eq!(next_event_id_at(&sequence, &journal).unwrap(), 81);
1250        assert_eq!(read_persisted_sequence(&sequence), Some(81));
1251    }
1252
1253    #[test]
1254    fn concurrent_processes_allocate_unique_event_ids() {
1255        let dir = tempfile::tempdir().expect("temp dir");
1256        let executable = std::env::current_exe().expect("test executable");
1257        let mut children = Vec::new();
1258        for writer in 0..4 {
1259            children.push(
1260                std::process::Command::new(&executable)
1261                    .args([
1262                        "--exact",
1263                        "core::events::tests::persistent_event_id_writer_child",
1264                    ])
1265                    .env("__LEAN_CTX_EVENT_ID_TEST_DIR", dir.path())
1266                    .env("__LEAN_CTX_EVENT_ID_TEST_WRITER", writer.to_string())
1267                    .spawn()
1268                    .expect("spawn event id writer"),
1269            );
1270        }
1271        for mut child in children {
1272            assert!(child.wait().expect("wait for event id writer").success());
1273        }
1274
1275        let mut ids = Vec::new();
1276        for writer in 0..4 {
1277            let content = std::fs::read_to_string(dir.path().join(format!("writer-{writer}.ids")))
1278                .expect("read allocated ids");
1279            ids.extend(content.lines().map(|line| line.parse::<u64>().unwrap()));
1280        }
1281        ids.sort_unstable();
1282        ids.dedup();
1283        assert_eq!(ids.len(), 400, "every process must receive unique ids");
1284        assert_eq!(ids.first(), Some(&1));
1285        assert_eq!(ids.last(), Some(&400));
1286        let sequence_path = dir.path().join("events.seq");
1287        assert_eq!(read_persisted_sequence(&sequence_path), Some(400));
1288        assert_eq!(
1289            std::fs::read_to_string(sequence_path)
1290                .expect("read sequence journal")
1291                .lines()
1292                .count(),
1293            4,
1294            "four writers should persist four block reservations, not 400 ids"
1295        );
1296    }
1297}