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 {
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
411pub(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
434pub 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 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
484pub 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
496pub 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 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 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
566pub 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 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 fn emit_persisted(kind: &EventKind) -> u64 {
669 use crate::core::context_os::ObservationPersistenceError;
670 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 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 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 #[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 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(¤t));
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}