Skip to main content

opensession_core/
jsonl.rs

1//! HAIL JSONL format: streaming serialization/deserialization
2//!
3//! A `.hail.jsonl` file has the structure:
4//! ```jsonl
5//! {"type":"header","version":"hail-1.0.0","session_id":"...","agent":{...},"context":{...}}
6//! {"type":"event","event_id":"e1","timestamp":"...","event_type":{...},"content":{...},...}
7//! {"type":"event","event_id":"e2","timestamp":"...","event_type":{...},"content":{...},...}
8//! {"type":"stats","event_count":42,"message_count":10,...}
9//! ```
10//!
11//! The header line contains session metadata (no events).
12//! Each event is one line.
13//! The last line is aggregate stats (optional on write, recomputed on read if missing).
14
15use crate::trace::{Agent, Event, Session, SessionContext, Stats};
16use serde::{de::DeserializeOwned, Deserialize, Serialize};
17use std::io::{self, BufRead, Write};
18
19/// A single line in a HAIL JSONL file
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "type")]
22#[non_exhaustive]
23pub enum HailLine {
24    /// First line: session metadata
25    #[serde(rename = "header")]
26    Header {
27        version: String,
28        session_id: String,
29        agent: Agent,
30        context: SessionContext,
31    },
32    /// Middle lines: one event per line
33    #[serde(rename = "event")]
34    Event(Event),
35    /// Last line: aggregate stats
36    #[serde(rename = "stats")]
37    Stats(Stats),
38}
39
40/// Error types for JSONL operations
41#[derive(Debug, thiserror::Error)]
42#[non_exhaustive]
43pub enum JsonlError {
44    #[error("IO error: {0}")]
45    Io(#[from] io::Error),
46    #[error("JSON error at line {line}: {source}")]
47    Json {
48        line: usize,
49        source: serde_json::Error,
50    },
51    #[error("Missing header line")]
52    MissingHeader,
53    #[error("Unexpected line type at line {0}: expected header")]
54    UnexpectedLineType(usize),
55}
56
57fn json_to_writer_line<W: Write, T: Serialize>(
58    writer: &mut W,
59    value: &T,
60    line: usize,
61) -> Result<(), JsonlError> {
62    serde_json::to_writer(writer, value).map_err(|source| JsonlError::Json { line, source })
63}
64
65fn json_from_str_line<T: DeserializeOwned>(input: &str, line: usize) -> Result<T, JsonlError> {
66    serde_json::from_str(input).map_err(|source| JsonlError::Json { line, source })
67}
68
69/// Write a Session as HAIL JSONL to a writer
70pub fn write_jsonl<W: Write>(session: &Session, mut writer: W) -> Result<(), JsonlError> {
71    // Line 1: header
72    let header = HailLine::Header {
73        version: session.version.clone(),
74        session_id: session.session_id.clone(),
75        agent: session.agent.clone(),
76        context: session.context.clone(),
77    };
78    json_to_writer_line(&mut writer, &header, 1)?;
79    writer.write_all(b"\n")?;
80
81    // Lines 2..N: events
82    for (i, event) in session.events.iter().enumerate() {
83        let line = HailLine::Event(event.clone());
84        json_to_writer_line(&mut writer, &line, i + 2)?;
85        writer.write_all(b"\n")?;
86    }
87
88    // Last line: stats
89    let stats_line = HailLine::Stats(session.stats.clone());
90    json_to_writer_line(&mut writer, &stats_line, session.events.len() + 2)?;
91    writer.write_all(b"\n")?;
92
93    Ok(())
94}
95
96/// Write a Session as HAIL JSONL to a String
97pub fn to_jsonl_string(session: &Session) -> Result<String, JsonlError> {
98    let mut buf = Vec::new();
99    write_jsonl(session, &mut buf)?;
100    // Safe: serde_json always produces valid UTF-8
101    Ok(String::from_utf8(buf).unwrap())
102}
103
104/// Read a Session from HAIL JSONL reader
105pub fn read_jsonl<R: BufRead>(reader: R) -> Result<Session, JsonlError> {
106    let mut lines = reader.lines();
107
108    // Line 1: header
109    let header_str = lines.next().ok_or(JsonlError::MissingHeader)??;
110    let header: HailLine = json_from_str_line(&header_str, 1)?;
111
112    let (version, session_id, agent, context) = match header {
113        HailLine::Header {
114            version,
115            session_id,
116            agent,
117            context,
118        } => (version, session_id, agent, context),
119        _ => return Err(JsonlError::UnexpectedLineType(1)),
120    };
121
122    let mut events = Vec::new();
123    let mut stats = None;
124    let mut line_num = 1usize;
125
126    for line_result in lines {
127        line_num += 1;
128        let line_str = line_result?;
129        if line_str.is_empty() {
130            continue;
131        }
132
133        let hail_line: HailLine = json_from_str_line(&line_str, line_num)?;
134
135        match hail_line {
136            HailLine::Event(event) => events.push(event),
137            HailLine::Stats(s) => stats = Some(s),
138            HailLine::Header { .. } => {
139                // Ignore duplicate headers
140            }
141        }
142    }
143
144    let has_stats = stats.is_some();
145    let mut session = Session {
146        version,
147        session_id,
148        agent,
149        context,
150        events,
151        stats: stats.unwrap_or_default(),
152    };
153
154    // If no stats line was present, recompute
155    if !has_stats {
156        session.recompute_stats();
157    }
158
159    Ok(session)
160}
161
162/// Read a Session from a HAIL JSONL string
163pub fn from_jsonl_str(s: &str) -> Result<Session, JsonlError> {
164    read_jsonl(io::BufReader::new(s.as_bytes()))
165}
166
167/// Read only the header (first line) from HAIL JSONL — useful for listing sessions
168/// without loading all events
169pub fn read_header<R: BufRead>(
170    reader: R,
171) -> Result<(String, String, Agent, SessionContext), JsonlError> {
172    let mut lines = reader.lines();
173    let header_str = lines.next().ok_or(JsonlError::MissingHeader)??;
174    let header: HailLine = json_from_str_line(&header_str, 1)?;
175
176    match header {
177        HailLine::Header {
178            version,
179            session_id,
180            agent,
181            context,
182        } => Ok((version, session_id, agent, context)),
183        _ => Err(JsonlError::UnexpectedLineType(1)),
184    }
185}
186
187/// Read header + stats (first and last line) without loading events.
188/// Returns (version, session_id, agent, context, stats_or_none)
189pub fn read_header_and_stats(
190    data: &str,
191) -> Result<(String, String, Agent, SessionContext, Option<Stats>), JsonlError> {
192    let mut lines = data.lines();
193
194    // First line: header
195    let header_str = lines.next().ok_or(JsonlError::MissingHeader)?;
196    let header: HailLine = json_from_str_line(header_str, 1)?;
197
198    let (version, session_id, agent, context) = match header {
199        HailLine::Header {
200            version,
201            session_id,
202            agent,
203            context,
204        } => (version, session_id, agent, context),
205        _ => return Err(JsonlError::UnexpectedLineType(1)),
206    };
207
208    // Try to read last non-empty line for stats
209    let mut last_line = None;
210    let mut line_num = 1usize;
211    for line in lines {
212        line_num += 1;
213        if !line.is_empty() {
214            last_line = Some((line_num, line));
215        }
216    }
217
218    let stats = if let Some((_ln, last)) = last_line {
219        match serde_json::from_str::<HailLine>(last) {
220            Ok(HailLine::Stats(s)) => Some(s),
221            Ok(_) => None,
222            Err(_) => None, // Last line isn't stats, that's ok (will recompute)
223        }
224    } else {
225        None
226    };
227
228    Ok((version, session_id, agent, context, stats))
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::trace::{Content, EventType};
235    use chrono::Utc;
236    use std::collections::HashMap;
237
238    fn make_test_session() -> Session {
239        let mut session = Session::new(
240            "test-jsonl-123".to_string(),
241            Agent {
242                provider: "anthropic".to_string(),
243                model: "claude-opus-4-6".to_string(),
244                tool: "claude-code".to_string(),
245                tool_version: Some("1.2.3".to_string()),
246            },
247        );
248        session.context.title = Some("Test JSONL session".to_string());
249
250        let ts = Utc::now();
251        session.events.push(Event {
252            event_id: "e1".to_string(),
253            timestamp: ts,
254            event_type: EventType::UserMessage,
255            task_id: None,
256            content: Content::text("Hello, can you help me?"),
257            duration_ms: None,
258            attributes: HashMap::new(),
259        });
260        session.events.push(Event {
261            event_id: "e2".to_string(),
262            timestamp: ts,
263            event_type: EventType::AgentMessage,
264            task_id: None,
265            content: Content::text("Sure! What do you need?"),
266            duration_ms: None,
267            attributes: HashMap::new(),
268        });
269        session.events.push(Event {
270            event_id: "e3".to_string(),
271            timestamp: ts,
272            event_type: EventType::FileRead {
273                path: "/tmp/test.rs".to_string(),
274            },
275            task_id: Some("t1".to_string()),
276            content: Content::code("fn main() {}", Some("rust".to_string())),
277            duration_ms: Some(50),
278            attributes: HashMap::new(),
279        });
280
281        session.recompute_stats();
282        session
283    }
284
285    #[test]
286    fn test_jsonl_roundtrip() {
287        let session = make_test_session();
288        let jsonl = to_jsonl_string(&session).unwrap();
289
290        // Should have exactly 5 lines (header + 3 events + stats)
291        let lines: Vec<&str> = jsonl.trim().lines().collect();
292        assert_eq!(lines.len(), 5);
293
294        // First line should be header
295        assert!(lines[0].contains("\"type\":\"header\""));
296        assert!(lines[0].contains("hail-1.0.0"));
297
298        // Middle lines should be events
299        assert!(lines[1].contains("\"type\":\"event\""));
300        assert!(lines[2].contains("\"type\":\"event\""));
301        assert!(lines[3].contains("\"type\":\"event\""));
302
303        // Last line should be stats
304        assert!(lines[4].contains("\"type\":\"stats\""));
305
306        // Roundtrip
307        let parsed = from_jsonl_str(&jsonl).unwrap();
308        assert_eq!(parsed.version, "hail-1.0.0");
309        assert_eq!(parsed.session_id, "test-jsonl-123");
310        assert_eq!(parsed.events.len(), 3);
311        assert_eq!(parsed.stats.message_count, 2);
312        assert_eq!(parsed.stats.tool_call_count, 1);
313        assert_eq!(parsed.stats.event_count, 3);
314        assert_eq!(parsed.agent.tool, "claude-code");
315        assert_eq!(parsed.context.title, Some("Test JSONL session".to_string()));
316    }
317
318    #[test]
319    fn test_jsonl_empty_session() {
320        let session = Session::new(
321            "empty-session".to_string(),
322            Agent {
323                provider: "openai".to_string(),
324                model: "gpt-4o".to_string(),
325                tool: "codex".to_string(),
326                tool_version: None,
327            },
328        );
329
330        let jsonl = to_jsonl_string(&session).unwrap();
331        let lines: Vec<&str> = jsonl.trim().lines().collect();
332        assert_eq!(lines.len(), 2); // header + stats only
333
334        let parsed = from_jsonl_str(&jsonl).unwrap();
335        assert_eq!(parsed.events.len(), 0);
336        assert_eq!(parsed.stats.event_count, 0);
337    }
338
339    #[test]
340    fn test_read_header_only() {
341        let session = make_test_session();
342        let jsonl = to_jsonl_string(&session).unwrap();
343
344        let (version, session_id, agent, context) =
345            read_header(io::BufReader::new(jsonl.as_bytes())).unwrap();
346
347        assert_eq!(version, "hail-1.0.0");
348        assert_eq!(session_id, "test-jsonl-123");
349        assert_eq!(agent.tool, "claude-code");
350        assert_eq!(context.title, Some("Test JSONL session".to_string()));
351    }
352
353    #[test]
354    fn test_read_header_and_stats() {
355        let session = make_test_session();
356        let jsonl = to_jsonl_string(&session).unwrap();
357
358        let (version, session_id, _agent, _context, stats) = read_header_and_stats(&jsonl).unwrap();
359
360        assert_eq!(version, "hail-1.0.0");
361        assert_eq!(session_id, "test-jsonl-123");
362        let stats = stats.unwrap();
363        assert_eq!(stats.event_count, 3);
364        assert_eq!(stats.message_count, 2);
365    }
366
367    #[test]
368    fn test_missing_stats_recomputes() {
369        // Manually construct JSONL without stats line
370        let session = make_test_session();
371        let jsonl = to_jsonl_string(&session).unwrap();
372
373        // Remove last line (stats)
374        let without_stats: String = jsonl.lines().take(4).collect::<Vec<_>>().join("\n") + "\n";
375
376        let parsed = from_jsonl_str(&without_stats).unwrap();
377        assert_eq!(parsed.stats.event_count, 3);
378        assert_eq!(parsed.stats.message_count, 2);
379    }
380
381    #[test]
382    fn test_hailline_serde_tag() {
383        let header = HailLine::Header {
384            version: "hail-1.0.0".to_string(),
385            session_id: "s1".to_string(),
386            agent: Agent {
387                provider: "test".to_string(),
388                model: "test".to_string(),
389                tool: "test".to_string(),
390                tool_version: None,
391            },
392            context: SessionContext::default(),
393        };
394
395        let json = serde_json::to_string(&header).unwrap();
396        assert!(json.contains("\"type\":\"header\""));
397
398        let parsed: HailLine = serde_json::from_str(&json).unwrap();
399        match parsed {
400            HailLine::Header { version, .. } => assert_eq!(version, "hail-1.0.0"),
401            _ => panic!("Expected Header"),
402        }
403    }
404
405    #[test]
406    fn test_jsonl_preserves_task_ids() {
407        let session = make_test_session();
408        let jsonl = to_jsonl_string(&session).unwrap();
409        let parsed = from_jsonl_str(&jsonl).unwrap();
410
411        // Event e3 has task_id "t1"
412        assert_eq!(parsed.events[2].task_id, Some("t1".to_string()));
413        // Events e1, e2 have no task_id
414        assert_eq!(parsed.events[0].task_id, None);
415    }
416}