Skip to main content

ostraka_runtime/
record.rs

1//! Writing the run log.
2//!
3//! Events are appended as they happen rather than assembled at the end, so an
4//! interrupted run still leaves an account of how far it got.
5
6use crate::progress::{Phase, Step, Watcher};
7use crate::{Error, Result};
8use ostraka_core::gate::CheckRecord;
9use ostraka_core::record::{Event, RunRecord};
10use std::fs::{self, File, OpenOptions};
11use std::io::Write;
12use std::path::{Path, PathBuf};
13
14pub struct RunLog {
15    dir: PathBuf,
16    events: File,
17    /// Told about everything the log is told about, when anyone is looking.
18    ///
19    /// It lives here rather than being threaded through the orchestrator
20    /// because every `append` is already the sentence "something happened",
21    /// and a second parameter carried through six functions to say the same
22    /// thing twice is how the two drift apart.
23    watcher: Option<Box<dyn Watcher>>,
24    /// Which part of the pipeline the run is in, so an event can be attributed
25    /// to the agent that produced it rather than arriving unlabelled.
26    phase: Phase,
27}
28
29impl RunLog {
30    /// Opens `<root>/runs/<run_id>/`, creating it if needed.
31    pub fn create(root: &Path, run_id: &str) -> Result<Self> {
32        let dir = root.join("runs").join(run_id);
33        fs::create_dir_all(&dir)?;
34        let events = OpenOptions::new()
35            .create(true)
36            .append(true)
37            .open(dir.join("events.jsonl"))?;
38        Ok(Self {
39            dir,
40            events,
41            watcher: None,
42            phase: Phase::Isolating,
43        })
44    }
45
46    /// Sends everything this log is told to whoever is watching.
47    pub fn watched_by(mut self, watcher: Option<Box<dyn Watcher>>) -> Self {
48        self.watcher = watcher;
49        self
50    }
51
52    /// Moves the run into a phase, and says so.
53    pub fn enter(&mut self, phase: Phase) {
54        self.phase = phase;
55        self.tell(Step::Entered(phase));
56    }
57
58    /// Reports one finished gate check.
59    ///
60    /// Not written here — the checks travel into the record whole, at the end,
61    /// where they belong. This is only so a screen does not have to wait for
62    /// the rest of the gate to learn that the first check passed.
63    pub fn checked(&mut self, record: &CheckRecord) {
64        self.tell(Step::Checked(record.clone()));
65    }
66
67    fn tell(&mut self, step: Step) {
68        if let Some(watcher) = self.watcher.as_mut() {
69            watcher.saw(step);
70        }
71    }
72
73    pub fn dir(&self) -> &Path {
74        &self.dir
75    }
76
77    pub fn append(&mut self, event: &Event) -> Result<()> {
78        let line = serde_json::to_string(event)
79            .map_err(|e| Error::Other(format!("serializing event: {e}")))?;
80        writeln!(self.events, "{line}")?;
81        self.events.flush()?;
82        // Told after it is written, not before. The file is the record; a
83        // watcher that saw an event the log then failed to write would be
84        // showing something that did not happen.
85        let phase = self.phase;
86        self.tell(Step::Said {
87            phase,
88            event: event.clone(),
89        });
90        Ok(())
91    }
92
93    pub fn write_record(&self, record: &RunRecord) -> Result<()> {
94        let json = serde_json::to_string_pretty(record)
95            .map_err(|e| Error::Other(format!("serializing record: {e}")))?;
96        fs::write(self.dir.join("record.json"), json)?;
97        Ok(())
98    }
99}
100
101/// Reads back an event log for replay.
102pub fn read_events(dir: &Path) -> Result<Vec<Event>> {
103    let text = fs::read_to_string(dir.join("events.jsonl"))?;
104    text.lines()
105        .filter(|l| !l.trim().is_empty())
106        .map(|l| serde_json::from_str(l).map_err(|e| Error::Other(format!("replay: {e}"))))
107        .collect()
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn a_watcher_is_told_what_the_log_is_told_and_in_which_phase() {
116        use crate::progress::{Phase, Step};
117        let root = std::env::temp_dir().join(format!("ostraka-watch-{}", std::process::id()));
118        let _ = fs::remove_dir_all(&root);
119        let (tx, rx) = std::sync::mpsc::channel();
120        let mut log = RunLog::create(&root, "run-w")
121            .expect("creates")
122            .watched_by(Some(Box::new(crate::progress::Channel(tx))));
123
124        log.enter(Phase::Authoring);
125        log.append(&Event::Message {
126            text: "working".into(),
127            raw: None,
128        })
129        .expect("appends");
130        log.enter(Phase::Gating);
131        log.checked(&ostraka_core::gate::CheckRecord {
132            name: "format".into(),
133            cmd: "fmt".into(),
134            exit_code: Some(0),
135            stdout: String::new(),
136            stderr: String::new(),
137            duration_ms: 3,
138        });
139
140        let steps: Vec<Step> = rx.try_iter().collect();
141        assert_eq!(steps.len(), 4, "{steps:?}");
142        assert!(matches!(steps[0], Step::Entered(Phase::Authoring)));
143        // The phase travels with the event, so a transcript can say which agent
144        // spoke rather than listing every line under one heading.
145        assert!(matches!(
146            steps[1],
147            Step::Said {
148                phase: Phase::Authoring,
149                ..
150            }
151        ));
152        assert!(matches!(steps[2], Step::Entered(Phase::Gating)));
153        assert!(matches!(steps[3], Step::Checked(_)));
154
155        fs::remove_dir_all(&root).ok();
156    }
157
158    #[test]
159    fn an_unwatched_log_writes_exactly_as_it_did_before() {
160        let root = std::env::temp_dir().join(format!("ostraka-unwatched-{}", std::process::id()));
161        let _ = fs::remove_dir_all(&root);
162        let mut log = RunLog::create(&root, "run-u").expect("creates");
163        log.enter(crate::progress::Phase::Gating);
164        log.append(&Event::Message {
165            text: "only".into(),
166            raw: None,
167        })
168        .expect("appends");
169        assert_eq!(read_events(log.dir()).expect("reads").len(), 1);
170        fs::remove_dir_all(&root).ok();
171    }
172
173    #[test]
174    fn events_survive_a_write_and_read_round_trip() {
175        let root = std::env::temp_dir().join(format!("ostraka-test-{}", std::process::id()));
176        let mut log = RunLog::create(&root, "run-1").expect("creates");
177        log.append(&Event::Message {
178            text: "first".into(),
179            raw: None,
180        })
181        .expect("appends");
182        log.append(&Event::Finished {
183            exit_code: Some(0),
184            files_touched: vec![],
185        })
186        .expect("appends");
187
188        let events = read_events(log.dir()).expect("reads back");
189        assert_eq!(events.len(), 2);
190
191        fs::remove_dir_all(&root).ok();
192    }
193}