Skip to main content

vtcode_memory/
event_log.rs

1//! Append-only per-session `ThreadEvent` log plus index and manifest.
2
3use std::collections::VecDeque;
4use std::fs::{File, OpenOptions};
5use std::io::{BufRead, Read, Seek, SeekFrom, Write};
6use std::path::{Path, PathBuf};
7use std::sync::Mutex;
8
9use chrono::Utc;
10use serde::{Deserialize, Serialize};
11use vtcode_exec_events::{EVENT_SCHEMA_VERSION, ThreadEvent, VersionedThreadEvent};
12
13use crate::error::SessionStoreError;
14use crate::manifest::ManifestStore;
15use crate::session_dir;
16
17/// Default maximum number of events retained per session before the oldest
18/// completed turns are evicted.
19pub const DEFAULT_MAX_EVENTS: usize = 10_000;
20
21/// Maximum serialized event bytes retained before an append forces a write.
22/// Turn boundaries and reads still flush immediately.
23const MAX_WRITE_BUFFER_BYTES: usize = 64 * 1024;
24
25/// Minimal envelope used while rebuilding the turn index.
26///
27/// The index only needs the event discriminator. Deserializing a complete
28/// [`VersionedThreadEvent`] here would allocate every nested tool argument,
29/// output, and thread item even though none of that payload is retained.
30#[derive(Debug, Deserialize)]
31struct VersionedEventKind<'a> {
32    #[serde(rename = "schema_version", borrow)]
33    _schema_version: &'a str,
34    #[serde(borrow)]
35    event: EventKind<'a>,
36}
37
38#[derive(Debug, Deserialize)]
39struct EventKind<'a> {
40    #[serde(rename = "type", borrow)]
41    kind: &'a str,
42}
43
44/// Zero-clone serialization envelope for `ThreadEvent`.
45///
46/// Produces JSON byte-identical to `VersionedThreadEvent` but borrows the
47/// event by reference instead of cloning it. `append` is called for every
48/// runtime event, and `ThreadEvent` can carry large tool outputs / thread
49/// items — cloning just to feed `serde_json::to_string` was pure waste.
50#[derive(Serialize)]
51struct BorrowedVersionedEvent<'a> {
52    schema_version: &'a str,
53    event: &'a ThreadEvent,
54}
55
56/// Turn-lifecycle discriminator extracted from either a `ThreadEvent` (at
57/// append time) or a raw `&str` kind (during scan).  This is the single
58/// representation that both code paths feed into
59/// [`LogState::apply_lifecycle_event`], eliminating a duplicated state machine.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61enum LifecycleKind {
62    ThreadStarted,
63    ThreadCompleted,
64    TurnStarted,
65    TurnCompleted,
66    TurnFailed,
67    Other,
68}
69
70impl LifecycleKind {
71    /// Discriminate from a runtime `ThreadEvent` at append time.
72    #[inline]
73    fn from_event(event: &ThreadEvent) -> Self {
74        match event {
75            ThreadEvent::ThreadStarted(_) => Self::ThreadStarted,
76            ThreadEvent::ThreadCompleted(_) => Self::ThreadCompleted,
77            ThreadEvent::TurnStarted(_) => Self::TurnStarted,
78            ThreadEvent::TurnCompleted(_) => Self::TurnCompleted,
79            ThreadEvent::TurnFailed(_) => Self::TurnFailed,
80            _ => Self::Other,
81        }
82    }
83
84    /// Discriminate from a raw event-type string at scan time.
85    #[inline]
86    fn from_kind(kind: &str) -> Self {
87        match kind {
88            "thread.started" => Self::ThreadStarted,
89            "thread.completed" => Self::ThreadCompleted,
90            "turn.started" => Self::TurnStarted,
91            "turn.completed" => Self::TurnCompleted,
92            "turn.failed" => Self::TurnFailed,
93            _ => Self::Other,
94        }
95    }
96}
97
98/// In-memory state protected by a mutex (cheap; appends are infrequent relative
99/// to model inference).
100struct LogState {
101    manifest: SessionManifest,
102    index: TurnIndex,
103    /// Whether we are currently inside a turn (between TurnStarted and
104    /// TurnCompleted/TurnFailed). Used to update the last index entry's
105    /// offsets as intermediate events arrive.
106    in_turn: bool,
107    /// Running byte offset of the next append. Avoids a `stat` syscall per
108    /// event (the previous implementation re-statted the file twice on every
109    /// `append`); initialized from the file length on `open`.
110    next_offset: u64,
111    /// Buffered pending writes to batch syscalls. Events are appended here
112    /// and flushed to disk at turn boundaries or before read operations.
113    write_buf: Vec<u8>,
114}
115
116impl LogState {
117    fn new(session_id: &str) -> Self {
118        Self {
119            manifest: SessionManifest::new(session_id),
120            index: TurnIndex::default(),
121            in_turn: false,
122            next_offset: 0,
123            write_buf: Vec::with_capacity(65536),
124        }
125    }
126
127    /// Serialize `event` directly into the reusable write buffer with rollback
128    /// on failure.
129    ///
130    /// This encapsulates the invariant that `write_buf` never contains a
131    /// partial JSON document: if `serde_json::to_writer` fails mid-write the
132    /// buffer is truncated back to its pre-serialization boundary.  Returns
133    /// the `(start, end)` byte offsets of the serialized event so the caller
134    /// can feed them to [`Self::apply_lifecycle_event`].
135    fn serialize_event(&mut self, event: &ThreadEvent) -> Result<(u64, u64), SessionStoreError> {
136        let start = self.next_offset;
137        let buf_len_before = self.write_buf.len();
138        if let Err(err) = serde_json::to_writer(
139            &mut self.write_buf,
140            &BorrowedVersionedEvent { schema_version: EVENT_SCHEMA_VERSION, event },
141        ) {
142            self.write_buf.truncate(buf_len_before);
143            return Err(err.into());
144        }
145        self.write_buf.push(b'\n');
146        let written = self.write_buf.len() - buf_len_before;
147        let end = start + written as u64;
148        self.next_offset = end;
149        Ok((start, end))
150    }
151
152    /// Update the in-memory turn index and manifest counters for a single
153    /// event.
154    ///
155    /// This is the single implementation of the turn-lifecycle state machine;
156    /// both the append path (via [`LifecycleKind::from_event`]) and the scan
157    /// path (via [`LifecycleKind::from_kind`]) route through here, eliminating
158    /// a previously duplicated match block.
159    ///
160    /// Returns `true` when the event closes a turn boundary
161    /// (`TurnCompleted` / `TurnFailed`) so the caller can persist metadata
162    /// at the appropriate time (append persists immediately; scan persists
163    /// once after the full scan).
164    fn apply_lifecycle_event(&mut self, kind: LifecycleKind, start: u64, end: u64) -> bool {
165        match kind {
166            LifecycleKind::ThreadStarted => {
167                self.manifest.status = "active".to_string();
168                false
169            }
170            LifecycleKind::ThreadCompleted => {
171                self.manifest.status = "completed".to_string();
172                true
173            }
174            LifecycleKind::TurnStarted => {
175                self.manifest.status = "active".to_string();
176                self.in_turn = true;
177                let n = self.manifest.turn_count + 1;
178                self.index.entries.push_back(TurnIndexEntry {
179                    turn_number: n,
180                    start_offset: start,
181                    end_offset: end,
182                    event_count: 1,
183                    ts: now_rfc3339(),
184                });
185                false
186            }
187            LifecycleKind::TurnCompleted | LifecycleKind::TurnFailed => {
188                if self.in_turn {
189                    if let Some(entry) = self.index.entries.back_mut() {
190                        entry.end_offset = end;
191                        entry.event_count += 1;
192                    }
193                    self.in_turn = false;
194                    self.manifest.turn_count = self.index.entries.len() as u64;
195                }
196                true
197            }
198            LifecycleKind::Other => {
199                if self.in_turn
200                    && let Some(entry) = self.index.entries.back_mut()
201                {
202                    entry.end_offset = end;
203                    entry.event_count += 1;
204                }
205                false
206            }
207        }
208    }
209
210    /// Plan a cap-enforcement eviction: pop the oldest completed turns from
211    /// the index until `event_count` is within `max_events`.
212    ///
213    /// Returns the byte offset at which the file should be truncated and the
214    /// number of events removed, so the caller can perform the I/O and adjust
215    /// `next_offset` / `event_count` in one place.  Returns `None` when no
216    /// eviction is needed.
217    fn plan_cap_eviction(&mut self, max_events: usize) -> Option<(u64, u64)> {
218        if max_events == 0 || self.manifest.event_count <= max_events as u64 {
219            return None;
220        }
221        let mut evicted_event_count = 0u64;
222        let mut truncate_offset = 0u64;
223        while self.manifest.event_count - evicted_event_count > max_events as u64
224            && let Some(oldest) = self.index.entries.front()
225        {
226            truncate_offset = oldest.end_offset;
227            evicted_event_count += oldest.event_count;
228            self.index.entries.pop_front();
229        }
230        if truncate_offset == 0 {
231            None
232        } else {
233            Some((truncate_offset, evicted_event_count))
234        }
235    }
236}
237
238/// Canonical append-only event log for a single session.
239///
240/// All session history is reconstructable from this log. Live conversation
241/// state is never read back into context from here; the log is only consumed
242/// for revert, compaction, analytics, and long-term-learning queries.
243pub struct SessionEventLog {
244    events_path: PathBuf,
245    manifest_store: ManifestStore,
246    file: Mutex<File>,
247    state: Mutex<LogState>,
248    max_events: usize,
249}
250
251impl SessionEventLog {
252    /// Open the log for `session_id`, creating the session directory tree and
253    /// rebuilding the index from `events.jsonl` if it already exists.
254    pub(crate) fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
255        let dir = session_dir(workspace, session_id);
256        std::fs::create_dir_all(dir.join(crate::DERIVED_DIR))
257            .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
258        std::fs::create_dir_all(dir.join("index"))
259            .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
260        let events_path = dir.join("events.jsonl");
261        let file = OpenOptions::new()
262            .create(true)
263            .read(true)
264            .append(true)
265            .open(&events_path)
266            .map_err(|e| SessionStoreError::io(events_path.clone(), e))?;
267        let manifest_store = ManifestStore::new(dir.clone());
268        let log = Self {
269            events_path: events_path.clone(),
270            manifest_store,
271            file: Mutex::new(file),
272            state: Mutex::new(LogState::new(session_id)),
273            max_events,
274        };
275        // Try the fast path: read the persisted manifest + index and skip
276        // the O(n) scan when they are present and consistent.
277        let manifest_opt = log.manifest_store.load_manifest()?;
278        let index_opt = log.manifest_store.load_turn_index()?;
279        let file_len = std::fs::metadata(&events_path)
280            .map_err(|e| SessionStoreError::io(events_path.clone(), e))?
281            .len();
282        match (manifest_opt, index_opt) {
283            (Some(manifest), Some(index)) => {
284                let mut st = log.state.lock().map_err(poison)?;
285                st.manifest = manifest;
286                st.index = index;
287                st.next_offset = file_len;
288            }
289            _ => {
290                log.scan()?;
291                let mut st = log.state.lock().map_err(poison)?;
292                st.next_offset = file_len;
293            }
294        }
295        Ok(log)
296    }
297
298    /// Append an event to the log and update the in-memory index/manifest.
299    pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
300        let mut st = self.state.lock().map_err(poison)?;
301
302        // Serialize into the write buffer with rollback on failure — the
303        // invariant that `write_buf` never contains partial JSON is
304        // encapsulated in `serialize_event`.
305        let (start, end) = st.serialize_event(event)?;
306
307        st.manifest.event_count += 1;
308        st.manifest.updated_at = now_rfc3339();
309
310        // Route through the single turn-lifecycle state machine.  When the
311        // event closes a turn, persist metadata immediately so a reopen
312        // after a mid-turn crash sees a consistent index.
313        let is_turn_boundary = st.apply_lifecycle_event(LifecycleKind::from_event(event), start, end);
314        if is_turn_boundary {
315            self.persist_meta_locked(&mut st)?;
316        }
317
318        if st.write_buf.len() >= MAX_WRITE_BUFFER_BYTES {
319            // Persist metadata with the bounded byte flush so a reopen after
320            // a mid-turn crash does not trust an index that predates these
321            // already-written events.
322            self.persist_meta_locked(&mut st)?;
323        }
324        drop(st);
325        self.enforce_event_cap()
326    }
327
328    /// Enforce the per-session event cap by evicting the oldest completed
329    /// turns when the log exceeds [`Self::max_events`]. Returns `Ok(())` even
330    /// when no truncation is needed or the cap is disabled (`max_events == 0`).
331    fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
332        let mut st = self.state.lock().map_err(poison)?;
333
334        // `plan_cap_eviction` encapsulates the index arithmetic and returns
335        // `None` when the cap is disabled or not yet exceeded.
336        let Some((truncate_offset, evicted_event_count)) = st.plan_cap_eviction(self.max_events) else {
337            return Ok(());
338        };
339
340        // Keep ordinary appends in memory until a turn boundary or an
341        // explicit read. Cap enforcement is the one append-time path that
342        // needs the complete on-disk file before rewriting it.
343        self.flush_write_buf_locked(&mut st)?;
344
345        {
346            let mut file = self.file.lock().map_err(poison)?;
347            file.seek(SeekFrom::Start(truncate_offset))
348                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
349            let mut remaining = Vec::new();
350            file.read_to_end(&mut remaining)
351                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
352            file.set_len(0).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
353            file.seek(SeekFrom::Start(0))
354                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
355            file.write_all(&remaining)
356                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
357            file.flush().map_err(|e| SessionStoreError::io(&self.events_path, e))?;
358        }
359
360        for entry in &mut st.index.entries {
361            entry.start_offset -= truncate_offset;
362            entry.end_offset -= truncate_offset;
363        }
364        st.next_offset -= truncate_offset;
365        st.manifest.event_count = st.manifest.event_count.saturating_sub(evicted_event_count);
366        // The rewrite changed byte offsets and retained counts; persist the
367        // derived metadata before exposing the append as successful.
368        self.persist_meta_locked(&mut st)?;
369        Ok(())
370    }
371
372    /// Reconstruct every event belonging to `turn`.
373    pub(crate) fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
374        let entry = {
375            let st = self.state.lock().map_err(poison)?;
376            st.index
377                .entries
378                .iter()
379                .find(|e| e.turn_number == turn)
380                .cloned()
381                .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
382        };
383        {
384            let mut st = self.state.lock().map_err(poison)?;
385            self.flush_write_buf_locked(&mut st)?;
386        }
387        let buf = {
388            let mut file = self.file.lock().map_err(poison)?;
389            file.seek(SeekFrom::Start(entry.start_offset))
390                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
391            let len = (entry.end_offset - entry.start_offset) as usize;
392            let mut buf = vec![0u8; len];
393            file.read_exact(&mut buf)
394                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
395            buf
396        };
397        let text = String::from_utf8_lossy(&buf);
398        let mut events = Vec::new();
399        for line in text.lines() {
400            let line = line.trim();
401            if line.is_empty() {
402                continue;
403            }
404            // The index scan only validates the event envelope (plus the
405            // lifecycle shape) so it can rebuild cheaply. A line accepted by
406            // the scan can therefore still fail full decoding here; skip it
407            // instead of failing the whole reconstruction (revert, compaction,
408            // and analytics must not break on a single malformed record).
409            let v: VersionedThreadEvent = match serde_json::from_str(line) {
410                Ok(v) => v,
411                Err(_) => continue,
412            };
413            events.push(v.into_event());
414        }
415        Ok(events)
416    }
417
418    /// Number of turns recorded.
419    #[must_use]
420    pub(crate) fn turn_count(&self) -> u64 {
421        self.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
422    }
423
424    /// Number of events recorded.
425    #[must_use]
426    pub fn event_count(&self) -> u64 {
427        self.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
428    }
429
430    /// Flush pending event bytes and metadata to the session store.
431    pub fn flush(&self) -> Result<(), SessionStoreError> {
432        let mut st = self.state.lock().map_err(poison)?;
433        self.persist_meta_locked(&mut st)
434    }
435
436    /// Snapshot of the session manifest.
437    #[must_use]
438    pub fn manifest(&self) -> SessionManifest {
439        self.state
440            .lock()
441            .map_err(poison)
442            .map(|s| s.manifest.clone())
443            .unwrap_or_else(|_| SessionManifest::new(""))
444    }
445
446    /// Snapshot of the turn index.
447    #[must_use]
448    pub fn turn_index(&self) -> TurnIndex {
449        self.state.lock().map_err(poison).map(|s| s.index.clone()).unwrap_or_default()
450    }
451
452    /// Flush metadata for callers that explicitly close a log handle.
453    ///
454    /// Terminal status is intentionally controlled only by a persisted
455    /// `thread.completed` event. This method does not synthesize lifecycle
456    /// state for callers that merely release a store handle.
457    pub(crate) fn complete(&self) -> Result<(), SessionStoreError> {
458        let mut st = self.state.lock().map_err(poison)?;
459        st.manifest.updated_at = now_rfc3339();
460        self.persist_meta_locked(&mut st)
461    }
462
463    /// Rebuild index + manifest by scanning `events.jsonl` (authoritative).
464    ///
465    /// Reads the file line-by-line via `BufReader` to avoid loading the entire
466    /// log into memory. Long-lived sessions can otherwise produce multi-megabyte
467    /// logs that spike memory on every reopen.
468    fn scan(&self) -> Result<(), SessionStoreError> {
469        let mut st = self.state.lock().map_err(poison)?;
470        if !self.events_path.exists() {
471            return Ok(());
472        }
473        let file = File::open(&self.events_path).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
474        let mut reader = std::io::BufReader::new(file);
475        let mut buf = Vec::new();
476        let mut pos = 0u64;
477        let mut first_ts: Option<String> = None;
478        loop {
479            buf.clear();
480            let n = reader
481                .read_until(b'\n', &mut buf)
482                .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
483            if n == 0 {
484                break;
485            }
486            let line_end = pos + n as u64;
487            let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
488            if !trimmed.is_empty()
489                && let Ok(v) = serde_json::from_str::<VersionedEventKind<'_>>(trimmed)
490            {
491                let kind = v.event.kind;
492                if requires_full_lifecycle_validation(kind)
493                    && serde_json::from_str::<VersionedThreadEvent>(trimmed).is_err()
494                {
495                    pos = line_end;
496                    continue;
497                }
498                st.manifest.event_count += 1;
499                // `thread.started` is not part of the turn lifecycle — it
500                // only seeds `created_at` on the first occurrence.
501                if kind == "thread.started" && first_ts.is_none() {
502                    first_ts = Some(now_rfc3339());
503                }
504                // Route turn-lifecycle events through the same state machine
505                // as `append`, eliminating a previously duplicated match block.
506                st.apply_lifecycle_event(LifecycleKind::from_kind(kind), pos, line_end);
507            }
508            pos = line_end;
509        }
510        // The scan uses `LogState.in_turn` via `apply_lifecycle_event`; reset
511        // it so a reopen that ends mid-turn does not leave the state machine
512        // in the "inside a turn" position (the fast path also starts with
513        // `in_turn = false`).
514        st.in_turn = false;
515        if let Some(ts) = first_ts
516            && st.manifest.created_at.is_empty()
517        {
518            st.manifest.created_at = ts;
519        }
520        Ok(())
521    }
522
523    fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
524        self.flush_write_buf_locked(st)?;
525        self.manifest_store.write_manifest(&st.manifest)?;
526        self.manifest_store.write_turn_index(&st.index)?;
527        Ok(())
528    }
529
530    /// Flush the in-memory write buffer to the underlying file.
531    fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
532        if st.write_buf.is_empty() {
533            return Ok(());
534        }
535        let mut file = self.file.lock().map_err(poison)?;
536        file.write_all(&st.write_buf)
537            .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
538        st.write_buf.clear();
539        Ok(())
540    }
541}
542
543fn requires_full_lifecycle_validation(kind: &str) -> bool {
544    matches!(kind, "thread.started" | "thread.completed" | "turn.started" | "turn.completed" | "turn.failed")
545}
546
547impl Drop for SessionEventLog {
548    fn drop(&mut self) {
549        if let Ok(mut st) = self.state.lock() {
550            // The fallible `flush` method is the authoritative shutdown path;
551            // Drop only provides a best-effort byte flush for callers that do
552            // not explicitly close the log. Rewriting metadata here could
553            // overwrite a manifest update made by another owner after the
554            // last append.
555            let _ = self.flush_write_buf_locked(&mut st);
556        }
557    }
558}
559
560/// Locate the next newline at or after `from`, returning a past-the-end index.
561fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
562    SessionStoreError::Io {
563        path: PathBuf::new(),
564        source: std::io::Error::other("session store lock poisoned"),
565    }
566}
567
568fn now_rfc3339() -> String {
569    Utc::now().to_rfc3339()
570}
571
572/// Session-level metadata persisted to `manifest.json`.
573#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
574pub struct SessionManifest {
575    /// Stable session identifier (directory name).
576    pub session_id: String,
577    /// Layout schema version (`SESSION_STORE_SCHEMA_VERSION`).
578    schema_version: u32,
579    /// RFC3339 creation timestamp.
580    pub created_at: String,
581    /// RFC3339 last-update timestamp.
582    pub updated_at: String,
583    /// Number of completed turns.
584    pub turn_count: u64,
585    /// Total number of events recorded.
586    pub event_count: u64,
587    /// Lifecycle status (`active` | `completed`).
588    pub status: String,
589}
590
591impl SessionManifest {
592    /// Create a fresh manifest for a session.
593    #[must_use]
594    pub(crate) fn new(session_id: &str) -> Self {
595        let ts = now_rfc3339();
596        Self {
597            session_id: session_id.to_string(),
598            schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
599            created_at: ts.clone(),
600            updated_at: ts,
601            turn_count: 0,
602            event_count: 0,
603            status: "active".to_string(),
604        }
605    }
606}
607
608/// Byte-offset index of a single turn within `events.jsonl`.
609#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
610pub struct TurnIndexEntry {
611    /// Turn ordinal (1-based).
612    turn_number: u64,
613    /// Byte offset of the turn's first event.
614    start_offset: u64,
615    /// Byte offset just past the turn's last event.
616    end_offset: u64,
617    /// Number of events in the turn.
618    event_count: u64,
619    /// RFC3339 timestamp of turn start.
620    ts: String,
621}
622
623/// Ordered index of all turns in a session.
624#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
625pub struct TurnIndex {
626    /// Turn entries in ordinal order.
627    entries: VecDeque<TurnIndexEntry>,
628}
629
630impl TurnIndex {
631    /// Number of indexed turns.
632    #[must_use]
633    pub fn len(&self) -> usize {
634        self.entries.len()
635    }
636
637    /// Whether the index is empty.
638    #[must_use]
639    pub fn is_empty(&self) -> bool {
640        self.entries.is_empty()
641    }
642}
643
644#[cfg(test)]
645mod borrowed_envelope_tests {
646    use super::{BorrowedVersionedEvent, EVENT_SCHEMA_VERSION};
647    use vtcode_exec_events::{
648        ThreadEvent, ThreadStartedEvent, TurnCompletedEvent, TurnStartedEvent, Usage, VersionedThreadEvent,
649    };
650
651    /// The borrowed envelope must produce JSON byte-identical to
652    /// `VersionedThreadEvent::new(event.clone())`. This guards against drift if
653    /// either the envelope or the canonical wrapper is modified.
654    #[test]
655    fn borrowed_envelope_matches_versioned_envelope() {
656        for event in [
657            ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "thread".to_string() }),
658            ThreadEvent::TurnStarted(TurnStartedEvent::default()),
659            ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() }),
660        ] {
661            let canonical =
662                serde_json::to_string(&VersionedThreadEvent::new(event.clone())).expect("canonical serialize");
663            let borrowed = serde_json::to_string(&BorrowedVersionedEvent {
664                schema_version: EVENT_SCHEMA_VERSION,
665                event: &event,
666            })
667            .expect("borrowed serialize");
668            assert_eq!(canonical, borrowed, "JSON differs for {event:?}");
669        }
670    }
671}
672
673#[cfg(test)]
674mod lifecycle_state_machine_tests {
675    use super::{LifecycleKind, LogState};
676    use vtcode_exec_events::{
677        ThreadCompletedEvent, ThreadCompletionSubtype, ThreadEvent, ThreadStartedEvent, TurnCompletedEvent,
678        TurnFailedEvent, TurnStartedEvent, Usage,
679    };
680
681    fn fresh_state() -> LogState {
682        LogState::new("test-session")
683    }
684
685    #[test]
686    fn lifecycle_kind_from_event_covers_all_variants() {
687        assert_eq!(
688            LifecycleKind::from_event(&ThreadEvent::TurnStarted(TurnStartedEvent::default())),
689            LifecycleKind::TurnStarted
690        );
691        assert_eq!(
692            LifecycleKind::from_event(&ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() })),
693            LifecycleKind::TurnCompleted
694        );
695        assert_eq!(
696            LifecycleKind::from_event(&ThreadEvent::TurnFailed(TurnFailedEvent {
697                message: "err".to_string(),
698                usage: None,
699            })),
700            LifecycleKind::TurnFailed
701        );
702        assert_eq!(
703            LifecycleKind::from_event(&ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "x".to_string() })),
704            LifecycleKind::ThreadStarted
705        );
706        assert_eq!(
707            LifecycleKind::from_event(&ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
708                thread_id: "x".to_string(),
709                session_id: "x".to_string(),
710                subtype: ThreadCompletionSubtype::Success,
711                outcome_code: "completed".to_string(),
712                result: None,
713                stop_reason: None,
714                usage: Usage::default(),
715                total_cost_usd: None,
716                num_turns: 1,
717            })),
718            LifecycleKind::ThreadCompleted
719        );
720        assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
721        assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
722    }
723
724    #[test]
725    fn lifecycle_kind_from_str_matches_event_discriminator() {
726        assert_eq!(LifecycleKind::from_kind("turn.started"), LifecycleKind::TurnStarted);
727        assert_eq!(LifecycleKind::from_kind("turn.completed"), LifecycleKind::TurnCompleted);
728        assert_eq!(LifecycleKind::from_kind("turn.failed"), LifecycleKind::TurnFailed);
729        assert_eq!(LifecycleKind::from_kind("tool.called"), LifecycleKind::Other);
730        assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
731        assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
732    }
733
734    #[test]
735    fn turn_started_pushes_index_entry_and_sets_in_turn() {
736        let mut st = fresh_state();
737        st.manifest.status = "completed".to_string();
738        let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
739        assert!(!is_boundary, "TurnStarted is not a turn boundary");
740        assert!(st.in_turn);
741        assert_eq!(st.manifest.status, "active");
742        assert_eq!(st.index.entries.len(), 1);
743        let entry = &st.index.entries[0];
744        assert_eq!(entry.turn_number, 1);
745        assert_eq!(entry.start_offset, 0);
746        assert_eq!(entry.end_offset, 100);
747        assert_eq!(entry.event_count, 1);
748    }
749
750    #[test]
751    fn intermediate_events_extend_current_turn() {
752        let mut st = fresh_state();
753        st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
754        // Simulate two intermediate events.
755        let is_b1 = st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
756        let is_b2 = st.apply_lifecycle_event(LifecycleKind::Other, 200, 300);
757        assert!(!is_b1 && !is_b2);
758        assert!(st.in_turn);
759        assert_eq!(st.index.entries.len(), 1);
760        let entry = &st.index.entries[0];
761        assert_eq!(entry.end_offset, 300);
762        assert_eq!(entry.event_count, 3);
763    }
764
765    #[test]
766    fn turn_completed_closes_turn_and_returns_boundary() {
767        let mut st = fresh_state();
768        st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
769        st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
770        let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 200, 300);
771        assert!(is_boundary);
772        assert!(!st.in_turn);
773        assert_eq!(st.manifest.turn_count, 1);
774        assert_eq!(st.manifest.status, "active");
775        st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 300, 400);
776        assert_eq!(st.manifest.status, "completed");
777        let entry = &st.index.entries[0];
778        assert_eq!(entry.end_offset, 300);
779        assert_eq!(entry.event_count, 3);
780    }
781
782    #[test]
783    fn turn_failed_closes_turn_without_terminal_thread_status() {
784        let mut st = fresh_state();
785        st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
786        let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnFailed, 100, 200);
787        assert!(is_boundary);
788        assert!(!st.in_turn);
789        assert_eq!(st.manifest.turn_count, 1);
790        assert_eq!(st.manifest.status, "active");
791        st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 200, 300);
792        assert_eq!(st.manifest.status, "completed");
793    }
794
795    #[test]
796    fn turn_completed_without_turn_started_is_idempotent() {
797        let mut st = fresh_state();
798        // Receiving TurnCompleted without a preceding TurnStarted should not
799        // panic or corrupt the index; terminal status remains active until the
800        // thread lifecycle itself completes.
801        let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 0, 100);
802        assert!(is_boundary);
803        assert!(!st.in_turn);
804        assert_eq!(st.manifest.turn_count, 0, "no turn was started");
805        assert_eq!(st.manifest.status, "active");
806        st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 100, 200);
807        assert_eq!(st.manifest.status, "completed");
808        assert!(st.index.entries.is_empty());
809    }
810
811    #[test]
812    fn multiple_turns_get_incrementing_ordinals() {
813        let mut st = fresh_state();
814        for n in 1..=3 {
815            st.apply_lifecycle_event(LifecycleKind::TurnStarted, n * 100, n * 100 + 50);
816            st.apply_lifecycle_event(LifecycleKind::TurnCompleted, n * 100 + 50, n * 100 + 100);
817        }
818        assert_eq!(st.index.entries.len(), 3);
819        for (i, entry) in st.index.entries.iter().enumerate() {
820            assert_eq!(entry.turn_number, (i + 1) as u64);
821        }
822        assert_eq!(st.manifest.turn_count, 3);
823    }
824}
825
826#[cfg(test)]
827mod cap_eviction_tests {
828    use super::{LogState, TurnIndexEntry};
829
830    /// Build a `LogState` with `turns` fake turns, each having `events_per_turn`
831    /// events, starting at byte offset 0.
832    fn state_with_turns(turns: usize, events_per_turn: u64) -> LogState {
833        let mut st = LogState::new("cap-test");
834        st.manifest.event_count = (turns as u64) * events_per_turn;
835        let mut offset = 0u64;
836        for n in 1..=turns {
837            st.index.entries.push_back(TurnIndexEntry {
838                turn_number: n as u64,
839                start_offset: offset,
840                end_offset: offset + events_per_turn * 10,
841                event_count: events_per_turn,
842                ts: "2026-01-01T00:00:00Z".to_string(),
843            });
844            offset += events_per_turn * 10;
845        }
846        st
847    }
848
849    #[test]
850    fn no_eviction_when_under_cap() {
851        let mut st = state_with_turns(3, 2); // 6 events
852        assert!(st.plan_cap_eviction(10).is_none());
853        assert_eq!(st.index.entries.len(), 3, "no turns should be evicted");
854    }
855
856    #[test]
857    fn no_eviction_when_cap_disabled() {
858        let mut st = state_with_turns(5, 2); // 10 events
859        assert!(st.plan_cap_eviction(0).is_none());
860        assert_eq!(st.index.entries.len(), 5);
861    }
862
863    #[test]
864    fn evicts_oldest_turns_to_meet_cap() {
865        // 5 turns × 2 events = 10 events; cap = 6 → need to evict 2 turns (4 events).
866        let mut st = state_with_turns(5, 2);
867        let (truncate_offset, evicted) = st.plan_cap_eviction(6).expect("eviction planned");
868        assert_eq!(evicted, 4, "should evict 4 events (2 turns)");
869        assert_eq!(st.index.entries.len(), 3, "should keep 3 turns");
870        // Truncate offset is the end of the last evicted turn.
871        assert_eq!(truncate_offset, 40); // 2 turns × 20 bytes each
872        // Remaining turns should be turns 3, 4, 5.
873        assert_eq!(st.index.entries[0].turn_number, 3);
874        assert_eq!(st.index.entries[2].turn_number, 5);
875    }
876
877    #[test]
878    fn evicts_all_turns_when_cap_smaller_than_one_turn() {
879        // 3 turns × 5 events = 15 events; cap = 3 → evict turns until ≤ 3 remain.
880        // Each turn has 5 events, so evicting 2 turns leaves 5 (>3), evicting
881        // 3 turns leaves 0.
882        let mut st = state_with_turns(3, 5);
883        let (_truncate_offset, evicted) = st.plan_cap_eviction(3).expect("eviction planned");
884        assert_eq!(evicted, 15, "all events evicted");
885        assert_eq!(st.index.entries.len(), 0);
886    }
887}