1use std::collections::{BTreeMap, HashMap, VecDeque};
4use std::fs::File;
5use std::io::{BufRead, Read, Seek, SeekFrom, Write};
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Arc, Mutex, OnceLock, Weak};
9
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12use vtcode_commons::VtCodePaths;
13use vtcode_exec_events::{EVENT_SCHEMA_VERSION, ThreadEvent, ThreadItemDetails, VersionedThreadEvent};
14
15use crate::error::SessionStoreError;
16
17pub(crate) const SESSION_LOCK_FILE: &str = "session.lock";
19use crate::manifest::{ManifestStore, PendingCapRewrite};
20use crate::session_dir;
21
22pub const DEFAULT_MAX_EVENTS: usize = 10_000;
25
26const MAX_WRITE_BUFFER_BYTES: usize = 64 * 1024;
29const MAX_EVICTION_GROUNDED_FACTS: usize = 32;
30const MAX_EVICTION_GROUNDED_FACT_BYTES: usize = 512;
31
32pub type EvictionSummaryHook = Arc<dyn Fn(&[ThreadEvent]) -> Result<(), SessionStoreError> + Send + Sync>;
39
40#[derive(Debug, Deserialize)]
46struct VersionedEventKind<'a> {
47 #[serde(rename = "schema_version", borrow)]
48 _schema_version: &'a str,
49 #[serde(borrow)]
50 event: EventKind<'a>,
51}
52
53#[derive(Debug, Deserialize)]
54struct EventKind<'a> {
55 #[serde(rename = "type", borrow)]
56 kind: &'a str,
57}
58
59#[derive(Serialize)]
66struct BorrowedVersionedEvent<'a> {
67 schema_version: &'a str,
68 event: &'a ThreadEvent,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76enum LifecycleKind {
77 ThreadStarted,
78 ThreadCompleted,
79 TurnStarted,
80 TurnCompleted,
81 TurnFailed,
82 Other,
83}
84
85impl LifecycleKind {
86 #[inline]
88 fn from_event(event: &ThreadEvent) -> Self {
89 match event {
90 ThreadEvent::ThreadStarted(_) => Self::ThreadStarted,
91 ThreadEvent::ThreadCompleted(_) => Self::ThreadCompleted,
92 ThreadEvent::TurnStarted(_) => Self::TurnStarted,
93 ThreadEvent::TurnCompleted(_) => Self::TurnCompleted,
94 ThreadEvent::TurnFailed(_) => Self::TurnFailed,
95 _ => Self::Other,
96 }
97 }
98
99 #[inline]
101 fn from_kind(kind: &str) -> Self {
102 match kind {
103 "thread.started" => Self::ThreadStarted,
104 "thread.completed" => Self::ThreadCompleted,
105 "turn.started" => Self::TurnStarted,
106 "turn.completed" => Self::TurnCompleted,
107 "turn.failed" => Self::TurnFailed,
108 _ => Self::Other,
109 }
110 }
111}
112
113struct LogState {
116 manifest: SessionManifest,
117 index: TurnIndex,
118 in_turn: bool,
122 next_offset: u64,
126 write_buf: Vec<u8>,
129}
130
131#[derive(Debug, Clone, Copy)]
132struct CapEvictionPlan {
133 truncate_offset: u64,
134 evicted_event_count: u64,
135 evicted_turn_count: usize,
136}
137
138impl LogState {
139 fn new(session_id: &str) -> Self {
140 Self {
141 manifest: SessionManifest::new(session_id),
142 index: TurnIndex::default(),
143 in_turn: false,
144 next_offset: 0,
145 write_buf: Vec::with_capacity(65536),
146 }
147 }
148
149 fn serialize_event(&mut self, event: &ThreadEvent) -> Result<(u64, u64), SessionStoreError> {
158 let start = self.next_offset;
159 let buf_len_before = self.write_buf.len();
160 if let Err(err) = serde_json::to_writer(
161 &mut self.write_buf,
162 &BorrowedVersionedEvent { schema_version: EVENT_SCHEMA_VERSION, event },
163 ) {
164 self.write_buf.truncate(buf_len_before);
165 return Err(err.into());
166 }
167 self.write_buf.push(b'\n');
168 let written = self.write_buf.len() - buf_len_before;
169 let end = start + written as u64;
170 self.next_offset = end;
171 Ok((start, end))
172 }
173
174 fn apply_lifecycle_event(&mut self, kind: LifecycleKind, start: u64, end: u64) -> bool {
187 let is_boundary = match kind {
188 LifecycleKind::ThreadStarted => {
189 self.manifest.status = "active".to_string();
190 false
191 }
192 LifecycleKind::ThreadCompleted => {
193 self.manifest.status = "completed".to_string();
194 true
195 }
196 LifecycleKind::TurnStarted => {
197 self.manifest.status = "active".to_string();
198 self.in_turn = true;
199 let n = self.manifest.turn_count + 1;
200 self.index.entries.push_back(TurnIndexEntry {
201 turn_number: n,
202 start_offset: start,
203 end_offset: end,
204 event_count: 1,
205 ts: now_rfc3339(),
206 });
207 false
208 }
209 LifecycleKind::TurnCompleted | LifecycleKind::TurnFailed => {
210 if self.in_turn {
211 if let Some(entry) = self.index.entries.back_mut() {
212 entry.end_offset = end;
213 entry.event_count += 1;
214 self.manifest.turn_count = self.manifest.turn_count.max(entry.turn_number);
219 }
220 self.in_turn = false;
221 }
222 true
223 }
224 LifecycleKind::Other => {
225 if self.in_turn
226 && let Some(entry) = self.index.entries.back_mut()
227 {
228 entry.end_offset = end;
229 entry.event_count += 1;
230 }
231 false
232 }
233 };
234 self.manifest.in_turn = Some(self.in_turn);
239 is_boundary
240 }
241
242 fn plan_cap_eviction(&self, max_events: usize) -> Option<CapEvictionPlan> {
249 if max_events == 0 || self.manifest.event_count <= max_events as u64 {
250 return None;
251 }
252 let mut evicted_event_count = 0u64;
253 let mut truncate_offset = 0u64;
254 let mut evicted_turn_count = 0;
255 for (entry_index, oldest) in self.index.entries.iter().enumerate() {
256 if self.in_turn && entry_index + 1 == self.index.entries.len() {
263 break;
264 }
265 if self.manifest.event_count.saturating_sub(evicted_event_count) <= max_events as u64 {
266 break;
267 }
268 truncate_offset = oldest.end_offset;
269 evicted_event_count += oldest.event_count;
270 evicted_turn_count += 1;
271 }
272 if truncate_offset == 0 || evicted_turn_count == 0 {
273 None
274 } else {
275 Some(CapEvictionPlan {
276 truncate_offset,
277 evicted_event_count,
278 evicted_turn_count,
279 })
280 }
281 }
282
283 fn apply_cap_eviction(&mut self, plan: CapEvictionPlan, next_offset: u64) {
284 for _ in 0..plan.evicted_turn_count {
285 let _ = self.index.entries.pop_front();
286 }
287 for entry in &mut self.index.entries {
288 entry.start_offset = entry.start_offset.saturating_sub(plan.truncate_offset);
289 entry.end_offset = entry.end_offset.saturating_sub(plan.truncate_offset);
290 }
291 self.next_offset = next_offset;
292 self.manifest.event_count = self.manifest.event_count.saturating_sub(plan.evicted_event_count);
293 self.manifest.retained_turn_base = Some(self.retained_turn_base_after_eviction(0));
294 }
295
296 fn retained_turn_base_after_eviction(&self, evicted_turn_count: usize) -> u64 {
297 self.index
298 .entries
299 .get(evicted_turn_count)
300 .map(|entry| entry.turn_number)
301 .or_else(|| self.manifest.turn_count.checked_add(1))
302 .unwrap_or(1)
303 .max(1)
304 }
305}
306
307struct SessionShared {
315 file: Mutex<Option<File>>,
316 state: Mutex<LogState>,
317 eviction_lock: Mutex<()>,
318 initialized: AtomicBool,
319 liveness_lock: Option<File>,
323}
324
325fn shared_session(events_path: &Path, session_id: &str) -> Result<Arc<SessionShared>, SessionStoreError> {
330 static SESSION_SHARED: OnceLock<Mutex<HashMap<PathBuf, Weak<SessionShared>>>> = OnceLock::new();
331
332 let key = events_path
333 .parent()
334 .and_then(|parent| vtcode_commons::paths::canonicalize(parent).ok())
335 .and_then(|parent| events_path.file_name().map(|name| parent.join(name)))
336 .unwrap_or_else(|| events_path.to_path_buf());
337 let registry = SESSION_SHARED.get_or_init(|| Mutex::new(HashMap::new()));
338 let mut shared_by_path = match registry.lock() {
339 Ok(locks) => locks,
340 Err(poisoned) => poisoned.into_inner(),
341 };
342 shared_by_path.retain(|_, shared| shared.strong_count() > 0);
346 if let Some(shared) = shared_by_path.get(&key).and_then(Weak::upgrade) {
347 return Ok(shared);
348 }
349
350 let file = VtCodePaths::open_private_append_file(events_path)
351 .map_err(|error| SessionStoreError::io(events_path.to_path_buf(), std::io::Error::other(error)))?;
352 let shared = Arc::new(SessionShared {
353 file: Mutex::new(Some(file)),
354 state: Mutex::new(LogState::new(session_id)),
355 eviction_lock: Mutex::new(()),
356 initialized: AtomicBool::new(false),
357 liveness_lock: acquire_liveness_lock(events_path),
358 });
359 shared_by_path.insert(key, Arc::downgrade(&shared));
360 Ok(shared)
361}
362
363fn acquire_liveness_lock(events_path: &Path) -> Option<File> {
372 let dir = events_path.parent()?;
373 let lock_path = dir.join(SESSION_LOCK_FILE);
374 VtCodePaths::write_private_file_atomic_if_absent(&lock_path, b"").ok()?;
375 let file = std::fs::OpenOptions::new().read(true).write(true).open(&lock_path).ok()?;
376 match file.try_lock() {
377 Ok(()) => Some(file),
378 Err(_) => None,
379 }
380}
381
382pub struct SessionEventLog {
388 events_path: PathBuf,
389 manifest_store: ManifestStore,
390 shared: Arc<SessionShared>,
391 max_events: usize,
392 eviction_summary_hook: EvictionSummaryHook,
393}
394
395impl SessionEventLog {
396 pub(crate) fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
399 let dir = session_dir(workspace, session_id);
400 let hook = default_eviction_summary_hook(dir.join(crate::DERIVED_DIR), session_id.to_string());
401 Self::open_with_eviction_summary(workspace, session_id, max_events, hook)
402 }
403
404 pub fn open_with_eviction_summary(
410 workspace: &Path,
411 session_id: &str,
412 max_events: usize,
413 eviction_summary_hook: EvictionSummaryHook,
414 ) -> Result<Self, SessionStoreError> {
415 let dir = session_dir(workspace, session_id);
416 crate::ensure_private_directory(&crate::sessions_root(workspace))?;
417 crate::ensure_private_directory(&dir)?;
418 crate::ensure_private_directory(&dir.join(crate::DERIVED_DIR))?;
419 crate::ensure_private_directory(&dir.join("index"))?;
420 let events_path = dir.join("events.jsonl");
421 let manifest_store = ManifestStore::new(dir.clone());
422 let pending_rewrite = manifest_store.load_pending_cap_rewrite()?;
423 let shared = shared_session(&events_path, session_id)?;
424 let log = Self {
425 events_path: events_path.clone(),
426 manifest_store,
427 shared,
428 max_events,
429 eviction_summary_hook,
430 };
431 let _eviction_guard = log.shared.eviction_lock.lock().map_err(poison)?;
432 if !log.shared.initialized.load(Ordering::Acquire) {
435 let manifest_opt = log.manifest_store.load_manifest()?;
436 let index_opt = log.manifest_store.load_turn_index()?;
437 let file_len = log.event_file_metadata_len()?;
438 let pending_rewrite_matches_file = pending_rewrite.as_ref().is_some_and(|pending| {
439 pending.new_file_len == file_len && pending.new_file_len < pending.previous_file_len
440 });
441 match (&manifest_opt, &index_opt) {
442 (Some(manifest), Some(index))
443 if !pending_rewrite_matches_file
444 && manifest.in_turn.is_some()
445 && manifest.persisted_file_len == Some(file_len)
446 && index.is_valid_for_file(file_len)
447 && index.is_consistent_with_manifest(manifest) =>
448 {
449 let mut st = log.shared.state.lock().map_err(poison)?;
450 st.in_turn = manifest.in_turn.unwrap_or(false);
451 st.manifest = manifest.clone();
452 st.index = index.clone();
453 st.next_offset = file_len;
454 }
455 _ => {
456 let scan_turn_base = infer_scan_turn_base(
457 manifest_opt.as_ref(),
458 index_opt.as_ref(),
459 pending_rewrite.as_ref().filter(|_| pending_rewrite_matches_file),
460 file_len,
461 );
462 {
463 let mut st = log.shared.state.lock().map_err(poison)?;
464 if let Some(previous) = manifest_opt.as_ref() {
465 st.manifest = previous.clone();
466 }
467 st.manifest.turn_count = scan_turn_base.saturating_sub(1);
471 st.manifest.event_count = 0;
472 st.manifest.status = "active".to_string();
473 st.manifest.in_turn = Some(false);
474 st.manifest.retained_turn_base = Some(scan_turn_base);
475 st.index = TurnIndex::default();
476 st.in_turn = false;
477 }
478 log.scan()?;
479 let mut st = log.shared.state.lock().map_err(poison)?;
480 st.next_offset = file_len;
481 log.persist_meta_locked(&mut st)?;
482 }
483 }
484 if pending_rewrite.is_some() {
485 log.manifest_store.clear_pending_cap_rewrite()?;
491 }
492 log.shared.initialized.store(true, Ordering::Release);
493 }
494 drop(_eviction_guard);
495 Ok(log)
496 }
497
498 pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
500 let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
501 let mut st = self.shared.state.lock().map_err(poison)?;
502
503 let (start, end) = st.serialize_event(event)?;
507
508 st.manifest.event_count += 1;
509 st.manifest.updated_at = now_rfc3339();
510
511 let is_turn_boundary = st.apply_lifecycle_event(LifecycleKind::from_event(event), start, end);
515 if is_turn_boundary {
516 self.persist_meta_locked(&mut st)?;
517 }
518
519 if st.write_buf.len() >= MAX_WRITE_BUFFER_BYTES {
520 self.persist_meta_locked(&mut st)?;
524 }
525 drop(st);
526 self.enforce_event_cap()
527 }
528
529 fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
533 let mut st = self.shared.state.lock().map_err(poison)?;
534
535 let Some(plan) = st.plan_cap_eviction(self.max_events) else {
538 return Ok(());
539 };
540
541 self.flush_write_buf_locked(&mut st)?;
545
546 let (evicted, mut remaining, previous_file_len) = {
547 let mut file_slot = self.shared.file.lock().map_err(poison)?;
548 let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
549 let file_len = file
550 .metadata()
551 .map_err(|error| SessionStoreError::io(&self.events_path, error))?
552 .len();
553 if plan.truncate_offset > file_len {
554 return Err(SessionStoreError::io(
555 &self.events_path,
556 std::io::Error::new(std::io::ErrorKind::InvalidData, "cap offset exceeds event log length"),
557 ));
558 }
559 file.seek(SeekFrom::Start(0))
560 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
561 let mut evicted = vec![
562 0u8;
563 usize::try_from(plan.truncate_offset).map_err(|error| {
564 SessionStoreError::io(
565 &self.events_path,
566 std::io::Error::new(std::io::ErrorKind::InvalidData, error),
567 )
568 })?
569 ];
570 file.read_exact(&mut evicted)
571 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
572 file.seek(SeekFrom::Start(plan.truncate_offset))
573 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
574 let mut remaining = Vec::new();
575 file.read_to_end(&mut remaining)
576 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
577 (evicted, remaining, file_len)
578 };
579 let retained_turn_base = st.retained_turn_base_after_eviction(plan.evicted_turn_count);
580 drop(st);
581
582 let mut plan = plan;
588 plan.evicted_event_count = count_persisted_event_records(&evicted);
589 let evicted_events = decode_events(&evicted);
590 (self.eviction_summary_hook)(&evicted_events)?;
591
592 let mut matrix_checkpoints = BTreeMap::new();
595 let mut planning_checkpoint = None;
596 for event in &evicted_events {
597 if let ThreadEvent::PlanDelta(delta) = event
598 && delta.recovery.is_some()
599 {
600 planning_checkpoint = Some(event);
601 }
602 if let ThreadEvent::MatrixUpdated(snapshot) = event {
603 matrix_checkpoints.insert(snapshot.spec.id.clone(), event);
604 }
605 }
606 for event in decode_events(&remaining) {
607 if let ThreadEvent::PlanDelta(delta) = &event
608 && delta.recovery.is_some()
609 {
610 planning_checkpoint = None;
611 }
612 if let ThreadEvent::MatrixUpdated(snapshot) = event {
613 matrix_checkpoints.remove(&snapshot.spec.id);
614 }
615 }
616 let mut checkpoint_prefix = Vec::new();
617 for event in matrix_checkpoints.values().copied().chain(planning_checkpoint) {
618 serde_json::to_writer(&mut checkpoint_prefix, &VersionedThreadEvent::new((*event).clone()))
619 .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))?;
620 checkpoint_prefix.push(b'\n');
621 }
622 let checkpoint_bytes = checkpoint_prefix.len() as u64;
623 let checkpoint_count = matrix_checkpoints.len() as u64 + u64::from(planning_checkpoint.is_some());
624 if !checkpoint_prefix.is_empty() {
625 checkpoint_prefix.extend_from_slice(&remaining);
626 remaining = checkpoint_prefix;
627 }
628
629 let new_file_len = u64::try_from(remaining.len()).map_err(|error| {
630 SessionStoreError::io(&self.events_path, std::io::Error::new(std::io::ErrorKind::InvalidData, error))
631 })?;
632 self.manifest_store.write_pending_cap_rewrite(&PendingCapRewrite {
633 previous_file_len,
634 new_file_len,
635 retained_turn_base,
636 })?;
637 let next_offset = self.replace_event_file_contents(&remaining)?;
638 let mut st = self.shared.state.lock().map_err(poison)?;
639 st.apply_cap_eviction(plan, next_offset);
640 for entry in &mut st.index.entries {
641 entry.start_offset += checkpoint_bytes;
642 entry.end_offset += checkpoint_bytes;
643 }
644 st.manifest.event_count += checkpoint_count;
645 self.persist_meta_locked(&mut st)?;
648 self.manifest_store.clear_pending_cap_rewrite()?;
649 Ok(())
650 }
651
652 pub(crate) fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
654 let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
658 let entry = {
659 let st = self.shared.state.lock().map_err(poison)?;
660 st.index
661 .entries
662 .iter()
663 .find(|e| e.turn_number == turn)
664 .cloned()
665 .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
666 };
667 {
668 let mut st = self.shared.state.lock().map_err(poison)?;
669 self.flush_write_buf_locked(&mut st)?;
670 }
671 let buf = {
672 let mut file_slot = self.shared.file.lock().map_err(poison)?;
673 let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
674 file.seek(SeekFrom::Start(entry.start_offset))
675 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
676 let len = usize::try_from(entry.end_offset.checked_sub(entry.start_offset).ok_or_else(|| {
677 SessionStoreError::io(
678 &self.events_path,
679 std::io::Error::new(std::io::ErrorKind::InvalidData, "turn index offsets are out of order"),
680 )
681 })?)
682 .map_err(|error| {
683 SessionStoreError::io(&self.events_path, std::io::Error::new(std::io::ErrorKind::InvalidData, error))
684 })?;
685 let mut buf = vec![0u8; len];
686 file.read_exact(&mut buf)
687 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
688 buf
689 };
690 let text = String::from_utf8_lossy(&buf);
691 let mut events = Vec::new();
692 for line in text.lines() {
693 let line = line.trim();
694 if line.is_empty() {
695 continue;
696 }
697 let v: VersionedThreadEvent = match serde_json::from_str(line) {
703 Ok(v) => v,
704 Err(_) => continue,
705 };
706 events.push(v.into_event());
707 }
708 Ok(events)
709 }
710
711 #[must_use]
713 pub(crate) fn turn_count(&self) -> u64 {
714 self.shared.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
715 }
716
717 #[must_use]
719 pub fn event_count(&self) -> u64 {
720 self.shared.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
721 }
722
723 pub fn visit_snapshot<F>(&self, mut visitor: F) -> Result<SessionManifest, SessionStoreError>
727 where
728 F: FnMut(u64, &[u8]),
729 {
730 use std::io::{BufRead, BufReader};
731 let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
732 let mut st = self.shared.state.lock().map_err(poison)?;
733 self.persist_meta_locked(&mut st)?;
734 let mut file_slot = self.shared.file.lock().map_err(poison)?;
735 let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
736 file.seek(SeekFrom::Start(0))
737 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
738 let mut reader = BufReader::new(file.take(st.next_offset));
739 let mut offset = 0;
740 let mut line = Vec::new();
741 loop {
742 line.clear();
743 let length = reader
744 .read_until(b'\n', &mut line)
745 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
746 if length == 0 {
747 break;
748 }
749 visitor(offset, &line);
750 offset += length as u64;
751 }
752 Ok(st.manifest.clone())
753 }
754
755 pub fn flush(&self) -> Result<(), SessionStoreError> {
757 let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
758 let mut st = self.shared.state.lock().map_err(poison)?;
759 self.persist_meta_locked(&mut st)
760 }
761
762 #[must_use]
764 pub fn manifest(&self) -> SessionManifest {
765 self.shared
766 .state
767 .lock()
768 .map_err(poison)
769 .map(|s| s.manifest.clone())
770 .unwrap_or_else(|_| SessionManifest::new(""))
771 }
772
773 #[must_use]
775 pub fn turn_index(&self) -> TurnIndex {
776 self.shared
777 .state
778 .lock()
779 .map_err(poison)
780 .map(|s| s.index.clone())
781 .unwrap_or_default()
782 }
783
784 pub(crate) fn complete(&self) -> Result<(), SessionStoreError> {
790 let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
791 let mut st = self.shared.state.lock().map_err(poison)?;
792 st.manifest.updated_at = now_rfc3339();
793 self.persist_meta_locked(&mut st)
794 }
795
796 fn scan(&self) -> Result<(), SessionStoreError> {
802 let mut st = self.shared.state.lock().map_err(poison)?;
803 let file = self
804 .shared
805 .file
806 .lock()
807 .map_err(poison)?
808 .as_ref()
809 .ok_or_else(|| self.event_file_unavailable())?
810 .try_clone()
811 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
812 let mut reader = std::io::BufReader::new(file);
813 reader
814 .seek(SeekFrom::Start(0))
815 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
816 let mut buf = Vec::new();
817 let mut pos = 0u64;
818 let mut first_ts: Option<String> = None;
819 loop {
820 buf.clear();
821 let n = reader
822 .read_until(b'\n', &mut buf)
823 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
824 if n == 0 {
825 break;
826 }
827 let line_end = pos + n as u64;
828 let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
829 if !trimmed.is_empty()
830 && let Ok(v) = serde_json::from_str::<VersionedEventKind<'_>>(trimmed)
831 {
832 let kind = v.event.kind;
833 if requires_full_lifecycle_validation(kind) && !valid_lifecycle_payload(kind, trimmed) {
834 pos = line_end;
835 continue;
836 }
837 st.manifest.event_count += 1;
838 if kind == "thread.started" && first_ts.is_none() {
841 first_ts = Some(now_rfc3339());
842 }
843 st.apply_lifecycle_event(LifecycleKind::from_kind(kind), pos, line_end);
846 }
847 pos = line_end;
848 }
849 st.manifest.in_turn = Some(st.in_turn);
853 if let Some(ts) = first_ts
854 && st.manifest.created_at.is_empty()
855 {
856 st.manifest.created_at = ts;
857 }
858 Ok(())
859 }
860
861 fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
862 self.flush_write_buf_locked(st)?;
863 st.manifest.persisted_file_len = Some(st.next_offset);
868 self.manifest_store.write_turn_index(&st.index)?;
873 self.manifest_store.write_manifest(&st.manifest)?;
874 Ok(())
875 }
876
877 fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
879 if st.write_buf.is_empty() {
880 return Ok(());
881 }
882 let mut file_slot = self.shared.file.lock().map_err(poison)?;
883 let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
884 let previous_len = file.metadata().map_err(|e| SessionStoreError::io(&self.events_path, e))?.len();
885 if let Err(error) = file.write_all(&st.write_buf) {
886 if file.set_len(previous_len).is_err() {
887 st.write_buf.clear();
888 }
889 return Err(SessionStoreError::io(&self.events_path, error));
890 }
891 if let Err(error) = file.sync_data() {
892 st.write_buf.clear();
893 return Err(SessionStoreError::io(&self.events_path, error));
894 }
895 st.write_buf.clear();
896 Ok(())
897 }
898
899 fn event_file_metadata_len(&self) -> Result<u64, SessionStoreError> {
900 let file_slot = self.shared.file.lock().map_err(poison)?;
901 file_slot
902 .as_ref()
903 .ok_or_else(|| self.event_file_unavailable())?
904 .metadata()
905 .map(|metadata| metadata.len())
906 .map_err(|error| SessionStoreError::io(&self.events_path, error))
907 }
908
909 fn open_event_file(&self) -> Result<File, SessionStoreError> {
910 VtCodePaths::open_private_append_file(&self.events_path)
911 .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
912 }
913
914 fn replace_event_file_contents(&self, contents: &[u8]) -> Result<u64, SessionStoreError> {
915 let old_file = {
916 let mut file_slot = self.shared.file.lock().map_err(poison)?;
917 file_slot.take().ok_or_else(|| self.event_file_unavailable())?
918 };
919 drop(old_file);
920
921 if let Err(error) = VtCodePaths::write_private_file_atomic(&self.events_path, contents)
922 .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
923 {
924 let restored = self.open_event_file();
925 if let Ok(file) = restored {
926 let mut file_slot = self.shared.file.lock().map_err(poison)?;
927 *file_slot = Some(file);
928 return Err(error);
929 }
930 return Err(SessionStoreError::io(
931 &self.events_path,
932 std::io::Error::other(format!("{error}; failed to restore event log handle")),
933 ));
934 }
935
936 let replacement = self.open_event_file()?;
937 let next_offset = replacement
938 .metadata()
939 .map_err(|error| SessionStoreError::io(&self.events_path, error))?
940 .len();
941 let mut file_slot = self.shared.file.lock().map_err(poison)?;
942 *file_slot = Some(replacement);
943 Ok(next_offset)
944 }
945
946 fn event_file_unavailable(&self) -> SessionStoreError {
947 SessionStoreError::io(&self.events_path, std::io::Error::other("event log file is unavailable"))
948 }
949}
950
951fn infer_scan_turn_base(
960 manifest: Option<&SessionManifest>,
961 index: Option<&TurnIndex>,
962 pending: Option<&PendingCapRewrite>,
963 file_len: u64,
964) -> u64 {
965 let marker_base = pending
966 .filter(|pending| pending.new_file_len == file_len && pending.new_file_len < pending.previous_file_len)
967 .map(|pending| pending.retained_turn_base);
968 let rewritten_base = manifest.and_then(|manifest| {
969 let previous_len = manifest.persisted_file_len?;
970 if previous_len <= file_len {
971 return None;
972 }
973 let removed_prefix = previous_len - file_len;
974 let index = index.filter(|index| index.is_valid_for_file(previous_len))?;
975 let first_retained = index
976 .entries
977 .iter()
978 .find(|entry| entry.start_offset >= removed_prefix && entry.end_offset <= previous_len)
979 .map(|entry| entry.turn_number);
980 first_retained.or_else(|| {
981 (removed_prefix >= previous_len).then(|| manifest.turn_count.saturating_add(1))
986 })
987 });
988
989 let legacy_index_base = index
990 .filter(|index| index.is_valid_for_file(file_len))
991 .and_then(|index| index.entries.front().map(|entry| entry.turn_number));
992
993 marker_base
994 .or(rewritten_base)
995 .or_else(|| manifest.and_then(|manifest| manifest.retained_turn_base))
996 .or(legacy_index_base)
997 .unwrap_or(1)
998 .max(1)
999}
1000
1001fn requires_full_lifecycle_validation(kind: &str) -> bool {
1002 matches!(kind, "thread.started" | "thread.completed" | "turn.started" | "turn.completed" | "turn.failed")
1003}
1004
1005fn valid_lifecycle_payload(kind: &str, line: &str) -> bool {
1006 if serde_json::from_str::<VersionedThreadEvent>(line).is_err() {
1007 return false;
1008 }
1009 if kind != "turn.completed" {
1010 return true;
1011 }
1012 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
1013 return false;
1014 };
1015 value
1016 .get("event")
1017 .and_then(|event| event.get("usage"))
1018 .is_some_and(serde_json::Value::is_object)
1019}
1020
1021fn decode_events(bytes: &[u8]) -> Vec<ThreadEvent> {
1022 bytes
1023 .split(|byte| *byte == b'\n')
1024 .filter_map(|line| {
1025 let line = std::str::from_utf8(line).ok()?.trim();
1026 if line.is_empty() {
1027 return None;
1028 }
1029 serde_json::from_str::<VersionedThreadEvent>(line)
1030 .ok()
1031 .map(VersionedThreadEvent::into_event)
1032 })
1033 .collect()
1034}
1035
1036fn count_persisted_event_records(bytes: &[u8]) -> u64 {
1041 bytes
1042 .split(|byte| *byte == b'\n')
1043 .filter_map(|line| std::str::from_utf8(line).ok())
1044 .map(str::trim)
1045 .filter(|line| !line.is_empty())
1046 .filter(|line| {
1047 let Ok(value) = serde_json::from_str::<VersionedEventKind<'_>>(line) else {
1048 return false;
1049 };
1050 let kind = value.event.kind;
1051 !requires_full_lifecycle_validation(kind) || valid_lifecycle_payload(kind, line)
1052 })
1053 .count() as u64
1054}
1055
1056#[derive(Debug, Serialize)]
1057struct EvictionSummary {
1058 session_id: String,
1059 evicted_event_count: usize,
1060 event_types: BTreeMap<String, u64>,
1061 grounded_facts: Vec<String>,
1062 created_at: String,
1063}
1064
1065fn extract_grounded_facts(events: &[ThreadEvent]) -> Vec<String> {
1072 let mut facts = Vec::new();
1073 for event in events {
1074 let candidates: Vec<String> = match event {
1075 ThreadEvent::ItemCompleted(completed) => item_facts(&completed.item.details),
1076 ThreadEvent::TurnFailed(failed) => vec![failed.message.clone()],
1077 ThreadEvent::TurnBlocked(blocked) => vec![blocked.message.clone()],
1078 ThreadEvent::Error(error) => vec![error.message.clone()],
1079 _ => Vec::new(),
1080 };
1081 for candidate in candidates {
1082 let fact = normalize_eviction_fact(&candidate);
1083 if fact.is_empty() || facts.iter().any(|existing| existing == &fact) {
1084 continue;
1085 }
1086 facts.push(fact);
1087 if facts.len() == MAX_EVICTION_GROUNDED_FACTS {
1088 return facts;
1089 }
1090 }
1091 }
1092 facts
1093}
1094
1095fn item_facts(details: &ThreadItemDetails) -> Vec<String> {
1096 match details {
1097 ThreadItemDetails::AgentMessage(item) => vec![item.text.clone()],
1098 ThreadItemDetails::Plan(item) => vec![item.text.clone()],
1099 ThreadItemDetails::FileChange(item) => item
1100 .changes
1101 .iter()
1102 .map(|change| {
1103 let kind = match change.kind {
1104 vtcode_exec_events::PatchChangeKind::Add => "add",
1105 vtcode_exec_events::PatchChangeKind::Delete => "delete",
1106 vtcode_exec_events::PatchChangeKind::Update => "update",
1107 };
1108 format!("file {kind}: {}", change.path)
1109 })
1110 .collect(),
1111 ThreadItemDetails::Harness(item) => item.message.clone().into_iter().collect(),
1112 _ => Vec::new(),
1113 }
1114}
1115
1116fn normalize_eviction_fact(value: &str) -> String {
1117 let normalized = vtcode_commons::formatting::collapse_whitespace(value);
1118 if normalized.len() <= MAX_EVICTION_GROUNDED_FACT_BYTES {
1119 return normalized;
1120 }
1121 let mut end = MAX_EVICTION_GROUNDED_FACT_BYTES - '…'.len_utf8();
1122 while !normalized.is_char_boundary(end) {
1123 end -= 1;
1124 }
1125 format!("{}…", &normalized[..end])
1126}
1127
1128fn default_eviction_summary_hook(derived_dir: PathBuf, session_id: String) -> EvictionSummaryHook {
1129 Arc::new(move |events| {
1130 let mut event_types = BTreeMap::new();
1131 for event in events {
1132 let kind = serde_json::to_value(event)
1133 .ok()
1134 .and_then(|value| value.get("type").and_then(|value| value.as_str()).map(str::to_owned))
1135 .unwrap_or_else(|| "unknown".to_owned());
1136 *event_types.entry(kind).or_insert(0) += 1;
1137 }
1138 let summary = EvictionSummary {
1139 session_id: session_id.clone(),
1140 evicted_event_count: events.len(),
1141 event_types,
1142 grounded_facts: extract_grounded_facts(events),
1143 created_at: now_rfc3339(),
1144 };
1145 let path = derived_dir.join(format!("eviction-summary-{}.json", uuid::Uuid::new_v4().simple()));
1146 let bytes = serde_json::to_vec(&summary)?;
1147 VtCodePaths::write_private_file_atomic(&path, &bytes)
1148 .map_err(|error| SessionStoreError::io(path, std::io::Error::other(error)))
1149 })
1150}
1151
1152impl Drop for SessionEventLog {
1153 fn drop(&mut self) {
1154 if let Ok(_eviction_guard) = self.shared.eviction_lock.lock()
1155 && let Ok(mut st) = self.shared.state.lock()
1156 {
1157 let _ = self.flush_write_buf_locked(&mut st);
1163 }
1164 }
1165}
1166
1167fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
1169 SessionStoreError::Io {
1170 path: PathBuf::new(),
1171 source: std::io::Error::other("session store lock poisoned"),
1172 }
1173}
1174
1175fn now_rfc3339() -> String {
1176 Utc::now().to_rfc3339()
1177}
1178
1179#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1181pub struct SessionManifest {
1182 pub session_id: String,
1184 schema_version: u32,
1186 pub created_at: String,
1188 pub updated_at: String,
1190 pub turn_count: u64,
1192 pub event_count: u64,
1194 pub status: String,
1196 #[serde(default)]
1202 in_turn: Option<bool>,
1203 #[serde(default)]
1209 persisted_file_len: Option<u64>,
1210 #[serde(default)]
1216 retained_turn_base: Option<u64>,
1217}
1218
1219impl SessionManifest {
1220 #[must_use]
1222 pub fn evicted_turn_count(&self) -> u64 {
1223 self.retained_turn_base.unwrap_or(1).saturating_sub(1)
1224 }
1225 #[must_use]
1227 pub(crate) fn new(session_id: &str) -> Self {
1228 let ts = now_rfc3339();
1229 Self {
1230 session_id: session_id.to_string(),
1231 schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
1232 created_at: ts.clone(),
1233 updated_at: ts,
1234 turn_count: 0,
1235 event_count: 0,
1236 status: "active".to_string(),
1237 in_turn: Some(false),
1238 persisted_file_len: Some(0),
1239 retained_turn_base: Some(1),
1240 }
1241 }
1242}
1243
1244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1246pub struct TurnIndexEntry {
1247 turn_number: u64,
1249 start_offset: u64,
1251 end_offset: u64,
1253 event_count: u64,
1255 ts: String,
1257}
1258
1259#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1261pub struct TurnIndex {
1262 entries: VecDeque<TurnIndexEntry>,
1264}
1265
1266impl TurnIndex {
1267 #[must_use]
1269 pub fn len(&self) -> usize {
1270 self.entries.len()
1271 }
1272
1273 #[must_use]
1275 pub fn is_empty(&self) -> bool {
1276 self.entries.is_empty()
1277 }
1278
1279 fn is_valid_for_file(&self, file_len: u64) -> bool {
1280 let mut previous_end = 0u64;
1281 self.entries.iter().all(|entry| {
1282 let valid = entry.event_count > 0
1283 && entry.start_offset >= previous_end
1284 && entry.start_offset <= entry.end_offset
1285 && entry.end_offset <= file_len;
1286 if valid {
1287 previous_end = entry.end_offset;
1288 }
1289 valid
1290 })
1291 }
1292
1293 fn is_consistent_with_manifest(&self, manifest: &SessionManifest) -> bool {
1294 let expected_last_turn = if manifest.in_turn == Some(true) {
1295 manifest.turn_count.saturating_add(1)
1296 } else {
1297 manifest.turn_count
1298 };
1299 let entries_are_contiguous = self
1300 .entries
1301 .iter()
1302 .map(|entry| entry.turn_number)
1303 .try_fold(None::<u64>, |previous, turn_number| {
1304 if previous.is_some_and(|previous| turn_number != previous.saturating_add(1)) {
1305 return Err(());
1306 }
1307 Ok(Some(turn_number))
1308 })
1309 .is_ok();
1310 if !entries_are_contiguous {
1311 return false;
1312 }
1313
1314 let expected_first_turn = manifest.retained_turn_base.unwrap_or(1).max(1);
1315 match (self.entries.front(), self.entries.back()) {
1316 (Some(first), Some(last)) => {
1317 first.turn_number == expected_first_turn && last.turn_number == expected_last_turn
1318 }
1319 (None, None) => {
1320 expected_last_turn == 0
1321 || manifest
1322 .retained_turn_base
1323 .is_some_and(|retained_turn_base| retained_turn_base > manifest.turn_count)
1324 }
1325 _ => false,
1326 }
1327 }
1328}
1329
1330#[cfg(test)]
1331mod borrowed_envelope_tests {
1332 use super::{BorrowedVersionedEvent, EVENT_SCHEMA_VERSION};
1333 use vtcode_exec_events::{
1334 ThreadEvent, ThreadStartedEvent, TurnCompletedEvent, TurnStartedEvent, Usage, VersionedThreadEvent,
1335 };
1336
1337 #[test]
1341 fn borrowed_envelope_matches_versioned_envelope() {
1342 for event in [
1343 ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "thread".to_string() }),
1344 ThreadEvent::TurnStarted(TurnStartedEvent::default()),
1345 ThreadEvent::TurnCompleted(TurnCompletedEvent {
1346 completed_at: None,
1347 usage: Usage::default(),
1348 in_progress_exec_sessions: Vec::new(),
1349 }),
1350 ] {
1351 let canonical =
1352 serde_json::to_string(&VersionedThreadEvent::new(event.clone())).expect("canonical serialize");
1353 let borrowed = serde_json::to_string(&BorrowedVersionedEvent {
1354 schema_version: EVENT_SCHEMA_VERSION,
1355 event: &event,
1356 })
1357 .expect("borrowed serialize");
1358 assert_eq!(canonical, borrowed, "JSON differs for {event:?}");
1359 }
1360 }
1361}
1362
1363#[cfg(test)]
1364mod lifecycle_state_machine_tests {
1365 use super::{LifecycleKind, LogState};
1366 use vtcode_exec_events::{
1367 ThreadCompletedEvent, ThreadCompletionSubtype, ThreadEvent, ThreadStartedEvent, TurnCompletedEvent,
1368 TurnFailedEvent, TurnStartedEvent, Usage,
1369 };
1370
1371 fn fresh_state() -> LogState {
1372 LogState::new("test-session")
1373 }
1374
1375 #[test]
1376 fn lifecycle_kind_from_event_covers_all_variants() {
1377 assert_eq!(
1378 LifecycleKind::from_event(&ThreadEvent::TurnStarted(TurnStartedEvent::default())),
1379 LifecycleKind::TurnStarted
1380 );
1381 assert_eq!(
1382 LifecycleKind::from_event(&ThreadEvent::TurnCompleted(TurnCompletedEvent {
1383 completed_at: None,
1384 usage: Usage::default(),
1385 in_progress_exec_sessions: Vec::new()
1386 })),
1387 LifecycleKind::TurnCompleted
1388 );
1389 assert_eq!(
1390 LifecycleKind::from_event(&ThreadEvent::TurnFailed(TurnFailedEvent {
1391 completed_at: None,
1392 message: "err".to_string(),
1393 usage: None,
1394 })),
1395 LifecycleKind::TurnFailed
1396 );
1397 assert_eq!(
1398 LifecycleKind::from_event(&ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "x".to_string() })),
1399 LifecycleKind::ThreadStarted
1400 );
1401 assert_eq!(
1402 LifecycleKind::from_event(&ThreadEvent::ThreadCompleted(Box::new(ThreadCompletedEvent {
1403 completed_at: None,
1404 thread_id: "x".to_string(),
1405 session_id: "x".to_string(),
1406 subtype: ThreadCompletionSubtype::Success,
1407 outcome_code: "completed".to_string(),
1408 result: None,
1409 stop_reason: None,
1410 usage: Usage::default(),
1411 total_cost_usd: None,
1412 num_turns: 1,
1413 }))),
1414 LifecycleKind::ThreadCompleted
1415 );
1416 assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
1417 assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
1418 }
1419
1420 #[test]
1421 fn lifecycle_kind_from_str_matches_event_discriminator() {
1422 assert_eq!(LifecycleKind::from_kind("turn.started"), LifecycleKind::TurnStarted);
1423 assert_eq!(LifecycleKind::from_kind("turn.completed"), LifecycleKind::TurnCompleted);
1424 assert_eq!(LifecycleKind::from_kind("turn.failed"), LifecycleKind::TurnFailed);
1425 assert_eq!(LifecycleKind::from_kind("tool.called"), LifecycleKind::Other);
1426 assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
1427 assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
1428 }
1429
1430 #[test]
1431 fn turn_started_pushes_index_entry_and_sets_in_turn() {
1432 let mut st = fresh_state();
1433 st.manifest.status = "completed".to_string();
1434 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1435 assert!(!is_boundary, "TurnStarted is not a turn boundary");
1436 assert!(st.in_turn);
1437 assert_eq!(st.manifest.status, "active");
1438 assert_eq!(st.index.entries.len(), 1);
1439 let entry = &st.index.entries[0];
1440 assert_eq!(entry.turn_number, 1);
1441 assert_eq!(entry.start_offset, 0);
1442 assert_eq!(entry.end_offset, 100);
1443 assert_eq!(entry.event_count, 1);
1444 }
1445
1446 #[test]
1447 fn intermediate_events_extend_current_turn() {
1448 let mut st = fresh_state();
1449 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1450 let is_b1 = st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
1452 let is_b2 = st.apply_lifecycle_event(LifecycleKind::Other, 200, 300);
1453 assert!(!is_b1 && !is_b2);
1454 assert!(st.in_turn);
1455 assert_eq!(st.index.entries.len(), 1);
1456 let entry = &st.index.entries[0];
1457 assert_eq!(entry.end_offset, 300);
1458 assert_eq!(entry.event_count, 3);
1459 }
1460
1461 #[test]
1462 fn turn_completed_closes_turn_and_returns_boundary() {
1463 let mut st = fresh_state();
1464 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1465 st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
1466 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 200, 300);
1467 assert!(is_boundary);
1468 assert!(!st.in_turn);
1469 assert_eq!(st.manifest.turn_count, 1);
1470 assert_eq!(st.manifest.status, "active");
1471 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 300, 400);
1472 assert_eq!(st.manifest.status, "completed");
1473 let entry = &st.index.entries[0];
1474 assert_eq!(entry.end_offset, 300);
1475 assert_eq!(entry.event_count, 3);
1476 }
1477
1478 #[test]
1479 fn turn_failed_closes_turn_without_terminal_thread_status() {
1480 let mut st = fresh_state();
1481 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1482 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnFailed, 100, 200);
1483 assert!(is_boundary);
1484 assert!(!st.in_turn);
1485 assert_eq!(st.manifest.turn_count, 1);
1486 assert_eq!(st.manifest.status, "active");
1487 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 200, 300);
1488 assert_eq!(st.manifest.status, "completed");
1489 }
1490
1491 #[test]
1492 fn turn_completed_without_turn_started_is_idempotent() {
1493 let mut st = fresh_state();
1494 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 0, 100);
1498 assert!(is_boundary);
1499 assert!(!st.in_turn);
1500 assert_eq!(st.manifest.turn_count, 0, "no turn was started");
1501 assert_eq!(st.manifest.status, "active");
1502 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 100, 200);
1503 assert_eq!(st.manifest.status, "completed");
1504 assert!(st.index.entries.is_empty());
1505 }
1506
1507 #[test]
1508 fn multiple_turns_get_incrementing_ordinals() {
1509 let mut st = fresh_state();
1510 for n in 1..=3 {
1511 st.apply_lifecycle_event(LifecycleKind::TurnStarted, n * 100, n * 100 + 50);
1512 st.apply_lifecycle_event(LifecycleKind::TurnCompleted, n * 100 + 50, n * 100 + 100);
1513 }
1514 assert_eq!(st.index.entries.len(), 3);
1515 for (i, entry) in st.index.entries.iter().enumerate() {
1516 assert_eq!(entry.turn_number, (i + 1) as u64);
1517 }
1518 assert_eq!(st.manifest.turn_count, 3);
1519 }
1520}
1521
1522#[cfg(test)]
1523mod cap_eviction_tests {
1524 use super::{LogState, TurnIndexEntry};
1525
1526 fn state_with_turns(turns: usize, events_per_turn: u64) -> LogState {
1529 let mut st = LogState::new("cap-test");
1530 st.manifest.event_count = (turns as u64) * events_per_turn;
1531 let mut offset = 0u64;
1532 for n in 1..=turns {
1533 st.index.entries.push_back(TurnIndexEntry {
1534 turn_number: n as u64,
1535 start_offset: offset,
1536 end_offset: offset + events_per_turn * 10,
1537 event_count: events_per_turn,
1538 ts: "2026-01-01T00:00:00Z".to_string(),
1539 });
1540 offset += events_per_turn * 10;
1541 }
1542 st
1543 }
1544
1545 #[test]
1546 fn no_eviction_when_under_cap() {
1547 let st = state_with_turns(3, 2); assert!(st.plan_cap_eviction(10).is_none());
1549 assert_eq!(st.index.entries.len(), 3, "no turns should be evicted");
1550 }
1551
1552 #[test]
1553 fn no_eviction_when_cap_disabled() {
1554 let st = state_with_turns(5, 2); assert!(st.plan_cap_eviction(0).is_none());
1556 assert_eq!(st.index.entries.len(), 5);
1557 }
1558
1559 #[test]
1560 fn evicts_oldest_turns_to_meet_cap() {
1561 let st = state_with_turns(5, 2);
1563 let plan = st.plan_cap_eviction(6).expect("eviction planned");
1564 assert_eq!(plan.evicted_event_count, 4, "should evict 4 events (2 turns)");
1565 assert_eq!(st.index.entries.len(), 5, "planning must not mutate state");
1566 assert_eq!(plan.truncate_offset, 40); let mut st = st;
1571 st.apply_cap_eviction(plan, 60);
1572 assert_eq!(st.index.entries.len(), 3, "should keep 3 turns");
1573 assert_eq!(st.index.entries[0].turn_number, 3);
1574 assert_eq!(st.index.entries[2].turn_number, 5);
1575 }
1576
1577 #[test]
1578 fn evicts_all_turns_when_cap_smaller_than_one_turn() {
1579 let st = state_with_turns(3, 5);
1583 let plan = st.plan_cap_eviction(3).expect("eviction planned");
1584 assert_eq!(plan.evicted_event_count, 15, "all events evicted");
1585 let mut st = st;
1586 st.apply_cap_eviction(plan, 0);
1587 assert_eq!(st.index.entries.len(), 0);
1588 }
1589}