1use std::fs::{File, OpenOptions};
4use std::io::{BufRead, Read, Seek, SeekFrom, Write};
5use std::path::{Path, PathBuf};
6use std::sync::Mutex;
7
8use chrono::Utc;
9use serde::{Deserialize, Serialize};
10use vtcode_exec_events::{ThreadEvent, VersionedThreadEvent};
11
12use crate::error::SessionStoreError;
13use crate::manifest::ManifestStore;
14use crate::session_dir;
15
16pub const DEFAULT_MAX_EVENTS: usize = 10_000;
19
20struct LogState {
23 manifest: SessionManifest,
24 index: TurnIndex,
25 in_turn: bool,
29 next_offset: u64,
33}
34
35impl LogState {
36 fn new(session_id: &str) -> Self {
37 Self {
38 manifest: SessionManifest::new(session_id),
39 index: TurnIndex::default(),
40 in_turn: false,
41 next_offset: 0,
42 }
43 }
44}
45
46pub struct SessionEventLog {
52 events_path: PathBuf,
53 manifest_store: ManifestStore,
54 file: Mutex<File>,
55 state: Mutex<LogState>,
56 max_events: usize,
57}
58
59impl SessionEventLog {
60 pub fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
63 let dir = session_dir(workspace, session_id);
64 std::fs::create_dir_all(dir.join(crate::DERIVED_DIR))
65 .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
66 std::fs::create_dir_all(dir.join("index"))
67 .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
68 let events_path = dir.join("events.jsonl");
69 let file = OpenOptions::new()
70 .create(true)
71 .read(true)
72 .append(true)
73 .open(&events_path)
74 .map_err(|e| SessionStoreError::io(events_path.clone(), e))?;
75 let manifest_store = ManifestStore::new(dir.clone());
76 let log = Self {
77 events_path: events_path.clone(),
78 manifest_store,
79 file: Mutex::new(file),
80 state: Mutex::new(LogState::new(session_id)),
81 max_events,
82 };
83 let manifest_opt = log.manifest_store.load_manifest()?;
86 let index_opt = log.manifest_store.load_turn_index()?;
87 let file_len = std::fs::metadata(&events_path)
88 .map_err(|e| SessionStoreError::io(events_path.clone(), e))?
89 .len();
90 match (manifest_opt, index_opt) {
91 (Some(manifest), Some(index)) => {
92 let mut st = log.state.lock().map_err(poison)?;
93 st.manifest = manifest;
94 st.index = index;
95 st.next_offset = file_len;
96 }
97 _ => {
98 log.scan()?;
99 let mut st = log.state.lock().map_err(poison)?;
100 st.next_offset = file_len;
101 }
102 }
103 Ok(log)
104 }
105
106 pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
108 let line = serde_json::to_string(&VersionedThreadEvent::new(event.clone()))?;
109 let written = line.len() + 1;
110 let mut st = self.state.lock().map_err(poison)?;
111 let start = st.next_offset;
112 {
113 let mut file = self.file.lock().map_err(poison)?;
114 writeln!(file, "{line}").map_err(|e| SessionStoreError::io(&self.events_path, e))?;
115 file.flush().map_err(|e| SessionStoreError::io(&self.events_path, e))?;
116 }
117 let end = start + written as u64;
118 st.next_offset = end;
119
120 st.manifest.event_count += 1;
121 st.manifest.updated_at = now_rfc3339();
122 match event {
123 ThreadEvent::TurnStarted(_) => {
124 st.in_turn = true;
125 let n = st.manifest.turn_count + 1;
126 st.index.entries.push(TurnIndexEntry {
127 turn_number: n,
128 start_offset: start,
129 end_offset: end,
130 event_count: 1,
131 ts: now_rfc3339(),
132 });
133 }
134 ThreadEvent::TurnCompleted(_) => {
135 if st.in_turn {
136 if let Some(entry) = st.index.entries.last_mut() {
137 entry.end_offset = end;
138 entry.event_count += 1;
139 }
140 st.in_turn = false;
141 st.manifest.turn_count = st.index.entries.len() as u64;
142 }
143 st.manifest.status = "completed".to_string();
144 self.persist_meta_locked(&st)?;
145 }
146 ThreadEvent::TurnFailed(_) => {
147 if st.in_turn {
148 if let Some(entry) = st.index.entries.last_mut() {
149 entry.end_offset = end;
150 entry.event_count += 1;
151 }
152 st.in_turn = false;
153 st.manifest.turn_count = st.index.entries.len() as u64;
154 }
155 st.manifest.status = "failed".to_string();
156 self.persist_meta_locked(&st)?;
157 }
158 _ => {
159 if st.in_turn
160 && let Some(entry) = st.index.entries.last_mut()
161 {
162 entry.end_offset = end;
163 entry.event_count += 1;
164 }
165 }
166 }
167 drop(st);
168 let _ = self.enforce_event_cap();
169 Ok(())
170 }
171
172 fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
176 if self.max_events == 0 {
177 return Ok(());
178 }
179 let mut st = self.state.lock().map_err(poison)?;
180 if st.manifest.event_count <= self.max_events as u64 {
181 return Ok(());
182 }
183 while st.manifest.event_count > self.max_events as u64
184 && let Some(oldest) = st.index.entries.first()
185 {
186 let truncate_offset = oldest.end_offset;
187 let _evicted = oldest.event_count;
188 {
189 let mut file = self.file.lock().map_err(poison)?;
190 file.seek(SeekFrom::Start(truncate_offset))
191 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
192 let mut remaining = Vec::new();
193 file.read_to_end(&mut remaining)
194 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
195 file.set_len(0).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
196 file.seek(SeekFrom::Start(0))
197 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
198 file.write_all(&remaining)
199 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
200 file.flush().map_err(|e| SessionStoreError::io(&self.events_path, e))?;
201 }
202 st.index.entries.remove(0);
203 for entry in &mut st.index.entries {
204 entry.start_offset -= truncate_offset;
205 entry.end_offset -= truncate_offset;
206 }
207 st.next_offset -= truncate_offset;
208 st.manifest.event_count = st.index.entries.iter().map(|e| e.event_count).sum();
209 let _ = st.manifest.event_count; }
211 Ok(())
212 }
213
214 pub fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
216 let entry = {
217 let st = self.state.lock().map_err(poison)?;
218 st.index
219 .entries
220 .iter()
221 .find(|e| e.turn_number == turn)
222 .cloned()
223 .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
224 };
225 let buf = {
226 let mut file = self.file.lock().map_err(poison)?;
227 file.seek(SeekFrom::Start(entry.start_offset))
228 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
229 let len = (entry.end_offset - entry.start_offset) as usize;
230 let mut buf = vec![0u8; len];
231 file.read_exact(&mut buf)
232 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
233 buf
234 };
235 let text = String::from_utf8_lossy(&buf);
236 let mut events = Vec::new();
237 for line in text.lines() {
238 let line = line.trim();
239 if line.is_empty() {
240 continue;
241 }
242 let v: VersionedThreadEvent = serde_json::from_str(line).map_err(SessionStoreError::Json)?;
243 events.push(v.into_event());
244 }
245 Ok(events)
246 }
247
248 #[must_use]
250 pub fn turn_count(&self) -> u64 {
251 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
252 }
253
254 #[must_use]
256 pub fn event_count(&self) -> u64 {
257 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
258 }
259
260 #[must_use]
262 pub fn manifest(&self) -> SessionManifest {
263 self.state
264 .lock()
265 .map_err(poison)
266 .map(|s| s.manifest.clone())
267 .unwrap_or_else(|_| SessionManifest::new(""))
268 }
269
270 #[must_use]
272 pub fn turn_index(&self) -> TurnIndex {
273 self.state.lock().map_err(poison).map(|s| s.index.clone()).unwrap_or_default()
274 }
275
276 pub fn complete(&self) -> Result<(), SessionStoreError> {
278 let mut st = self.state.lock().map_err(poison)?;
279 st.manifest.status = "completed".to_string();
280 st.manifest.updated_at = now_rfc3339();
281 self.persist_meta_locked(&st)
282 }
283
284 fn scan(&self) -> Result<(), SessionStoreError> {
290 let mut st = self.state.lock().map_err(poison)?;
291 if !self.events_path.exists() {
292 return Ok(());
293 }
294 let file = File::open(&self.events_path).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
295 let mut reader = std::io::BufReader::new(file);
296 let mut buf = Vec::new();
297 let mut pos = 0u64;
298 let mut first_ts: Option<String> = None;
299 let mut in_turn = false;
300 loop {
301 buf.clear();
302 let n = reader
303 .read_until(b'\n', &mut buf)
304 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
305 if n == 0 {
306 break;
307 }
308 let line_end = pos + n as u64;
309 let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
310 if !trimmed.is_empty()
311 && let Ok(v) = serde_json::from_str::<VersionedThreadEvent>(trimmed)
312 {
313 let event = v.into_event();
314 st.manifest.event_count += 1;
315 match &event {
316 ThreadEvent::ThreadStarted(_) => {
317 if first_ts.is_none() {
318 first_ts = Some(now_rfc3339());
319 }
320 }
321 ThreadEvent::TurnStarted(_) => {
322 in_turn = true;
323 let n = st.manifest.turn_count + 1;
324 st.index.entries.push(TurnIndexEntry {
325 turn_number: n,
326 start_offset: pos,
327 end_offset: line_end,
328 event_count: 1,
329 ts: now_rfc3339(),
330 });
331 }
332 ThreadEvent::TurnCompleted(_) | ThreadEvent::TurnFailed(_) => {
333 if in_turn {
334 if let Some(entry) = st.index.entries.last_mut() {
335 entry.end_offset = line_end;
336 entry.event_count += 1;
337 }
338 in_turn = false;
339 st.manifest.turn_count = st.index.entries.len() as u64;
340 }
341 match &event {
342 ThreadEvent::TurnCompleted(_) => {
343 st.manifest.status = "completed".to_string();
344 }
345 ThreadEvent::TurnFailed(_) => {
346 st.manifest.status = "failed".to_string();
347 }
348 _ => {}
349 }
350 }
351 _ => {
352 if in_turn && let Some(entry) = st.index.entries.last_mut() {
353 entry.end_offset = line_end;
354 entry.event_count += 1;
355 }
356 }
357 }
358 }
359 pos = line_end;
360 }
361 if let Some(ts) = first_ts
362 && st.manifest.created_at.is_empty()
363 {
364 st.manifest.created_at = ts;
365 }
366 Ok(())
367 }
368
369 fn persist_meta_locked(&self, st: &LogState) -> Result<(), SessionStoreError> {
370 self.manifest_store.write_manifest(&st.manifest)?;
371 self.manifest_store.write_turn_index(&st.index)?;
372 Ok(())
373 }
374}
375
376fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
378 SessionStoreError::Io {
379 path: PathBuf::new(),
380 source: std::io::Error::other("session store lock poisoned"),
381 }
382}
383
384fn now_rfc3339() -> String {
385 Utc::now().to_rfc3339()
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
390pub struct SessionManifest {
391 pub session_id: String,
393 pub schema_version: u32,
395 pub created_at: String,
397 pub updated_at: String,
399 pub turn_count: u64,
401 pub event_count: u64,
403 pub status: String,
405}
406
407impl SessionManifest {
408 #[must_use]
410 pub fn new(session_id: &str) -> Self {
411 let ts = now_rfc3339();
412 Self {
413 session_id: session_id.to_string(),
414 schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
415 created_at: ts.clone(),
416 updated_at: ts,
417 turn_count: 0,
418 event_count: 0,
419 status: "active".to_string(),
420 }
421 }
422}
423
424#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
426pub struct TurnIndexEntry {
427 pub turn_number: u64,
429 pub start_offset: u64,
431 pub end_offset: u64,
433 pub event_count: u64,
435 pub ts: String,
437}
438
439#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
441pub struct TurnIndex {
442 pub entries: Vec<TurnIndexEntry>,
444}
445
446impl TurnIndex {
447 #[must_use]
449 pub fn len(&self) -> usize {
450 self.entries.len()
451 }
452
453 #[must_use]
455 pub fn is_empty(&self) -> bool {
456 self.entries.is_empty()
457 }
458}