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;
16use crate::manifest::{ManifestStore, PendingCapRewrite};
17use crate::session_dir;
18
19pub const DEFAULT_MAX_EVENTS: usize = 10_000;
22
23const MAX_WRITE_BUFFER_BYTES: usize = 64 * 1024;
26const MAX_EVICTION_GROUNDED_FACTS: usize = 32;
27const MAX_EVICTION_GROUNDED_FACT_BYTES: usize = 512;
28
29pub type EvictionSummaryHook = Arc<dyn Fn(&[ThreadEvent]) -> Result<(), SessionStoreError> + Send + Sync>;
36
37#[derive(Debug, Deserialize)]
43struct VersionedEventKind<'a> {
44 #[serde(rename = "schema_version", borrow)]
45 _schema_version: &'a str,
46 #[serde(borrow)]
47 event: EventKind<'a>,
48}
49
50#[derive(Debug, Deserialize)]
51struct EventKind<'a> {
52 #[serde(rename = "type", borrow)]
53 kind: &'a str,
54}
55
56#[derive(Serialize)]
63struct BorrowedVersionedEvent<'a> {
64 schema_version: &'a str,
65 event: &'a ThreadEvent,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73enum LifecycleKind {
74 ThreadStarted,
75 ThreadCompleted,
76 TurnStarted,
77 TurnCompleted,
78 TurnFailed,
79 Other,
80}
81
82impl LifecycleKind {
83 #[inline]
85 fn from_event(event: &ThreadEvent) -> Self {
86 match event {
87 ThreadEvent::ThreadStarted(_) => Self::ThreadStarted,
88 ThreadEvent::ThreadCompleted(_) => Self::ThreadCompleted,
89 ThreadEvent::TurnStarted(_) => Self::TurnStarted,
90 ThreadEvent::TurnCompleted(_) => Self::TurnCompleted,
91 ThreadEvent::TurnFailed(_) => Self::TurnFailed,
92 _ => Self::Other,
93 }
94 }
95
96 #[inline]
98 fn from_kind(kind: &str) -> Self {
99 match kind {
100 "thread.started" => Self::ThreadStarted,
101 "thread.completed" => Self::ThreadCompleted,
102 "turn.started" => Self::TurnStarted,
103 "turn.completed" => Self::TurnCompleted,
104 "turn.failed" => Self::TurnFailed,
105 _ => Self::Other,
106 }
107 }
108}
109
110struct LogState {
113 manifest: SessionManifest,
114 index: TurnIndex,
115 in_turn: bool,
119 next_offset: u64,
123 write_buf: Vec<u8>,
126}
127
128#[derive(Debug, Clone, Copy)]
129struct CapEvictionPlan {
130 truncate_offset: u64,
131 evicted_event_count: u64,
132 evicted_turn_count: usize,
133}
134
135impl LogState {
136 fn new(session_id: &str) -> Self {
137 Self {
138 manifest: SessionManifest::new(session_id),
139 index: TurnIndex::default(),
140 in_turn: false,
141 next_offset: 0,
142 write_buf: Vec::with_capacity(65536),
143 }
144 }
145
146 fn serialize_event(&mut self, event: &ThreadEvent) -> Result<(u64, u64), SessionStoreError> {
155 let start = self.next_offset;
156 let buf_len_before = self.write_buf.len();
157 if let Err(err) = serde_json::to_writer(
158 &mut self.write_buf,
159 &BorrowedVersionedEvent { schema_version: EVENT_SCHEMA_VERSION, event },
160 ) {
161 self.write_buf.truncate(buf_len_before);
162 return Err(err.into());
163 }
164 self.write_buf.push(b'\n');
165 let written = self.write_buf.len() - buf_len_before;
166 let end = start + written as u64;
167 self.next_offset = end;
168 Ok((start, end))
169 }
170
171 fn apply_lifecycle_event(&mut self, kind: LifecycleKind, start: u64, end: u64) -> bool {
184 let is_boundary = match kind {
185 LifecycleKind::ThreadStarted => {
186 self.manifest.status = "active".to_string();
187 false
188 }
189 LifecycleKind::ThreadCompleted => {
190 self.manifest.status = "completed".to_string();
191 true
192 }
193 LifecycleKind::TurnStarted => {
194 self.manifest.status = "active".to_string();
195 self.in_turn = true;
196 let n = self.manifest.turn_count + 1;
197 self.index.entries.push_back(TurnIndexEntry {
198 turn_number: n,
199 start_offset: start,
200 end_offset: end,
201 event_count: 1,
202 ts: now_rfc3339(),
203 });
204 false
205 }
206 LifecycleKind::TurnCompleted | LifecycleKind::TurnFailed => {
207 if self.in_turn {
208 if let Some(entry) = self.index.entries.back_mut() {
209 entry.end_offset = end;
210 entry.event_count += 1;
211 self.manifest.turn_count = self.manifest.turn_count.max(entry.turn_number);
216 }
217 self.in_turn = false;
218 }
219 true
220 }
221 LifecycleKind::Other => {
222 if self.in_turn
223 && let Some(entry) = self.index.entries.back_mut()
224 {
225 entry.end_offset = end;
226 entry.event_count += 1;
227 }
228 false
229 }
230 };
231 self.manifest.in_turn = Some(self.in_turn);
236 is_boundary
237 }
238
239 fn plan_cap_eviction(&self, max_events: usize) -> Option<CapEvictionPlan> {
246 if max_events == 0 || self.manifest.event_count <= max_events as u64 {
247 return None;
248 }
249 let mut evicted_event_count = 0u64;
250 let mut truncate_offset = 0u64;
251 let mut evicted_turn_count = 0;
252 for (entry_index, oldest) in self.index.entries.iter().enumerate() {
253 if self.in_turn && entry_index + 1 == self.index.entries.len() {
260 break;
261 }
262 if self.manifest.event_count.saturating_sub(evicted_event_count) <= max_events as u64 {
263 break;
264 }
265 truncate_offset = oldest.end_offset;
266 evicted_event_count += oldest.event_count;
267 evicted_turn_count += 1;
268 }
269 if truncate_offset == 0 || evicted_turn_count == 0 {
270 None
271 } else {
272 Some(CapEvictionPlan {
273 truncate_offset,
274 evicted_event_count,
275 evicted_turn_count,
276 })
277 }
278 }
279
280 fn apply_cap_eviction(&mut self, plan: CapEvictionPlan, next_offset: u64) {
281 for _ in 0..plan.evicted_turn_count {
282 let _ = self.index.entries.pop_front();
283 }
284 for entry in &mut self.index.entries {
285 entry.start_offset = entry.start_offset.saturating_sub(plan.truncate_offset);
286 entry.end_offset = entry.end_offset.saturating_sub(plan.truncate_offset);
287 }
288 self.next_offset = next_offset;
289 self.manifest.event_count = self.manifest.event_count.saturating_sub(plan.evicted_event_count);
290 self.manifest.retained_turn_base = Some(self.retained_turn_base_after_eviction(0));
291 }
292
293 fn retained_turn_base_after_eviction(&self, evicted_turn_count: usize) -> u64 {
294 self.index
295 .entries
296 .get(evicted_turn_count)
297 .map(|entry| entry.turn_number)
298 .or_else(|| self.manifest.turn_count.checked_add(1))
299 .unwrap_or(1)
300 .max(1)
301 }
302}
303
304struct SessionShared {
312 file: Mutex<Option<File>>,
313 state: Mutex<LogState>,
314 eviction_lock: Mutex<()>,
315 initialized: AtomicBool,
316}
317
318fn shared_session(events_path: &Path, session_id: &str) -> Result<Arc<SessionShared>, SessionStoreError> {
323 static SESSION_SHARED: OnceLock<Mutex<HashMap<PathBuf, Weak<SessionShared>>>> = OnceLock::new();
324
325 let key = events_path
326 .parent()
327 .and_then(|parent| vtcode_commons::paths::canonicalize(parent).ok())
328 .and_then(|parent| events_path.file_name().map(|name| parent.join(name)))
329 .unwrap_or_else(|| events_path.to_path_buf());
330 let registry = SESSION_SHARED.get_or_init(|| Mutex::new(HashMap::new()));
331 let mut shared_by_path = match registry.lock() {
332 Ok(locks) => locks,
333 Err(poisoned) => poisoned.into_inner(),
334 };
335 shared_by_path.retain(|_, shared| shared.strong_count() > 0);
339 if let Some(shared) = shared_by_path.get(&key).and_then(Weak::upgrade) {
340 return Ok(shared);
341 }
342
343 let file = VtCodePaths::open_private_append_file(events_path)
344 .map_err(|error| SessionStoreError::io(events_path.to_path_buf(), std::io::Error::other(error)))?;
345 let shared = Arc::new(SessionShared {
346 file: Mutex::new(Some(file)),
347 state: Mutex::new(LogState::new(session_id)),
348 eviction_lock: Mutex::new(()),
349 initialized: AtomicBool::new(false),
350 });
351 shared_by_path.insert(key, Arc::downgrade(&shared));
352 Ok(shared)
353}
354
355pub struct SessionEventLog {
361 events_path: PathBuf,
362 manifest_store: ManifestStore,
363 shared: Arc<SessionShared>,
364 max_events: usize,
365 eviction_summary_hook: EvictionSummaryHook,
366}
367
368impl SessionEventLog {
369 pub(crate) fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
372 let dir = session_dir(workspace, session_id);
373 let hook = default_eviction_summary_hook(dir.join(crate::DERIVED_DIR), session_id.to_string());
374 Self::open_with_eviction_summary(workspace, session_id, max_events, hook)
375 }
376
377 pub fn open_with_eviction_summary(
383 workspace: &Path,
384 session_id: &str,
385 max_events: usize,
386 eviction_summary_hook: EvictionSummaryHook,
387 ) -> Result<Self, SessionStoreError> {
388 let dir = session_dir(workspace, session_id);
389 crate::ensure_private_directory(&crate::sessions_root(workspace))?;
390 crate::ensure_private_directory(&dir)?;
391 crate::ensure_private_directory(&dir.join(crate::DERIVED_DIR))?;
392 crate::ensure_private_directory(&dir.join("index"))?;
393 let events_path = dir.join("events.jsonl");
394 let manifest_store = ManifestStore::new(dir.clone());
395 let pending_rewrite = manifest_store.load_pending_cap_rewrite()?;
396 let shared = shared_session(&events_path, session_id)?;
397 let log = Self {
398 events_path: events_path.clone(),
399 manifest_store,
400 shared,
401 max_events,
402 eviction_summary_hook,
403 };
404 let _eviction_guard = log.shared.eviction_lock.lock().map_err(poison)?;
405 if !log.shared.initialized.load(Ordering::Acquire) {
408 let manifest_opt = log.manifest_store.load_manifest()?;
409 let index_opt = log.manifest_store.load_turn_index()?;
410 let file_len = log.event_file_metadata_len()?;
411 let pending_rewrite_matches_file = pending_rewrite.as_ref().is_some_and(|pending| {
412 pending.new_file_len == file_len && pending.new_file_len < pending.previous_file_len
413 });
414 match (&manifest_opt, &index_opt) {
415 (Some(manifest), Some(index))
416 if !pending_rewrite_matches_file
417 && manifest.in_turn.is_some()
418 && manifest.persisted_file_len == Some(file_len)
419 && index.is_valid_for_file(file_len)
420 && index.is_consistent_with_manifest(manifest) =>
421 {
422 let mut st = log.shared.state.lock().map_err(poison)?;
423 st.in_turn = manifest.in_turn.unwrap_or(false);
424 st.manifest = manifest.clone();
425 st.index = index.clone();
426 st.next_offset = file_len;
427 }
428 _ => {
429 let scan_turn_base = infer_scan_turn_base(
430 manifest_opt.as_ref(),
431 index_opt.as_ref(),
432 pending_rewrite.as_ref().filter(|_| pending_rewrite_matches_file),
433 file_len,
434 );
435 {
436 let mut st = log.shared.state.lock().map_err(poison)?;
437 if let Some(previous) = manifest_opt.as_ref() {
438 st.manifest = previous.clone();
439 }
440 st.manifest.turn_count = scan_turn_base.saturating_sub(1);
444 st.manifest.event_count = 0;
445 st.manifest.status = "active".to_string();
446 st.manifest.in_turn = Some(false);
447 st.manifest.retained_turn_base = Some(scan_turn_base);
448 st.index = TurnIndex::default();
449 st.in_turn = false;
450 }
451 log.scan()?;
452 let mut st = log.shared.state.lock().map_err(poison)?;
453 st.next_offset = file_len;
454 log.persist_meta_locked(&mut st)?;
455 }
456 }
457 if pending_rewrite.is_some() {
458 log.manifest_store.clear_pending_cap_rewrite()?;
464 }
465 log.shared.initialized.store(true, Ordering::Release);
466 }
467 drop(_eviction_guard);
468 Ok(log)
469 }
470
471 pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
473 let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
474 let mut st = self.shared.state.lock().map_err(poison)?;
475
476 let (start, end) = st.serialize_event(event)?;
480
481 st.manifest.event_count += 1;
482 st.manifest.updated_at = now_rfc3339();
483
484 let is_turn_boundary = st.apply_lifecycle_event(LifecycleKind::from_event(event), start, end);
488 if is_turn_boundary {
489 self.persist_meta_locked(&mut st)?;
490 }
491
492 if st.write_buf.len() >= MAX_WRITE_BUFFER_BYTES {
493 self.persist_meta_locked(&mut st)?;
497 }
498 drop(st);
499 self.enforce_event_cap()
500 }
501
502 fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
506 let mut st = self.shared.state.lock().map_err(poison)?;
507
508 let Some(plan) = st.plan_cap_eviction(self.max_events) else {
511 return Ok(());
512 };
513
514 self.flush_write_buf_locked(&mut st)?;
518
519 let (evicted, remaining, previous_file_len) = {
520 let mut file_slot = self.shared.file.lock().map_err(poison)?;
521 let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
522 let file_len = file
523 .metadata()
524 .map_err(|error| SessionStoreError::io(&self.events_path, error))?
525 .len();
526 if plan.truncate_offset > file_len {
527 return Err(SessionStoreError::io(
528 &self.events_path,
529 std::io::Error::new(std::io::ErrorKind::InvalidData, "cap offset exceeds event log length"),
530 ));
531 }
532 file.seek(SeekFrom::Start(0))
533 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
534 let mut evicted = vec![
535 0u8;
536 usize::try_from(plan.truncate_offset).map_err(|error| {
537 SessionStoreError::io(
538 &self.events_path,
539 std::io::Error::new(std::io::ErrorKind::InvalidData, error),
540 )
541 })?
542 ];
543 file.read_exact(&mut evicted)
544 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
545 file.seek(SeekFrom::Start(plan.truncate_offset))
546 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
547 let mut remaining = Vec::new();
548 file.read_to_end(&mut remaining)
549 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
550 (evicted, remaining, file_len)
551 };
552 let retained_turn_base = st.retained_turn_base_after_eviction(plan.evicted_turn_count);
553 drop(st);
554
555 let mut plan = plan;
561 plan.evicted_event_count = count_persisted_event_records(&evicted);
562 let evicted_events = decode_events(&evicted);
563 (self.eviction_summary_hook)(&evicted_events)?;
564
565 let new_file_len = u64::try_from(remaining.len()).map_err(|error| {
566 SessionStoreError::io(&self.events_path, std::io::Error::new(std::io::ErrorKind::InvalidData, error))
567 })?;
568 self.manifest_store.write_pending_cap_rewrite(&PendingCapRewrite {
569 previous_file_len,
570 new_file_len,
571 retained_turn_base,
572 })?;
573 let next_offset = self.replace_event_file_contents(&remaining)?;
574 let mut st = self.shared.state.lock().map_err(poison)?;
575 st.apply_cap_eviction(plan, next_offset);
576 self.persist_meta_locked(&mut st)?;
579 self.manifest_store.clear_pending_cap_rewrite()?;
580 Ok(())
581 }
582
583 pub(crate) fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
585 let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
589 let entry = {
590 let st = self.shared.state.lock().map_err(poison)?;
591 st.index
592 .entries
593 .iter()
594 .find(|e| e.turn_number == turn)
595 .cloned()
596 .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
597 };
598 {
599 let mut st = self.shared.state.lock().map_err(poison)?;
600 self.flush_write_buf_locked(&mut st)?;
601 }
602 let buf = {
603 let mut file_slot = self.shared.file.lock().map_err(poison)?;
604 let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
605 file.seek(SeekFrom::Start(entry.start_offset))
606 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
607 let len = usize::try_from(entry.end_offset.checked_sub(entry.start_offset).ok_or_else(|| {
608 SessionStoreError::io(
609 &self.events_path,
610 std::io::Error::new(std::io::ErrorKind::InvalidData, "turn index offsets are out of order"),
611 )
612 })?)
613 .map_err(|error| {
614 SessionStoreError::io(&self.events_path, std::io::Error::new(std::io::ErrorKind::InvalidData, error))
615 })?;
616 let mut buf = vec![0u8; len];
617 file.read_exact(&mut buf)
618 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
619 buf
620 };
621 let text = String::from_utf8_lossy(&buf);
622 let mut events = Vec::new();
623 for line in text.lines() {
624 let line = line.trim();
625 if line.is_empty() {
626 continue;
627 }
628 let v: VersionedThreadEvent = match serde_json::from_str(line) {
634 Ok(v) => v,
635 Err(_) => continue,
636 };
637 events.push(v.into_event());
638 }
639 Ok(events)
640 }
641
642 #[must_use]
644 pub(crate) fn turn_count(&self) -> u64 {
645 self.shared.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
646 }
647
648 #[must_use]
650 pub fn event_count(&self) -> u64 {
651 self.shared.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
652 }
653
654 pub fn flush(&self) -> Result<(), SessionStoreError> {
656 let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
657 let mut st = self.shared.state.lock().map_err(poison)?;
658 self.persist_meta_locked(&mut st)
659 }
660
661 #[must_use]
663 pub fn manifest(&self) -> SessionManifest {
664 self.shared
665 .state
666 .lock()
667 .map_err(poison)
668 .map(|s| s.manifest.clone())
669 .unwrap_or_else(|_| SessionManifest::new(""))
670 }
671
672 #[must_use]
674 pub fn turn_index(&self) -> TurnIndex {
675 self.shared
676 .state
677 .lock()
678 .map_err(poison)
679 .map(|s| s.index.clone())
680 .unwrap_or_default()
681 }
682
683 pub(crate) fn complete(&self) -> Result<(), SessionStoreError> {
689 let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
690 let mut st = self.shared.state.lock().map_err(poison)?;
691 st.manifest.updated_at = now_rfc3339();
692 self.persist_meta_locked(&mut st)
693 }
694
695 fn scan(&self) -> Result<(), SessionStoreError> {
701 let mut st = self.shared.state.lock().map_err(poison)?;
702 let file = self
703 .shared
704 .file
705 .lock()
706 .map_err(poison)?
707 .as_ref()
708 .ok_or_else(|| self.event_file_unavailable())?
709 .try_clone()
710 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
711 let mut reader = std::io::BufReader::new(file);
712 reader
713 .seek(SeekFrom::Start(0))
714 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
715 let mut buf = Vec::new();
716 let mut pos = 0u64;
717 let mut first_ts: Option<String> = None;
718 loop {
719 buf.clear();
720 let n = reader
721 .read_until(b'\n', &mut buf)
722 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
723 if n == 0 {
724 break;
725 }
726 let line_end = pos + n as u64;
727 let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
728 if !trimmed.is_empty()
729 && let Ok(v) = serde_json::from_str::<VersionedEventKind<'_>>(trimmed)
730 {
731 let kind = v.event.kind;
732 if requires_full_lifecycle_validation(kind) && !valid_lifecycle_payload(kind, trimmed) {
733 pos = line_end;
734 continue;
735 }
736 st.manifest.event_count += 1;
737 if kind == "thread.started" && first_ts.is_none() {
740 first_ts = Some(now_rfc3339());
741 }
742 st.apply_lifecycle_event(LifecycleKind::from_kind(kind), pos, line_end);
745 }
746 pos = line_end;
747 }
748 st.manifest.in_turn = Some(st.in_turn);
752 if let Some(ts) = first_ts
753 && st.manifest.created_at.is_empty()
754 {
755 st.manifest.created_at = ts;
756 }
757 Ok(())
758 }
759
760 fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
761 self.flush_write_buf_locked(st)?;
762 st.manifest.persisted_file_len = Some(st.next_offset);
767 self.manifest_store.write_turn_index(&st.index)?;
772 self.manifest_store.write_manifest(&st.manifest)?;
773 Ok(())
774 }
775
776 fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
778 if st.write_buf.is_empty() {
779 return Ok(());
780 }
781 let mut file_slot = self.shared.file.lock().map_err(poison)?;
782 let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
783 let previous_len = file.metadata().map_err(|e| SessionStoreError::io(&self.events_path, e))?.len();
784 if let Err(error) = file.write_all(&st.write_buf) {
785 if file.set_len(previous_len).is_err() {
786 st.write_buf.clear();
787 }
788 return Err(SessionStoreError::io(&self.events_path, error));
789 }
790 if let Err(error) = file.sync_data() {
791 st.write_buf.clear();
792 return Err(SessionStoreError::io(&self.events_path, error));
793 }
794 st.write_buf.clear();
795 Ok(())
796 }
797
798 fn event_file_metadata_len(&self) -> Result<u64, SessionStoreError> {
799 let file_slot = self.shared.file.lock().map_err(poison)?;
800 file_slot
801 .as_ref()
802 .ok_or_else(|| self.event_file_unavailable())?
803 .metadata()
804 .map(|metadata| metadata.len())
805 .map_err(|error| SessionStoreError::io(&self.events_path, error))
806 }
807
808 fn open_event_file(&self) -> Result<File, SessionStoreError> {
809 VtCodePaths::open_private_append_file(&self.events_path)
810 .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
811 }
812
813 fn replace_event_file_contents(&self, contents: &[u8]) -> Result<u64, SessionStoreError> {
814 let old_file = {
815 let mut file_slot = self.shared.file.lock().map_err(poison)?;
816 file_slot.take().ok_or_else(|| self.event_file_unavailable())?
817 };
818 drop(old_file);
819
820 if let Err(error) = VtCodePaths::write_private_file_atomic(&self.events_path, contents)
821 .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
822 {
823 let restored = self.open_event_file();
824 if let Ok(file) = restored {
825 let mut file_slot = self.shared.file.lock().map_err(poison)?;
826 *file_slot = Some(file);
827 return Err(error);
828 }
829 return Err(SessionStoreError::io(
830 &self.events_path,
831 std::io::Error::other(format!("{error}; failed to restore event log handle")),
832 ));
833 }
834
835 let replacement = self.open_event_file()?;
836 let next_offset = replacement
837 .metadata()
838 .map_err(|error| SessionStoreError::io(&self.events_path, error))?
839 .len();
840 let mut file_slot = self.shared.file.lock().map_err(poison)?;
841 *file_slot = Some(replacement);
842 Ok(next_offset)
843 }
844
845 fn event_file_unavailable(&self) -> SessionStoreError {
846 SessionStoreError::io(&self.events_path, std::io::Error::other("event log file is unavailable"))
847 }
848}
849
850fn infer_scan_turn_base(
859 manifest: Option<&SessionManifest>,
860 index: Option<&TurnIndex>,
861 pending: Option<&PendingCapRewrite>,
862 file_len: u64,
863) -> u64 {
864 let marker_base = pending
865 .filter(|pending| pending.new_file_len == file_len && pending.new_file_len < pending.previous_file_len)
866 .map(|pending| pending.retained_turn_base);
867 let rewritten_base = manifest.and_then(|manifest| {
868 let previous_len = manifest.persisted_file_len?;
869 if previous_len <= file_len {
870 return None;
871 }
872 let removed_prefix = previous_len - file_len;
873 let index = index.filter(|index| index.is_valid_for_file(previous_len))?;
874 let first_retained = index
875 .entries
876 .iter()
877 .find(|entry| entry.start_offset >= removed_prefix && entry.end_offset <= previous_len)
878 .map(|entry| entry.turn_number);
879 first_retained.or_else(|| {
880 (removed_prefix >= previous_len).then(|| manifest.turn_count.saturating_add(1))
885 })
886 });
887
888 let legacy_index_base = index
889 .filter(|index| index.is_valid_for_file(file_len))
890 .and_then(|index| index.entries.front().map(|entry| entry.turn_number));
891
892 marker_base
893 .or(rewritten_base)
894 .or_else(|| manifest.and_then(|manifest| manifest.retained_turn_base))
895 .or(legacy_index_base)
896 .unwrap_or(1)
897 .max(1)
898}
899
900fn requires_full_lifecycle_validation(kind: &str) -> bool {
901 matches!(kind, "thread.started" | "thread.completed" | "turn.started" | "turn.completed" | "turn.failed")
902}
903
904fn valid_lifecycle_payload(kind: &str, line: &str) -> bool {
905 if serde_json::from_str::<VersionedThreadEvent>(line).is_err() {
906 return false;
907 }
908 if kind != "turn.completed" {
909 return true;
910 }
911 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
912 return false;
913 };
914 value
915 .get("event")
916 .and_then(|event| event.get("usage"))
917 .is_some_and(serde_json::Value::is_object)
918}
919
920fn decode_events(bytes: &[u8]) -> Vec<ThreadEvent> {
921 bytes
922 .split(|byte| *byte == b'\n')
923 .filter_map(|line| {
924 let line = std::str::from_utf8(line).ok()?.trim();
925 if line.is_empty() {
926 return None;
927 }
928 serde_json::from_str::<VersionedThreadEvent>(line)
929 .ok()
930 .map(VersionedThreadEvent::into_event)
931 })
932 .collect()
933}
934
935fn count_persisted_event_records(bytes: &[u8]) -> u64 {
940 bytes
941 .split(|byte| *byte == b'\n')
942 .filter_map(|line| std::str::from_utf8(line).ok())
943 .map(str::trim)
944 .filter(|line| !line.is_empty())
945 .filter(|line| {
946 let Ok(value) = serde_json::from_str::<VersionedEventKind<'_>>(line) else {
947 return false;
948 };
949 let kind = value.event.kind;
950 !requires_full_lifecycle_validation(kind) || valid_lifecycle_payload(kind, line)
951 })
952 .count() as u64
953}
954
955#[derive(Debug, Serialize)]
956struct EvictionSummary {
957 session_id: String,
958 evicted_event_count: usize,
959 event_types: BTreeMap<String, u64>,
960 grounded_facts: Vec<String>,
961 created_at: String,
962}
963
964fn extract_grounded_facts(events: &[ThreadEvent]) -> Vec<String> {
971 let mut facts = Vec::new();
972 for event in events {
973 let candidates: Vec<String> = match event {
974 ThreadEvent::ItemCompleted(completed) => item_facts(&completed.item.details),
975 ThreadEvent::TurnFailed(failed) => vec![failed.message.clone()],
976 ThreadEvent::TurnBlocked(blocked) => vec![blocked.message.clone()],
977 ThreadEvent::Error(error) => vec![error.message.clone()],
978 _ => Vec::new(),
979 };
980 for candidate in candidates {
981 let fact = normalize_eviction_fact(&candidate);
982 if fact.is_empty() || facts.iter().any(|existing| existing == &fact) {
983 continue;
984 }
985 facts.push(fact);
986 if facts.len() == MAX_EVICTION_GROUNDED_FACTS {
987 return facts;
988 }
989 }
990 }
991 facts
992}
993
994fn item_facts(details: &ThreadItemDetails) -> Vec<String> {
995 match details {
996 ThreadItemDetails::AgentMessage(item) => vec![item.text.clone()],
997 ThreadItemDetails::Plan(item) => vec![item.text.clone()],
998 ThreadItemDetails::FileChange(item) => item
999 .changes
1000 .iter()
1001 .map(|change| {
1002 let kind = match change.kind {
1003 vtcode_exec_events::PatchChangeKind::Add => "add",
1004 vtcode_exec_events::PatchChangeKind::Delete => "delete",
1005 vtcode_exec_events::PatchChangeKind::Update => "update",
1006 };
1007 format!("file {kind}: {}", change.path)
1008 })
1009 .collect(),
1010 ThreadItemDetails::Harness(item) => item.message.clone().into_iter().collect(),
1011 _ => Vec::new(),
1012 }
1013}
1014
1015fn normalize_eviction_fact(value: &str) -> String {
1016 let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
1017 if normalized.len() <= MAX_EVICTION_GROUNDED_FACT_BYTES {
1018 return normalized;
1019 }
1020 let mut end = MAX_EVICTION_GROUNDED_FACT_BYTES - '…'.len_utf8();
1021 while !normalized.is_char_boundary(end) {
1022 end -= 1;
1023 }
1024 format!("{}…", &normalized[..end])
1025}
1026
1027fn default_eviction_summary_hook(derived_dir: PathBuf, session_id: String) -> EvictionSummaryHook {
1028 Arc::new(move |events| {
1029 let mut event_types = BTreeMap::new();
1030 for event in events {
1031 let kind = serde_json::to_value(event)
1032 .ok()
1033 .and_then(|value| value.get("type").and_then(|value| value.as_str()).map(str::to_owned))
1034 .unwrap_or_else(|| "unknown".to_owned());
1035 *event_types.entry(kind).or_insert(0) += 1;
1036 }
1037 let summary = EvictionSummary {
1038 session_id: session_id.clone(),
1039 evicted_event_count: events.len(),
1040 event_types,
1041 grounded_facts: extract_grounded_facts(events),
1042 created_at: now_rfc3339(),
1043 };
1044 let path = derived_dir.join(format!("eviction-summary-{}.json", uuid::Uuid::new_v4().simple()));
1045 let bytes = serde_json::to_vec(&summary)?;
1046 VtCodePaths::write_private_file_atomic(&path, &bytes)
1047 .map_err(|error| SessionStoreError::io(path, std::io::Error::other(error)))
1048 })
1049}
1050
1051impl Drop for SessionEventLog {
1052 fn drop(&mut self) {
1053 if let Ok(_eviction_guard) = self.shared.eviction_lock.lock()
1054 && let Ok(mut st) = self.shared.state.lock()
1055 {
1056 let _ = self.flush_write_buf_locked(&mut st);
1062 }
1063 }
1064}
1065
1066fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
1068 SessionStoreError::Io {
1069 path: PathBuf::new(),
1070 source: std::io::Error::other("session store lock poisoned"),
1071 }
1072}
1073
1074fn now_rfc3339() -> String {
1075 Utc::now().to_rfc3339()
1076}
1077
1078#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1080pub struct SessionManifest {
1081 pub session_id: String,
1083 schema_version: u32,
1085 pub created_at: String,
1087 pub updated_at: String,
1089 pub turn_count: u64,
1091 pub event_count: u64,
1093 pub status: String,
1095 #[serde(default)]
1101 in_turn: Option<bool>,
1102 #[serde(default)]
1108 persisted_file_len: Option<u64>,
1109 #[serde(default)]
1115 retained_turn_base: Option<u64>,
1116}
1117
1118impl SessionManifest {
1119 #[must_use]
1121 pub(crate) fn new(session_id: &str) -> Self {
1122 let ts = now_rfc3339();
1123 Self {
1124 session_id: session_id.to_string(),
1125 schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
1126 created_at: ts.clone(),
1127 updated_at: ts,
1128 turn_count: 0,
1129 event_count: 0,
1130 status: "active".to_string(),
1131 in_turn: Some(false),
1132 persisted_file_len: Some(0),
1133 retained_turn_base: Some(1),
1134 }
1135 }
1136}
1137
1138#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1140pub struct TurnIndexEntry {
1141 turn_number: u64,
1143 start_offset: u64,
1145 end_offset: u64,
1147 event_count: u64,
1149 ts: String,
1151}
1152
1153#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1155pub struct TurnIndex {
1156 entries: VecDeque<TurnIndexEntry>,
1158}
1159
1160impl TurnIndex {
1161 #[must_use]
1163 pub fn len(&self) -> usize {
1164 self.entries.len()
1165 }
1166
1167 #[must_use]
1169 pub fn is_empty(&self) -> bool {
1170 self.entries.is_empty()
1171 }
1172
1173 fn is_valid_for_file(&self, file_len: u64) -> bool {
1174 let mut previous_end = 0u64;
1175 self.entries.iter().all(|entry| {
1176 let valid = entry.event_count > 0
1177 && entry.start_offset >= previous_end
1178 && entry.start_offset <= entry.end_offset
1179 && entry.end_offset <= file_len;
1180 if valid {
1181 previous_end = entry.end_offset;
1182 }
1183 valid
1184 })
1185 }
1186
1187 fn is_consistent_with_manifest(&self, manifest: &SessionManifest) -> bool {
1188 let expected_last_turn = if manifest.in_turn == Some(true) {
1189 manifest.turn_count.saturating_add(1)
1190 } else {
1191 manifest.turn_count
1192 };
1193 let entries_are_contiguous = self
1194 .entries
1195 .iter()
1196 .map(|entry| entry.turn_number)
1197 .try_fold(None::<u64>, |previous, turn_number| {
1198 if previous.is_some_and(|previous| turn_number != previous.saturating_add(1)) {
1199 return Err(());
1200 }
1201 Ok(Some(turn_number))
1202 })
1203 .is_ok();
1204 if !entries_are_contiguous {
1205 return false;
1206 }
1207
1208 let expected_first_turn = manifest.retained_turn_base.unwrap_or(1).max(1);
1209 match (self.entries.front(), self.entries.back()) {
1210 (Some(first), Some(last)) => {
1211 first.turn_number == expected_first_turn && last.turn_number == expected_last_turn
1212 }
1213 (None, None) => {
1214 expected_last_turn == 0
1215 || manifest
1216 .retained_turn_base
1217 .is_some_and(|retained_turn_base| retained_turn_base > manifest.turn_count)
1218 }
1219 _ => false,
1220 }
1221 }
1222}
1223
1224#[cfg(test)]
1225mod borrowed_envelope_tests {
1226 use super::{BorrowedVersionedEvent, EVENT_SCHEMA_VERSION};
1227 use vtcode_exec_events::{
1228 ThreadEvent, ThreadStartedEvent, TurnCompletedEvent, TurnStartedEvent, Usage, VersionedThreadEvent,
1229 };
1230
1231 #[test]
1235 fn borrowed_envelope_matches_versioned_envelope() {
1236 for event in [
1237 ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "thread".to_string() }),
1238 ThreadEvent::TurnStarted(TurnStartedEvent::default()),
1239 ThreadEvent::TurnCompleted(TurnCompletedEvent {
1240 usage: Usage::default(),
1241 in_progress_exec_sessions: Vec::new(),
1242 }),
1243 ] {
1244 let canonical =
1245 serde_json::to_string(&VersionedThreadEvent::new(event.clone())).expect("canonical serialize");
1246 let borrowed = serde_json::to_string(&BorrowedVersionedEvent {
1247 schema_version: EVENT_SCHEMA_VERSION,
1248 event: &event,
1249 })
1250 .expect("borrowed serialize");
1251 assert_eq!(canonical, borrowed, "JSON differs for {event:?}");
1252 }
1253 }
1254}
1255
1256#[cfg(test)]
1257mod lifecycle_state_machine_tests {
1258 use super::{LifecycleKind, LogState};
1259 use vtcode_exec_events::{
1260 ThreadCompletedEvent, ThreadCompletionSubtype, ThreadEvent, ThreadStartedEvent, TurnCompletedEvent,
1261 TurnFailedEvent, TurnStartedEvent, Usage,
1262 };
1263
1264 fn fresh_state() -> LogState {
1265 LogState::new("test-session")
1266 }
1267
1268 #[test]
1269 fn lifecycle_kind_from_event_covers_all_variants() {
1270 assert_eq!(
1271 LifecycleKind::from_event(&ThreadEvent::TurnStarted(TurnStartedEvent::default())),
1272 LifecycleKind::TurnStarted
1273 );
1274 assert_eq!(
1275 LifecycleKind::from_event(&ThreadEvent::TurnCompleted(TurnCompletedEvent {
1276 usage: Usage::default(),
1277 in_progress_exec_sessions: Vec::new()
1278 })),
1279 LifecycleKind::TurnCompleted
1280 );
1281 assert_eq!(
1282 LifecycleKind::from_event(&ThreadEvent::TurnFailed(TurnFailedEvent {
1283 message: "err".to_string(),
1284 usage: None,
1285 })),
1286 LifecycleKind::TurnFailed
1287 );
1288 assert_eq!(
1289 LifecycleKind::from_event(&ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "x".to_string() })),
1290 LifecycleKind::ThreadStarted
1291 );
1292 assert_eq!(
1293 LifecycleKind::from_event(&ThreadEvent::ThreadCompleted(Box::new(ThreadCompletedEvent {
1294 thread_id: "x".to_string(),
1295 session_id: "x".to_string(),
1296 subtype: ThreadCompletionSubtype::Success,
1297 outcome_code: "completed".to_string(),
1298 result: None,
1299 stop_reason: None,
1300 usage: Usage::default(),
1301 total_cost_usd: None,
1302 num_turns: 1,
1303 }))),
1304 LifecycleKind::ThreadCompleted
1305 );
1306 assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
1307 assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
1308 }
1309
1310 #[test]
1311 fn lifecycle_kind_from_str_matches_event_discriminator() {
1312 assert_eq!(LifecycleKind::from_kind("turn.started"), LifecycleKind::TurnStarted);
1313 assert_eq!(LifecycleKind::from_kind("turn.completed"), LifecycleKind::TurnCompleted);
1314 assert_eq!(LifecycleKind::from_kind("turn.failed"), LifecycleKind::TurnFailed);
1315 assert_eq!(LifecycleKind::from_kind("tool.called"), LifecycleKind::Other);
1316 assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
1317 assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
1318 }
1319
1320 #[test]
1321 fn turn_started_pushes_index_entry_and_sets_in_turn() {
1322 let mut st = fresh_state();
1323 st.manifest.status = "completed".to_string();
1324 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1325 assert!(!is_boundary, "TurnStarted is not a turn boundary");
1326 assert!(st.in_turn);
1327 assert_eq!(st.manifest.status, "active");
1328 assert_eq!(st.index.entries.len(), 1);
1329 let entry = &st.index.entries[0];
1330 assert_eq!(entry.turn_number, 1);
1331 assert_eq!(entry.start_offset, 0);
1332 assert_eq!(entry.end_offset, 100);
1333 assert_eq!(entry.event_count, 1);
1334 }
1335
1336 #[test]
1337 fn intermediate_events_extend_current_turn() {
1338 let mut st = fresh_state();
1339 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1340 let is_b1 = st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
1342 let is_b2 = st.apply_lifecycle_event(LifecycleKind::Other, 200, 300);
1343 assert!(!is_b1 && !is_b2);
1344 assert!(st.in_turn);
1345 assert_eq!(st.index.entries.len(), 1);
1346 let entry = &st.index.entries[0];
1347 assert_eq!(entry.end_offset, 300);
1348 assert_eq!(entry.event_count, 3);
1349 }
1350
1351 #[test]
1352 fn turn_completed_closes_turn_and_returns_boundary() {
1353 let mut st = fresh_state();
1354 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1355 st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
1356 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 200, 300);
1357 assert!(is_boundary);
1358 assert!(!st.in_turn);
1359 assert_eq!(st.manifest.turn_count, 1);
1360 assert_eq!(st.manifest.status, "active");
1361 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 300, 400);
1362 assert_eq!(st.manifest.status, "completed");
1363 let entry = &st.index.entries[0];
1364 assert_eq!(entry.end_offset, 300);
1365 assert_eq!(entry.event_count, 3);
1366 }
1367
1368 #[test]
1369 fn turn_failed_closes_turn_without_terminal_thread_status() {
1370 let mut st = fresh_state();
1371 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1372 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnFailed, 100, 200);
1373 assert!(is_boundary);
1374 assert!(!st.in_turn);
1375 assert_eq!(st.manifest.turn_count, 1);
1376 assert_eq!(st.manifest.status, "active");
1377 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 200, 300);
1378 assert_eq!(st.manifest.status, "completed");
1379 }
1380
1381 #[test]
1382 fn turn_completed_without_turn_started_is_idempotent() {
1383 let mut st = fresh_state();
1384 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 0, 100);
1388 assert!(is_boundary);
1389 assert!(!st.in_turn);
1390 assert_eq!(st.manifest.turn_count, 0, "no turn was started");
1391 assert_eq!(st.manifest.status, "active");
1392 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 100, 200);
1393 assert_eq!(st.manifest.status, "completed");
1394 assert!(st.index.entries.is_empty());
1395 }
1396
1397 #[test]
1398 fn multiple_turns_get_incrementing_ordinals() {
1399 let mut st = fresh_state();
1400 for n in 1..=3 {
1401 st.apply_lifecycle_event(LifecycleKind::TurnStarted, n * 100, n * 100 + 50);
1402 st.apply_lifecycle_event(LifecycleKind::TurnCompleted, n * 100 + 50, n * 100 + 100);
1403 }
1404 assert_eq!(st.index.entries.len(), 3);
1405 for (i, entry) in st.index.entries.iter().enumerate() {
1406 assert_eq!(entry.turn_number, (i + 1) as u64);
1407 }
1408 assert_eq!(st.manifest.turn_count, 3);
1409 }
1410}
1411
1412#[cfg(test)]
1413mod cap_eviction_tests {
1414 use super::{LogState, TurnIndexEntry};
1415
1416 fn state_with_turns(turns: usize, events_per_turn: u64) -> LogState {
1419 let mut st = LogState::new("cap-test");
1420 st.manifest.event_count = (turns as u64) * events_per_turn;
1421 let mut offset = 0u64;
1422 for n in 1..=turns {
1423 st.index.entries.push_back(TurnIndexEntry {
1424 turn_number: n as u64,
1425 start_offset: offset,
1426 end_offset: offset + events_per_turn * 10,
1427 event_count: events_per_turn,
1428 ts: "2026-01-01T00:00:00Z".to_string(),
1429 });
1430 offset += events_per_turn * 10;
1431 }
1432 st
1433 }
1434
1435 #[test]
1436 fn no_eviction_when_under_cap() {
1437 let st = state_with_turns(3, 2); assert!(st.plan_cap_eviction(10).is_none());
1439 assert_eq!(st.index.entries.len(), 3, "no turns should be evicted");
1440 }
1441
1442 #[test]
1443 fn no_eviction_when_cap_disabled() {
1444 let st = state_with_turns(5, 2); assert!(st.plan_cap_eviction(0).is_none());
1446 assert_eq!(st.index.entries.len(), 5);
1447 }
1448
1449 #[test]
1450 fn evicts_oldest_turns_to_meet_cap() {
1451 let st = state_with_turns(5, 2);
1453 let plan = st.plan_cap_eviction(6).expect("eviction planned");
1454 assert_eq!(plan.evicted_event_count, 4, "should evict 4 events (2 turns)");
1455 assert_eq!(st.index.entries.len(), 5, "planning must not mutate state");
1456 assert_eq!(plan.truncate_offset, 40); let mut st = st;
1461 st.apply_cap_eviction(plan, 60);
1462 assert_eq!(st.index.entries.len(), 3, "should keep 3 turns");
1463 assert_eq!(st.index.entries[0].turn_number, 3);
1464 assert_eq!(st.index.entries[2].turn_number, 5);
1465 }
1466
1467 #[test]
1468 fn evicts_all_turns_when_cap_smaller_than_one_turn() {
1469 let st = state_with_turns(3, 5);
1473 let plan = st.plan_cap_eviction(3).expect("eviction planned");
1474 assert_eq!(plan.evicted_event_count, 15, "all events evicted");
1475 let mut st = st;
1476 st.apply_cap_eviction(plan, 0);
1477 assert_eq!(st.index.entries.len(), 0);
1478 }
1479}