contextgraph_trace/
journal.rs1use crate::event::{EventBody, TraceEvent};
10
11#[derive(Debug, thiserror::Error)]
14pub enum JournalError {
15 #[error("journal line {line} is not a valid trace event: {source}")]
16 Malformed {
17 line: usize,
18 #[source]
19 source: serde_json::Error,
20 },
21 #[error("journal contains no events")]
22 Empty,
23}
24
25#[derive(Debug, Clone, PartialEq)]
29pub struct Journal {
30 pub events: Vec<TraceEvent>,
31}
32
33impl Journal {
34 pub fn from_ndjson(input: &str) -> Result<Self, JournalError> {
38 let mut events = Vec::new();
39 for (index, line) in input.lines().enumerate() {
40 if line.trim().is_empty() {
41 continue;
42 }
43 let event: TraceEvent =
44 serde_json::from_str(line).map_err(|source| JournalError::Malformed {
45 line: index + 1,
46 source,
47 })?;
48 events.push(event);
49 }
50 if events.is_empty() {
51 return Err(JournalError::Empty);
52 }
53 Ok(Self { events })
54 }
55
56 pub fn describe(&self) -> String {
60 let session = self
61 .events
62 .first()
63 .map(|event| event.session.as_str())
64 .unwrap_or("<empty>");
65 match self.events.first().map(|event| &event.body) {
66 Some(EventBody::SessionStart { agent, harness, .. }) => format!(
67 "session {session} — agent {agent} (harness {harness}), {} event(s)",
68 self.events.len()
69 ),
70 _ => format!("session {session}, {} event(s)", self.events.len()),
71 }
72 }
73}
74
75#[cfg(test)]
76mod tests {
77 use super::*;
78
79 const TWO_LINES: &str = concat!(
80 r#"{"seq":1,"at":"2026-07-23T09:00:00Z","session":"sess_1","event":"session_start","agent":"example-agent","harness":"stella/0.9"}"#,
81 "\n",
82 r#"{"seq":2,"at":"2026-07-23T09:00:01Z","session":"sess_1","event":"session_end","outcome":"completed"}"#,
83 "\n",
84 );
85
86 #[test]
87 fn a_well_formed_journal_parses_in_file_order() {
88 let journal = Journal::from_ndjson(TWO_LINES).unwrap();
89 assert_eq!(journal.events.len(), 2);
90 assert_eq!(journal.events[0].seq, 1);
91 assert_eq!(journal.events[1].body.kind(), "session_end");
92 assert_eq!(
93 journal.describe(),
94 "session sess_1 — agent example-agent (harness stella/0.9), 2 event(s)"
95 );
96 }
97
98 #[test]
99 fn blank_lines_are_permitted_but_garbage_names_its_line() {
100 let with_blank = format!("\n{TWO_LINES}\n");
101 assert!(Journal::from_ndjson(&with_blank).is_ok());
102
103 let with_garbage = format!("{TWO_LINES}not json {{{{\n");
104 let error = Journal::from_ndjson(&with_garbage).unwrap_err();
105 assert!(matches!(error, JournalError::Malformed { line: 3, .. }));
108 }
109
110 #[test]
111 fn an_empty_journal_is_an_error_not_a_vacuous_pass() {
112 assert!(matches!(
113 Journal::from_ndjson("\n\n"),
114 Err(JournalError::Empty)
115 ));
116 }
117}