Skip to main content

treeship_core/session/
event_log.rs

1//! Append-only, file-backed event log for session events.
2//!
3//! Events are stored as newline-delimited JSON (JSONL) in
4//! `.treeship/sessions/<session_id>/events.jsonl`.
5
6use std::io::{BufRead, Write};
7use std::path::{Path, PathBuf};
8use std::sync::atomic::{AtomicU64, Ordering};
9
10use crate::session::event::SessionEvent;
11
12/// Error from event log operations.
13#[derive(Debug)]
14pub enum EventLogError {
15    Io(std::io::Error),
16    Json(serde_json::Error),
17}
18
19impl std::fmt::Display for EventLogError {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Self::Io(e) => write!(f, "event log io: {e}"),
23            Self::Json(e) => write!(f, "event log json: {e}"),
24        }
25    }
26}
27
28impl std::error::Error for EventLogError {}
29impl From<std::io::Error> for EventLogError {
30    fn from(e: std::io::Error) -> Self { Self::Io(e) }
31}
32impl From<serde_json::Error> for EventLogError {
33    fn from(e: serde_json::Error) -> Self { Self::Json(e) }
34}
35
36/// An append-only event log backed by a JSONL file.
37pub struct EventLog {
38    path: PathBuf,
39    sequence: AtomicU64,
40}
41
42impl EventLog {
43    /// Open or create an event log for the given session directory.
44    ///
45    /// The session directory is typically `.treeship/sessions/<session_id>/`.
46    /// If the directory does not exist, it will be created.
47    pub fn open(session_dir: &Path) -> Result<Self, EventLogError> {
48        std::fs::create_dir_all(session_dir)?;
49        let path = session_dir.join("events.jsonl");
50
51        // Count existing events to initialize the sequence counter.
52        let sequence = if path.exists() {
53            let file = std::fs::File::open(&path)?;
54            let reader = std::io::BufReader::new(file);
55            let count = reader.lines().filter(|l| l.is_ok()).count() as u64;
56            AtomicU64::new(count)
57        } else {
58            AtomicU64::new(0)
59        };
60
61        Ok(Self { path, sequence })
62    }
63
64    /// Append a single event to the log.
65    ///
66    /// The event's `sequence_no` is set automatically based on the log position.
67    pub fn append(&self, event: &mut SessionEvent) -> Result<(), EventLogError> {
68        event.sequence_no = self.sequence.fetch_add(1, Ordering::SeqCst);
69
70        let mut line = serde_json::to_vec(event)?;
71        line.push(b'\n');
72
73        let mut file = std::fs::OpenOptions::new()
74            .create(true)
75            .append(true)
76            .open(&self.path)?;
77        file.write_all(&line)?;
78        file.flush()?;
79
80        Ok(())
81    }
82
83    /// Read all events from the log.
84    pub fn read_all(&self) -> Result<Vec<SessionEvent>, EventLogError> {
85        if !self.path.exists() {
86            return Ok(Vec::new());
87        }
88        let file = std::fs::File::open(&self.path)?;
89        let reader = std::io::BufReader::new(file);
90        let mut events = Vec::new();
91        for line in reader.lines() {
92            let line = line?;
93            if line.trim().is_empty() {
94                continue;
95            }
96            let event: SessionEvent = serde_json::from_str(&line)?;
97            events.push(event);
98        }
99        Ok(events)
100    }
101
102    /// Return the current event count.
103    pub fn event_count(&self) -> u64 {
104        self.sequence.load(Ordering::SeqCst)
105    }
106
107    /// Return the path to the JSONL file.
108    pub fn path(&self) -> &Path {
109        &self.path
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::session::event::*;
117
118    fn make_event(session_id: &str, event_type: EventType) -> SessionEvent {
119        SessionEvent {
120            session_id: session_id.into(),
121            event_id: generate_event_id(),
122            timestamp: "2026-04-05T08:00:00Z".into(),
123            sequence_no: 0,
124            trace_id: generate_trace_id(),
125            span_id: generate_span_id(),
126            parent_span_id: None,
127            agent_id: "agent://test".into(),
128            agent_instance_id: "ai_test_1".into(),
129            agent_name: "test-agent".into(),
130            agent_role: None,
131            host_id: "host_test".into(),
132            tool_runtime_id: None,
133            event_type,
134            artifact_ref: None,
135            meta: None,
136        }
137    }
138
139    #[test]
140    fn append_and_read_back() {
141        let dir = std::env::temp_dir().join(format!("treeship-evtlog-test-{}", rand::random::<u32>()));
142        let log = EventLog::open(&dir).unwrap();
143
144        let mut e1 = make_event("ssn_001", EventType::SessionStarted);
145        let mut e2 = make_event("ssn_001", EventType::AgentStarted {
146            parent_agent_instance_id: None,
147        });
148
149        log.append(&mut e1).unwrap();
150        log.append(&mut e2).unwrap();
151
152        assert_eq!(log.event_count(), 2);
153        assert_eq!(e1.sequence_no, 0);
154        assert_eq!(e2.sequence_no, 1);
155
156        let events = log.read_all().unwrap();
157        assert_eq!(events.len(), 2);
158        assert_eq!(events[0].sequence_no, 0);
159        assert_eq!(events[1].sequence_no, 1);
160
161        let _ = std::fs::remove_dir_all(&dir);
162    }
163
164    #[test]
165    fn reopen_preserves_sequence() {
166        let dir = std::env::temp_dir().join(format!("treeship-evtlog-reopen-{}", rand::random::<u32>()));
167
168        {
169            let log = EventLog::open(&dir).unwrap();
170            let mut e = make_event("ssn_001", EventType::SessionStarted);
171            log.append(&mut e).unwrap();
172        }
173
174        // Reopen
175        let log = EventLog::open(&dir).unwrap();
176        assert_eq!(log.event_count(), 1);
177
178        let mut e2 = make_event("ssn_001", EventType::AgentStarted {
179            parent_agent_instance_id: None,
180        });
181        log.append(&mut e2).unwrap();
182        assert_eq!(e2.sequence_no, 1);
183
184        let _ = std::fs::remove_dir_all(&dir);
185    }
186}