use std::collections::{BTreeMap, HashMap, VecDeque};
use std::fs::File;
use std::io::{BufRead, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, OnceLock, Weak};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use vtcode_commons::VtCodePaths;
use vtcode_exec_events::{EVENT_SCHEMA_VERSION, ThreadEvent, ThreadItemDetails, VersionedThreadEvent};
use crate::error::SessionStoreError;
pub(crate) const SESSION_LOCK_FILE: &str = "session.lock";
use crate::manifest::{ManifestStore, PendingCapRewrite};
use crate::session_dir;
mod append;
mod eviction;
mod manifest;
mod scan;
use eviction::default_eviction_summary_hook;
pub use manifest::{SessionManifest, TurnIndex, TurnIndexEntry};
use scan::{
count_persisted_event_records, decode_events, infer_scan_turn_base, requires_full_lifecycle_validation,
valid_lifecycle_payload,
};
pub const DEFAULT_MAX_EVENTS: usize = 10_000;
const MAX_WRITE_BUFFER_BYTES: usize = 64 * 1024;
pub type EvictionSummaryHook = Arc<dyn Fn(&[ThreadEvent]) -> Result<(), SessionStoreError> + Send + Sync>;
#[derive(Debug, Deserialize)]
struct VersionedEventKind<'a> {
#[serde(rename = "schema_version", borrow)]
_schema_version: &'a str,
#[serde(borrow)]
event: EventKind<'a>,
}
#[derive(Debug, Deserialize)]
struct EventKind<'a> {
#[serde(rename = "type", borrow)]
kind: &'a str,
}
#[derive(Serialize)]
struct BorrowedVersionedEvent<'a> {
schema_version: &'a str,
event: &'a ThreadEvent,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LifecycleKind {
ThreadStarted,
ThreadCompleted,
TurnStarted,
TurnCompleted,
TurnFailed,
Other,
}
impl LifecycleKind {
#[inline]
fn from_event(event: &ThreadEvent) -> Self {
match event {
ThreadEvent::ThreadStarted(_) => Self::ThreadStarted,
ThreadEvent::ThreadCompleted(_) => Self::ThreadCompleted,
ThreadEvent::TurnStarted(_) => Self::TurnStarted,
ThreadEvent::TurnCompleted(_) => Self::TurnCompleted,
ThreadEvent::TurnFailed(_) => Self::TurnFailed,
_ => Self::Other,
}
}
#[inline]
fn from_kind(kind: &str) -> Self {
match kind {
"thread.started" => Self::ThreadStarted,
"thread.completed" => Self::ThreadCompleted,
"turn.started" => Self::TurnStarted,
"turn.completed" => Self::TurnCompleted,
"turn.failed" => Self::TurnFailed,
_ => Self::Other,
}
}
}
struct LogState {
manifest: SessionManifest,
index: TurnIndex,
in_turn: bool,
next_offset: u64,
write_buf: Vec<u8>,
}
#[derive(Debug, Clone, Copy)]
struct CapEvictionPlan {
truncate_offset: u64,
evicted_event_count: u64,
evicted_turn_count: usize,
}
impl LogState {
fn new(session_id: &str) -> Self {
Self {
manifest: SessionManifest::new(session_id),
index: TurnIndex::default(),
in_turn: false,
next_offset: 0,
write_buf: Vec::with_capacity(65536),
}
}
fn serialize_event(&mut self, event: &ThreadEvent) -> Result<(u64, u64), SessionStoreError> {
let start = self.next_offset;
let buf_len_before = self.write_buf.len();
if let Err(err) = serde_json::to_writer(
&mut self.write_buf,
&BorrowedVersionedEvent { schema_version: EVENT_SCHEMA_VERSION, event },
) {
self.write_buf.truncate(buf_len_before);
return Err(err.into());
}
self.write_buf.push(b'\n');
let written = self.write_buf.len() - buf_len_before;
let end = start + written as u64;
self.next_offset = end;
Ok((start, end))
}
fn apply_lifecycle_event(&mut self, kind: LifecycleKind, start: u64, end: u64) -> bool {
let is_boundary = match kind {
LifecycleKind::ThreadStarted => {
self.manifest.status = "active".to_string();
false
}
LifecycleKind::ThreadCompleted => {
self.manifest.status = "completed".to_string();
true
}
LifecycleKind::TurnStarted => {
self.manifest.status = "active".to_string();
self.in_turn = true;
let n = self.manifest.turn_count + 1;
self.index.entries.push_back(TurnIndexEntry {
turn_number: n,
start_offset: start,
end_offset: end,
event_count: 1,
ts: now_rfc3339(),
});
false
}
LifecycleKind::TurnCompleted | LifecycleKind::TurnFailed => {
if self.in_turn {
if let Some(entry) = self.index.entries.back_mut() {
entry.end_offset = end;
entry.event_count += 1;
self.manifest.turn_count = self.manifest.turn_count.max(entry.turn_number);
}
self.in_turn = false;
}
true
}
LifecycleKind::Other => {
if self.in_turn
&& let Some(entry) = self.index.entries.back_mut()
{
entry.end_offset = end;
entry.event_count += 1;
}
false
}
};
self.manifest.in_turn = Some(self.in_turn);
is_boundary
}
fn plan_cap_eviction(&self, max_events: usize) -> Option<CapEvictionPlan> {
if max_events == 0 || self.manifest.event_count <= max_events as u64 {
return None;
}
let mut evicted_event_count = 0u64;
let mut truncate_offset = 0u64;
let mut evicted_turn_count = 0;
for (entry_index, oldest) in self.index.entries.iter().enumerate() {
if self.in_turn && entry_index + 1 == self.index.entries.len() {
break;
}
if self.manifest.event_count.saturating_sub(evicted_event_count) <= max_events as u64 {
break;
}
truncate_offset = oldest.end_offset;
evicted_event_count += oldest.event_count;
evicted_turn_count += 1;
}
if truncate_offset == 0 || evicted_turn_count == 0 {
None
} else {
Some(CapEvictionPlan {
truncate_offset,
evicted_event_count,
evicted_turn_count,
})
}
}
fn apply_cap_eviction(&mut self, plan: CapEvictionPlan, next_offset: u64) {
for _ in 0..plan.evicted_turn_count {
let _ = self.index.entries.pop_front();
}
for entry in &mut self.index.entries {
entry.start_offset = entry.start_offset.saturating_sub(plan.truncate_offset);
entry.end_offset = entry.end_offset.saturating_sub(plan.truncate_offset);
}
self.next_offset = next_offset;
self.manifest.event_count = self.manifest.event_count.saturating_sub(plan.evicted_event_count);
self.manifest.retained_turn_base = Some(self.retained_turn_base_after_eviction(0));
}
fn retained_turn_base_after_eviction(&self, evicted_turn_count: usize) -> u64 {
self.index
.entries
.get(evicted_turn_count)
.map(|entry| entry.turn_number)
.or_else(|| self.manifest.turn_count.checked_add(1))
.unwrap_or(1)
.max(1)
}
}
struct SessionShared {
file: Mutex<Option<File>>,
state: Mutex<LogState>,
eviction_lock: Mutex<()>,
initialized: AtomicBool,
liveness_lock: Mutex<Option<File>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OpenMode {
Shared,
Exclusive,
}
fn shared_session(
events_path: &Path,
session_id: &str,
mode: OpenMode,
) -> Result<Arc<SessionShared>, SessionStoreError> {
static SESSION_SHARED: OnceLock<Mutex<HashMap<PathBuf, Weak<SessionShared>>>> = OnceLock::new();
let key = events_path
.parent()
.and_then(|parent| vtcode_commons::paths::canonicalize(parent).ok())
.and_then(|parent| events_path.file_name().map(|name| parent.join(name)))
.unwrap_or_else(|| events_path.to_path_buf());
let registry = SESSION_SHARED.get_or_init(|| Mutex::new(HashMap::new()));
let mut shared_by_path = match registry.lock() {
Ok(locks) => locks,
Err(poisoned) => poisoned.into_inner(),
};
shared_by_path.retain(|_, shared| shared.strong_count() > 0);
if let Some(shared) = shared_by_path.get(&key).and_then(Weak::upgrade) {
if mode == OpenMode::Exclusive {
shared.ensure_exclusive_liveness(events_path, session_id)?;
}
return Ok(shared);
}
let file = VtCodePaths::open_private_append_file(events_path)
.map_err(|error| SessionStoreError::io(events_path.to_path_buf(), std::io::Error::other(error)))?;
let liveness_lock = match mode {
OpenMode::Shared => acquire_liveness_lock(events_path, session_id),
OpenMode::Exclusive => acquire_liveness_lock_strict(events_path, session_id)?,
};
let shared = Arc::new(SessionShared {
file: Mutex::new(Some(file)),
state: Mutex::new(LogState::new(session_id)),
eviction_lock: Mutex::new(()),
initialized: AtomicBool::new(false),
liveness_lock: Mutex::new(liveness_lock),
});
shared_by_path.insert(key, Arc::downgrade(&shared));
Ok(shared)
}
fn acquire_liveness_lock(events_path: &Path, session_id: &str) -> Option<File> {
acquire_liveness_lock_strict(events_path, session_id).unwrap_or(None)
}
fn acquire_liveness_lock_strict(events_path: &Path, session_id: &str) -> Result<Option<File>, SessionStoreError> {
let Some(dir) = events_path.parent() else {
return Ok(None);
};
let lock_path = dir.join(SESSION_LOCK_FILE);
if VtCodePaths::write_private_file_atomic_if_absent(&lock_path, b"").is_err() {
return Ok(None);
}
let file = match std::fs::OpenOptions::new().read(true).write(true).open(&lock_path) {
Ok(file) => file,
Err(_) => return Ok(None),
};
match file.try_lock().map_err(std::io::Error::from) {
Ok(()) => Ok(Some(file)),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
Err(SessionStoreError::SessionBusy { session: session_id.to_owned() })
}
Err(_) => Ok(None),
}
}
impl SessionShared {
fn ensure_exclusive_liveness(&self, events_path: &Path, session_id: &str) -> Result<(), SessionStoreError> {
let mut slot = self.liveness_lock.lock().map_err(poison)?;
if slot.is_none() {
*slot = acquire_liveness_lock_strict(events_path, session_id)?;
}
Ok(())
}
}
pub struct SessionEventLog {
events_path: PathBuf,
manifest_store: ManifestStore,
shared: Arc<SessionShared>,
max_events: usize,
eviction_summary_hook: EvictionSummaryHook,
}
impl SessionEventLog {
pub(crate) fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
let dir = session_dir(workspace, session_id);
let hook = default_eviction_summary_hook(dir.join(crate::DERIVED_DIR), session_id.to_string());
Self::open_inner(workspace, session_id, max_events, hook, OpenMode::Shared)
}
pub(crate) fn open_exclusive(
workspace: &Path,
session_id: &str,
max_events: usize,
) -> Result<Self, SessionStoreError> {
let dir = session_dir(workspace, session_id);
let hook = default_eviction_summary_hook(dir.join(crate::DERIVED_DIR), session_id.to_string());
Self::open_inner(workspace, session_id, max_events, hook, OpenMode::Exclusive)
}
pub fn open_with_eviction_summary(
workspace: &Path,
session_id: &str,
max_events: usize,
eviction_summary_hook: EvictionSummaryHook,
) -> Result<Self, SessionStoreError> {
Self::open_inner(workspace, session_id, max_events, eviction_summary_hook, OpenMode::Shared)
}
fn open_inner(
workspace: &Path,
session_id: &str,
max_events: usize,
eviction_summary_hook: EvictionSummaryHook,
mode: OpenMode,
) -> Result<Self, SessionStoreError> {
let dir = session_dir(workspace, session_id);
crate::ensure_private_directory(&crate::sessions_root(workspace))?;
crate::ensure_private_directory(&dir)?;
crate::ensure_private_directory(&dir.join(crate::DERIVED_DIR))?;
crate::ensure_private_directory(&dir.join("index"))?;
let events_path = dir.join("events.jsonl");
let manifest_store = ManifestStore::new(dir.clone());
let pending_rewrite = manifest_store.load_pending_cap_rewrite()?;
let shared = shared_session(&events_path, session_id, mode)?;
let log = Self {
events_path: events_path.clone(),
manifest_store,
shared,
max_events,
eviction_summary_hook,
};
let _eviction_guard = log.shared.eviction_lock.lock().map_err(poison)?;
if !log.shared.initialized.load(Ordering::Acquire) {
let manifest_opt = log.manifest_store.load_manifest()?;
let index_opt = log.manifest_store.load_turn_index()?;
let file_len = log.event_file_metadata_len()?;
let pending_rewrite_matches_file = pending_rewrite.as_ref().is_some_and(|pending| {
pending.new_file_len == file_len && pending.new_file_len < pending.previous_file_len
});
match (&manifest_opt, &index_opt) {
(Some(manifest), Some(index))
if !pending_rewrite_matches_file
&& manifest.in_turn.is_some()
&& manifest.persisted_file_len == Some(file_len)
&& index.is_valid_for_file(file_len)
&& index.is_consistent_with_manifest(manifest) =>
{
let mut st = log.shared.state.lock().map_err(poison)?;
st.in_turn = manifest.in_turn.unwrap_or(false);
st.manifest = manifest.clone();
st.index = index.clone();
st.next_offset = file_len;
}
_ => {
let scan_turn_base = infer_scan_turn_base(
manifest_opt.as_ref(),
index_opt.as_ref(),
pending_rewrite.as_ref().filter(|_| pending_rewrite_matches_file),
file_len,
);
{
let mut st = log.shared.state.lock().map_err(poison)?;
if let Some(previous) = manifest_opt.as_ref() {
st.manifest = previous.clone();
}
st.manifest.turn_count = scan_turn_base.saturating_sub(1);
st.manifest.event_count = 0;
st.manifest.status = "active".to_string();
st.manifest.in_turn = Some(false);
st.manifest.retained_turn_base = Some(scan_turn_base);
st.index = TurnIndex::default();
st.in_turn = false;
}
log.scan()?;
let mut st = log.shared.state.lock().map_err(poison)?;
st.next_offset = file_len;
log.persist_meta_locked(&mut st)?;
}
}
if pending_rewrite.is_some() {
log.manifest_store.clear_pending_cap_rewrite()?;
}
log.shared.initialized.store(true, Ordering::Release);
}
drop(_eviction_guard);
Ok(log)
}
pub(crate) fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
let entry = {
let st = self.shared.state.lock().map_err(poison)?;
st.index
.entries
.iter()
.find(|e| e.turn_number == turn)
.cloned()
.ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
};
{
let mut st = self.shared.state.lock().map_err(poison)?;
self.flush_write_buf_locked(&mut st)?;
}
let buf = {
let mut file_slot = self.shared.file.lock().map_err(poison)?;
let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
file.seek(SeekFrom::Start(entry.start_offset))
.map_err(|e| SessionStoreError::io(&self.events_path, e))?;
let len = usize::try_from(entry.end_offset.checked_sub(entry.start_offset).ok_or_else(|| {
SessionStoreError::io(
&self.events_path,
std::io::Error::new(std::io::ErrorKind::InvalidData, "turn index offsets are out of order"),
)
})?)
.map_err(|error| {
SessionStoreError::io(&self.events_path, std::io::Error::new(std::io::ErrorKind::InvalidData, error))
})?;
let mut buf = vec![0u8; len];
file.read_exact(&mut buf)
.map_err(|e| SessionStoreError::io(&self.events_path, e))?;
buf
};
let text = String::from_utf8_lossy(&buf);
let mut events = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let v: VersionedThreadEvent = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
events.push(v.into_event());
}
Ok(events)
}
#[must_use]
pub(crate) fn turn_count(&self) -> u64 {
self.shared.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
}
#[must_use]
pub fn event_count(&self) -> u64 {
self.shared.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
}
pub fn visit_snapshot<F>(&self, mut visitor: F) -> Result<SessionManifest, SessionStoreError>
where
F: FnMut(u64, &[u8]),
{
use std::io::{BufRead, BufReader};
let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
let mut st = self.shared.state.lock().map_err(poison)?;
self.persist_meta_locked(&mut st)?;
let mut file_slot = self.shared.file.lock().map_err(poison)?;
let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
file.seek(SeekFrom::Start(0))
.map_err(|e| SessionStoreError::io(&self.events_path, e))?;
let mut reader = BufReader::new(file.take(st.next_offset));
let mut offset = 0;
let mut line = Vec::new();
loop {
line.clear();
let length = reader
.read_until(b'\n', &mut line)
.map_err(|e| SessionStoreError::io(&self.events_path, e))?;
if length == 0 {
break;
}
visitor(offset, &line);
offset += length as u64;
}
Ok(st.manifest.clone())
}
pub fn flush(&self) -> Result<(), SessionStoreError> {
let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
let mut st = self.shared.state.lock().map_err(poison)?;
self.persist_meta_locked(&mut st)
}
#[must_use]
pub fn manifest(&self) -> SessionManifest {
self.shared
.state
.lock()
.map_err(poison)
.map(|s| s.manifest.clone())
.unwrap_or_else(|_| SessionManifest::new(""))
}
#[must_use]
pub fn turn_index(&self) -> TurnIndex {
self.shared
.state
.lock()
.map_err(poison)
.map(|s| s.index.clone())
.unwrap_or_default()
}
pub(crate) fn complete(&self) -> Result<(), SessionStoreError> {
let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
let mut st = self.shared.state.lock().map_err(poison)?;
st.manifest.updated_at = now_rfc3339();
self.persist_meta_locked(&mut st)
}
fn scan(&self) -> Result<(), SessionStoreError> {
let mut st = self.shared.state.lock().map_err(poison)?;
let file = self
.shared
.file
.lock()
.map_err(poison)?
.as_ref()
.ok_or_else(|| self.event_file_unavailable())?
.try_clone()
.map_err(|e| SessionStoreError::io(&self.events_path, e))?;
let mut reader = std::io::BufReader::new(file);
reader
.seek(SeekFrom::Start(0))
.map_err(|e| SessionStoreError::io(&self.events_path, e))?;
let mut buf = Vec::new();
let mut pos = 0u64;
let mut first_ts: Option<String> = None;
loop {
buf.clear();
let n = reader
.read_until(b'\n', &mut buf)
.map_err(|e| SessionStoreError::io(&self.events_path, e))?;
if n == 0 {
break;
}
let line_end = pos + n as u64;
let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
if !trimmed.is_empty()
&& let Ok(v) = serde_json::from_str::<VersionedEventKind<'_>>(trimmed)
{
let kind = v.event.kind;
if requires_full_lifecycle_validation(kind) && !valid_lifecycle_payload(kind, trimmed) {
pos = line_end;
continue;
}
st.manifest.event_count += 1;
if kind == "thread.started" && first_ts.is_none() {
first_ts = Some(now_rfc3339());
}
st.apply_lifecycle_event(LifecycleKind::from_kind(kind), pos, line_end);
}
pos = line_end;
}
st.manifest.in_turn = Some(st.in_turn);
if let Some(ts) = first_ts
&& st.manifest.created_at.is_empty()
{
st.manifest.created_at = ts;
}
Ok(())
}
fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
self.flush_write_buf_locked(st)?;
st.manifest.persisted_file_len = Some(st.next_offset);
self.manifest_store.write_turn_index(&st.index)?;
self.manifest_store.write_manifest(&st.manifest)?;
Ok(())
}
fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
if st.write_buf.is_empty() {
return Ok(());
}
let mut file_slot = self.shared.file.lock().map_err(poison)?;
let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
let previous_len = file.metadata().map_err(|e| SessionStoreError::io(&self.events_path, e))?.len();
if let Err(error) = file.write_all(&st.write_buf) {
if file.set_len(previous_len).is_err() {
st.write_buf.clear();
}
return Err(SessionStoreError::io(&self.events_path, error));
}
if let Err(error) = file.sync_data() {
st.write_buf.clear();
return Err(SessionStoreError::io(&self.events_path, error));
}
st.write_buf.clear();
Ok(())
}
fn event_file_metadata_len(&self) -> Result<u64, SessionStoreError> {
let file_slot = self.shared.file.lock().map_err(poison)?;
file_slot
.as_ref()
.ok_or_else(|| self.event_file_unavailable())?
.metadata()
.map(|metadata| metadata.len())
.map_err(|error| SessionStoreError::io(&self.events_path, error))
}
fn open_event_file(&self) -> Result<File, SessionStoreError> {
VtCodePaths::open_private_append_file(&self.events_path)
.map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
}
fn replace_event_file_contents(&self, contents: &[u8]) -> Result<u64, SessionStoreError> {
let old_file = {
let mut file_slot = self.shared.file.lock().map_err(poison)?;
file_slot.take().ok_or_else(|| self.event_file_unavailable())?
};
drop(old_file);
if let Err(error) = VtCodePaths::write_private_file_atomic(&self.events_path, contents)
.map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
{
let restored = self.open_event_file();
if let Ok(file) = restored {
let mut file_slot = self.shared.file.lock().map_err(poison)?;
*file_slot = Some(file);
return Err(error);
}
return Err(SessionStoreError::io(
&self.events_path,
std::io::Error::other(format!("{error}; failed to restore event log handle")),
));
}
let replacement = self.open_event_file()?;
let next_offset = replacement
.metadata()
.map_err(|error| SessionStoreError::io(&self.events_path, error))?
.len();
let mut file_slot = self.shared.file.lock().map_err(poison)?;
*file_slot = Some(replacement);
Ok(next_offset)
}
fn event_file_unavailable(&self) -> SessionStoreError {
SessionStoreError::io(&self.events_path, std::io::Error::other("event log file is unavailable"))
}
}
impl Drop for SessionEventLog {
fn drop(&mut self) {
if let Ok(_eviction_guard) = self.shared.eviction_lock.lock()
&& let Ok(mut st) = self.shared.state.lock()
{
let _ = self.flush_write_buf_locked(&mut st);
}
}
}
fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
SessionStoreError::Io {
path: PathBuf::new(),
source: std::io::Error::other("session store lock poisoned"),
}
}
fn now_rfc3339() -> String {
Utc::now().to_rfc3339()
}
#[cfg(test)]
mod borrowed_envelope_tests;
#[cfg(test)]
mod cap_eviction_tests;
#[cfg(test)]
mod lifecycle_state_machine_tests;