1use std::fs::{File, OpenOptions};
4use std::io::{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::session_dir;
14
15struct LogState {
18 manifest: SessionManifest,
19 index: TurnIndex,
20 in_turn: bool,
24}
25
26impl LogState {
27 fn new(session_id: &str) -> Self {
28 Self {
29 manifest: SessionManifest::new(session_id),
30 index: TurnIndex::default(),
31 in_turn: false,
32 }
33 }
34}
35
36pub struct SessionEventLog {
42 session_dir: PathBuf,
43 events_path: PathBuf,
44 file: Mutex<File>,
45 state: Mutex<LogState>,
46}
47
48impl SessionEventLog {
49 pub fn open(workspace: &Path, session_id: &str) -> Result<Self, SessionStoreError> {
52 let dir = session_dir(workspace, session_id);
53 std::fs::create_dir_all(dir.join(crate::DERIVED_DIR)).map_err(|e| {
54 SessionStoreError::CreateDir {
55 path: dir.clone(),
56 source: e,
57 }
58 })?;
59 std::fs::create_dir_all(dir.join("index")).map_err(|e| SessionStoreError::CreateDir {
60 path: dir.clone(),
61 source: e,
62 })?;
63 let events_path = dir.join("events.jsonl");
64 let file = OpenOptions::new()
65 .create(true)
66 .read(true)
67 .append(true)
68 .open(&events_path)
69 .map_err(|e| SessionStoreError::io(events_path.clone(), e))?;
70 let log = Self {
71 session_dir: dir,
72 events_path,
73 file: Mutex::new(file),
74 state: Mutex::new(LogState::new(session_id)),
75 };
76 log.scan()?;
77 Ok(log)
78 }
79
80 pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
82 let line = serde_json::to_string(&VersionedThreadEvent::new(event.clone()))?;
83 let start = {
84 let mut file = self.file.lock().map_err(poison)?;
85 let start = file
86 .metadata()
87 .map_err(|e| SessionStoreError::io(&self.events_path, e))?
88 .len();
89 writeln!(file, "{line}").map_err(|e| SessionStoreError::io(&self.events_path, e))?;
90 let end = file
91 .metadata()
92 .map_err(|e| SessionStoreError::io(&self.events_path, e))?
93 .len();
94 (start, end)
95 };
96 let (start, end) = start;
97
98 let mut st = self.state.lock().map_err(poison)?;
99 st.manifest.event_count += 1;
100 st.manifest.updated_at = now_rfc3339();
101 match event {
102 ThreadEvent::TurnStarted(_) => {
103 st.in_turn = true;
104 let n = st.manifest.turn_count + 1;
105 st.index.entries.push(TurnIndexEntry {
106 turn_number: n,
107 start_offset: start,
108 end_offset: end,
109 event_count: 1,
110 ts: now_rfc3339(),
111 });
112 }
113 ThreadEvent::TurnCompleted(_) => {
114 if st.in_turn {
115 if let Some(entry) = st.index.entries.last_mut() {
116 entry.end_offset = end;
117 entry.event_count += 1;
118 }
119 st.in_turn = false;
120 st.manifest.turn_count = st.index.entries.len() as u64;
121 }
122 st.manifest.status = "completed".to_string();
123 self.persist_meta_locked(&st)?;
124 }
125 ThreadEvent::TurnFailed(_) => {
126 if st.in_turn {
127 if let Some(entry) = st.index.entries.last_mut() {
128 entry.end_offset = end;
129 entry.event_count += 1;
130 }
131 st.in_turn = false;
132 st.manifest.turn_count = st.index.entries.len() as u64;
133 }
134 st.manifest.status = "failed".to_string();
135 self.persist_meta_locked(&st)?;
136 }
137 _ => {
138 if st.in_turn {
139 if let Some(entry) = st.index.entries.last_mut() {
140 entry.end_offset = end;
141 entry.event_count += 1;
142 }
143 }
144 }
145 }
146 Ok(())
147 }
148
149 pub fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
151 let entry = {
152 let st = self.state.lock().map_err(poison)?;
153 st.index
154 .entries
155 .iter()
156 .find(|e| e.turn_number == turn)
157 .cloned()
158 .ok_or(SessionStoreError::TurnNotFound {
159 session: st.manifest.session_id.clone(),
160 turn,
161 })?
162 };
163 let buf = {
164 let mut file = self.file.lock().map_err(poison)?;
165 file.seek(SeekFrom::Start(entry.start_offset))
166 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
167 let len = (entry.end_offset - entry.start_offset) as usize;
168 let mut buf = vec![0u8; len];
169 file.read_exact(&mut buf)
170 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
171 buf
172 };
173 let text = String::from_utf8_lossy(&buf);
174 let mut events = Vec::new();
175 for line in text.lines() {
176 let line = line.trim();
177 if line.is_empty() {
178 continue;
179 }
180 let v: VersionedThreadEvent =
181 serde_json::from_str(line).map_err(SessionStoreError::Json)?;
182 events.push(v.into_event());
183 }
184 Ok(events)
185 }
186
187 #[must_use]
189 pub fn turn_count(&self) -> u64 {
190 self.state
191 .lock()
192 .map_err(poison)
193 .map_or(0, |s| s.manifest.turn_count)
194 }
195
196 #[must_use]
198 pub fn event_count(&self) -> u64 {
199 self.state
200 .lock()
201 .map_err(poison)
202 .map_or(0, |s| s.manifest.event_count)
203 }
204
205 #[must_use]
207 pub fn manifest(&self) -> SessionManifest {
208 self.state
209 .lock()
210 .map_err(poison)
211 .map(|s| s.manifest.clone())
212 .unwrap_or_else(|_| SessionManifest::new(""))
213 }
214
215 #[must_use]
217 pub fn turn_index(&self) -> TurnIndex {
218 self.state
219 .lock()
220 .map_err(poison)
221 .map(|s| s.index.clone())
222 .unwrap_or_default()
223 }
224
225 pub fn complete(&self) -> Result<(), SessionStoreError> {
227 let mut st = self.state.lock().map_err(poison)?;
228 st.manifest.status = "completed".to_string();
229 st.manifest.updated_at = now_rfc3339();
230 self.persist_meta_locked(&st)
231 }
232
233 fn scan(&self) -> Result<(), SessionStoreError> {
238 let mut st = self.state.lock().map_err(poison)?;
239 if !self.events_path.exists() {
240 return Ok(());
241 }
242 let content = std::fs::read_to_string(&self.events_path)
243 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
244 let bytes = content.as_bytes();
245 let mut first_ts: Option<String> = None;
246 let mut pos = 0usize;
247 let mut in_turn = false;
248 while pos < bytes.len() {
249 let line_end = memchr_newline(bytes, pos);
250 let trimmed = std::str::from_utf8(&bytes[pos..line_end])
251 .unwrap_or("")
252 .trim();
253 if !trimmed.is_empty() {
254 if let Ok(v) = serde_json::from_str::<VersionedThreadEvent>(trimmed) {
255 let event = v.into_event();
256 st.manifest.event_count += 1;
257 match &event {
258 ThreadEvent::ThreadStarted(_) => {
259 if first_ts.is_none() {
260 first_ts = Some(now_rfc3339());
261 }
262 }
263 ThreadEvent::TurnStarted(_) => {
264 in_turn = true;
265 let n = st.manifest.turn_count + 1;
266 st.index.entries.push(TurnIndexEntry {
267 turn_number: n,
268 start_offset: pos as u64,
269 end_offset: 0,
270 event_count: 1,
271 ts: now_rfc3339(),
272 });
273 }
274 ThreadEvent::TurnCompleted(_) | ThreadEvent::TurnFailed(_) => {
275 if in_turn {
276 if let Some(entry) = st.index.entries.last_mut() {
277 entry.end_offset = line_end as u64;
278 entry.event_count += 1;
279 }
280 in_turn = false;
281 st.manifest.turn_count = st.index.entries.len() as u64;
282 }
283 match &event {
284 ThreadEvent::TurnCompleted(_) => {
285 st.manifest.status = "completed".to_string();
286 }
287 ThreadEvent::TurnFailed(_) => {
288 st.manifest.status = "failed".to_string();
289 }
290 _ => {}
291 }
292 }
293 _ => {
294 if in_turn {
295 if let Some(entry) = st.index.entries.last_mut() {
296 entry.end_offset = line_end as u64;
297 entry.event_count += 1;
298 }
299 }
300 }
301 }
302 }
303 }
304 pos = line_end;
305 }
306 if let Some(ts) = first_ts {
307 if st.manifest.created_at.is_empty() {
308 st.manifest.created_at = ts;
309 }
310 }
311 Ok(())
312 }
313
314 fn persist_meta_locked(&self, st: &LogState) -> Result<(), SessionStoreError> {
315 let mpath = self.session_dir.join("manifest.json");
316 let bytes = serde_json::to_string_pretty(&st.manifest)?;
317 std::fs::write(&mpath, bytes).map_err(|e| SessionStoreError::io(mpath, e))?;
318 let ipath = self.session_dir.join("index").join("turns.json");
319 let bytes = serde_json::to_string_pretty(&st.index)?;
320 std::fs::write(&ipath, bytes).map_err(|e| SessionStoreError::io(ipath, e))?;
321 Ok(())
322 }
323}
324
325fn memchr_newline(bytes: &[u8], from: usize) -> usize {
327 let slice = &bytes[from..];
328 match slice.iter().position(|&b| b == b'\n') {
329 Some(p) => from + p + 1,
330 None => bytes.len(),
331 }
332}
333
334fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
335 SessionStoreError::Io {
336 path: PathBuf::new(),
337 source: std::io::Error::other("session store lock poisoned"),
338 }
339}
340
341fn now_rfc3339() -> String {
342 Utc::now().to_rfc3339()
343}
344
345#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
347pub struct SessionManifest {
348 pub session_id: String,
350 pub schema_version: u32,
352 pub created_at: String,
354 pub updated_at: String,
356 pub turn_count: u64,
358 pub event_count: u64,
360 pub status: String,
362}
363
364impl SessionManifest {
365 #[must_use]
367 pub fn new(session_id: &str) -> Self {
368 let ts = now_rfc3339();
369 Self {
370 session_id: session_id.to_string(),
371 schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
372 created_at: ts.clone(),
373 updated_at: ts,
374 turn_count: 0,
375 event_count: 0,
376 status: "active".to_string(),
377 }
378 }
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
383pub struct TurnIndexEntry {
384 pub turn_number: u64,
386 pub start_offset: u64,
388 pub end_offset: u64,
390 pub event_count: u64,
392 pub ts: String,
394}
395
396#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
398pub struct TurnIndex {
399 pub entries: Vec<TurnIndexEntry>,
401}
402
403impl TurnIndex {
404 #[must_use]
406 pub fn len(&self) -> usize {
407 self.entries.len()
408 }
409
410 #[must_use]
412 pub fn is_empty(&self) -> bool {
413 self.entries.is_empty()
414 }
415}