Skip to main content

vtcode_memory/event_log/
manifest.rs

1use std::collections::VecDeque;
2
3use serde::{Deserialize, Serialize};
4
5use super::now_rfc3339;
6
7/// Session-level metadata persisted to `manifest.json`.
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
9pub struct SessionManifest {
10    /// Stable session identifier (directory name).
11    pub session_id: String,
12    /// Layout schema version (`SESSION_STORE_SCHEMA_VERSION`).
13    schema_version: u32,
14    /// RFC3339 creation timestamp.
15    pub created_at: String,
16    /// RFC3339 last-update timestamp.
17    pub updated_at: String,
18    /// Number of completed turns.
19    pub turn_count: u64,
20    /// Total number of events recorded.
21    pub event_count: u64,
22    /// Lifecycle status (`active` | `completed`).
23    pub status: String,
24    /// Whether the canonical log currently ends inside an open turn.
25    ///
26    /// This is optional on read so manifests written before open-turn
27    /// persistence was introduced trigger a safe event-log scan instead of
28    /// silently losing lifecycle state.
29    #[serde(default)]
30    pub(super) in_turn: Option<bool>,
31    /// Byte length covered by the persisted manifest and turn index.
32    ///
33    /// This is optional for compatibility with manifests written before the
34    /// fast-path freshness guard existed; those manifests are rebuilt from the
35    /// canonical event log on reopen.
36    #[serde(default)]
37    pub(super) persisted_file_len: Option<u64>,
38    /// Ordinal of the first turn retained in the canonical log.
39    ///
40    /// Cap eviction removes completed turns but must keep later turn numbers
41    /// monotonic. The field lets an authoritative scan restore those ordinals
42    /// even when the derived index is stale or missing.
43    #[serde(default)]
44    pub(super) retained_turn_base: Option<u64>,
45}
46
47impl SessionManifest {
48    /// Number of turns preceding the retained canonical range.
49    #[must_use]
50    pub fn evicted_turn_count(&self) -> u64 {
51        self.retained_turn_base.unwrap_or(1).saturating_sub(1)
52    }
53    /// Create a fresh manifest for a session.
54    #[must_use]
55    pub(crate) fn new(session_id: &str) -> Self {
56        let ts = now_rfc3339();
57        Self {
58            session_id: session_id.to_string(),
59            schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
60            created_at: ts.clone(),
61            updated_at: ts,
62            turn_count: 0,
63            event_count: 0,
64            status: "active".to_string(),
65            in_turn: Some(false),
66            persisted_file_len: Some(0),
67            retained_turn_base: Some(1),
68        }
69    }
70}
71
72/// Byte-offset index of a single turn within `events.jsonl`.
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
74pub struct TurnIndexEntry {
75    /// Turn ordinal (1-based).
76    pub(super) turn_number: u64,
77    /// Byte offset of the turn's first event.
78    pub(super) start_offset: u64,
79    /// Byte offset just past the turn's last event.
80    pub(super) end_offset: u64,
81    /// Number of events in the turn.
82    pub(super) event_count: u64,
83    /// RFC3339 timestamp of turn start.
84    pub(super) ts: String,
85}
86
87/// Ordered index of all turns in a session.
88#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
89pub struct TurnIndex {
90    /// Turn entries in ordinal order.
91    pub(super) entries: VecDeque<TurnIndexEntry>,
92}
93
94impl TurnIndex {
95    /// Number of indexed turns.
96    #[must_use]
97    pub fn len(&self) -> usize {
98        self.entries.len()
99    }
100
101    /// Whether the index is empty.
102    #[must_use]
103    pub fn is_empty(&self) -> bool {
104        self.entries.is_empty()
105    }
106
107    pub(super) fn is_valid_for_file(&self, file_len: u64) -> bool {
108        let mut previous_end = 0u64;
109        self.entries.iter().all(|entry| {
110            let valid = entry.event_count > 0
111                && entry.start_offset >= previous_end
112                && entry.start_offset <= entry.end_offset
113                && entry.end_offset <= file_len;
114            if valid {
115                previous_end = entry.end_offset;
116            }
117            valid
118        })
119    }
120
121    pub(super) fn is_consistent_with_manifest(&self, manifest: &SessionManifest) -> bool {
122        let expected_last_turn = if manifest.in_turn == Some(true) {
123            manifest.turn_count.saturating_add(1)
124        } else {
125            manifest.turn_count
126        };
127        let entries_are_contiguous = self
128            .entries
129            .iter()
130            .map(|entry| entry.turn_number)
131            .try_fold(None::<u64>, |previous, turn_number| {
132                if previous.is_some_and(|previous| turn_number != previous.saturating_add(1)) {
133                    return Err(());
134                }
135                Ok(Some(turn_number))
136            })
137            .is_ok();
138        if !entries_are_contiguous {
139            return false;
140        }
141
142        let expected_first_turn = manifest.retained_turn_base.unwrap_or(1).max(1);
143        match (self.entries.front(), self.entries.back()) {
144            (Some(first), Some(last)) => {
145                first.turn_number == expected_first_turn && last.turn_number == expected_last_turn
146            }
147            (None, None) => {
148                expected_last_turn == 0
149                    || manifest
150                        .retained_turn_base
151                        .is_some_and(|retained_turn_base| retained_turn_base > manifest.turn_count)
152            }
153            _ => false,
154        }
155    }
156}