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, 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;
26
27pub type EvictionSummaryHook = Arc<dyn Fn(&[ThreadEvent]) -> Result<(), SessionStoreError> + Send + Sync>;
34
35#[derive(Debug, Deserialize)]
41struct VersionedEventKind<'a> {
42 #[serde(rename = "schema_version", borrow)]
43 _schema_version: &'a str,
44 #[serde(borrow)]
45 event: EventKind<'a>,
46}
47
48#[derive(Debug, Deserialize)]
49struct EventKind<'a> {
50 #[serde(rename = "type", borrow)]
51 kind: &'a str,
52}
53
54#[derive(Serialize)]
61struct BorrowedVersionedEvent<'a> {
62 schema_version: &'a str,
63 event: &'a ThreadEvent,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71enum LifecycleKind {
72 ThreadStarted,
73 ThreadCompleted,
74 TurnStarted,
75 TurnCompleted,
76 TurnFailed,
77 Other,
78}
79
80impl LifecycleKind {
81 #[inline]
83 fn from_event(event: &ThreadEvent) -> Self {
84 match event {
85 ThreadEvent::ThreadStarted(_) => Self::ThreadStarted,
86 ThreadEvent::ThreadCompleted(_) => Self::ThreadCompleted,
87 ThreadEvent::TurnStarted(_) => Self::TurnStarted,
88 ThreadEvent::TurnCompleted(_) => Self::TurnCompleted,
89 ThreadEvent::TurnFailed(_) => Self::TurnFailed,
90 _ => Self::Other,
91 }
92 }
93
94 #[inline]
96 fn from_kind(kind: &str) -> Self {
97 match kind {
98 "thread.started" => Self::ThreadStarted,
99 "thread.completed" => Self::ThreadCompleted,
100 "turn.started" => Self::TurnStarted,
101 "turn.completed" => Self::TurnCompleted,
102 "turn.failed" => Self::TurnFailed,
103 _ => Self::Other,
104 }
105 }
106}
107
108struct LogState {
111 manifest: SessionManifest,
112 index: TurnIndex,
113 in_turn: bool,
117 next_offset: u64,
121 write_buf: Vec<u8>,
124}
125
126#[derive(Debug, Clone, Copy)]
127struct CapEvictionPlan {
128 truncate_offset: u64,
129 evicted_event_count: u64,
130 evicted_turn_count: usize,
131}
132
133impl LogState {
134 fn new(session_id: &str) -> Self {
135 Self {
136 manifest: SessionManifest::new(session_id),
137 index: TurnIndex::default(),
138 in_turn: false,
139 next_offset: 0,
140 write_buf: Vec::with_capacity(65536),
141 }
142 }
143
144 fn serialize_event(&mut self, event: &ThreadEvent) -> Result<(u64, u64), SessionStoreError> {
153 let start = self.next_offset;
154 let buf_len_before = self.write_buf.len();
155 if let Err(err) = serde_json::to_writer(
156 &mut self.write_buf,
157 &BorrowedVersionedEvent { schema_version: EVENT_SCHEMA_VERSION, event },
158 ) {
159 self.write_buf.truncate(buf_len_before);
160 return Err(err.into());
161 }
162 self.write_buf.push(b'\n');
163 let written = self.write_buf.len() - buf_len_before;
164 let end = start + written as u64;
165 self.next_offset = end;
166 Ok((start, end))
167 }
168
169 fn apply_lifecycle_event(&mut self, kind: LifecycleKind, start: u64, end: u64) -> bool {
182 let is_boundary = match kind {
183 LifecycleKind::ThreadStarted => {
184 self.manifest.status = "active".to_string();
185 false
186 }
187 LifecycleKind::ThreadCompleted => {
188 self.manifest.status = "completed".to_string();
189 true
190 }
191 LifecycleKind::TurnStarted => {
192 self.manifest.status = "active".to_string();
193 self.in_turn = true;
194 let n = self.manifest.turn_count + 1;
195 self.index.entries.push_back(TurnIndexEntry {
196 turn_number: n,
197 start_offset: start,
198 end_offset: end,
199 event_count: 1,
200 ts: now_rfc3339(),
201 });
202 false
203 }
204 LifecycleKind::TurnCompleted | LifecycleKind::TurnFailed => {
205 if self.in_turn {
206 if let Some(entry) = self.index.entries.back_mut() {
207 entry.end_offset = end;
208 entry.event_count += 1;
209 self.manifest.turn_count = self.manifest.turn_count.max(entry.turn_number);
214 }
215 self.in_turn = false;
216 }
217 true
218 }
219 LifecycleKind::Other => {
220 if self.in_turn
221 && let Some(entry) = self.index.entries.back_mut()
222 {
223 entry.end_offset = end;
224 entry.event_count += 1;
225 }
226 false
227 }
228 };
229 self.manifest.in_turn = Some(self.in_turn);
234 is_boundary
235 }
236
237 fn plan_cap_eviction(&self, max_events: usize) -> Option<CapEvictionPlan> {
244 if max_events == 0 || self.manifest.event_count <= max_events as u64 {
245 return None;
246 }
247 let mut evicted_event_count = 0u64;
248 let mut truncate_offset = 0u64;
249 let mut evicted_turn_count = 0;
250 for (entry_index, oldest) in self.index.entries.iter().enumerate() {
251 if self.in_turn && entry_index + 1 == self.index.entries.len() {
258 break;
259 }
260 if self.manifest.event_count.saturating_sub(evicted_event_count) <= max_events as u64 {
261 break;
262 }
263 truncate_offset = oldest.end_offset;
264 evicted_event_count += oldest.event_count;
265 evicted_turn_count += 1;
266 }
267 if truncate_offset == 0 || evicted_turn_count == 0 {
268 None
269 } else {
270 Some(CapEvictionPlan {
271 truncate_offset,
272 evicted_event_count,
273 evicted_turn_count,
274 })
275 }
276 }
277
278 fn apply_cap_eviction(&mut self, plan: CapEvictionPlan, next_offset: u64) {
279 for _ in 0..plan.evicted_turn_count {
280 let _ = self.index.entries.pop_front();
281 }
282 for entry in &mut self.index.entries {
283 entry.start_offset = entry.start_offset.saturating_sub(plan.truncate_offset);
284 entry.end_offset = entry.end_offset.saturating_sub(plan.truncate_offset);
285 }
286 self.next_offset = next_offset;
287 self.manifest.event_count = self.manifest.event_count.saturating_sub(plan.evicted_event_count);
288 self.manifest.retained_turn_base = Some(self.retained_turn_base_after_eviction(0));
289 }
290
291 fn retained_turn_base_after_eviction(&self, evicted_turn_count: usize) -> u64 {
292 self.index
293 .entries
294 .get(evicted_turn_count)
295 .map(|entry| entry.turn_number)
296 .or_else(|| self.manifest.turn_count.checked_add(1))
297 .unwrap_or(1)
298 .max(1)
299 }
300}
301
302struct SessionShared {
310 file: Mutex<Option<File>>,
311 state: Mutex<LogState>,
312 eviction_lock: Mutex<()>,
313 initialized: AtomicBool,
314}
315
316fn shared_session(events_path: &Path, session_id: &str) -> Result<Arc<SessionShared>, SessionStoreError> {
321 static SESSION_SHARED: OnceLock<Mutex<HashMap<PathBuf, Weak<SessionShared>>>> = OnceLock::new();
322
323 let key = events_path
324 .parent()
325 .and_then(|parent| vtcode_commons::paths::canonicalize(parent).ok())
326 .and_then(|parent| events_path.file_name().map(|name| parent.join(name)))
327 .unwrap_or_else(|| events_path.to_path_buf());
328 let registry = SESSION_SHARED.get_or_init(|| Mutex::new(HashMap::new()));
329 let mut shared_by_path = match registry.lock() {
330 Ok(locks) => locks,
331 Err(poisoned) => poisoned.into_inner(),
332 };
333 shared_by_path.retain(|_, shared| shared.strong_count() > 0);
337 if let Some(shared) = shared_by_path.get(&key).and_then(Weak::upgrade) {
338 return Ok(shared);
339 }
340
341 let file = VtCodePaths::open_private_append_file(events_path)
342 .map_err(|error| SessionStoreError::io(events_path.to_path_buf(), std::io::Error::other(error)))?;
343 let shared = Arc::new(SessionShared {
344 file: Mutex::new(Some(file)),
345 state: Mutex::new(LogState::new(session_id)),
346 eviction_lock: Mutex::new(()),
347 initialized: AtomicBool::new(false),
348 });
349 shared_by_path.insert(key, Arc::downgrade(&shared));
350 Ok(shared)
351}
352
353pub struct SessionEventLog {
359 events_path: PathBuf,
360 manifest_store: ManifestStore,
361 shared: Arc<SessionShared>,
362 max_events: usize,
363 eviction_summary_hook: EvictionSummaryHook,
364}
365
366impl SessionEventLog {
367 pub(crate) fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
370 let dir = session_dir(workspace, session_id);
371 let hook = default_eviction_summary_hook(dir.join(crate::DERIVED_DIR), session_id.to_string());
372 Self::open_with_eviction_summary(workspace, session_id, max_events, hook)
373 }
374
375 pub fn open_with_eviction_summary(
381 workspace: &Path,
382 session_id: &str,
383 max_events: usize,
384 eviction_summary_hook: EvictionSummaryHook,
385 ) -> Result<Self, SessionStoreError> {
386 let dir = session_dir(workspace, session_id);
387 crate::ensure_private_directory(&crate::sessions_root(workspace))?;
388 crate::ensure_private_directory(&dir)?;
389 crate::ensure_private_directory(&dir.join(crate::DERIVED_DIR))?;
390 crate::ensure_private_directory(&dir.join("index"))?;
391 let events_path = dir.join("events.jsonl");
392 let manifest_store = ManifestStore::new(dir.clone());
393 let pending_rewrite = manifest_store.load_pending_cap_rewrite()?;
394 let shared = shared_session(&events_path, session_id)?;
395 let log = Self {
396 events_path: events_path.clone(),
397 manifest_store,
398 shared,
399 max_events,
400 eviction_summary_hook,
401 };
402 let _eviction_guard = log.shared.eviction_lock.lock().map_err(poison)?;
403 if !log.shared.initialized.load(Ordering::Acquire) {
406 let manifest_opt = log.manifest_store.load_manifest()?;
407 let index_opt = log.manifest_store.load_turn_index()?;
408 let file_len = log.event_file_metadata_len()?;
409 let pending_rewrite_matches_file = pending_rewrite.as_ref().is_some_and(|pending| {
410 pending.new_file_len == file_len && pending.new_file_len < pending.previous_file_len
411 });
412 match (&manifest_opt, &index_opt) {
413 (Some(manifest), Some(index))
414 if !pending_rewrite_matches_file
415 && manifest.in_turn.is_some()
416 && manifest.persisted_file_len == Some(file_len)
417 && index.is_valid_for_file(file_len)
418 && index.is_consistent_with_manifest(manifest) =>
419 {
420 let mut st = log.shared.state.lock().map_err(poison)?;
421 st.in_turn = manifest.in_turn.unwrap_or(false);
422 st.manifest = manifest.clone();
423 st.index = index.clone();
424 st.next_offset = file_len;
425 }
426 _ => {
427 let scan_turn_base = infer_scan_turn_base(
428 manifest_opt.as_ref(),
429 index_opt.as_ref(),
430 pending_rewrite.as_ref().filter(|_| pending_rewrite_matches_file),
431 file_len,
432 );
433 {
434 let mut st = log.shared.state.lock().map_err(poison)?;
435 if let Some(previous) = manifest_opt.as_ref() {
436 st.manifest = previous.clone();
437 }
438 st.manifest.turn_count = scan_turn_base.saturating_sub(1);
442 st.manifest.event_count = 0;
443 st.manifest.status = "active".to_string();
444 st.manifest.in_turn = Some(false);
445 st.manifest.retained_turn_base = Some(scan_turn_base);
446 st.index = TurnIndex::default();
447 st.in_turn = false;
448 }
449 log.scan()?;
450 let mut st = log.shared.state.lock().map_err(poison)?;
451 st.next_offset = file_len;
452 log.persist_meta_locked(&mut st)?;
453 }
454 }
455 if pending_rewrite.is_some() {
456 log.manifest_store.clear_pending_cap_rewrite()?;
462 }
463 log.shared.initialized.store(true, Ordering::Release);
464 }
465 drop(_eviction_guard);
466 Ok(log)
467 }
468
469 pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
471 let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
472 let mut st = self.shared.state.lock().map_err(poison)?;
473
474 let (start, end) = st.serialize_event(event)?;
478
479 st.manifest.event_count += 1;
480 st.manifest.updated_at = now_rfc3339();
481
482 let is_turn_boundary = st.apply_lifecycle_event(LifecycleKind::from_event(event), start, end);
486 if is_turn_boundary {
487 self.persist_meta_locked(&mut st)?;
488 }
489
490 if st.write_buf.len() >= MAX_WRITE_BUFFER_BYTES {
491 self.persist_meta_locked(&mut st)?;
495 }
496 drop(st);
497 self.enforce_event_cap()
498 }
499
500 fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
504 let mut st = self.shared.state.lock().map_err(poison)?;
505
506 let Some(plan) = st.plan_cap_eviction(self.max_events) else {
509 return Ok(());
510 };
511
512 self.flush_write_buf_locked(&mut st)?;
516
517 let (evicted, remaining, previous_file_len) = {
518 let mut file_slot = self.shared.file.lock().map_err(poison)?;
519 let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
520 let file_len = file
521 .metadata()
522 .map_err(|error| SessionStoreError::io(&self.events_path, error))?
523 .len();
524 if plan.truncate_offset > file_len {
525 return Err(SessionStoreError::io(
526 &self.events_path,
527 std::io::Error::new(std::io::ErrorKind::InvalidData, "cap offset exceeds event log length"),
528 ));
529 }
530 file.seek(SeekFrom::Start(0))
531 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
532 let mut evicted = vec![
533 0u8;
534 usize::try_from(plan.truncate_offset).map_err(|error| {
535 SessionStoreError::io(
536 &self.events_path,
537 std::io::Error::new(std::io::ErrorKind::InvalidData, error),
538 )
539 })?
540 ];
541 file.read_exact(&mut evicted)
542 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
543 file.seek(SeekFrom::Start(plan.truncate_offset))
544 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
545 let mut remaining = Vec::new();
546 file.read_to_end(&mut remaining)
547 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
548 (evicted, remaining, file_len)
549 };
550 let retained_turn_base = st.retained_turn_base_after_eviction(plan.evicted_turn_count);
551 drop(st);
552
553 let mut plan = plan;
559 plan.evicted_event_count = count_persisted_event_records(&evicted);
560 let evicted_events = decode_events(&evicted);
561 (self.eviction_summary_hook)(&evicted_events)?;
562
563 let new_file_len = u64::try_from(remaining.len()).map_err(|error| {
564 SessionStoreError::io(&self.events_path, std::io::Error::new(std::io::ErrorKind::InvalidData, error))
565 })?;
566 self.manifest_store.write_pending_cap_rewrite(&PendingCapRewrite {
567 previous_file_len,
568 new_file_len,
569 retained_turn_base,
570 })?;
571 let next_offset = self.replace_event_file_contents(&remaining)?;
572 let mut st = self.shared.state.lock().map_err(poison)?;
573 st.apply_cap_eviction(plan, next_offset);
574 self.persist_meta_locked(&mut st)?;
577 self.manifest_store.clear_pending_cap_rewrite()?;
578 Ok(())
579 }
580
581 pub(crate) fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
583 let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
587 let entry = {
588 let st = self.shared.state.lock().map_err(poison)?;
589 st.index
590 .entries
591 .iter()
592 .find(|e| e.turn_number == turn)
593 .cloned()
594 .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
595 };
596 {
597 let mut st = self.shared.state.lock().map_err(poison)?;
598 self.flush_write_buf_locked(&mut st)?;
599 }
600 let buf = {
601 let mut file_slot = self.shared.file.lock().map_err(poison)?;
602 let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
603 file.seek(SeekFrom::Start(entry.start_offset))
604 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
605 let len = usize::try_from(entry.end_offset.checked_sub(entry.start_offset).ok_or_else(|| {
606 SessionStoreError::io(
607 &self.events_path,
608 std::io::Error::new(std::io::ErrorKind::InvalidData, "turn index offsets are out of order"),
609 )
610 })?)
611 .map_err(|error| {
612 SessionStoreError::io(&self.events_path, std::io::Error::new(std::io::ErrorKind::InvalidData, error))
613 })?;
614 let mut buf = vec![0u8; len];
615 file.read_exact(&mut buf)
616 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
617 buf
618 };
619 let text = String::from_utf8_lossy(&buf);
620 let mut events = Vec::new();
621 for line in text.lines() {
622 let line = line.trim();
623 if line.is_empty() {
624 continue;
625 }
626 let v: VersionedThreadEvent = match serde_json::from_str(line) {
632 Ok(v) => v,
633 Err(_) => continue,
634 };
635 events.push(v.into_event());
636 }
637 Ok(events)
638 }
639
640 #[must_use]
642 pub(crate) fn turn_count(&self) -> u64 {
643 self.shared.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
644 }
645
646 #[must_use]
648 pub fn event_count(&self) -> u64 {
649 self.shared.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
650 }
651
652 pub fn flush(&self) -> Result<(), SessionStoreError> {
654 let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
655 let mut st = self.shared.state.lock().map_err(poison)?;
656 self.persist_meta_locked(&mut st)
657 }
658
659 #[must_use]
661 pub fn manifest(&self) -> SessionManifest {
662 self.shared
663 .state
664 .lock()
665 .map_err(poison)
666 .map(|s| s.manifest.clone())
667 .unwrap_or_else(|_| SessionManifest::new(""))
668 }
669
670 #[must_use]
672 pub fn turn_index(&self) -> TurnIndex {
673 self.shared
674 .state
675 .lock()
676 .map_err(poison)
677 .map(|s| s.index.clone())
678 .unwrap_or_default()
679 }
680
681 pub(crate) fn complete(&self) -> Result<(), SessionStoreError> {
687 let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
688 let mut st = self.shared.state.lock().map_err(poison)?;
689 st.manifest.updated_at = now_rfc3339();
690 self.persist_meta_locked(&mut st)
691 }
692
693 fn scan(&self) -> Result<(), SessionStoreError> {
699 let mut st = self.shared.state.lock().map_err(poison)?;
700 let file = self
701 .shared
702 .file
703 .lock()
704 .map_err(poison)?
705 .as_ref()
706 .ok_or_else(|| self.event_file_unavailable())?
707 .try_clone()
708 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
709 let mut reader = std::io::BufReader::new(file);
710 reader
711 .seek(SeekFrom::Start(0))
712 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
713 let mut buf = Vec::new();
714 let mut pos = 0u64;
715 let mut first_ts: Option<String> = None;
716 loop {
717 buf.clear();
718 let n = reader
719 .read_until(b'\n', &mut buf)
720 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
721 if n == 0 {
722 break;
723 }
724 let line_end = pos + n as u64;
725 let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
726 if !trimmed.is_empty()
727 && let Ok(v) = serde_json::from_str::<VersionedEventKind<'_>>(trimmed)
728 {
729 let kind = v.event.kind;
730 if requires_full_lifecycle_validation(kind) && !valid_lifecycle_payload(kind, trimmed) {
731 pos = line_end;
732 continue;
733 }
734 st.manifest.event_count += 1;
735 if kind == "thread.started" && first_ts.is_none() {
738 first_ts = Some(now_rfc3339());
739 }
740 st.apply_lifecycle_event(LifecycleKind::from_kind(kind), pos, line_end);
743 }
744 pos = line_end;
745 }
746 st.manifest.in_turn = Some(st.in_turn);
750 if let Some(ts) = first_ts
751 && st.manifest.created_at.is_empty()
752 {
753 st.manifest.created_at = ts;
754 }
755 Ok(())
756 }
757
758 fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
759 self.flush_write_buf_locked(st)?;
760 st.manifest.persisted_file_len = Some(st.next_offset);
765 self.manifest_store.write_turn_index(&st.index)?;
770 self.manifest_store.write_manifest(&st.manifest)?;
771 Ok(())
772 }
773
774 fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
776 if st.write_buf.is_empty() {
777 return Ok(());
778 }
779 let mut file_slot = self.shared.file.lock().map_err(poison)?;
780 let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
781 let previous_len = file.metadata().map_err(|e| SessionStoreError::io(&self.events_path, e))?.len();
782 if let Err(error) = file.write_all(&st.write_buf) {
783 if file.set_len(previous_len).is_err() {
784 st.write_buf.clear();
785 }
786 return Err(SessionStoreError::io(&self.events_path, error));
787 }
788 if let Err(error) = file.sync_data() {
789 st.write_buf.clear();
790 return Err(SessionStoreError::io(&self.events_path, error));
791 }
792 st.write_buf.clear();
793 Ok(())
794 }
795
796 fn event_file_metadata_len(&self) -> Result<u64, SessionStoreError> {
797 let file_slot = self.shared.file.lock().map_err(poison)?;
798 file_slot
799 .as_ref()
800 .ok_or_else(|| self.event_file_unavailable())?
801 .metadata()
802 .map(|metadata| metadata.len())
803 .map_err(|error| SessionStoreError::io(&self.events_path, error))
804 }
805
806 fn open_event_file(&self) -> Result<File, SessionStoreError> {
807 VtCodePaths::open_private_append_file(&self.events_path)
808 .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
809 }
810
811 fn replace_event_file_contents(&self, contents: &[u8]) -> Result<u64, SessionStoreError> {
812 let old_file = {
813 let mut file_slot = self.shared.file.lock().map_err(poison)?;
814 file_slot.take().ok_or_else(|| self.event_file_unavailable())?
815 };
816 drop(old_file);
817
818 if let Err(error) = VtCodePaths::write_private_file_atomic(&self.events_path, contents)
819 .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
820 {
821 let restored = self.open_event_file();
822 if let Ok(file) = restored {
823 let mut file_slot = self.shared.file.lock().map_err(poison)?;
824 *file_slot = Some(file);
825 return Err(error);
826 }
827 return Err(SessionStoreError::io(
828 &self.events_path,
829 std::io::Error::other(format!("{error}; failed to restore event log handle")),
830 ));
831 }
832
833 let replacement = self.open_event_file()?;
834 let next_offset = replacement
835 .metadata()
836 .map_err(|error| SessionStoreError::io(&self.events_path, error))?
837 .len();
838 let mut file_slot = self.shared.file.lock().map_err(poison)?;
839 *file_slot = Some(replacement);
840 Ok(next_offset)
841 }
842
843 fn event_file_unavailable(&self) -> SessionStoreError {
844 SessionStoreError::io(&self.events_path, std::io::Error::other("event log file is unavailable"))
845 }
846}
847
848fn infer_scan_turn_base(
857 manifest: Option<&SessionManifest>,
858 index: Option<&TurnIndex>,
859 pending: Option<&PendingCapRewrite>,
860 file_len: u64,
861) -> u64 {
862 let marker_base = pending
863 .filter(|pending| pending.new_file_len == file_len && pending.new_file_len < pending.previous_file_len)
864 .map(|pending| pending.retained_turn_base);
865 let rewritten_base = manifest.and_then(|manifest| {
866 let previous_len = manifest.persisted_file_len?;
867 if previous_len <= file_len {
868 return None;
869 }
870 let removed_prefix = previous_len - file_len;
871 let index = index.filter(|index| index.is_valid_for_file(previous_len))?;
872 let first_retained = index
873 .entries
874 .iter()
875 .find(|entry| entry.start_offset >= removed_prefix && entry.end_offset <= previous_len)
876 .map(|entry| entry.turn_number);
877 first_retained.or_else(|| {
878 (removed_prefix >= previous_len).then(|| manifest.turn_count.saturating_add(1))
883 })
884 });
885
886 let legacy_index_base = index
887 .filter(|index| index.is_valid_for_file(file_len))
888 .and_then(|index| index.entries.front().map(|entry| entry.turn_number));
889
890 marker_base
891 .or(rewritten_base)
892 .or_else(|| manifest.and_then(|manifest| manifest.retained_turn_base))
893 .or(legacy_index_base)
894 .unwrap_or(1)
895 .max(1)
896}
897
898fn requires_full_lifecycle_validation(kind: &str) -> bool {
899 matches!(kind, "thread.started" | "thread.completed" | "turn.started" | "turn.completed" | "turn.failed")
900}
901
902fn valid_lifecycle_payload(kind: &str, line: &str) -> bool {
903 if serde_json::from_str::<VersionedThreadEvent>(line).is_err() {
904 return false;
905 }
906 if kind != "turn.completed" {
907 return true;
908 }
909 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
910 return false;
911 };
912 value
913 .get("event")
914 .and_then(|event| event.get("usage"))
915 .is_some_and(serde_json::Value::is_object)
916}
917
918fn decode_events(bytes: &[u8]) -> Vec<ThreadEvent> {
919 bytes
920 .split(|byte| *byte == b'\n')
921 .filter_map(|line| {
922 let line = std::str::from_utf8(line).ok()?.trim();
923 if line.is_empty() {
924 return None;
925 }
926 serde_json::from_str::<VersionedThreadEvent>(line)
927 .ok()
928 .map(VersionedThreadEvent::into_event)
929 })
930 .collect()
931}
932
933fn count_persisted_event_records(bytes: &[u8]) -> u64 {
938 bytes
939 .split(|byte| *byte == b'\n')
940 .filter_map(|line| std::str::from_utf8(line).ok())
941 .map(str::trim)
942 .filter(|line| !line.is_empty())
943 .filter(|line| {
944 let Ok(value) = serde_json::from_str::<VersionedEventKind<'_>>(line) else {
945 return false;
946 };
947 let kind = value.event.kind;
948 !requires_full_lifecycle_validation(kind) || valid_lifecycle_payload(kind, line)
949 })
950 .count() as u64
951}
952
953#[derive(Debug, Serialize)]
954struct EvictionSummary {
955 session_id: String,
956 evicted_event_count: usize,
957 event_types: BTreeMap<String, u64>,
958 created_at: String,
959}
960
961fn default_eviction_summary_hook(derived_dir: PathBuf, session_id: String) -> EvictionSummaryHook {
962 Arc::new(move |events| {
963 let mut event_types = BTreeMap::new();
964 for event in events {
965 let kind = serde_json::to_value(event)
966 .ok()
967 .and_then(|value| value.get("type").and_then(|value| value.as_str()).map(str::to_owned))
968 .unwrap_or_else(|| "unknown".to_owned());
969 *event_types.entry(kind).or_insert(0) += 1;
970 }
971 let summary = EvictionSummary {
972 session_id: session_id.clone(),
973 evicted_event_count: events.len(),
974 event_types,
975 created_at: now_rfc3339(),
976 };
977 let path = derived_dir.join(format!("eviction-summary-{}.json", uuid::Uuid::new_v4().simple()));
978 let bytes = serde_json::to_vec(&summary)?;
979 VtCodePaths::write_private_file_atomic(&path, &bytes)
980 .map_err(|error| SessionStoreError::io(path, std::io::Error::other(error)))
981 })
982}
983
984impl Drop for SessionEventLog {
985 fn drop(&mut self) {
986 if let Ok(_eviction_guard) = self.shared.eviction_lock.lock()
987 && let Ok(mut st) = self.shared.state.lock()
988 {
989 let _ = self.flush_write_buf_locked(&mut st);
995 }
996 }
997}
998
999fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
1001 SessionStoreError::Io {
1002 path: PathBuf::new(),
1003 source: std::io::Error::other("session store lock poisoned"),
1004 }
1005}
1006
1007fn now_rfc3339() -> String {
1008 Utc::now().to_rfc3339()
1009}
1010
1011#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1013pub struct SessionManifest {
1014 pub session_id: String,
1016 schema_version: u32,
1018 pub created_at: String,
1020 pub updated_at: String,
1022 pub turn_count: u64,
1024 pub event_count: u64,
1026 pub status: String,
1028 #[serde(default)]
1034 in_turn: Option<bool>,
1035 #[serde(default)]
1041 persisted_file_len: Option<u64>,
1042 #[serde(default)]
1048 retained_turn_base: Option<u64>,
1049}
1050
1051impl SessionManifest {
1052 #[must_use]
1054 pub(crate) fn new(session_id: &str) -> Self {
1055 let ts = now_rfc3339();
1056 Self {
1057 session_id: session_id.to_string(),
1058 schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
1059 created_at: ts.clone(),
1060 updated_at: ts,
1061 turn_count: 0,
1062 event_count: 0,
1063 status: "active".to_string(),
1064 in_turn: Some(false),
1065 persisted_file_len: Some(0),
1066 retained_turn_base: Some(1),
1067 }
1068 }
1069}
1070
1071#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1073pub struct TurnIndexEntry {
1074 turn_number: u64,
1076 start_offset: u64,
1078 end_offset: u64,
1080 event_count: u64,
1082 ts: String,
1084}
1085
1086#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1088pub struct TurnIndex {
1089 entries: VecDeque<TurnIndexEntry>,
1091}
1092
1093impl TurnIndex {
1094 #[must_use]
1096 pub fn len(&self) -> usize {
1097 self.entries.len()
1098 }
1099
1100 #[must_use]
1102 pub fn is_empty(&self) -> bool {
1103 self.entries.is_empty()
1104 }
1105
1106 fn is_valid_for_file(&self, file_len: u64) -> bool {
1107 let mut previous_end = 0u64;
1108 self.entries.iter().all(|entry| {
1109 let valid = entry.event_count > 0
1110 && entry.start_offset >= previous_end
1111 && entry.start_offset <= entry.end_offset
1112 && entry.end_offset <= file_len;
1113 if valid {
1114 previous_end = entry.end_offset;
1115 }
1116 valid
1117 })
1118 }
1119
1120 fn is_consistent_with_manifest(&self, manifest: &SessionManifest) -> bool {
1121 let expected_last_turn = if manifest.in_turn == Some(true) {
1122 manifest.turn_count.saturating_add(1)
1123 } else {
1124 manifest.turn_count
1125 };
1126 let entries_are_contiguous = self
1127 .entries
1128 .iter()
1129 .map(|entry| entry.turn_number)
1130 .try_fold(None::<u64>, |previous, turn_number| {
1131 if previous.is_some_and(|previous| turn_number != previous.saturating_add(1)) {
1132 return Err(());
1133 }
1134 Ok(Some(turn_number))
1135 })
1136 .is_ok();
1137 if !entries_are_contiguous {
1138 return false;
1139 }
1140
1141 let expected_first_turn = manifest.retained_turn_base.unwrap_or(1).max(1);
1142 match (self.entries.front(), self.entries.back()) {
1143 (Some(first), Some(last)) => {
1144 first.turn_number == expected_first_turn && last.turn_number == expected_last_turn
1145 }
1146 (None, None) => {
1147 expected_last_turn == 0
1148 || manifest
1149 .retained_turn_base
1150 .is_some_and(|retained_turn_base| retained_turn_base > manifest.turn_count)
1151 }
1152 _ => false,
1153 }
1154 }
1155}
1156
1157#[cfg(test)]
1158mod borrowed_envelope_tests {
1159 use super::{BorrowedVersionedEvent, EVENT_SCHEMA_VERSION};
1160 use vtcode_exec_events::{
1161 ThreadEvent, ThreadStartedEvent, TurnCompletedEvent, TurnStartedEvent, Usage, VersionedThreadEvent,
1162 };
1163
1164 #[test]
1168 fn borrowed_envelope_matches_versioned_envelope() {
1169 for event in [
1170 ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "thread".to_string() }),
1171 ThreadEvent::TurnStarted(TurnStartedEvent::default()),
1172 ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() }),
1173 ] {
1174 let canonical =
1175 serde_json::to_string(&VersionedThreadEvent::new(event.clone())).expect("canonical serialize");
1176 let borrowed = serde_json::to_string(&BorrowedVersionedEvent {
1177 schema_version: EVENT_SCHEMA_VERSION,
1178 event: &event,
1179 })
1180 .expect("borrowed serialize");
1181 assert_eq!(canonical, borrowed, "JSON differs for {event:?}");
1182 }
1183 }
1184}
1185
1186#[cfg(test)]
1187mod lifecycle_state_machine_tests {
1188 use super::{LifecycleKind, LogState};
1189 use vtcode_exec_events::{
1190 ThreadCompletedEvent, ThreadCompletionSubtype, ThreadEvent, ThreadStartedEvent, TurnCompletedEvent,
1191 TurnFailedEvent, TurnStartedEvent, Usage,
1192 };
1193
1194 fn fresh_state() -> LogState {
1195 LogState::new("test-session")
1196 }
1197
1198 #[test]
1199 fn lifecycle_kind_from_event_covers_all_variants() {
1200 assert_eq!(
1201 LifecycleKind::from_event(&ThreadEvent::TurnStarted(TurnStartedEvent::default())),
1202 LifecycleKind::TurnStarted
1203 );
1204 assert_eq!(
1205 LifecycleKind::from_event(&ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() })),
1206 LifecycleKind::TurnCompleted
1207 );
1208 assert_eq!(
1209 LifecycleKind::from_event(&ThreadEvent::TurnFailed(TurnFailedEvent {
1210 message: "err".to_string(),
1211 usage: None,
1212 })),
1213 LifecycleKind::TurnFailed
1214 );
1215 assert_eq!(
1216 LifecycleKind::from_event(&ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "x".to_string() })),
1217 LifecycleKind::ThreadStarted
1218 );
1219 assert_eq!(
1220 LifecycleKind::from_event(&ThreadEvent::ThreadCompleted(Box::new(ThreadCompletedEvent {
1221 thread_id: "x".to_string(),
1222 session_id: "x".to_string(),
1223 subtype: ThreadCompletionSubtype::Success,
1224 outcome_code: "completed".to_string(),
1225 result: None,
1226 stop_reason: None,
1227 usage: Usage::default(),
1228 total_cost_usd: None,
1229 num_turns: 1,
1230 }))),
1231 LifecycleKind::ThreadCompleted
1232 );
1233 assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
1234 assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
1235 }
1236
1237 #[test]
1238 fn lifecycle_kind_from_str_matches_event_discriminator() {
1239 assert_eq!(LifecycleKind::from_kind("turn.started"), LifecycleKind::TurnStarted);
1240 assert_eq!(LifecycleKind::from_kind("turn.completed"), LifecycleKind::TurnCompleted);
1241 assert_eq!(LifecycleKind::from_kind("turn.failed"), LifecycleKind::TurnFailed);
1242 assert_eq!(LifecycleKind::from_kind("tool.called"), LifecycleKind::Other);
1243 assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
1244 assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
1245 }
1246
1247 #[test]
1248 fn turn_started_pushes_index_entry_and_sets_in_turn() {
1249 let mut st = fresh_state();
1250 st.manifest.status = "completed".to_string();
1251 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1252 assert!(!is_boundary, "TurnStarted is not a turn boundary");
1253 assert!(st.in_turn);
1254 assert_eq!(st.manifest.status, "active");
1255 assert_eq!(st.index.entries.len(), 1);
1256 let entry = &st.index.entries[0];
1257 assert_eq!(entry.turn_number, 1);
1258 assert_eq!(entry.start_offset, 0);
1259 assert_eq!(entry.end_offset, 100);
1260 assert_eq!(entry.event_count, 1);
1261 }
1262
1263 #[test]
1264 fn intermediate_events_extend_current_turn() {
1265 let mut st = fresh_state();
1266 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1267 let is_b1 = st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
1269 let is_b2 = st.apply_lifecycle_event(LifecycleKind::Other, 200, 300);
1270 assert!(!is_b1 && !is_b2);
1271 assert!(st.in_turn);
1272 assert_eq!(st.index.entries.len(), 1);
1273 let entry = &st.index.entries[0];
1274 assert_eq!(entry.end_offset, 300);
1275 assert_eq!(entry.event_count, 3);
1276 }
1277
1278 #[test]
1279 fn turn_completed_closes_turn_and_returns_boundary() {
1280 let mut st = fresh_state();
1281 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1282 st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
1283 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 200, 300);
1284 assert!(is_boundary);
1285 assert!(!st.in_turn);
1286 assert_eq!(st.manifest.turn_count, 1);
1287 assert_eq!(st.manifest.status, "active");
1288 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 300, 400);
1289 assert_eq!(st.manifest.status, "completed");
1290 let entry = &st.index.entries[0];
1291 assert_eq!(entry.end_offset, 300);
1292 assert_eq!(entry.event_count, 3);
1293 }
1294
1295 #[test]
1296 fn turn_failed_closes_turn_without_terminal_thread_status() {
1297 let mut st = fresh_state();
1298 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1299 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnFailed, 100, 200);
1300 assert!(is_boundary);
1301 assert!(!st.in_turn);
1302 assert_eq!(st.manifest.turn_count, 1);
1303 assert_eq!(st.manifest.status, "active");
1304 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 200, 300);
1305 assert_eq!(st.manifest.status, "completed");
1306 }
1307
1308 #[test]
1309 fn turn_completed_without_turn_started_is_idempotent() {
1310 let mut st = fresh_state();
1311 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 0, 100);
1315 assert!(is_boundary);
1316 assert!(!st.in_turn);
1317 assert_eq!(st.manifest.turn_count, 0, "no turn was started");
1318 assert_eq!(st.manifest.status, "active");
1319 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 100, 200);
1320 assert_eq!(st.manifest.status, "completed");
1321 assert!(st.index.entries.is_empty());
1322 }
1323
1324 #[test]
1325 fn multiple_turns_get_incrementing_ordinals() {
1326 let mut st = fresh_state();
1327 for n in 1..=3 {
1328 st.apply_lifecycle_event(LifecycleKind::TurnStarted, n * 100, n * 100 + 50);
1329 st.apply_lifecycle_event(LifecycleKind::TurnCompleted, n * 100 + 50, n * 100 + 100);
1330 }
1331 assert_eq!(st.index.entries.len(), 3);
1332 for (i, entry) in st.index.entries.iter().enumerate() {
1333 assert_eq!(entry.turn_number, (i + 1) as u64);
1334 }
1335 assert_eq!(st.manifest.turn_count, 3);
1336 }
1337}
1338
1339#[cfg(test)]
1340mod cap_eviction_tests {
1341 use super::{LogState, TurnIndexEntry};
1342
1343 fn state_with_turns(turns: usize, events_per_turn: u64) -> LogState {
1346 let mut st = LogState::new("cap-test");
1347 st.manifest.event_count = (turns as u64) * events_per_turn;
1348 let mut offset = 0u64;
1349 for n in 1..=turns {
1350 st.index.entries.push_back(TurnIndexEntry {
1351 turn_number: n as u64,
1352 start_offset: offset,
1353 end_offset: offset + events_per_turn * 10,
1354 event_count: events_per_turn,
1355 ts: "2026-01-01T00:00:00Z".to_string(),
1356 });
1357 offset += events_per_turn * 10;
1358 }
1359 st
1360 }
1361
1362 #[test]
1363 fn no_eviction_when_under_cap() {
1364 let st = state_with_turns(3, 2); assert!(st.plan_cap_eviction(10).is_none());
1366 assert_eq!(st.index.entries.len(), 3, "no turns should be evicted");
1367 }
1368
1369 #[test]
1370 fn no_eviction_when_cap_disabled() {
1371 let st = state_with_turns(5, 2); assert!(st.plan_cap_eviction(0).is_none());
1373 assert_eq!(st.index.entries.len(), 5);
1374 }
1375
1376 #[test]
1377 fn evicts_oldest_turns_to_meet_cap() {
1378 let st = state_with_turns(5, 2);
1380 let plan = st.plan_cap_eviction(6).expect("eviction planned");
1381 assert_eq!(plan.evicted_event_count, 4, "should evict 4 events (2 turns)");
1382 assert_eq!(st.index.entries.len(), 5, "planning must not mutate state");
1383 assert_eq!(plan.truncate_offset, 40); let mut st = st;
1388 st.apply_cap_eviction(plan, 60);
1389 assert_eq!(st.index.entries.len(), 3, "should keep 3 turns");
1390 assert_eq!(st.index.entries[0].turn_number, 3);
1391 assert_eq!(st.index.entries[2].turn_number, 5);
1392 }
1393
1394 #[test]
1395 fn evicts_all_turns_when_cap_smaller_than_one_turn() {
1396 let st = state_with_turns(3, 5);
1400 let plan = st.plan_cap_eviction(3).expect("eviction planned");
1401 assert_eq!(plan.evicted_event_count, 15, "all events evicted");
1402 let mut st = st;
1403 st.apply_cap_eviction(plan, 0);
1404 assert_eq!(st.index.entries.len(), 0);
1405 }
1406}