Skip to main content

contextgraph_trace/
journal.rs

1//! Journal parsing — NDJSON in, ordered [`TraceEvent`]s out.
2//!
3//! Parsing is deliberately **strict**: a journal is the harness's own
4//! recording, so a malformed line means the recorder is broken, and a broken
5//! recorder must fail the run loudly rather than have its unparseable lines
6//! quietly skipped — a lenient parser here would let a harness escape the
7//! oracles by garbling exactly the events that would convict it.
8
9use crate::event::{EventBody, TraceEvent};
10
11/// Why a journal failed to parse. Carries the 1-based line number so the
12/// failure is actionable against the file.
13#[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/// A parsed journal: the recording of one session, including its resumes, in
26/// file order. The oracles ([`crate::oracle::run_oracles`]) judge it; this
27/// type only carries it.
28#[derive(Debug, Clone, PartialEq)]
29pub struct Journal {
30    pub events: Vec<TraceEvent>,
31}
32
33impl Journal {
34    /// Parse an NDJSON journal. Blank lines are permitted (trailing newline,
35    /// human editing); anything else that does not parse as a [`TraceEvent`]
36    /// is an error naming its line.
37    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    /// A one-line human description of the recording, for the report header:
57    /// the session id, the agent/harness when the journal opens with a
58    /// `session_start`, and the event count.
59    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        // Strictness is the point: a recorder that garbles a line must fail
106        // the run, not have the line skipped.
107        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}