1use 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::{ThreadEvent, VersionedThreadEvent};
12
13use crate::error::SessionStoreError;
14use crate::manifest::ManifestStore;
15use crate::session_dir;
16
17pub const DEFAULT_MAX_EVENTS: usize = 10_000;
20
21const MAX_WRITE_BUFFER_BYTES: usize = 64 * 1024;
24
25struct LogState {
28 manifest: SessionManifest,
29 index: TurnIndex,
30 in_turn: bool,
34 next_offset: u64,
38 write_buf: Vec<u8>,
41}
42
43impl LogState {
44 fn new(session_id: &str) -> Self {
45 Self {
46 manifest: SessionManifest::new(session_id),
47 index: TurnIndex::default(),
48 in_turn: false,
49 next_offset: 0,
50 write_buf: Vec::with_capacity(65536),
51 }
52 }
53}
54
55pub struct SessionEventLog {
61 events_path: PathBuf,
62 manifest_store: ManifestStore,
63 file: Mutex<File>,
64 state: Mutex<LogState>,
65 max_events: usize,
66}
67
68impl SessionEventLog {
69 pub(crate) fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
72 let dir = session_dir(workspace, session_id);
73 std::fs::create_dir_all(dir.join(crate::DERIVED_DIR))
74 .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
75 std::fs::create_dir_all(dir.join("index"))
76 .map_err(|e| SessionStoreError::CreateDir { path: dir.clone(), source: e })?;
77 let events_path = dir.join("events.jsonl");
78 let file = OpenOptions::new()
79 .create(true)
80 .read(true)
81 .append(true)
82 .open(&events_path)
83 .map_err(|e| SessionStoreError::io(events_path.clone(), e))?;
84 let manifest_store = ManifestStore::new(dir.clone());
85 let log = Self {
86 events_path: events_path.clone(),
87 manifest_store,
88 file: Mutex::new(file),
89 state: Mutex::new(LogState::new(session_id)),
90 max_events,
91 };
92 let manifest_opt = log.manifest_store.load_manifest()?;
95 let index_opt = log.manifest_store.load_turn_index()?;
96 let file_len = std::fs::metadata(&events_path)
97 .map_err(|e| SessionStoreError::io(events_path.clone(), e))?
98 .len();
99 match (manifest_opt, index_opt) {
100 (Some(manifest), Some(index)) => {
101 let mut st = log.state.lock().map_err(poison)?;
102 st.manifest = manifest;
103 st.index = index;
104 st.next_offset = file_len;
105 }
106 _ => {
107 log.scan()?;
108 let mut st = log.state.lock().map_err(poison)?;
109 st.next_offset = file_len;
110 }
111 }
112 Ok(log)
113 }
114
115 pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
117 let line = serde_json::to_string(&VersionedThreadEvent::new(event.clone()))?;
118 let written = line.len() + 1;
119 let mut st = self.state.lock().map_err(poison)?;
120 let start = st.next_offset;
121 writeln!(&mut st.write_buf, "{line}").map_err(|e| SessionStoreError::io(&self.events_path, e))?;
122 let end = start + written as u64;
123 st.next_offset = end;
124
125 st.manifest.event_count += 1;
126 st.manifest.updated_at = now_rfc3339();
127 match event {
128 ThreadEvent::TurnStarted(_) => {
129 st.in_turn = true;
130 let n = st.manifest.turn_count + 1;
131 st.index.entries.push_back(TurnIndexEntry {
132 turn_number: n,
133 start_offset: start,
134 end_offset: end,
135 event_count: 1,
136 ts: now_rfc3339(),
137 });
138 }
139 ThreadEvent::TurnCompleted(_) => {
140 if st.in_turn {
141 if let Some(entry) = st.index.entries.back_mut() {
142 entry.end_offset = end;
143 entry.event_count += 1;
144 }
145 st.in_turn = false;
146 st.manifest.turn_count = st.index.entries.len() as u64;
147 }
148 st.manifest.status = "completed".to_string();
149 self.persist_meta_locked(&mut st)?;
150 }
151 ThreadEvent::TurnFailed(_) => {
152 if st.in_turn {
153 if let Some(entry) = st.index.entries.back_mut() {
154 entry.end_offset = end;
155 entry.event_count += 1;
156 }
157 st.in_turn = false;
158 st.manifest.turn_count = st.index.entries.len() as u64;
159 }
160 st.manifest.status = "failed".to_string();
161 self.persist_meta_locked(&mut st)?;
162 }
163 _ => {
164 if st.in_turn
165 && let Some(entry) = st.index.entries.back_mut()
166 {
167 entry.end_offset = end;
168 entry.event_count += 1;
169 }
170 }
171 }
172 if st.write_buf.len() >= MAX_WRITE_BUFFER_BYTES {
173 self.persist_meta_locked(&mut st)?;
177 }
178 drop(st);
179 self.enforce_event_cap()
180 }
181
182 fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
186 if self.max_events == 0 {
187 return Ok(());
188 }
189 let mut st = self.state.lock().map_err(poison)?;
190 if st.manifest.event_count <= self.max_events as u64 {
191 return Ok(());
192 }
193
194 self.flush_write_buf_locked(&mut st)?;
198
199 let _excess = st.manifest.event_count as i64 - self.max_events as i64;
200 let mut evicted_event_count = 0u64;
201 let mut truncate_offset = 0u64;
202
203 while st.manifest.event_count > self.max_events as u64
204 && let Some(oldest) = st.index.entries.front()
205 {
206 truncate_offset = oldest.end_offset;
207 evicted_event_count += oldest.event_count;
208 st.index.entries.pop_front();
209 }
210
211 if truncate_offset == 0 {
212 return Ok(());
213 }
214
215 {
216 let mut file = self.file.lock().map_err(poison)?;
217 file.seek(SeekFrom::Start(truncate_offset))
218 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
219 let mut remaining = Vec::new();
220 file.read_to_end(&mut remaining)
221 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
222 file.set_len(0).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
223 file.seek(SeekFrom::Start(0))
224 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
225 file.write_all(&remaining)
226 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
227 file.flush().map_err(|e| SessionStoreError::io(&self.events_path, e))?;
228 }
229
230 for entry in &mut st.index.entries {
231 entry.start_offset -= truncate_offset;
232 entry.end_offset -= truncate_offset;
233 }
234 st.next_offset -= truncate_offset;
235 st.manifest.event_count = st.manifest.event_count.saturating_sub(evicted_event_count);
236 self.persist_meta_locked(&mut st)?;
239 Ok(())
240 }
241
242 pub(crate) fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
244 let entry = {
245 let st = self.state.lock().map_err(poison)?;
246 st.index
247 .entries
248 .iter()
249 .find(|e| e.turn_number == turn)
250 .cloned()
251 .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
252 };
253 {
254 let mut st = self.state.lock().map_err(poison)?;
255 self.flush_write_buf_locked(&mut st)?;
256 }
257 let buf = {
258 let mut file = self.file.lock().map_err(poison)?;
259 file.seek(SeekFrom::Start(entry.start_offset))
260 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
261 let len = (entry.end_offset - entry.start_offset) as usize;
262 let mut buf = vec![0u8; len];
263 file.read_exact(&mut buf)
264 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
265 buf
266 };
267 let text = String::from_utf8_lossy(&buf);
268 let mut events = Vec::new();
269 for line in text.lines() {
270 let line = line.trim();
271 if line.is_empty() {
272 continue;
273 }
274 let v: VersionedThreadEvent = serde_json::from_str(line).map_err(SessionStoreError::Json)?;
275 events.push(v.into_event());
276 }
277 Ok(events)
278 }
279
280 #[must_use]
282 pub(crate) fn turn_count(&self) -> u64 {
283 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
284 }
285
286 #[must_use]
288 pub fn event_count(&self) -> u64 {
289 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
290 }
291
292 pub fn flush(&self) -> Result<(), SessionStoreError> {
294 let mut st = self.state.lock().map_err(poison)?;
295 self.persist_meta_locked(&mut st)
296 }
297
298 #[must_use]
300 pub fn manifest(&self) -> SessionManifest {
301 self.state
302 .lock()
303 .map_err(poison)
304 .map(|s| s.manifest.clone())
305 .unwrap_or_else(|_| SessionManifest::new(""))
306 }
307
308 #[must_use]
310 pub fn turn_index(&self) -> TurnIndex {
311 self.state.lock().map_err(poison).map(|s| s.index.clone()).unwrap_or_default()
312 }
313
314 pub(crate) fn complete(&self) -> Result<(), SessionStoreError> {
316 let mut st = self.state.lock().map_err(poison)?;
317 st.manifest.status = "completed".to_string();
318 st.manifest.updated_at = now_rfc3339();
319 self.persist_meta_locked(&mut st)
320 }
321
322 fn scan(&self) -> Result<(), SessionStoreError> {
328 let mut st = self.state.lock().map_err(poison)?;
329 if !self.events_path.exists() {
330 return Ok(());
331 }
332 let file = File::open(&self.events_path).map_err(|e| SessionStoreError::io(&self.events_path, e))?;
333 let mut reader = std::io::BufReader::new(file);
334 let mut buf = Vec::new();
335 let mut pos = 0u64;
336 let mut first_ts: Option<String> = None;
337 let mut in_turn = false;
338 loop {
339 buf.clear();
340 let n = reader
341 .read_until(b'\n', &mut buf)
342 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
343 if n == 0 {
344 break;
345 }
346 let line_end = pos + n as u64;
347 let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
348 if !trimmed.is_empty()
349 && let Ok(v) = serde_json::from_str::<VersionedThreadEvent>(trimmed)
350 {
351 let event = v.into_event();
352 st.manifest.event_count += 1;
353 match &event {
354 ThreadEvent::ThreadStarted(_) => {
355 if first_ts.is_none() {
356 first_ts = Some(now_rfc3339());
357 }
358 }
359 ThreadEvent::TurnStarted(_) => {
360 in_turn = true;
361 let n = st.manifest.turn_count + 1;
362 st.index.entries.push_back(TurnIndexEntry {
363 turn_number: n,
364 start_offset: pos,
365 end_offset: line_end,
366 event_count: 1,
367 ts: now_rfc3339(),
368 });
369 }
370 ThreadEvent::TurnCompleted(_) | ThreadEvent::TurnFailed(_) => {
371 if in_turn {
372 if let Some(entry) = st.index.entries.back_mut() {
373 entry.end_offset = line_end;
374 entry.event_count += 1;
375 }
376 in_turn = false;
377 st.manifest.turn_count = st.index.entries.len() as u64;
378 }
379 match &event {
380 ThreadEvent::TurnCompleted(_) => {
381 st.manifest.status = "completed".to_string();
382 }
383 ThreadEvent::TurnFailed(_) => {
384 st.manifest.status = "failed".to_string();
385 }
386 _ => {}
387 }
388 }
389 _ => {
390 if in_turn && let Some(entry) = st.index.entries.back_mut() {
391 entry.end_offset = line_end;
392 entry.event_count += 1;
393 }
394 }
395 }
396 }
397 pos = line_end;
398 }
399 if let Some(ts) = first_ts
400 && st.manifest.created_at.is_empty()
401 {
402 st.manifest.created_at = ts;
403 }
404 Ok(())
405 }
406
407 fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
408 self.flush_write_buf_locked(st)?;
409 self.manifest_store.write_manifest(&st.manifest)?;
410 self.manifest_store.write_turn_index(&st.index)?;
411 Ok(())
412 }
413
414 fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
416 if st.write_buf.is_empty() {
417 return Ok(());
418 }
419 let mut file = self.file.lock().map_err(poison)?;
420 file.write_all(&st.write_buf)
421 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
422 st.write_buf.clear();
423 Ok(())
424 }
425}
426
427impl Drop for SessionEventLog {
428 fn drop(&mut self) {
429 if let Ok(mut st) = self.state.lock() {
430 let _ = self.flush_write_buf_locked(&mut st);
436 }
437 }
438}
439
440fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
442 SessionStoreError::Io {
443 path: PathBuf::new(),
444 source: std::io::Error::other("session store lock poisoned"),
445 }
446}
447
448fn now_rfc3339() -> String {
449 Utc::now().to_rfc3339()
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
454pub struct SessionManifest {
455 pub session_id: String,
457 schema_version: u32,
459 pub created_at: String,
461 pub updated_at: String,
463 pub turn_count: u64,
465 pub event_count: u64,
467 pub status: String,
469}
470
471impl SessionManifest {
472 #[must_use]
474 pub(crate) fn new(session_id: &str) -> Self {
475 let ts = now_rfc3339();
476 Self {
477 session_id: session_id.to_string(),
478 schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
479 created_at: ts.clone(),
480 updated_at: ts,
481 turn_count: 0,
482 event_count: 0,
483 status: "active".to_string(),
484 }
485 }
486}
487
488#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
490pub struct TurnIndexEntry {
491 turn_number: u64,
493 start_offset: u64,
495 end_offset: u64,
497 event_count: u64,
499 ts: String,
501}
502
503#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
505pub struct TurnIndex {
506 entries: VecDeque<TurnIndexEntry>,
508}
509
510impl TurnIndex {
511 #[must_use]
513 pub fn len(&self) -> usize {
514 self.entries.len()
515 }
516
517 #[must_use]
519 pub fn is_empty(&self) -> bool {
520 self.entries.is_empty()
521 }
522}