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