ostraka_runtime/
record.rs1use 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 watcher: Option<Box<dyn Watcher>>,
24 phase: Phase,
27}
28
29impl RunLog {
30 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 pub fn watched_by(mut self, watcher: Option<Box<dyn Watcher>>) -> Self {
48 self.watcher = watcher;
49 self
50 }
51
52 pub fn enter(&mut self, phase: Phase) {
54 self.phase = phase;
55 self.tell(Step::Entered(phase));
56 }
57
58 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 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
101pub 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 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}