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