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