1use 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
87const RETIRED_EVENT_KINDS: &[&str] = &[
92 "BudgetWarning",
93 "BudgetExhausted",
94 "SloViolation",
95 "Anomaly",
96];
97
98pub(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#[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 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 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
313fn 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 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 file.write_all(&line)
389 })();
390
391 let _ = FileExt::unlock(&lock);
392 result
393}
394
395pub 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
422pub(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
459fn 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
472const 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#[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
508pub 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 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
566fn 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 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
613pub 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 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
644pub 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 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
694pub 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
706pub 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 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 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
776pub 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 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 fn emit_persisted(kind: &EventKind) -> u64 {
879 use crate::core::context_os::ObservationPersistenceError;
880 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 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 #[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 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 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 #[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 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(¤t));
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}