Skip to main content

vtcode_memory/
event_log.rs

1//! Append-only per-session `ThreadEvent` log plus index and manifest.
2
3use std::collections::{BTreeMap, HashMap, VecDeque};
4use std::fs::File;
5use std::io::{BufRead, Read, Seek, SeekFrom, Write};
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::{Arc, Mutex, OnceLock, Weak};
9
10use chrono::Utc;
11use serde::{Deserialize, Serialize};
12use vtcode_commons::VtCodePaths;
13use vtcode_exec_events::{EVENT_SCHEMA_VERSION, ThreadEvent, ThreadItemDetails, VersionedThreadEvent};
14
15use crate::error::SessionStoreError;
16
17/// Sidecar lock file flock-held while a session's event-log handles are open.
18pub(crate) const SESSION_LOCK_FILE: &str = "session.lock";
19use crate::manifest::{ManifestStore, PendingCapRewrite};
20use crate::session_dir;
21
22/// Default maximum number of events retained per session before the oldest
23/// completed turns are evicted.
24pub const DEFAULT_MAX_EVENTS: usize = 10_000;
25
26/// Maximum serialized event bytes retained before an append forces a write.
27/// Turn boundaries and reads still flush immediately.
28const MAX_WRITE_BUFFER_BYTES: usize = 64 * 1024;
29const MAX_EVICTION_GROUNDED_FACTS: usize = 32;
30const MAX_EVICTION_GROUNDED_FACT_BYTES: usize = 512;
31
32/// Callback used to persist a summary of events before they are evicted.
33///
34/// The callback runs after the event bytes have been flushed and decoded, but
35/// before the canonical log is rewritten. A failure leaves the original log
36/// and in-memory index untouched, so retention never silently discards
37/// history.
38pub type EvictionSummaryHook = Arc<dyn Fn(&[ThreadEvent]) -> Result<(), SessionStoreError> + Send + Sync>;
39
40/// Minimal envelope used while rebuilding the turn index.
41///
42/// The index only needs the event discriminator. Deserializing a complete
43/// [`VersionedThreadEvent`] here would allocate every nested tool argument,
44/// output, and thread item even though none of that payload is retained.
45#[derive(Debug, Deserialize)]
46struct VersionedEventKind<'a> {
47    #[serde(rename = "schema_version", borrow)]
48    _schema_version: &'a str,
49    #[serde(borrow)]
50    event: EventKind<'a>,
51}
52
53#[derive(Debug, Deserialize)]
54struct EventKind<'a> {
55    #[serde(rename = "type", borrow)]
56    kind: &'a str,
57}
58
59/// Zero-clone serialization envelope for `ThreadEvent`.
60///
61/// Produces JSON byte-identical to `VersionedThreadEvent` but borrows the
62/// event by reference instead of cloning it. `append` is called for every
63/// runtime event, and `ThreadEvent` can carry large tool outputs / thread
64/// items — cloning just to feed `serde_json::to_string` was pure waste.
65#[derive(Serialize)]
66struct BorrowedVersionedEvent<'a> {
67    schema_version: &'a str,
68    event: &'a ThreadEvent,
69}
70
71/// Turn-lifecycle discriminator extracted from either a `ThreadEvent` (at
72/// append time) or a raw `&str` kind (during scan).  This is the single
73/// representation that both code paths feed into
74/// [`LogState::apply_lifecycle_event`], eliminating a duplicated state machine.
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76enum LifecycleKind {
77    ThreadStarted,
78    ThreadCompleted,
79    TurnStarted,
80    TurnCompleted,
81    TurnFailed,
82    Other,
83}
84
85impl LifecycleKind {
86    /// Discriminate from a runtime `ThreadEvent` at append time.
87    #[inline]
88    fn from_event(event: &ThreadEvent) -> Self {
89        match event {
90            ThreadEvent::ThreadStarted(_) => Self::ThreadStarted,
91            ThreadEvent::ThreadCompleted(_) => Self::ThreadCompleted,
92            ThreadEvent::TurnStarted(_) => Self::TurnStarted,
93            ThreadEvent::TurnCompleted(_) => Self::TurnCompleted,
94            ThreadEvent::TurnFailed(_) => Self::TurnFailed,
95            _ => Self::Other,
96        }
97    }
98
99    /// Discriminate from a raw event-type string at scan time.
100    #[inline]
101    fn from_kind(kind: &str) -> Self {
102        match kind {
103            "thread.started" => Self::ThreadStarted,
104            "thread.completed" => Self::ThreadCompleted,
105            "turn.started" => Self::TurnStarted,
106            "turn.completed" => Self::TurnCompleted,
107            "turn.failed" => Self::TurnFailed,
108            _ => Self::Other,
109        }
110    }
111}
112
113/// In-memory state protected by a mutex (cheap; appends are infrequent relative
114/// to model inference).
115struct LogState {
116    manifest: SessionManifest,
117    index: TurnIndex,
118    /// Whether we are currently inside a turn (between TurnStarted and
119    /// TurnCompleted/TurnFailed). Used to update the last index entry's
120    /// offsets as intermediate events arrive.
121    in_turn: bool,
122    /// Running byte offset of the next append. Avoids a `stat` syscall per
123    /// event (the previous implementation re-statted the file twice on every
124    /// `append`); initialized from the file length on `open`.
125    next_offset: u64,
126    /// Buffered pending writes to batch syscalls. Events are appended here
127    /// and flushed to disk at turn boundaries or before read operations.
128    write_buf: Vec<u8>,
129}
130
131#[derive(Debug, Clone, Copy)]
132struct CapEvictionPlan {
133    truncate_offset: u64,
134    evicted_event_count: u64,
135    evicted_turn_count: usize,
136}
137
138impl LogState {
139    fn new(session_id: &str) -> Self {
140        Self {
141            manifest: SessionManifest::new(session_id),
142            index: TurnIndex::default(),
143            in_turn: false,
144            next_offset: 0,
145            write_buf: Vec::with_capacity(65536),
146        }
147    }
148
149    /// Serialize `event` directly into the reusable write buffer with rollback
150    /// on failure.
151    ///
152    /// This encapsulates the invariant that `write_buf` never contains a
153    /// partial JSON document: if `serde_json::to_writer` fails mid-write the
154    /// buffer is truncated back to its pre-serialization boundary.  Returns
155    /// the `(start, end)` byte offsets of the serialized event so the caller
156    /// can feed them to [`Self::apply_lifecycle_event`].
157    fn serialize_event(&mut self, event: &ThreadEvent) -> Result<(u64, u64), SessionStoreError> {
158        let start = self.next_offset;
159        let buf_len_before = self.write_buf.len();
160        if let Err(err) = serde_json::to_writer(
161            &mut self.write_buf,
162            &BorrowedVersionedEvent { schema_version: EVENT_SCHEMA_VERSION, event },
163        ) {
164            self.write_buf.truncate(buf_len_before);
165            return Err(err.into());
166        }
167        self.write_buf.push(b'\n');
168        let written = self.write_buf.len() - buf_len_before;
169        let end = start + written as u64;
170        self.next_offset = end;
171        Ok((start, end))
172    }
173
174    /// Update the in-memory turn index and manifest counters for a single
175    /// event.
176    ///
177    /// This is the single implementation of the turn-lifecycle state machine;
178    /// both the append path (via [`LifecycleKind::from_event`]) and the scan
179    /// path (via [`LifecycleKind::from_kind`]) route through here, eliminating
180    /// a previously duplicated match block.
181    ///
182    /// Returns `true` when the event closes a turn boundary
183    /// (`TurnCompleted` / `TurnFailed`) so the caller can persist metadata
184    /// at the appropriate time (append persists immediately; scan persists
185    /// once after the full scan).
186    fn apply_lifecycle_event(&mut self, kind: LifecycleKind, start: u64, end: u64) -> bool {
187        let is_boundary = match kind {
188            LifecycleKind::ThreadStarted => {
189                self.manifest.status = "active".to_string();
190                false
191            }
192            LifecycleKind::ThreadCompleted => {
193                self.manifest.status = "completed".to_string();
194                true
195            }
196            LifecycleKind::TurnStarted => {
197                self.manifest.status = "active".to_string();
198                self.in_turn = true;
199                let n = self.manifest.turn_count + 1;
200                self.index.entries.push_back(TurnIndexEntry {
201                    turn_number: n,
202                    start_offset: start,
203                    end_offset: end,
204                    event_count: 1,
205                    ts: now_rfc3339(),
206                });
207                false
208            }
209            LifecycleKind::TurnCompleted | LifecycleKind::TurnFailed => {
210                if self.in_turn {
211                    if let Some(entry) = self.index.entries.back_mut() {
212                        entry.end_offset = end;
213                        entry.event_count += 1;
214                        // `turn_number` remains monotonic when older completed
215                        // turns have been evicted. The manifest is the source
216                        // for the next ordinal, so never replace it with the
217                        // retained index length.
218                        self.manifest.turn_count = self.manifest.turn_count.max(entry.turn_number);
219                    }
220                    self.in_turn = false;
221                }
222                true
223            }
224            LifecycleKind::Other => {
225                if self.in_turn
226                    && let Some(entry) = self.index.entries.back_mut()
227                {
228                    entry.end_offset = end;
229                    entry.event_count += 1;
230                }
231                false
232            }
233        };
234        // Persist the open-turn marker alongside the manifest. The marker is
235        // deliberately optional for backwards compatibility: an older
236        // manifest without it forces a scan on reopen so the state can be
237        // reconstructed from the canonical event log.
238        self.manifest.in_turn = Some(self.in_turn);
239        is_boundary
240    }
241
242    /// Plan a cap-enforcement eviction: pop the oldest completed turns from
243    /// the index until `event_count` is within `max_events`.
244    ///
245    /// Returns the byte offset at which the file should be rewritten and the
246    /// counts needed to apply the eviction after successful I/O. Returns
247    /// `None` when no eviction is needed.
248    fn plan_cap_eviction(&self, max_events: usize) -> Option<CapEvictionPlan> {
249        if max_events == 0 || self.manifest.event_count <= max_events as u64 {
250            return None;
251        }
252        let mut evicted_event_count = 0u64;
253        let mut truncate_offset = 0u64;
254        let mut evicted_turn_count = 0;
255        for (entry_index, oldest) in self.index.entries.iter().enumerate() {
256            // Never evict the active turn. Its entry has a provisional end
257            // offset and will be closed by a later completion/failure event;
258            // removing it here would make that event unindexed and lose the
259            // in-flight turn from reconstruction. If the completed history
260            // alone cannot bring the log under the cap, retain the active
261            // turn until it reaches a terminal boundary.
262            if self.in_turn && entry_index + 1 == self.index.entries.len() {
263                break;
264            }
265            if self.manifest.event_count.saturating_sub(evicted_event_count) <= max_events as u64 {
266                break;
267            }
268            truncate_offset = oldest.end_offset;
269            evicted_event_count += oldest.event_count;
270            evicted_turn_count += 1;
271        }
272        if truncate_offset == 0 || evicted_turn_count == 0 {
273            None
274        } else {
275            Some(CapEvictionPlan {
276                truncate_offset,
277                evicted_event_count,
278                evicted_turn_count,
279            })
280        }
281    }
282
283    fn apply_cap_eviction(&mut self, plan: CapEvictionPlan, next_offset: u64) {
284        for _ in 0..plan.evicted_turn_count {
285            let _ = self.index.entries.pop_front();
286        }
287        for entry in &mut self.index.entries {
288            entry.start_offset = entry.start_offset.saturating_sub(plan.truncate_offset);
289            entry.end_offset = entry.end_offset.saturating_sub(plan.truncate_offset);
290        }
291        self.next_offset = next_offset;
292        self.manifest.event_count = self.manifest.event_count.saturating_sub(plan.evicted_event_count);
293        self.manifest.retained_turn_base = Some(self.retained_turn_base_after_eviction(0));
294    }
295
296    fn retained_turn_base_after_eviction(&self, evicted_turn_count: usize) -> u64 {
297        self.index
298            .entries
299            .get(evicted_turn_count)
300            .map(|entry| entry.turn_number)
301            .or_else(|| self.manifest.turn_count.checked_add(1))
302            .unwrap_or(1)
303            .max(1)
304    }
305}
306
307/// State and file handle shared by every owner of one session in this process.
308///
309/// A keyed operation lock alone is insufficient: two independently opened
310/// handles could still carry stale turn counters and overwrite each other's
311/// metadata after taking the lock. Sharing the mutable state and append file
312/// makes the lock a true session boundary while preserving the value-type
313/// `SessionEventLog` API.
314struct SessionShared {
315    file: Mutex<Option<File>>,
316    state: Mutex<LogState>,
317    eviction_lock: Mutex<()>,
318    initialized: AtomicBool,
319    /// Exclusive flock on the session's `session.lock`, held for as long as
320    /// any handle to this session exists. Retention reads it as a liveness
321    /// signal so an open-but-idle session is never marked or evicted.
322    liveness_lock: Option<File>,
323}
324
325/// Return the process-wide shared state for one session's canonical event file.
326///
327/// The weak registry avoids retaining closed sessions forever while still
328/// making repeated `open` calls converge on one file handle and turn state.
329fn shared_session(events_path: &Path, session_id: &str) -> Result<Arc<SessionShared>, SessionStoreError> {
330    static SESSION_SHARED: OnceLock<Mutex<HashMap<PathBuf, Weak<SessionShared>>>> = OnceLock::new();
331
332    let key = events_path
333        .parent()
334        .and_then(|parent| vtcode_commons::paths::canonicalize(parent).ok())
335        .and_then(|parent| events_path.file_name().map(|name| parent.join(name)))
336        .unwrap_or_else(|| events_path.to_path_buf());
337    let registry = SESSION_SHARED.get_or_init(|| Mutex::new(HashMap::new()));
338    let mut shared_by_path = match registry.lock() {
339        Ok(locks) => locks,
340        Err(poisoned) => poisoned.into_inner(),
341    };
342    // Do not retain dead weak entries for every session ever opened by a
343    // long-running process. The registry is only an in-process coordination
344    // aid, so removing entries with no live owners is safe.
345    shared_by_path.retain(|_, shared| shared.strong_count() > 0);
346    if let Some(shared) = shared_by_path.get(&key).and_then(Weak::upgrade) {
347        return Ok(shared);
348    }
349
350    let file = VtCodePaths::open_private_append_file(events_path)
351        .map_err(|error| SessionStoreError::io(events_path.to_path_buf(), std::io::Error::other(error)))?;
352    let shared = Arc::new(SessionShared {
353        file: Mutex::new(Some(file)),
354        state: Mutex::new(LogState::new(session_id)),
355        eviction_lock: Mutex::new(()),
356        initialized: AtomicBool::new(false),
357        liveness_lock: acquire_liveness_lock(events_path),
358    });
359    shared_by_path.insert(key, Arc::downgrade(&shared));
360    Ok(shared)
361}
362
363/// Best-effort exclusive flock on the session's `session.lock`.
364///
365/// The lock is the liveness signal retention reads (`session_dir_is_live`): a
366/// held lock means a live process still has the session open. The crate has
367/// no logging surface and this is an advisory signal, not persistence, so a
368/// failure to create/open/lock degrades to unlocked (previous retention
369/// behavior) instead of failing the session open or swallowing a persistence
370/// error. The fd is released automatically when the shared handle drops.
371fn acquire_liveness_lock(events_path: &Path) -> Option<File> {
372    let dir = events_path.parent()?;
373    let lock_path = dir.join(SESSION_LOCK_FILE);
374    VtCodePaths::write_private_file_atomic_if_absent(&lock_path, b"").ok()?;
375    let file = std::fs::OpenOptions::new().read(true).write(true).open(&lock_path).ok()?;
376    match file.try_lock() {
377        Ok(()) => Some(file),
378        Err(_) => None,
379    }
380}
381
382/// Canonical append-only event log for a single session.
383///
384/// All session history is reconstructable from this log. Live conversation
385/// state is never read back into context from here; the log is only consumed
386/// for revert, compaction, analytics, and long-term-learning queries.
387pub struct SessionEventLog {
388    events_path: PathBuf,
389    manifest_store: ManifestStore,
390    shared: Arc<SessionShared>,
391    max_events: usize,
392    eviction_summary_hook: EvictionSummaryHook,
393}
394
395impl SessionEventLog {
396    /// Open the log for `session_id`, creating the session directory tree and
397    /// rebuilding the index from `events.jsonl` if it already exists.
398    pub(crate) fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
399        let dir = session_dir(workspace, session_id);
400        let hook = default_eviction_summary_hook(dir.join(crate::DERIVED_DIR), session_id.to_string());
401        Self::open_with_eviction_summary(workspace, session_id, max_events, hook)
402    }
403
404    /// Open a log with an explicit eviction-summary callback.
405    ///
406    /// This is useful for hosts that keep derived memory in another store and
407    /// for deterministic failure-path tests. The callback must persist its
408    /// summary before returning `Ok(())`.
409    pub fn open_with_eviction_summary(
410        workspace: &Path,
411        session_id: &str,
412        max_events: usize,
413        eviction_summary_hook: EvictionSummaryHook,
414    ) -> Result<Self, SessionStoreError> {
415        let dir = session_dir(workspace, session_id);
416        crate::ensure_private_directory(&crate::sessions_root(workspace))?;
417        crate::ensure_private_directory(&dir)?;
418        crate::ensure_private_directory(&dir.join(crate::DERIVED_DIR))?;
419        crate::ensure_private_directory(&dir.join("index"))?;
420        let events_path = dir.join("events.jsonl");
421        let manifest_store = ManifestStore::new(dir.clone());
422        let pending_rewrite = manifest_store.load_pending_cap_rewrite()?;
423        let shared = shared_session(&events_path, session_id)?;
424        let log = Self {
425            events_path: events_path.clone(),
426            manifest_store,
427            shared,
428            max_events,
429            eviction_summary_hook,
430        };
431        let _eviction_guard = log.shared.eviction_lock.lock().map_err(poison)?;
432        // Try the fast path: read the persisted manifest + index and skip
433        // the O(n) scan when they are present and consistent.
434        if !log.shared.initialized.load(Ordering::Acquire) {
435            let manifest_opt = log.manifest_store.load_manifest()?;
436            let index_opt = log.manifest_store.load_turn_index()?;
437            let file_len = log.event_file_metadata_len()?;
438            let pending_rewrite_matches_file = pending_rewrite.as_ref().is_some_and(|pending| {
439                pending.new_file_len == file_len && pending.new_file_len < pending.previous_file_len
440            });
441            match (&manifest_opt, &index_opt) {
442                (Some(manifest), Some(index))
443                    if !pending_rewrite_matches_file
444                        && manifest.in_turn.is_some()
445                        && manifest.persisted_file_len == Some(file_len)
446                        && index.is_valid_for_file(file_len)
447                        && index.is_consistent_with_manifest(manifest) =>
448                {
449                    let mut st = log.shared.state.lock().map_err(poison)?;
450                    st.in_turn = manifest.in_turn.unwrap_or(false);
451                    st.manifest = manifest.clone();
452                    st.index = index.clone();
453                    st.next_offset = file_len;
454                }
455                _ => {
456                    let scan_turn_base = infer_scan_turn_base(
457                        manifest_opt.as_ref(),
458                        index_opt.as_ref(),
459                        pending_rewrite.as_ref().filter(|_| pending_rewrite_matches_file),
460                        file_len,
461                    );
462                    {
463                        let mut st = log.shared.state.lock().map_err(poison)?;
464                        if let Some(previous) = manifest_opt.as_ref() {
465                            st.manifest = previous.clone();
466                        }
467                        // The canonical event file is authoritative after any
468                        // stale/corrupt metadata. Preserve the ordinal of the
469                        // first retained turn while rebuilding all counters.
470                        st.manifest.turn_count = scan_turn_base.saturating_sub(1);
471                        st.manifest.event_count = 0;
472                        st.manifest.status = "active".to_string();
473                        st.manifest.in_turn = Some(false);
474                        st.manifest.retained_turn_base = Some(scan_turn_base);
475                        st.index = TurnIndex::default();
476                        st.in_turn = false;
477                    }
478                    log.scan()?;
479                    let mut st = log.shared.state.lock().map_err(poison)?;
480                    st.next_offset = file_len;
481                    log.persist_meta_locked(&mut st)?;
482                }
483            }
484            if pending_rewrite.is_some() {
485                // A marker whose file length did not match either side of the
486                // rewrite is stale, while a matching marker has now been
487                // incorporated into the repaired metadata. In both cases it
488                // is safe to remove it after the open path has persisted the
489                // authoritative state.
490                log.manifest_store.clear_pending_cap_rewrite()?;
491            }
492            log.shared.initialized.store(true, Ordering::Release);
493        }
494        drop(_eviction_guard);
495        Ok(log)
496    }
497
498    /// Append an event to the log and update the in-memory index/manifest.
499    pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
500        let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
501        let mut st = self.shared.state.lock().map_err(poison)?;
502
503        // Serialize into the write buffer with rollback on failure — the
504        // invariant that `write_buf` never contains partial JSON is
505        // encapsulated in `serialize_event`.
506        let (start, end) = st.serialize_event(event)?;
507
508        st.manifest.event_count += 1;
509        st.manifest.updated_at = now_rfc3339();
510
511        // Route through the single turn-lifecycle state machine.  When the
512        // event closes a turn, persist metadata immediately so a reopen
513        // after a mid-turn crash sees a consistent index.
514        let is_turn_boundary = st.apply_lifecycle_event(LifecycleKind::from_event(event), start, end);
515        if is_turn_boundary {
516            self.persist_meta_locked(&mut st)?;
517        }
518
519        if st.write_buf.len() >= MAX_WRITE_BUFFER_BYTES {
520            // Persist metadata with the bounded byte flush so a reopen after
521            // a mid-turn crash does not trust an index that predates these
522            // already-written events.
523            self.persist_meta_locked(&mut st)?;
524        }
525        drop(st);
526        self.enforce_event_cap()
527    }
528
529    /// Enforce the per-session event cap by evicting the oldest completed
530    /// turns when the log exceeds [`Self::max_events`]. Returns `Ok(())` even
531    /// when no truncation is needed or the cap is disabled (`max_events == 0`).
532    fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
533        let mut st = self.shared.state.lock().map_err(poison)?;
534
535        // `plan_cap_eviction` encapsulates the index arithmetic and returns
536        // `None` when the cap is disabled or not yet exceeded.
537        let Some(plan) = st.plan_cap_eviction(self.max_events) else {
538            return Ok(());
539        };
540
541        // Keep ordinary appends in memory until a turn boundary or an
542        // explicit read. Cap enforcement is the one append-time path that
543        // needs the complete on-disk file before rewriting it.
544        self.flush_write_buf_locked(&mut st)?;
545
546        let (evicted, mut remaining, previous_file_len) = {
547            let mut file_slot = self.shared.file.lock().map_err(poison)?;
548            let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
549            let file_len = file
550                .metadata()
551                .map_err(|error| SessionStoreError::io(&self.events_path, error))?
552                .len();
553            if plan.truncate_offset > file_len {
554                return Err(SessionStoreError::io(
555                    &self.events_path,
556                    std::io::Error::new(std::io::ErrorKind::InvalidData, "cap offset exceeds event log length"),
557                ));
558            }
559            file.seek(SeekFrom::Start(0))
560                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
561            let mut evicted = vec![
562                0u8;
563                usize::try_from(plan.truncate_offset).map_err(|error| {
564                    SessionStoreError::io(
565                        &self.events_path,
566                        std::io::Error::new(std::io::ErrorKind::InvalidData, error),
567                    )
568                })?
569            ];
570            file.read_exact(&mut evicted)
571                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
572            file.seek(SeekFrom::Start(plan.truncate_offset))
573                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
574            let mut remaining = Vec::new();
575            file.read_to_end(&mut remaining)
576                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
577            (evicted, remaining, file_len)
578        };
579        let retained_turn_base = st.retained_turn_base_after_eviction(plan.evicted_turn_count);
580        drop(st);
581
582        // The turn index counts only records inside indexed turns. The bytes
583        // removed by a cap rewrite may also contain valid session-level
584        // records (for example `thread.started`) before the first turn, so
585        // reconcile the manifest against the actual persisted prefix rather
586        // than the turn-only estimate from `plan_cap_eviction`.
587        let mut plan = plan;
588        plan.evicted_event_count = count_persisted_event_records(&evicted);
589        let evicted_events = decode_events(&evicted);
590        (self.eviction_summary_hook)(&evicted_events)?;
591
592        // Matrix snapshots are complete session-level checkpoints. Preserve the
593        // latest evicted checkpoint only when no newer snapshot survives.
594        let mut matrix_checkpoints = BTreeMap::new();
595        let mut planning_checkpoint = None;
596        for event in &evicted_events {
597            if let ThreadEvent::PlanDelta(delta) = event
598                && delta.recovery.is_some()
599            {
600                planning_checkpoint = Some(event);
601            }
602            if let ThreadEvent::MatrixUpdated(snapshot) = event {
603                matrix_checkpoints.insert(snapshot.spec.id.clone(), event);
604            }
605        }
606        for event in decode_events(&remaining) {
607            if let ThreadEvent::PlanDelta(delta) = &event
608                && delta.recovery.is_some()
609            {
610                planning_checkpoint = None;
611            }
612            if let ThreadEvent::MatrixUpdated(snapshot) = event {
613                matrix_checkpoints.remove(&snapshot.spec.id);
614            }
615        }
616        let mut checkpoint_prefix = Vec::new();
617        for event in matrix_checkpoints.values().copied().chain(planning_checkpoint) {
618            serde_json::to_writer(&mut checkpoint_prefix, &VersionedThreadEvent::new((*event).clone()))
619                .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))?;
620            checkpoint_prefix.push(b'\n');
621        }
622        let checkpoint_bytes = checkpoint_prefix.len() as u64;
623        let checkpoint_count = matrix_checkpoints.len() as u64 + u64::from(planning_checkpoint.is_some());
624        if !checkpoint_prefix.is_empty() {
625            checkpoint_prefix.extend_from_slice(&remaining);
626            remaining = checkpoint_prefix;
627        }
628
629        let new_file_len = u64::try_from(remaining.len()).map_err(|error| {
630            SessionStoreError::io(&self.events_path, std::io::Error::new(std::io::ErrorKind::InvalidData, error))
631        })?;
632        self.manifest_store.write_pending_cap_rewrite(&PendingCapRewrite {
633            previous_file_len,
634            new_file_len,
635            retained_turn_base,
636        })?;
637        let next_offset = self.replace_event_file_contents(&remaining)?;
638        let mut st = self.shared.state.lock().map_err(poison)?;
639        st.apply_cap_eviction(plan, next_offset);
640        for entry in &mut st.index.entries {
641            entry.start_offset += checkpoint_bytes;
642            entry.end_offset += checkpoint_bytes;
643        }
644        st.manifest.event_count += checkpoint_count;
645        // The rewrite changed byte offsets and retained counts; persist the
646        // derived metadata before exposing the append as successful.
647        self.persist_meta_locked(&mut st)?;
648        self.manifest_store.clear_pending_cap_rewrite()?;
649        Ok(())
650    }
651
652    /// Reconstruct every event belonging to `turn`.
653    pub(crate) fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
654        // Keep the index snapshot and byte-range read together with cap
655        // rewriting. Otherwise an eviction can replace the file between these
656        // steps and leave the snapshot offsets pointing into unrelated events.
657        let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
658        let entry = {
659            let st = self.shared.state.lock().map_err(poison)?;
660            st.index
661                .entries
662                .iter()
663                .find(|e| e.turn_number == turn)
664                .cloned()
665                .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
666        };
667        {
668            let mut st = self.shared.state.lock().map_err(poison)?;
669            self.flush_write_buf_locked(&mut st)?;
670        }
671        let buf = {
672            let mut file_slot = self.shared.file.lock().map_err(poison)?;
673            let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
674            file.seek(SeekFrom::Start(entry.start_offset))
675                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
676            let len = usize::try_from(entry.end_offset.checked_sub(entry.start_offset).ok_or_else(|| {
677                SessionStoreError::io(
678                    &self.events_path,
679                    std::io::Error::new(std::io::ErrorKind::InvalidData, "turn index offsets are out of order"),
680                )
681            })?)
682            .map_err(|error| {
683                SessionStoreError::io(&self.events_path, std::io::Error::new(std::io::ErrorKind::InvalidData, error))
684            })?;
685            let mut buf = vec![0u8; len];
686            file.read_exact(&mut buf)
687                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
688            buf
689        };
690        let text = String::from_utf8_lossy(&buf);
691        let mut events = Vec::new();
692        for line in text.lines() {
693            let line = line.trim();
694            if line.is_empty() {
695                continue;
696            }
697            // The index scan only validates the event envelope (plus the
698            // lifecycle shape) so it can rebuild cheaply. A line accepted by
699            // the scan can therefore still fail full decoding here; skip it
700            // instead of failing the whole reconstruction (revert, compaction,
701            // and analytics must not break on a single malformed record).
702            let v: VersionedThreadEvent = match serde_json::from_str(line) {
703                Ok(v) => v,
704                Err(_) => continue,
705            };
706            events.push(v.into_event());
707        }
708        Ok(events)
709    }
710
711    /// Number of turns recorded.
712    #[must_use]
713    pub(crate) fn turn_count(&self) -> u64 {
714        self.shared.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
715    }
716
717    /// Number of events recorded.
718    #[must_use]
719    pub fn event_count(&self) -> u64 {
720        self.shared.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
721    }
722
723    /// Flush pending event bytes and metadata to the session store.
724    /// Visit one consistent retained range, including records outside turns.
725    /// Reads are streamed while cap rewrites and appends are excluded.
726    pub fn visit_snapshot<F>(&self, mut visitor: F) -> Result<SessionManifest, SessionStoreError>
727    where
728        F: FnMut(u64, &[u8]),
729    {
730        use std::io::{BufRead, BufReader};
731        let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
732        let mut st = self.shared.state.lock().map_err(poison)?;
733        self.persist_meta_locked(&mut st)?;
734        let mut file_slot = self.shared.file.lock().map_err(poison)?;
735        let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
736        file.seek(SeekFrom::Start(0))
737            .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
738        let mut reader = BufReader::new(file.take(st.next_offset));
739        let mut offset = 0;
740        let mut line = Vec::new();
741        loop {
742            line.clear();
743            let length = reader
744                .read_until(b'\n', &mut line)
745                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
746            if length == 0 {
747                break;
748            }
749            visitor(offset, &line);
750            offset += length as u64;
751        }
752        Ok(st.manifest.clone())
753    }
754
755    /// Flush pending event bytes and metadata to the session store.
756    pub fn flush(&self) -> Result<(), SessionStoreError> {
757        let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
758        let mut st = self.shared.state.lock().map_err(poison)?;
759        self.persist_meta_locked(&mut st)
760    }
761
762    /// Snapshot of the session manifest.
763    #[must_use]
764    pub fn manifest(&self) -> SessionManifest {
765        self.shared
766            .state
767            .lock()
768            .map_err(poison)
769            .map(|s| s.manifest.clone())
770            .unwrap_or_else(|_| SessionManifest::new(""))
771    }
772
773    /// Snapshot of the turn index.
774    #[must_use]
775    pub fn turn_index(&self) -> TurnIndex {
776        self.shared
777            .state
778            .lock()
779            .map_err(poison)
780            .map(|s| s.index.clone())
781            .unwrap_or_default()
782    }
783
784    /// Flush metadata for callers that explicitly close a log handle.
785    ///
786    /// Terminal status is intentionally controlled only by a persisted
787    /// `thread.completed` event. This method does not synthesize lifecycle
788    /// state for callers that merely release a store handle.
789    pub(crate) fn complete(&self) -> Result<(), SessionStoreError> {
790        let _eviction_guard = self.shared.eviction_lock.lock().map_err(poison)?;
791        let mut st = self.shared.state.lock().map_err(poison)?;
792        st.manifest.updated_at = now_rfc3339();
793        self.persist_meta_locked(&mut st)
794    }
795
796    /// Rebuild index + manifest by scanning `events.jsonl` (authoritative).
797    ///
798    /// Reads the file line-by-line via `BufReader` to avoid loading the entire
799    /// log into memory. Long-lived sessions can otherwise produce multi-megabyte
800    /// logs that spike memory on every reopen.
801    fn scan(&self) -> Result<(), SessionStoreError> {
802        let mut st = self.shared.state.lock().map_err(poison)?;
803        let file = self
804            .shared
805            .file
806            .lock()
807            .map_err(poison)?
808            .as_ref()
809            .ok_or_else(|| self.event_file_unavailable())?
810            .try_clone()
811            .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
812        let mut reader = std::io::BufReader::new(file);
813        reader
814            .seek(SeekFrom::Start(0))
815            .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
816        let mut buf = Vec::new();
817        let mut pos = 0u64;
818        let mut first_ts: Option<String> = None;
819        loop {
820            buf.clear();
821            let n = reader
822                .read_until(b'\n', &mut buf)
823                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
824            if n == 0 {
825                break;
826            }
827            let line_end = pos + n as u64;
828            let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
829            if !trimmed.is_empty()
830                && let Ok(v) = serde_json::from_str::<VersionedEventKind<'_>>(trimmed)
831            {
832                let kind = v.event.kind;
833                if requires_full_lifecycle_validation(kind) && !valid_lifecycle_payload(kind, trimmed) {
834                    pos = line_end;
835                    continue;
836                }
837                st.manifest.event_count += 1;
838                // `thread.started` is not part of the turn lifecycle — it
839                // only seeds `created_at` on the first occurrence.
840                if kind == "thread.started" && first_ts.is_none() {
841                    first_ts = Some(now_rfc3339());
842                }
843                // Route turn-lifecycle events through the same state machine
844                // as `append`, eliminating a previously duplicated match block.
845                st.apply_lifecycle_event(LifecycleKind::from_kind(kind), pos, line_end);
846            }
847            pos = line_end;
848        }
849        // Keep the open-turn state reconstructed from the canonical event log.
850        // This lets a reopened session continue a turn that was flushed before
851        // its completion event was written.
852        st.manifest.in_turn = Some(st.in_turn);
853        if let Some(ts) = first_ts
854            && st.manifest.created_at.is_empty()
855        {
856            st.manifest.created_at = ts;
857        }
858        Ok(())
859    }
860
861    fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
862        self.flush_write_buf_locked(st)?;
863        // The manifest is only eligible for the fast reopen path when it
864        // describes the complete on-disk event file. Drop intentionally
865        // flushes bytes without metadata, so a length mismatch safely forces
866        // the authoritative scan on the next open.
867        st.manifest.persisted_file_len = Some(st.next_offset);
868        // Publish the derived index first. If a process stops between these
869        // two atomic renames, the older manifest still carries a stale file
870        // length and forces a scan instead of allowing the new manifest to
871        // pair with an older, apparently valid index.
872        self.manifest_store.write_turn_index(&st.index)?;
873        self.manifest_store.write_manifest(&st.manifest)?;
874        Ok(())
875    }
876
877    /// Flush the in-memory write buffer to the underlying file.
878    fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
879        if st.write_buf.is_empty() {
880            return Ok(());
881        }
882        let mut file_slot = self.shared.file.lock().map_err(poison)?;
883        let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
884        let previous_len = file.metadata().map_err(|e| SessionStoreError::io(&self.events_path, e))?.len();
885        if let Err(error) = file.write_all(&st.write_buf) {
886            if file.set_len(previous_len).is_err() {
887                st.write_buf.clear();
888            }
889            return Err(SessionStoreError::io(&self.events_path, error));
890        }
891        if let Err(error) = file.sync_data() {
892            st.write_buf.clear();
893            return Err(SessionStoreError::io(&self.events_path, error));
894        }
895        st.write_buf.clear();
896        Ok(())
897    }
898
899    fn event_file_metadata_len(&self) -> Result<u64, SessionStoreError> {
900        let file_slot = self.shared.file.lock().map_err(poison)?;
901        file_slot
902            .as_ref()
903            .ok_or_else(|| self.event_file_unavailable())?
904            .metadata()
905            .map(|metadata| metadata.len())
906            .map_err(|error| SessionStoreError::io(&self.events_path, error))
907    }
908
909    fn open_event_file(&self) -> Result<File, SessionStoreError> {
910        VtCodePaths::open_private_append_file(&self.events_path)
911            .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
912    }
913
914    fn replace_event_file_contents(&self, contents: &[u8]) -> Result<u64, SessionStoreError> {
915        let old_file = {
916            let mut file_slot = self.shared.file.lock().map_err(poison)?;
917            file_slot.take().ok_or_else(|| self.event_file_unavailable())?
918        };
919        drop(old_file);
920
921        if let Err(error) = VtCodePaths::write_private_file_atomic(&self.events_path, contents)
922            .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
923        {
924            let restored = self.open_event_file();
925            if let Ok(file) = restored {
926                let mut file_slot = self.shared.file.lock().map_err(poison)?;
927                *file_slot = Some(file);
928                return Err(error);
929            }
930            return Err(SessionStoreError::io(
931                &self.events_path,
932                std::io::Error::other(format!("{error}; failed to restore event log handle")),
933            ));
934        }
935
936        let replacement = self.open_event_file()?;
937        let next_offset = replacement
938            .metadata()
939            .map_err(|error| SessionStoreError::io(&self.events_path, error))?
940            .len();
941        let mut file_slot = self.shared.file.lock().map_err(poison)?;
942        *file_slot = Some(replacement);
943        Ok(next_offset)
944    }
945
946    fn event_file_unavailable(&self) -> SessionStoreError {
947        SessionStoreError::io(&self.events_path, std::io::Error::other("event log file is unavailable"))
948    }
949}
950
951/// Recover the ordinal of the first retained turn when metadata is stale.
952///
953/// A cap rewrite atomically replaces the event file before it publishes the
954/// shortened index and manifest. If the process crashes in that interval,
955/// the old index still records the pre-rewrite offsets. The difference between
956/// its persisted length and the current file length is exactly the removed
957/// prefix, so the first old index entry at that boundary supplies the retained
958/// turn base. Other stale metadata falls back to the durable base field.
959fn infer_scan_turn_base(
960    manifest: Option<&SessionManifest>,
961    index: Option<&TurnIndex>,
962    pending: Option<&PendingCapRewrite>,
963    file_len: u64,
964) -> u64 {
965    let marker_base = pending
966        .filter(|pending| pending.new_file_len == file_len && pending.new_file_len < pending.previous_file_len)
967        .map(|pending| pending.retained_turn_base);
968    let rewritten_base = manifest.and_then(|manifest| {
969        let previous_len = manifest.persisted_file_len?;
970        if previous_len <= file_len {
971            return None;
972        }
973        let removed_prefix = previous_len - file_len;
974        let index = index.filter(|index| index.is_valid_for_file(previous_len))?;
975        let first_retained = index
976            .entries
977            .iter()
978            .find(|entry| entry.start_offset >= removed_prefix && entry.end_offset <= previous_len)
979            .map(|entry| entry.turn_number);
980        first_retained.or_else(|| {
981            // If no indexed turn starts in the shortened file, the rewrite
982            // evicted every previously indexed turn. Preserve the next
983            // ordinal from the stale manifest so a subsequent append cannot
984            // reuse an already-observed turn number.
985            (removed_prefix >= previous_len).then(|| manifest.turn_count.saturating_add(1))
986        })
987    });
988
989    let legacy_index_base = index
990        .filter(|index| index.is_valid_for_file(file_len))
991        .and_then(|index| index.entries.front().map(|entry| entry.turn_number));
992
993    marker_base
994        .or(rewritten_base)
995        .or_else(|| manifest.and_then(|manifest| manifest.retained_turn_base))
996        .or(legacy_index_base)
997        .unwrap_or(1)
998        .max(1)
999}
1000
1001fn requires_full_lifecycle_validation(kind: &str) -> bool {
1002    matches!(kind, "thread.started" | "thread.completed" | "turn.started" | "turn.completed" | "turn.failed")
1003}
1004
1005fn valid_lifecycle_payload(kind: &str, line: &str) -> bool {
1006    if serde_json::from_str::<VersionedThreadEvent>(line).is_err() {
1007        return false;
1008    }
1009    if kind != "turn.completed" {
1010        return true;
1011    }
1012    let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
1013        return false;
1014    };
1015    value
1016        .get("event")
1017        .and_then(|event| event.get("usage"))
1018        .is_some_and(serde_json::Value::is_object)
1019}
1020
1021fn decode_events(bytes: &[u8]) -> Vec<ThreadEvent> {
1022    bytes
1023        .split(|byte| *byte == b'\n')
1024        .filter_map(|line| {
1025            let line = std::str::from_utf8(line).ok()?.trim();
1026            if line.is_empty() {
1027                return None;
1028            }
1029            serde_json::from_str::<VersionedThreadEvent>(line)
1030                .ok()
1031                .map(VersionedThreadEvent::into_event)
1032        })
1033        .collect()
1034}
1035
1036/// Count records that the authoritative scan would include in the manifest.
1037/// This deliberately parses the lightweight event envelope instead of using
1038/// the turn index: a cap rewrite can remove session-level records that never
1039/// belong to an indexed turn.
1040fn count_persisted_event_records(bytes: &[u8]) -> u64 {
1041    bytes
1042        .split(|byte| *byte == b'\n')
1043        .filter_map(|line| std::str::from_utf8(line).ok())
1044        .map(str::trim)
1045        .filter(|line| !line.is_empty())
1046        .filter(|line| {
1047            let Ok(value) = serde_json::from_str::<VersionedEventKind<'_>>(line) else {
1048                return false;
1049            };
1050            let kind = value.event.kind;
1051            !requires_full_lifecycle_validation(kind) || valid_lifecycle_payload(kind, line)
1052        })
1053        .count() as u64
1054}
1055
1056#[derive(Debug, Serialize)]
1057struct EvictionSummary {
1058    session_id: String,
1059    evicted_event_count: usize,
1060    event_types: BTreeMap<String, u64>,
1061    grounded_facts: Vec<String>,
1062    created_at: String,
1063}
1064
1065/// Extract a bounded, deterministic set of facts from canonical event
1066/// payloads. This is intentionally structural rather than model-generated:
1067/// eviction must remain synchronous, reproducible, and safe when the model is
1068/// unavailable. Only completed item snapshots and terminal thread errors are
1069/// considered, so streaming deltas and raw tool output cannot flood the
1070/// derived summary.
1071fn extract_grounded_facts(events: &[ThreadEvent]) -> Vec<String> {
1072    let mut facts = Vec::new();
1073    for event in events {
1074        let candidates: Vec<String> = match event {
1075            ThreadEvent::ItemCompleted(completed) => item_facts(&completed.item.details),
1076            ThreadEvent::TurnFailed(failed) => vec![failed.message.clone()],
1077            ThreadEvent::TurnBlocked(blocked) => vec![blocked.message.clone()],
1078            ThreadEvent::Error(error) => vec![error.message.clone()],
1079            _ => Vec::new(),
1080        };
1081        for candidate in candidates {
1082            let fact = normalize_eviction_fact(&candidate);
1083            if fact.is_empty() || facts.iter().any(|existing| existing == &fact) {
1084                continue;
1085            }
1086            facts.push(fact);
1087            if facts.len() == MAX_EVICTION_GROUNDED_FACTS {
1088                return facts;
1089            }
1090        }
1091    }
1092    facts
1093}
1094
1095fn item_facts(details: &ThreadItemDetails) -> Vec<String> {
1096    match details {
1097        ThreadItemDetails::AgentMessage(item) => vec![item.text.clone()],
1098        ThreadItemDetails::Plan(item) => vec![item.text.clone()],
1099        ThreadItemDetails::FileChange(item) => item
1100            .changes
1101            .iter()
1102            .map(|change| {
1103                let kind = match change.kind {
1104                    vtcode_exec_events::PatchChangeKind::Add => "add",
1105                    vtcode_exec_events::PatchChangeKind::Delete => "delete",
1106                    vtcode_exec_events::PatchChangeKind::Update => "update",
1107                };
1108                format!("file {kind}: {}", change.path)
1109            })
1110            .collect(),
1111        ThreadItemDetails::Harness(item) => item.message.clone().into_iter().collect(),
1112        _ => Vec::new(),
1113    }
1114}
1115
1116fn normalize_eviction_fact(value: &str) -> String {
1117    let normalized = vtcode_commons::formatting::collapse_whitespace(value);
1118    if normalized.len() <= MAX_EVICTION_GROUNDED_FACT_BYTES {
1119        return normalized;
1120    }
1121    let mut end = MAX_EVICTION_GROUNDED_FACT_BYTES - '…'.len_utf8();
1122    while !normalized.is_char_boundary(end) {
1123        end -= 1;
1124    }
1125    format!("{}…", &normalized[..end])
1126}
1127
1128fn default_eviction_summary_hook(derived_dir: PathBuf, session_id: String) -> EvictionSummaryHook {
1129    Arc::new(move |events| {
1130        let mut event_types = BTreeMap::new();
1131        for event in events {
1132            let kind = serde_json::to_value(event)
1133                .ok()
1134                .and_then(|value| value.get("type").and_then(|value| value.as_str()).map(str::to_owned))
1135                .unwrap_or_else(|| "unknown".to_owned());
1136            *event_types.entry(kind).or_insert(0) += 1;
1137        }
1138        let summary = EvictionSummary {
1139            session_id: session_id.clone(),
1140            evicted_event_count: events.len(),
1141            event_types,
1142            grounded_facts: extract_grounded_facts(events),
1143            created_at: now_rfc3339(),
1144        };
1145        let path = derived_dir.join(format!("eviction-summary-{}.json", uuid::Uuid::new_v4().simple()));
1146        let bytes = serde_json::to_vec(&summary)?;
1147        VtCodePaths::write_private_file_atomic(&path, &bytes)
1148            .map_err(|error| SessionStoreError::io(path, std::io::Error::other(error)))
1149    })
1150}
1151
1152impl Drop for SessionEventLog {
1153    fn drop(&mut self) {
1154        if let Ok(_eviction_guard) = self.shared.eviction_lock.lock()
1155            && let Ok(mut st) = self.shared.state.lock()
1156        {
1157            // The fallible `flush` method is the authoritative shutdown path;
1158            // Drop only provides a best-effort byte flush for callers that do
1159            // not explicitly close the log. Rewriting metadata here could
1160            // overwrite a manifest update made by another owner after the
1161            // last append.
1162            let _ = self.flush_write_buf_locked(&mut st);
1163        }
1164    }
1165}
1166
1167/// Locate the next newline at or after `from`, returning a past-the-end index.
1168fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
1169    SessionStoreError::Io {
1170        path: PathBuf::new(),
1171        source: std::io::Error::other("session store lock poisoned"),
1172    }
1173}
1174
1175fn now_rfc3339() -> String {
1176    Utc::now().to_rfc3339()
1177}
1178
1179/// Session-level metadata persisted to `manifest.json`.
1180#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1181pub struct SessionManifest {
1182    /// Stable session identifier (directory name).
1183    pub session_id: String,
1184    /// Layout schema version (`SESSION_STORE_SCHEMA_VERSION`).
1185    schema_version: u32,
1186    /// RFC3339 creation timestamp.
1187    pub created_at: String,
1188    /// RFC3339 last-update timestamp.
1189    pub updated_at: String,
1190    /// Number of completed turns.
1191    pub turn_count: u64,
1192    /// Total number of events recorded.
1193    pub event_count: u64,
1194    /// Lifecycle status (`active` | `completed`).
1195    pub status: String,
1196    /// Whether the canonical log currently ends inside an open turn.
1197    ///
1198    /// This is optional on read so manifests written before open-turn
1199    /// persistence was introduced trigger a safe event-log scan instead of
1200    /// silently losing lifecycle state.
1201    #[serde(default)]
1202    in_turn: Option<bool>,
1203    /// Byte length covered by the persisted manifest and turn index.
1204    ///
1205    /// This is optional for compatibility with manifests written before the
1206    /// fast-path freshness guard existed; those manifests are rebuilt from the
1207    /// canonical event log on reopen.
1208    #[serde(default)]
1209    persisted_file_len: Option<u64>,
1210    /// Ordinal of the first turn retained in the canonical log.
1211    ///
1212    /// Cap eviction removes completed turns but must keep later turn numbers
1213    /// monotonic. The field lets an authoritative scan restore those ordinals
1214    /// even when the derived index is stale or missing.
1215    #[serde(default)]
1216    retained_turn_base: Option<u64>,
1217}
1218
1219impl SessionManifest {
1220    /// Number of turns preceding the retained canonical range.
1221    #[must_use]
1222    pub fn evicted_turn_count(&self) -> u64 {
1223        self.retained_turn_base.unwrap_or(1).saturating_sub(1)
1224    }
1225    /// Create a fresh manifest for a session.
1226    #[must_use]
1227    pub(crate) fn new(session_id: &str) -> Self {
1228        let ts = now_rfc3339();
1229        Self {
1230            session_id: session_id.to_string(),
1231            schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
1232            created_at: ts.clone(),
1233            updated_at: ts,
1234            turn_count: 0,
1235            event_count: 0,
1236            status: "active".to_string(),
1237            in_turn: Some(false),
1238            persisted_file_len: Some(0),
1239            retained_turn_base: Some(1),
1240        }
1241    }
1242}
1243
1244/// Byte-offset index of a single turn within `events.jsonl`.
1245#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1246pub struct TurnIndexEntry {
1247    /// Turn ordinal (1-based).
1248    turn_number: u64,
1249    /// Byte offset of the turn's first event.
1250    start_offset: u64,
1251    /// Byte offset just past the turn's last event.
1252    end_offset: u64,
1253    /// Number of events in the turn.
1254    event_count: u64,
1255    /// RFC3339 timestamp of turn start.
1256    ts: String,
1257}
1258
1259/// Ordered index of all turns in a session.
1260#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
1261pub struct TurnIndex {
1262    /// Turn entries in ordinal order.
1263    entries: VecDeque<TurnIndexEntry>,
1264}
1265
1266impl TurnIndex {
1267    /// Number of indexed turns.
1268    #[must_use]
1269    pub fn len(&self) -> usize {
1270        self.entries.len()
1271    }
1272
1273    /// Whether the index is empty.
1274    #[must_use]
1275    pub fn is_empty(&self) -> bool {
1276        self.entries.is_empty()
1277    }
1278
1279    fn is_valid_for_file(&self, file_len: u64) -> bool {
1280        let mut previous_end = 0u64;
1281        self.entries.iter().all(|entry| {
1282            let valid = entry.event_count > 0
1283                && entry.start_offset >= previous_end
1284                && entry.start_offset <= entry.end_offset
1285                && entry.end_offset <= file_len;
1286            if valid {
1287                previous_end = entry.end_offset;
1288            }
1289            valid
1290        })
1291    }
1292
1293    fn is_consistent_with_manifest(&self, manifest: &SessionManifest) -> bool {
1294        let expected_last_turn = if manifest.in_turn == Some(true) {
1295            manifest.turn_count.saturating_add(1)
1296        } else {
1297            manifest.turn_count
1298        };
1299        let entries_are_contiguous = self
1300            .entries
1301            .iter()
1302            .map(|entry| entry.turn_number)
1303            .try_fold(None::<u64>, |previous, turn_number| {
1304                if previous.is_some_and(|previous| turn_number != previous.saturating_add(1)) {
1305                    return Err(());
1306                }
1307                Ok(Some(turn_number))
1308            })
1309            .is_ok();
1310        if !entries_are_contiguous {
1311            return false;
1312        }
1313
1314        let expected_first_turn = manifest.retained_turn_base.unwrap_or(1).max(1);
1315        match (self.entries.front(), self.entries.back()) {
1316            (Some(first), Some(last)) => {
1317                first.turn_number == expected_first_turn && last.turn_number == expected_last_turn
1318            }
1319            (None, None) => {
1320                expected_last_turn == 0
1321                    || manifest
1322                        .retained_turn_base
1323                        .is_some_and(|retained_turn_base| retained_turn_base > manifest.turn_count)
1324            }
1325            _ => false,
1326        }
1327    }
1328}
1329
1330#[cfg(test)]
1331mod borrowed_envelope_tests {
1332    use super::{BorrowedVersionedEvent, EVENT_SCHEMA_VERSION};
1333    use vtcode_exec_events::{
1334        ThreadEvent, ThreadStartedEvent, TurnCompletedEvent, TurnStartedEvent, Usage, VersionedThreadEvent,
1335    };
1336
1337    /// The borrowed envelope must produce JSON byte-identical to
1338    /// `VersionedThreadEvent::new(event.clone())`. This guards against drift if
1339    /// either the envelope or the canonical wrapper is modified.
1340    #[test]
1341    fn borrowed_envelope_matches_versioned_envelope() {
1342        for event in [
1343            ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "thread".to_string() }),
1344            ThreadEvent::TurnStarted(TurnStartedEvent::default()),
1345            ThreadEvent::TurnCompleted(TurnCompletedEvent {
1346                completed_at: None,
1347                usage: Usage::default(),
1348                in_progress_exec_sessions: Vec::new(),
1349            }),
1350        ] {
1351            let canonical =
1352                serde_json::to_string(&VersionedThreadEvent::new(event.clone())).expect("canonical serialize");
1353            let borrowed = serde_json::to_string(&BorrowedVersionedEvent {
1354                schema_version: EVENT_SCHEMA_VERSION,
1355                event: &event,
1356            })
1357            .expect("borrowed serialize");
1358            assert_eq!(canonical, borrowed, "JSON differs for {event:?}");
1359        }
1360    }
1361}
1362
1363#[cfg(test)]
1364mod lifecycle_state_machine_tests {
1365    use super::{LifecycleKind, LogState};
1366    use vtcode_exec_events::{
1367        ThreadCompletedEvent, ThreadCompletionSubtype, ThreadEvent, ThreadStartedEvent, TurnCompletedEvent,
1368        TurnFailedEvent, TurnStartedEvent, Usage,
1369    };
1370
1371    fn fresh_state() -> LogState {
1372        LogState::new("test-session")
1373    }
1374
1375    #[test]
1376    fn lifecycle_kind_from_event_covers_all_variants() {
1377        assert_eq!(
1378            LifecycleKind::from_event(&ThreadEvent::TurnStarted(TurnStartedEvent::default())),
1379            LifecycleKind::TurnStarted
1380        );
1381        assert_eq!(
1382            LifecycleKind::from_event(&ThreadEvent::TurnCompleted(TurnCompletedEvent {
1383                completed_at: None,
1384                usage: Usage::default(),
1385                in_progress_exec_sessions: Vec::new()
1386            })),
1387            LifecycleKind::TurnCompleted
1388        );
1389        assert_eq!(
1390            LifecycleKind::from_event(&ThreadEvent::TurnFailed(TurnFailedEvent {
1391                completed_at: None,
1392                message: "err".to_string(),
1393                usage: None,
1394            })),
1395            LifecycleKind::TurnFailed
1396        );
1397        assert_eq!(
1398            LifecycleKind::from_event(&ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "x".to_string() })),
1399            LifecycleKind::ThreadStarted
1400        );
1401        assert_eq!(
1402            LifecycleKind::from_event(&ThreadEvent::ThreadCompleted(Box::new(ThreadCompletedEvent {
1403                completed_at: None,
1404                thread_id: "x".to_string(),
1405                session_id: "x".to_string(),
1406                subtype: ThreadCompletionSubtype::Success,
1407                outcome_code: "completed".to_string(),
1408                result: None,
1409                stop_reason: None,
1410                usage: Usage::default(),
1411                total_cost_usd: None,
1412                num_turns: 1,
1413            }))),
1414            LifecycleKind::ThreadCompleted
1415        );
1416        assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
1417        assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
1418    }
1419
1420    #[test]
1421    fn lifecycle_kind_from_str_matches_event_discriminator() {
1422        assert_eq!(LifecycleKind::from_kind("turn.started"), LifecycleKind::TurnStarted);
1423        assert_eq!(LifecycleKind::from_kind("turn.completed"), LifecycleKind::TurnCompleted);
1424        assert_eq!(LifecycleKind::from_kind("turn.failed"), LifecycleKind::TurnFailed);
1425        assert_eq!(LifecycleKind::from_kind("tool.called"), LifecycleKind::Other);
1426        assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
1427        assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
1428    }
1429
1430    #[test]
1431    fn turn_started_pushes_index_entry_and_sets_in_turn() {
1432        let mut st = fresh_state();
1433        st.manifest.status = "completed".to_string();
1434        let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1435        assert!(!is_boundary, "TurnStarted is not a turn boundary");
1436        assert!(st.in_turn);
1437        assert_eq!(st.manifest.status, "active");
1438        assert_eq!(st.index.entries.len(), 1);
1439        let entry = &st.index.entries[0];
1440        assert_eq!(entry.turn_number, 1);
1441        assert_eq!(entry.start_offset, 0);
1442        assert_eq!(entry.end_offset, 100);
1443        assert_eq!(entry.event_count, 1);
1444    }
1445
1446    #[test]
1447    fn intermediate_events_extend_current_turn() {
1448        let mut st = fresh_state();
1449        st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1450        // Simulate two intermediate events.
1451        let is_b1 = st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
1452        let is_b2 = st.apply_lifecycle_event(LifecycleKind::Other, 200, 300);
1453        assert!(!is_b1 && !is_b2);
1454        assert!(st.in_turn);
1455        assert_eq!(st.index.entries.len(), 1);
1456        let entry = &st.index.entries[0];
1457        assert_eq!(entry.end_offset, 300);
1458        assert_eq!(entry.event_count, 3);
1459    }
1460
1461    #[test]
1462    fn turn_completed_closes_turn_and_returns_boundary() {
1463        let mut st = fresh_state();
1464        st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1465        st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
1466        let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 200, 300);
1467        assert!(is_boundary);
1468        assert!(!st.in_turn);
1469        assert_eq!(st.manifest.turn_count, 1);
1470        assert_eq!(st.manifest.status, "active");
1471        st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 300, 400);
1472        assert_eq!(st.manifest.status, "completed");
1473        let entry = &st.index.entries[0];
1474        assert_eq!(entry.end_offset, 300);
1475        assert_eq!(entry.event_count, 3);
1476    }
1477
1478    #[test]
1479    fn turn_failed_closes_turn_without_terminal_thread_status() {
1480        let mut st = fresh_state();
1481        st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
1482        let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnFailed, 100, 200);
1483        assert!(is_boundary);
1484        assert!(!st.in_turn);
1485        assert_eq!(st.manifest.turn_count, 1);
1486        assert_eq!(st.manifest.status, "active");
1487        st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 200, 300);
1488        assert_eq!(st.manifest.status, "completed");
1489    }
1490
1491    #[test]
1492    fn turn_completed_without_turn_started_is_idempotent() {
1493        let mut st = fresh_state();
1494        // Receiving TurnCompleted without a preceding TurnStarted should not
1495        // panic or corrupt the index; terminal status remains active until the
1496        // thread lifecycle itself completes.
1497        let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 0, 100);
1498        assert!(is_boundary);
1499        assert!(!st.in_turn);
1500        assert_eq!(st.manifest.turn_count, 0, "no turn was started");
1501        assert_eq!(st.manifest.status, "active");
1502        st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 100, 200);
1503        assert_eq!(st.manifest.status, "completed");
1504        assert!(st.index.entries.is_empty());
1505    }
1506
1507    #[test]
1508    fn multiple_turns_get_incrementing_ordinals() {
1509        let mut st = fresh_state();
1510        for n in 1..=3 {
1511            st.apply_lifecycle_event(LifecycleKind::TurnStarted, n * 100, n * 100 + 50);
1512            st.apply_lifecycle_event(LifecycleKind::TurnCompleted, n * 100 + 50, n * 100 + 100);
1513        }
1514        assert_eq!(st.index.entries.len(), 3);
1515        for (i, entry) in st.index.entries.iter().enumerate() {
1516            assert_eq!(entry.turn_number, (i + 1) as u64);
1517        }
1518        assert_eq!(st.manifest.turn_count, 3);
1519    }
1520}
1521
1522#[cfg(test)]
1523mod cap_eviction_tests {
1524    use super::{LogState, TurnIndexEntry};
1525
1526    /// Build a `LogState` with `turns` fake turns, each having `events_per_turn`
1527    /// events, starting at byte offset 0.
1528    fn state_with_turns(turns: usize, events_per_turn: u64) -> LogState {
1529        let mut st = LogState::new("cap-test");
1530        st.manifest.event_count = (turns as u64) * events_per_turn;
1531        let mut offset = 0u64;
1532        for n in 1..=turns {
1533            st.index.entries.push_back(TurnIndexEntry {
1534                turn_number: n as u64,
1535                start_offset: offset,
1536                end_offset: offset + events_per_turn * 10,
1537                event_count: events_per_turn,
1538                ts: "2026-01-01T00:00:00Z".to_string(),
1539            });
1540            offset += events_per_turn * 10;
1541        }
1542        st
1543    }
1544
1545    #[test]
1546    fn no_eviction_when_under_cap() {
1547        let st = state_with_turns(3, 2); // 6 events
1548        assert!(st.plan_cap_eviction(10).is_none());
1549        assert_eq!(st.index.entries.len(), 3, "no turns should be evicted");
1550    }
1551
1552    #[test]
1553    fn no_eviction_when_cap_disabled() {
1554        let st = state_with_turns(5, 2); // 10 events
1555        assert!(st.plan_cap_eviction(0).is_none());
1556        assert_eq!(st.index.entries.len(), 5);
1557    }
1558
1559    #[test]
1560    fn evicts_oldest_turns_to_meet_cap() {
1561        // 5 turns × 2 events = 10 events; cap = 6 → need to evict 2 turns (4 events).
1562        let st = state_with_turns(5, 2);
1563        let plan = st.plan_cap_eviction(6).expect("eviction planned");
1564        assert_eq!(plan.evicted_event_count, 4, "should evict 4 events (2 turns)");
1565        assert_eq!(st.index.entries.len(), 5, "planning must not mutate state");
1566        // Truncate offset is the end of the last evicted turn.
1567        assert_eq!(plan.truncate_offset, 40); // 2 turns × 20 bytes each
1568
1569        // Applying the plan leaves turns 3, 4, 5.
1570        let mut st = st;
1571        st.apply_cap_eviction(plan, 60);
1572        assert_eq!(st.index.entries.len(), 3, "should keep 3 turns");
1573        assert_eq!(st.index.entries[0].turn_number, 3);
1574        assert_eq!(st.index.entries[2].turn_number, 5);
1575    }
1576
1577    #[test]
1578    fn evicts_all_turns_when_cap_smaller_than_one_turn() {
1579        // 3 turns × 5 events = 15 events; cap = 3 → evict turns until ≤ 3 remain.
1580        // Each turn has 5 events, so evicting 2 turns leaves 5 (>3), evicting
1581        // 3 turns leaves 0.
1582        let st = state_with_turns(3, 5);
1583        let plan = st.plan_cap_eviction(3).expect("eviction planned");
1584        assert_eq!(plan.evicted_event_count, 15, "all events evicted");
1585        let mut st = st;
1586        st.apply_cap_eviction(plan, 0);
1587        assert_eq!(st.index.entries.len(), 0);
1588    }
1589}