1use std::fs::{self, File, OpenOptions};
6use std::io::{self, BufRead, BufReader, Write};
7use std::path::Path;
8use std::sync::{Arc, Mutex};
9
10use base64::Engine;
11use base64::engine::general_purpose::STANDARD as BASE64;
12use serde::{Deserialize, Serialize};
13use time::OffsetDateTime;
14use time::format_description::well_known::Rfc3339;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum OutputSource {
19 Agent,
20 Aftercare,
21}
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "snake_case")]
25pub enum OutputStream {
26 Stdout,
27 Stderr,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(tag = "encoding", rename_all = "snake_case")]
34pub enum OutputChunk {
35 Utf8 { text: String },
36 Base64 { data: String },
37}
38
39impl OutputChunk {
40 pub fn from_bytes(bytes: &[u8]) -> Self {
41 match std::str::from_utf8(bytes) {
42 Ok(text) => Self::Utf8 { text: text.into() },
43 Err(_) => Self::Base64 {
44 data: BASE64.encode(bytes),
45 },
46 }
47 }
48
49 pub fn into_bytes(self) -> Vec<u8> {
50 match self {
51 Self::Utf8 { text } => text.into_bytes(),
52 Self::Base64 { data } => BASE64.decode(data).unwrap_or_default(),
53 }
54 }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58pub struct OutputRecord {
59 pub sequence: u64,
60 pub timestamp: String,
61 pub source: OutputSource,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub stage: Option<String>,
64 pub stream: OutputStream,
65 #[serde(flatten)]
66 pub chunk: OutputChunk,
67}
68
69#[derive(Clone)]
72pub struct RunLogWriter {
73 inner: Arc<Mutex<Inner>>,
74}
75
76struct Inner {
77 file: File,
78 next_sequence: u64,
79}
80
81impl RunLogWriter {
82 pub fn open(path: &Path) -> io::Result<Self> {
86 if let Some(parent) = path.parent() {
87 fs::create_dir_all(parent)?;
88 }
89 let next_sequence = last_sequence(path)?.map_or(1, |last| last + 1);
90 let mut file = OpenOptions::new().create(true).append(true).open(path)?;
91 if !ends_with_newline(path)? {
94 file.write_all(b"\n")?;
95 }
96 Ok(Self {
97 inner: Arc::new(Mutex::new(Inner {
98 file,
99 next_sequence,
100 })),
101 })
102 }
103
104 pub fn append(
107 &self,
108 source: OutputSource,
109 stage: Option<&str>,
110 stream: OutputStream,
111 bytes: &[u8],
112 ) -> io::Result<u64> {
113 let timestamp = OffsetDateTime::now_utc()
114 .format(&Rfc3339)
115 .unwrap_or_else(|_| "1970-01-01T00:00:00Z".into());
116 let mut inner = self
117 .inner
118 .lock()
119 .map_err(|_| io::Error::other("run log lock poisoned"))?;
120 let record = OutputRecord {
121 sequence: inner.next_sequence,
122 timestamp,
123 source,
124 stage: stage.map(str::to_owned),
125 stream,
126 chunk: OutputChunk::from_bytes(bytes),
127 };
128 let mut line = serde_json::to_vec(&record).map_err(io::Error::other)?;
129 line.push(b'\n');
130 inner.file.write_all(&line)?;
131 inner.file.sync_data()?;
132 inner.next_sequence += 1;
133 Ok(record.sequence)
134 }
135}
136
137fn ends_with_newline(path: &Path) -> io::Result<bool> {
140 use std::io::{Read, Seek, SeekFrom};
141 let mut file = match File::open(path) {
142 Ok(file) => file,
143 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
144 Err(error) => return Err(error),
145 };
146 if file.metadata()?.len() == 0 {
147 return Ok(true);
148 }
149 file.seek(SeekFrom::End(-1))?;
150 let mut last = [0u8; 1];
151 file.read_exact(&mut last)?;
152 Ok(last[0] == b'\n')
153}
154
155fn last_sequence(path: &Path) -> io::Result<Option<u64>> {
158 let file = match File::open(path) {
159 Ok(file) => file,
160 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
161 Err(error) => return Err(error),
162 };
163 let mut last = None;
164 for line in BufReader::new(file).lines() {
165 if let Ok(record) = serde_json::from_str::<OutputRecord>(&line?) {
166 last = Some(record.sequence);
167 }
168 }
169 Ok(last)
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct OutputPage {
174 pub entries: Vec<OutputRecord>,
175 pub next_cursor: u64,
178 pub complete: bool,
180}
181
182pub fn read_page(path: &Path, after: u64, limit: usize) -> io::Result<OutputPage> {
185 let file = match File::open(path) {
186 Ok(file) => file,
187 Err(error) if error.kind() == io::ErrorKind::NotFound => {
188 return Ok(OutputPage {
189 entries: Vec::new(),
190 next_cursor: after,
191 complete: true,
192 });
193 }
194 Err(error) => return Err(error),
195 };
196
197 let mut entries = Vec::new();
198 let mut complete = true;
199 for line in BufReader::new(file).lines() {
200 let Ok(record) = serde_json::from_str::<OutputRecord>(&line?) else {
203 continue;
204 };
205 if record.sequence <= after {
206 continue;
207 }
208 if entries.len() == limit {
209 complete = false;
210 break;
211 }
212 entries.push(record);
213 }
214 let next_cursor = entries.last().map_or(after, |record| record.sequence);
215 Ok(OutputPage {
216 entries,
217 next_cursor,
218 complete,
219 })
220}
221
222#[cfg(test)]
223mod tests {
224 use serde_json::{Value, json};
225 use tempfile::tempdir;
226
227 use super::{OutputChunk, OutputSource, OutputStream, RunLogWriter, read_page};
228
229 #[test]
230 fn utf8_and_binary_chunks_serialize_to_the_documented_shapes() {
231 let writer_dir = tempdir().unwrap();
232 let path = writer_dir.path().join("runs/R1/output.ndjson");
233 let writer = RunLogWriter::open(&path).unwrap();
234
235 writer
236 .append(OutputSource::Agent, None, OutputStream::Stdout, b"hello\n")
237 .unwrap();
238 writer
239 .append(
240 OutputSource::Aftercare,
241 Some("test"),
242 OutputStream::Stderr,
243 &[0xff, 0x00],
244 )
245 .unwrap();
246
247 let contents = std::fs::read_to_string(&path).unwrap();
248 let records: Vec<Value> = contents
249 .lines()
250 .map(|line| serde_json::from_str(line).unwrap())
251 .collect();
252
253 assert_eq!(records[0]["sequence"], 1);
254 assert_eq!(records[0]["source"], "agent");
255 assert_eq!(records[0]["stream"], "stdout");
256 assert_eq!(records[0]["encoding"], "utf8");
257 assert_eq!(records[0]["text"], "hello\n");
258 assert_eq!(records[0].get("stage"), None);
259 assert!(records[0]["timestamp"].as_str().unwrap().ends_with('Z'));
260
261 assert_eq!(records[1]["sequence"], 2);
262 assert_eq!(records[1]["source"], "aftercare");
263 assert_eq!(records[1]["stage"], "test");
264 assert_eq!(records[1]["encoding"], "base64");
265 assert_eq!(records[1]["data"], "/wA=");
266 }
267
268 #[test]
269 fn binary_chunks_round_trip_without_loss() {
270 let bytes = [0xff, 0xfe, 0x00, 0x41];
271 let chunk = OutputChunk::from_bytes(&bytes);
272 assert!(matches!(chunk, OutputChunk::Base64 { .. }));
273 assert_eq!(chunk.into_bytes(), bytes);
274 }
275
276 #[test]
277 fn reopening_appends_after_existing_records() {
278 let directory = tempdir().unwrap();
279 let path = directory.path().join("output.ndjson");
280
281 let writer = RunLogWriter::open(&path).unwrap();
282 writer
283 .append(OutputSource::Agent, None, OutputStream::Stdout, b"one")
284 .unwrap();
285 drop(writer);
286
287 let writer = RunLogWriter::open(&path).unwrap();
288 let sequence = writer
289 .append(OutputSource::Agent, None, OutputStream::Stdout, b"two")
290 .unwrap();
291 assert_eq!(sequence, 2);
292
293 let page = read_page(&path, 0, 10).unwrap();
294 assert_eq!(page.entries.len(), 2);
295 assert_eq!(
296 page.entries[1].chunk,
297 OutputChunk::Utf8 { text: "two".into() }
298 );
299 }
300
301 #[test]
302 fn a_truncated_tail_hides_no_earlier_records() {
303 let directory = tempdir().unwrap();
304 let path = directory.path().join("output.ndjson");
305 let writer = RunLogWriter::open(&path).unwrap();
306 writer
307 .append(OutputSource::Agent, None, OutputStream::Stdout, b"kept")
308 .unwrap();
309 drop(writer);
310
311 use std::io::Write;
312 let mut file = std::fs::OpenOptions::new()
313 .append(true)
314 .open(&path)
315 .unwrap();
316 file.write_all(b"{\"sequence\":2,\"timest").unwrap();
317 drop(file);
318
319 let page = read_page(&path, 0, 10).unwrap();
320 assert_eq!(page.entries.len(), 1);
321 assert!(page.complete);
322
323 let writer = RunLogWriter::open(&path).unwrap();
326 let sequence = writer
327 .append(OutputSource::Agent, None, OutputStream::Stdout, b"next")
328 .unwrap();
329 assert_eq!(sequence, 2);
330
331 let page = read_page(&path, 0, 10).unwrap();
332 assert_eq!(page.entries.len(), 2);
333 assert_eq!(
334 page.entries[1].chunk,
335 OutputChunk::Utf8 {
336 text: "next".into()
337 }
338 );
339 }
340
341 #[test]
342 fn pagination_is_stable_across_sequence_cursors() {
343 let directory = tempdir().unwrap();
344 let path = directory.path().join("output.ndjson");
345 let writer = RunLogWriter::open(&path).unwrap();
346 for index in 0..5 {
347 writer
348 .append(
349 OutputSource::Agent,
350 None,
351 OutputStream::Stdout,
352 format!("chunk {index}").as_bytes(),
353 )
354 .unwrap();
355 }
356
357 let first = read_page(&path, 0, 2).unwrap();
358 assert_eq!(first.entries.len(), 2);
359 assert_eq!(first.next_cursor, 2);
360 assert!(!first.complete);
361
362 let second = read_page(&path, first.next_cursor, 10).unwrap();
363 assert_eq!(second.entries.len(), 3);
364 assert_eq!(second.next_cursor, 5);
365 assert!(second.complete);
366
367 let missing = read_page(&directory.path().join("absent.ndjson"), 0, 10).unwrap();
368 assert!(missing.entries.is_empty() && missing.complete);
369 }
370
371 #[test]
372 fn records_round_trip_through_serde() {
373 let record = super::OutputRecord {
374 sequence: 7,
375 timestamp: "2026-07-13T20:00:01Z".into(),
376 source: OutputSource::Aftercare,
377 stage: Some("test".into()),
378 stream: OutputStream::Stderr,
379 chunk: OutputChunk::Utf8 { text: "x".into() },
380 };
381 let encoded = serde_json::to_value(&record).unwrap();
382 assert_eq!(
383 encoded,
384 json!({
385 "sequence": 7,
386 "timestamp": "2026-07-13T20:00:01Z",
387 "source": "aftercare",
388 "stage": "test",
389 "stream": "stderr",
390 "encoding": "utf8",
391 "text": "x"
392 })
393 );
394 let decoded: super::OutputRecord = serde_json::from_value(encoded).unwrap();
395 assert_eq!(decoded, record);
396 }
397}