1use std::collections::VecDeque;
4use std::fs::File;
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_commons::VtCodePaths;
12use vtcode_exec_events::{EVENT_SCHEMA_VERSION, ThreadEvent, VersionedThreadEvent};
13
14use crate::error::SessionStoreError;
15use crate::manifest::ManifestStore;
16use crate::session_dir;
17
18pub const DEFAULT_MAX_EVENTS: usize = 10_000;
21
22const MAX_WRITE_BUFFER_BYTES: usize = 64 * 1024;
25
26#[derive(Debug, Deserialize)]
32struct VersionedEventKind<'a> {
33 #[serde(rename = "schema_version", borrow)]
34 _schema_version: &'a str,
35 #[serde(borrow)]
36 event: EventKind<'a>,
37}
38
39#[derive(Debug, Deserialize)]
40struct EventKind<'a> {
41 #[serde(rename = "type", borrow)]
42 kind: &'a str,
43}
44
45#[derive(Serialize)]
52struct BorrowedVersionedEvent<'a> {
53 schema_version: &'a str,
54 event: &'a ThreadEvent,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62enum LifecycleKind {
63 ThreadStarted,
64 ThreadCompleted,
65 TurnStarted,
66 TurnCompleted,
67 TurnFailed,
68 Other,
69}
70
71impl LifecycleKind {
72 #[inline]
74 fn from_event(event: &ThreadEvent) -> Self {
75 match event {
76 ThreadEvent::ThreadStarted(_) => Self::ThreadStarted,
77 ThreadEvent::ThreadCompleted(_) => Self::ThreadCompleted,
78 ThreadEvent::TurnStarted(_) => Self::TurnStarted,
79 ThreadEvent::TurnCompleted(_) => Self::TurnCompleted,
80 ThreadEvent::TurnFailed(_) => Self::TurnFailed,
81 _ => Self::Other,
82 }
83 }
84
85 #[inline]
87 fn from_kind(kind: &str) -> Self {
88 match kind {
89 "thread.started" => Self::ThreadStarted,
90 "thread.completed" => Self::ThreadCompleted,
91 "turn.started" => Self::TurnStarted,
92 "turn.completed" => Self::TurnCompleted,
93 "turn.failed" => Self::TurnFailed,
94 _ => Self::Other,
95 }
96 }
97}
98
99struct LogState {
102 manifest: SessionManifest,
103 index: TurnIndex,
104 in_turn: bool,
108 next_offset: u64,
112 write_buf: Vec<u8>,
115}
116
117#[derive(Debug, Clone, Copy)]
118struct CapEvictionPlan {
119 truncate_offset: u64,
120 evicted_event_count: u64,
121 evicted_turn_count: usize,
122}
123
124impl LogState {
125 fn new(session_id: &str) -> Self {
126 Self {
127 manifest: SessionManifest::new(session_id),
128 index: TurnIndex::default(),
129 in_turn: false,
130 next_offset: 0,
131 write_buf: Vec::with_capacity(65536),
132 }
133 }
134
135 fn serialize_event(&mut self, event: &ThreadEvent) -> Result<(u64, u64), SessionStoreError> {
144 let start = self.next_offset;
145 let buf_len_before = self.write_buf.len();
146 if let Err(err) = serde_json::to_writer(
147 &mut self.write_buf,
148 &BorrowedVersionedEvent { schema_version: EVENT_SCHEMA_VERSION, event },
149 ) {
150 self.write_buf.truncate(buf_len_before);
151 return Err(err.into());
152 }
153 self.write_buf.push(b'\n');
154 let written = self.write_buf.len() - buf_len_before;
155 let end = start + written as u64;
156 self.next_offset = end;
157 Ok((start, end))
158 }
159
160 fn apply_lifecycle_event(&mut self, kind: LifecycleKind, start: u64, end: u64) -> bool {
173 match kind {
174 LifecycleKind::ThreadStarted => {
175 self.manifest.status = "active".to_string();
176 false
177 }
178 LifecycleKind::ThreadCompleted => {
179 self.manifest.status = "completed".to_string();
180 true
181 }
182 LifecycleKind::TurnStarted => {
183 self.manifest.status = "active".to_string();
184 self.in_turn = true;
185 let n = self.manifest.turn_count + 1;
186 self.index.entries.push_back(TurnIndexEntry {
187 turn_number: n,
188 start_offset: start,
189 end_offset: end,
190 event_count: 1,
191 ts: now_rfc3339(),
192 });
193 false
194 }
195 LifecycleKind::TurnCompleted | LifecycleKind::TurnFailed => {
196 if self.in_turn {
197 if let Some(entry) = self.index.entries.back_mut() {
198 entry.end_offset = end;
199 entry.event_count += 1;
200 }
201 self.in_turn = false;
202 self.manifest.turn_count = self.index.entries.len() as u64;
203 }
204 true
205 }
206 LifecycleKind::Other => {
207 if self.in_turn
208 && let Some(entry) = self.index.entries.back_mut()
209 {
210 entry.end_offset = end;
211 entry.event_count += 1;
212 }
213 false
214 }
215 }
216 }
217
218 fn plan_cap_eviction(&self, max_events: usize) -> Option<CapEvictionPlan> {
225 if max_events == 0 || self.manifest.event_count <= max_events as u64 {
226 return None;
227 }
228 let mut evicted_event_count = 0u64;
229 let mut truncate_offset = 0u64;
230 let mut evicted_turn_count = 0;
231 for oldest in &self.index.entries {
232 if self.manifest.event_count.saturating_sub(evicted_event_count) <= max_events as u64 {
233 break;
234 }
235 truncate_offset = oldest.end_offset;
236 evicted_event_count += oldest.event_count;
237 evicted_turn_count += 1;
238 }
239 if truncate_offset == 0 || evicted_turn_count == 0 {
240 None
241 } else {
242 Some(CapEvictionPlan {
243 truncate_offset,
244 evicted_event_count,
245 evicted_turn_count,
246 })
247 }
248 }
249
250 fn apply_cap_eviction(&mut self, plan: CapEvictionPlan, next_offset: u64) {
251 for _ in 0..plan.evicted_turn_count {
252 let _ = self.index.entries.pop_front();
253 }
254 for entry in &mut self.index.entries {
255 entry.start_offset = entry.start_offset.saturating_sub(plan.truncate_offset);
256 entry.end_offset = entry.end_offset.saturating_sub(plan.truncate_offset);
257 }
258 self.next_offset = next_offset;
259 self.manifest.event_count = self.manifest.event_count.saturating_sub(plan.evicted_event_count);
260 }
261}
262
263pub struct SessionEventLog {
269 events_path: PathBuf,
270 manifest_store: ManifestStore,
271 file: Mutex<Option<File>>,
272 state: Mutex<LogState>,
273 max_events: usize,
274}
275
276impl SessionEventLog {
277 pub(crate) fn open(workspace: &Path, session_id: &str, max_events: usize) -> Result<Self, SessionStoreError> {
280 let dir = session_dir(workspace, session_id);
281 crate::ensure_private_directory(&crate::sessions_root(workspace))?;
282 crate::ensure_private_directory(&dir)?;
283 crate::ensure_private_directory(&dir.join(crate::DERIVED_DIR))?;
284 crate::ensure_private_directory(&dir.join("index"))?;
285 let events_path = dir.join("events.jsonl");
286 let file = VtCodePaths::open_private_append_file(&events_path)
287 .map_err(|error| SessionStoreError::io(events_path.clone(), std::io::Error::other(error)))?;
288 let manifest_store = ManifestStore::new(dir.clone());
289 let log = Self {
290 events_path: events_path.clone(),
291 manifest_store,
292 file: Mutex::new(Some(file)),
293 state: Mutex::new(LogState::new(session_id)),
294 max_events,
295 };
296 let manifest_opt = log.manifest_store.load_manifest()?;
299 let index_opt = log.manifest_store.load_turn_index()?;
300 let file_len = log.event_file_metadata_len()?;
301 match (manifest_opt, index_opt) {
302 (Some(manifest), Some(index)) if index.is_valid_for_file(file_len) => {
303 let mut st = log.state.lock().map_err(poison)?;
304 st.manifest = manifest;
305 st.index = index;
306 st.next_offset = file_len;
307 }
308 _ => {
309 log.scan()?;
310 let mut st = log.state.lock().map_err(poison)?;
311 st.next_offset = file_len;
312 log.persist_meta_locked(&mut st)?;
313 }
314 }
315 Ok(log)
316 }
317
318 pub fn append(&self, event: &ThreadEvent) -> Result<(), SessionStoreError> {
320 let mut st = self.state.lock().map_err(poison)?;
321
322 let (start, end) = st.serialize_event(event)?;
326
327 st.manifest.event_count += 1;
328 st.manifest.updated_at = now_rfc3339();
329
330 let is_turn_boundary = st.apply_lifecycle_event(LifecycleKind::from_event(event), start, end);
334 if is_turn_boundary {
335 self.persist_meta_locked(&mut st)?;
336 }
337
338 if st.write_buf.len() >= MAX_WRITE_BUFFER_BYTES {
339 self.persist_meta_locked(&mut st)?;
343 }
344 drop(st);
345 self.enforce_event_cap()
346 }
347
348 fn enforce_event_cap(&self) -> Result<(), SessionStoreError> {
352 let mut st = self.state.lock().map_err(poison)?;
353
354 let Some(plan) = st.plan_cap_eviction(self.max_events) else {
357 return Ok(());
358 };
359
360 self.flush_write_buf_locked(&mut st)?;
364
365 let remaining = {
366 let mut file_slot = self.file.lock().map_err(poison)?;
367 let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
368 let file_len = file
369 .metadata()
370 .map_err(|error| SessionStoreError::io(&self.events_path, error))?
371 .len();
372 if plan.truncate_offset > file_len {
373 return Err(SessionStoreError::io(
374 &self.events_path,
375 std::io::Error::new(std::io::ErrorKind::InvalidData, "cap offset exceeds event log length"),
376 ));
377 }
378 file.seek(SeekFrom::Start(plan.truncate_offset))
379 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
380 let mut remaining = Vec::new();
381 file.read_to_end(&mut remaining)
382 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
383 remaining
384 };
385
386 let next_offset = self.replace_event_file_contents(&remaining)?;
387 st.apply_cap_eviction(plan, next_offset);
388 self.persist_meta_locked(&mut st)?;
391 Ok(())
392 }
393
394 pub(crate) fn reconstruct_turn(&self, turn: u64) -> Result<Vec<ThreadEvent>, SessionStoreError> {
396 let entry = {
397 let st = self.state.lock().map_err(poison)?;
398 st.index
399 .entries
400 .iter()
401 .find(|e| e.turn_number == turn)
402 .cloned()
403 .ok_or(SessionStoreError::TurnNotFound { session: st.manifest.session_id.clone(), turn })?
404 };
405 {
406 let mut st = self.state.lock().map_err(poison)?;
407 self.flush_write_buf_locked(&mut st)?;
408 }
409 let buf = {
410 let mut file_slot = self.file.lock().map_err(poison)?;
411 let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
412 file.seek(SeekFrom::Start(entry.start_offset))
413 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
414 let len = usize::try_from(entry.end_offset.checked_sub(entry.start_offset).ok_or_else(|| {
415 SessionStoreError::io(
416 &self.events_path,
417 std::io::Error::new(std::io::ErrorKind::InvalidData, "turn index offsets are out of order"),
418 )
419 })?)
420 .map_err(|error| {
421 SessionStoreError::io(&self.events_path, std::io::Error::new(std::io::ErrorKind::InvalidData, error))
422 })?;
423 let mut buf = vec![0u8; len];
424 file.read_exact(&mut buf)
425 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
426 buf
427 };
428 let text = String::from_utf8_lossy(&buf);
429 let mut events = Vec::new();
430 for line in text.lines() {
431 let line = line.trim();
432 if line.is_empty() {
433 continue;
434 }
435 let v: VersionedThreadEvent = match serde_json::from_str(line) {
441 Ok(v) => v,
442 Err(_) => continue,
443 };
444 events.push(v.into_event());
445 }
446 Ok(events)
447 }
448
449 #[must_use]
451 pub(crate) fn turn_count(&self) -> u64 {
452 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.turn_count)
453 }
454
455 #[must_use]
457 pub fn event_count(&self) -> u64 {
458 self.state.lock().map_err(poison).map_or(0, |s| s.manifest.event_count)
459 }
460
461 pub fn flush(&self) -> Result<(), SessionStoreError> {
463 let mut st = self.state.lock().map_err(poison)?;
464 self.persist_meta_locked(&mut st)
465 }
466
467 #[must_use]
469 pub fn manifest(&self) -> SessionManifest {
470 self.state
471 .lock()
472 .map_err(poison)
473 .map(|s| s.manifest.clone())
474 .unwrap_or_else(|_| SessionManifest::new(""))
475 }
476
477 #[must_use]
479 pub fn turn_index(&self) -> TurnIndex {
480 self.state.lock().map_err(poison).map(|s| s.index.clone()).unwrap_or_default()
481 }
482
483 pub(crate) fn complete(&self) -> Result<(), SessionStoreError> {
489 let mut st = self.state.lock().map_err(poison)?;
490 st.manifest.updated_at = now_rfc3339();
491 self.persist_meta_locked(&mut st)
492 }
493
494 fn scan(&self) -> Result<(), SessionStoreError> {
500 let mut st = self.state.lock().map_err(poison)?;
501 let file = self
502 .file
503 .lock()
504 .map_err(poison)?
505 .as_ref()
506 .ok_or_else(|| self.event_file_unavailable())?
507 .try_clone()
508 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
509 let mut reader = std::io::BufReader::new(file);
510 reader
511 .seek(SeekFrom::Start(0))
512 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
513 let mut buf = Vec::new();
514 let mut pos = 0u64;
515 let mut first_ts: Option<String> = None;
516 loop {
517 buf.clear();
518 let n = reader
519 .read_until(b'\n', &mut buf)
520 .map_err(|e| SessionStoreError::io(&self.events_path, e))?;
521 if n == 0 {
522 break;
523 }
524 let line_end = pos + n as u64;
525 let trimmed = std::str::from_utf8(&buf).unwrap_or("").trim();
526 if !trimmed.is_empty()
527 && let Ok(v) = serde_json::from_str::<VersionedEventKind<'_>>(trimmed)
528 {
529 let kind = v.event.kind;
530 if requires_full_lifecycle_validation(kind)
531 && serde_json::from_str::<VersionedThreadEvent>(trimmed).is_err()
532 {
533 pos = line_end;
534 continue;
535 }
536 st.manifest.event_count += 1;
537 if kind == "thread.started" && first_ts.is_none() {
540 first_ts = Some(now_rfc3339());
541 }
542 st.apply_lifecycle_event(LifecycleKind::from_kind(kind), pos, line_end);
545 }
546 pos = line_end;
547 }
548 st.in_turn = false;
553 if let Some(ts) = first_ts
554 && st.manifest.created_at.is_empty()
555 {
556 st.manifest.created_at = ts;
557 }
558 Ok(())
559 }
560
561 fn persist_meta_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
562 self.flush_write_buf_locked(st)?;
563 self.manifest_store.write_manifest(&st.manifest)?;
564 self.manifest_store.write_turn_index(&st.index)?;
565 Ok(())
566 }
567
568 fn flush_write_buf_locked(&self, st: &mut LogState) -> Result<(), SessionStoreError> {
570 if st.write_buf.is_empty() {
571 return Ok(());
572 }
573 let mut file_slot = self.file.lock().map_err(poison)?;
574 let file = file_slot.as_mut().ok_or_else(|| self.event_file_unavailable())?;
575 let previous_len = file.metadata().map_err(|e| SessionStoreError::io(&self.events_path, e))?.len();
576 if let Err(error) = file.write_all(&st.write_buf) {
577 if file.set_len(previous_len).is_err() {
578 st.write_buf.clear();
579 }
580 return Err(SessionStoreError::io(&self.events_path, error));
581 }
582 if let Err(error) = file.sync_data() {
583 st.write_buf.clear();
584 return Err(SessionStoreError::io(&self.events_path, error));
585 }
586 st.write_buf.clear();
587 Ok(())
588 }
589
590 fn event_file_metadata_len(&self) -> Result<u64, SessionStoreError> {
591 let file_slot = self.file.lock().map_err(poison)?;
592 file_slot
593 .as_ref()
594 .ok_or_else(|| self.event_file_unavailable())?
595 .metadata()
596 .map(|metadata| metadata.len())
597 .map_err(|error| SessionStoreError::io(&self.events_path, error))
598 }
599
600 fn open_event_file(&self) -> Result<File, SessionStoreError> {
601 VtCodePaths::open_private_append_file(&self.events_path)
602 .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
603 }
604
605 fn replace_event_file_contents(&self, contents: &[u8]) -> Result<u64, SessionStoreError> {
606 let old_file = {
607 let mut file_slot = self.file.lock().map_err(poison)?;
608 file_slot.take().ok_or_else(|| self.event_file_unavailable())?
609 };
610 drop(old_file);
611
612 if let Err(error) = VtCodePaths::write_private_file_atomic(&self.events_path, contents)
613 .map_err(|error| SessionStoreError::io(&self.events_path, std::io::Error::other(error)))
614 {
615 let restored = self.open_event_file();
616 if let Ok(file) = restored {
617 let mut file_slot = self.file.lock().map_err(poison)?;
618 *file_slot = Some(file);
619 return Err(error);
620 }
621 return Err(SessionStoreError::io(
622 &self.events_path,
623 std::io::Error::other(format!("{error}; failed to restore event log handle")),
624 ));
625 }
626
627 let replacement = self.open_event_file()?;
628 let next_offset = replacement
629 .metadata()
630 .map_err(|error| SessionStoreError::io(&self.events_path, error))?
631 .len();
632 let mut file_slot = self.file.lock().map_err(poison)?;
633 *file_slot = Some(replacement);
634 Ok(next_offset)
635 }
636
637 fn event_file_unavailable(&self) -> SessionStoreError {
638 SessionStoreError::io(&self.events_path, std::io::Error::other("event log file is unavailable"))
639 }
640}
641
642fn requires_full_lifecycle_validation(kind: &str) -> bool {
643 matches!(kind, "thread.started" | "thread.completed" | "turn.started" | "turn.completed" | "turn.failed")
644}
645
646impl Drop for SessionEventLog {
647 fn drop(&mut self) {
648 if let Ok(mut st) = self.state.lock() {
649 let _ = self.flush_write_buf_locked(&mut st);
655 }
656 }
657}
658
659fn poison<T>(_e: std::sync::PoisonError<T>) -> SessionStoreError {
661 SessionStoreError::Io {
662 path: PathBuf::new(),
663 source: std::io::Error::other("session store lock poisoned"),
664 }
665}
666
667fn now_rfc3339() -> String {
668 Utc::now().to_rfc3339()
669}
670
671#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
673pub struct SessionManifest {
674 pub session_id: String,
676 schema_version: u32,
678 pub created_at: String,
680 pub updated_at: String,
682 pub turn_count: u64,
684 pub event_count: u64,
686 pub status: String,
688}
689
690impl SessionManifest {
691 #[must_use]
693 pub(crate) fn new(session_id: &str) -> Self {
694 let ts = now_rfc3339();
695 Self {
696 session_id: session_id.to_string(),
697 schema_version: crate::SESSION_STORE_SCHEMA_VERSION,
698 created_at: ts.clone(),
699 updated_at: ts,
700 turn_count: 0,
701 event_count: 0,
702 status: "active".to_string(),
703 }
704 }
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
709pub struct TurnIndexEntry {
710 turn_number: u64,
712 start_offset: u64,
714 end_offset: u64,
716 event_count: u64,
718 ts: String,
720}
721
722#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
724pub struct TurnIndex {
725 entries: VecDeque<TurnIndexEntry>,
727}
728
729impl TurnIndex {
730 #[must_use]
732 pub fn len(&self) -> usize {
733 self.entries.len()
734 }
735
736 #[must_use]
738 pub fn is_empty(&self) -> bool {
739 self.entries.is_empty()
740 }
741
742 fn is_valid_for_file(&self, file_len: u64) -> bool {
743 let mut previous_end = 0u64;
744 self.entries.iter().all(|entry| {
745 let valid = entry.event_count > 0
746 && entry.start_offset >= previous_end
747 && entry.start_offset <= entry.end_offset
748 && entry.end_offset <= file_len;
749 if valid {
750 previous_end = entry.end_offset;
751 }
752 valid
753 })
754 }
755}
756
757#[cfg(test)]
758mod borrowed_envelope_tests {
759 use super::{BorrowedVersionedEvent, EVENT_SCHEMA_VERSION};
760 use vtcode_exec_events::{
761 ThreadEvent, ThreadStartedEvent, TurnCompletedEvent, TurnStartedEvent, Usage, VersionedThreadEvent,
762 };
763
764 #[test]
768 fn borrowed_envelope_matches_versioned_envelope() {
769 for event in [
770 ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "thread".to_string() }),
771 ThreadEvent::TurnStarted(TurnStartedEvent::default()),
772 ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() }),
773 ] {
774 let canonical =
775 serde_json::to_string(&VersionedThreadEvent::new(event.clone())).expect("canonical serialize");
776 let borrowed = serde_json::to_string(&BorrowedVersionedEvent {
777 schema_version: EVENT_SCHEMA_VERSION,
778 event: &event,
779 })
780 .expect("borrowed serialize");
781 assert_eq!(canonical, borrowed, "JSON differs for {event:?}");
782 }
783 }
784}
785
786#[cfg(test)]
787mod lifecycle_state_machine_tests {
788 use super::{LifecycleKind, LogState};
789 use vtcode_exec_events::{
790 ThreadCompletedEvent, ThreadCompletionSubtype, ThreadEvent, ThreadStartedEvent, TurnCompletedEvent,
791 TurnFailedEvent, TurnStartedEvent, Usage,
792 };
793
794 fn fresh_state() -> LogState {
795 LogState::new("test-session")
796 }
797
798 #[test]
799 fn lifecycle_kind_from_event_covers_all_variants() {
800 assert_eq!(
801 LifecycleKind::from_event(&ThreadEvent::TurnStarted(TurnStartedEvent::default())),
802 LifecycleKind::TurnStarted
803 );
804 assert_eq!(
805 LifecycleKind::from_event(&ThreadEvent::TurnCompleted(TurnCompletedEvent { usage: Usage::default() })),
806 LifecycleKind::TurnCompleted
807 );
808 assert_eq!(
809 LifecycleKind::from_event(&ThreadEvent::TurnFailed(TurnFailedEvent {
810 message: "err".to_string(),
811 usage: None,
812 })),
813 LifecycleKind::TurnFailed
814 );
815 assert_eq!(
816 LifecycleKind::from_event(&ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "x".to_string() })),
817 LifecycleKind::ThreadStarted
818 );
819 assert_eq!(
820 LifecycleKind::from_event(&ThreadEvent::ThreadCompleted(ThreadCompletedEvent {
821 thread_id: "x".to_string(),
822 session_id: "x".to_string(),
823 subtype: ThreadCompletionSubtype::Success,
824 outcome_code: "completed".to_string(),
825 result: None,
826 stop_reason: None,
827 usage: Usage::default(),
828 total_cost_usd: None,
829 num_turns: 1,
830 })),
831 LifecycleKind::ThreadCompleted
832 );
833 assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
834 assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
835 }
836
837 #[test]
838 fn lifecycle_kind_from_str_matches_event_discriminator() {
839 assert_eq!(LifecycleKind::from_kind("turn.started"), LifecycleKind::TurnStarted);
840 assert_eq!(LifecycleKind::from_kind("turn.completed"), LifecycleKind::TurnCompleted);
841 assert_eq!(LifecycleKind::from_kind("turn.failed"), LifecycleKind::TurnFailed);
842 assert_eq!(LifecycleKind::from_kind("tool.called"), LifecycleKind::Other);
843 assert_eq!(LifecycleKind::from_kind("thread.started"), LifecycleKind::ThreadStarted);
844 assert_eq!(LifecycleKind::from_kind("thread.completed"), LifecycleKind::ThreadCompleted);
845 }
846
847 #[test]
848 fn turn_started_pushes_index_entry_and_sets_in_turn() {
849 let mut st = fresh_state();
850 st.manifest.status = "completed".to_string();
851 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
852 assert!(!is_boundary, "TurnStarted is not a turn boundary");
853 assert!(st.in_turn);
854 assert_eq!(st.manifest.status, "active");
855 assert_eq!(st.index.entries.len(), 1);
856 let entry = &st.index.entries[0];
857 assert_eq!(entry.turn_number, 1);
858 assert_eq!(entry.start_offset, 0);
859 assert_eq!(entry.end_offset, 100);
860 assert_eq!(entry.event_count, 1);
861 }
862
863 #[test]
864 fn intermediate_events_extend_current_turn() {
865 let mut st = fresh_state();
866 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
867 let is_b1 = st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
869 let is_b2 = st.apply_lifecycle_event(LifecycleKind::Other, 200, 300);
870 assert!(!is_b1 && !is_b2);
871 assert!(st.in_turn);
872 assert_eq!(st.index.entries.len(), 1);
873 let entry = &st.index.entries[0];
874 assert_eq!(entry.end_offset, 300);
875 assert_eq!(entry.event_count, 3);
876 }
877
878 #[test]
879 fn turn_completed_closes_turn_and_returns_boundary() {
880 let mut st = fresh_state();
881 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
882 st.apply_lifecycle_event(LifecycleKind::Other, 100, 200);
883 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 200, 300);
884 assert!(is_boundary);
885 assert!(!st.in_turn);
886 assert_eq!(st.manifest.turn_count, 1);
887 assert_eq!(st.manifest.status, "active");
888 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 300, 400);
889 assert_eq!(st.manifest.status, "completed");
890 let entry = &st.index.entries[0];
891 assert_eq!(entry.end_offset, 300);
892 assert_eq!(entry.event_count, 3);
893 }
894
895 #[test]
896 fn turn_failed_closes_turn_without_terminal_thread_status() {
897 let mut st = fresh_state();
898 st.apply_lifecycle_event(LifecycleKind::TurnStarted, 0, 100);
899 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnFailed, 100, 200);
900 assert!(is_boundary);
901 assert!(!st.in_turn);
902 assert_eq!(st.manifest.turn_count, 1);
903 assert_eq!(st.manifest.status, "active");
904 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 200, 300);
905 assert_eq!(st.manifest.status, "completed");
906 }
907
908 #[test]
909 fn turn_completed_without_turn_started_is_idempotent() {
910 let mut st = fresh_state();
911 let is_boundary = st.apply_lifecycle_event(LifecycleKind::TurnCompleted, 0, 100);
915 assert!(is_boundary);
916 assert!(!st.in_turn);
917 assert_eq!(st.manifest.turn_count, 0, "no turn was started");
918 assert_eq!(st.manifest.status, "active");
919 st.apply_lifecycle_event(LifecycleKind::ThreadCompleted, 100, 200);
920 assert_eq!(st.manifest.status, "completed");
921 assert!(st.index.entries.is_empty());
922 }
923
924 #[test]
925 fn multiple_turns_get_incrementing_ordinals() {
926 let mut st = fresh_state();
927 for n in 1..=3 {
928 st.apply_lifecycle_event(LifecycleKind::TurnStarted, n * 100, n * 100 + 50);
929 st.apply_lifecycle_event(LifecycleKind::TurnCompleted, n * 100 + 50, n * 100 + 100);
930 }
931 assert_eq!(st.index.entries.len(), 3);
932 for (i, entry) in st.index.entries.iter().enumerate() {
933 assert_eq!(entry.turn_number, (i + 1) as u64);
934 }
935 assert_eq!(st.manifest.turn_count, 3);
936 }
937}
938
939#[cfg(test)]
940mod cap_eviction_tests {
941 use super::{LogState, TurnIndexEntry};
942
943 fn state_with_turns(turns: usize, events_per_turn: u64) -> LogState {
946 let mut st = LogState::new("cap-test");
947 st.manifest.event_count = (turns as u64) * events_per_turn;
948 let mut offset = 0u64;
949 for n in 1..=turns {
950 st.index.entries.push_back(TurnIndexEntry {
951 turn_number: n as u64,
952 start_offset: offset,
953 end_offset: offset + events_per_turn * 10,
954 event_count: events_per_turn,
955 ts: "2026-01-01T00:00:00Z".to_string(),
956 });
957 offset += events_per_turn * 10;
958 }
959 st
960 }
961
962 #[test]
963 fn no_eviction_when_under_cap() {
964 let st = state_with_turns(3, 2); assert!(st.plan_cap_eviction(10).is_none());
966 assert_eq!(st.index.entries.len(), 3, "no turns should be evicted");
967 }
968
969 #[test]
970 fn no_eviction_when_cap_disabled() {
971 let st = state_with_turns(5, 2); assert!(st.plan_cap_eviction(0).is_none());
973 assert_eq!(st.index.entries.len(), 5);
974 }
975
976 #[test]
977 fn evicts_oldest_turns_to_meet_cap() {
978 let st = state_with_turns(5, 2);
980 let plan = st.plan_cap_eviction(6).expect("eviction planned");
981 assert_eq!(plan.evicted_event_count, 4, "should evict 4 events (2 turns)");
982 assert_eq!(st.index.entries.len(), 5, "planning must not mutate state");
983 assert_eq!(plan.truncate_offset, 40); let mut st = st;
988 st.apply_cap_eviction(plan, 60);
989 assert_eq!(st.index.entries.len(), 3, "should keep 3 turns");
990 assert_eq!(st.index.entries[0].turn_number, 3);
991 assert_eq!(st.index.entries[2].turn_number, 5);
992 }
993
994 #[test]
995 fn evicts_all_turns_when_cap_smaller_than_one_turn() {
996 let st = state_with_turns(3, 5);
1000 let plan = st.plan_cap_eviction(3).expect("eviction planned");
1001 assert_eq!(plan.evicted_event_count, 15, "all events evicted");
1002 let mut st = st;
1003 st.apply_cap_eviction(plan, 0);
1004 assert_eq!(st.index.entries.len(), 0);
1005 }
1006}