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 let original_len = inner.file.metadata()?.len();
131 if let Err(error) = inner
132 .file
133 .write_all(&line)
134 .and_then(|()| inner.file.sync_data())
135 {
136 let _ = inner.file.set_len(original_len);
139 return Err(error);
140 }
141 inner.next_sequence += 1;
142 Ok(record.sequence)
143 }
144}
145
146fn ends_with_newline(path: &Path) -> io::Result<bool> {
149 use std::io::{Read, Seek, SeekFrom};
150 let mut file = match File::open(path) {
151 Ok(file) => file,
152 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
153 Err(error) => return Err(error),
154 };
155 if file.metadata()?.len() == 0 {
156 return Ok(true);
157 }
158 file.seek(SeekFrom::End(-1))?;
159 let mut last = [0u8; 1];
160 file.read_exact(&mut last)?;
161 Ok(last[0] == b'\n')
162}
163
164fn last_sequence(path: &Path) -> io::Result<Option<u64>> {
167 let file = match File::open(path) {
168 Ok(file) => file,
169 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
170 Err(error) => return Err(error),
171 };
172 let mut last = None;
173 for line in BufReader::new(file).lines() {
174 if let Ok(record) = serde_json::from_str::<OutputRecord>(&line?) {
175 last = Some(record.sequence);
176 }
177 }
178 Ok(last)
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct OutputPage {
183 pub entries: Vec<OutputRecord>,
184 pub next_cursor: u64,
187 pub complete: bool,
189}
190
191#[derive(Debug, Clone, Default, PartialEq, Eq)]
192pub struct AgentOutput {
193 pub stdout: Vec<u8>,
194 pub stderr: Vec<u8>,
195}
196
197pub fn read_agent_output(path: &Path) -> io::Result<AgentOutput> {
200 let mut output = AgentOutput::default();
201 visit_agent_output(path, |stream, bytes| {
202 let destination = match stream {
203 OutputStream::Stdout => &mut output.stdout,
204 OutputStream::Stderr => &mut output.stderr,
205 };
206 destination.extend_from_slice(bytes);
207 })?;
208 Ok(output)
209}
210
211pub fn visit_agent_output(
214 path: &Path,
215 mut visitor: impl FnMut(OutputStream, &[u8]),
216) -> io::Result<()> {
217 let file = match File::open(path) {
218 Ok(file) => file,
219 Err(error) if error.kind() == io::ErrorKind::NotFound => {
220 return Ok(());
221 }
222 Err(error) => return Err(error),
223 };
224 for line in BufReader::new(file).lines() {
225 if let Ok(record) = serde_json::from_str::<OutputRecord>(&line?)
226 && record.source == OutputSource::Agent
227 {
228 let bytes = record.chunk.into_bytes();
229 visitor(record.stream, &bytes);
230 }
231 }
232 Ok(())
233}
234
235pub fn read_page(path: &Path, after: u64, limit: usize) -> io::Result<OutputPage> {
238 let file = match File::open(path) {
239 Ok(file) => file,
240 Err(error) if error.kind() == io::ErrorKind::NotFound => {
241 return Ok(OutputPage {
242 entries: Vec::new(),
243 next_cursor: after,
244 complete: true,
245 });
246 }
247 Err(error) => return Err(error),
248 };
249
250 let mut entries = Vec::new();
251 let mut complete = true;
252 for line in BufReader::new(file).lines() {
253 let Ok(record) = serde_json::from_str::<OutputRecord>(&line?) else {
256 continue;
257 };
258 if record.sequence <= after {
259 continue;
260 }
261 if entries.len() == limit {
262 complete = false;
263 break;
264 }
265 entries.push(record);
266 }
267 let next_cursor = entries.last().map_or(after, |record| record.sequence);
268 Ok(OutputPage {
269 entries,
270 next_cursor,
271 complete,
272 })
273}
274
275#[cfg(test)]
276mod tests {
277 use serde_json::{Value, json};
278 use tempfile::tempdir;
279
280 use super::{
281 OutputChunk, OutputSource, OutputStream, RunLogWriter, read_agent_output, read_page,
282 };
283
284 #[test]
285 fn utf8_and_binary_chunks_serialize_to_the_documented_shapes() {
286 let writer_dir = tempdir().unwrap();
287 let path = writer_dir.path().join("runs/R1/output.ndjson");
288 let writer = RunLogWriter::open(&path).unwrap();
289
290 writer
291 .append(OutputSource::Agent, None, OutputStream::Stdout, b"hello\n")
292 .unwrap();
293 writer
294 .append(
295 OutputSource::Aftercare,
296 Some("test"),
297 OutputStream::Stderr,
298 &[0xff, 0x00],
299 )
300 .unwrap();
301
302 let contents = std::fs::read_to_string(&path).unwrap();
303 let records: Vec<Value> = contents
304 .lines()
305 .map(|line| serde_json::from_str(line).unwrap())
306 .collect();
307
308 assert_eq!(records[0]["sequence"], 1);
309 assert_eq!(records[0]["source"], "agent");
310 assert_eq!(records[0]["stream"], "stdout");
311 assert_eq!(records[0]["encoding"], "utf8");
312 assert_eq!(records[0]["text"], "hello\n");
313 assert_eq!(records[0].get("stage"), None);
314 assert!(records[0]["timestamp"].as_str().unwrap().ends_with('Z'));
315
316 assert_eq!(records[1]["sequence"], 2);
317 assert_eq!(records[1]["source"], "aftercare");
318 assert_eq!(records[1]["stage"], "test");
319 assert_eq!(records[1]["encoding"], "base64");
320 assert_eq!(records[1]["data"], "/wA=");
321 }
322
323 #[test]
324 fn binary_chunks_round_trip_without_loss() {
325 let bytes = [0xff, 0xfe, 0x00, 0x41];
326 let chunk = OutputChunk::from_bytes(&bytes);
327 assert!(matches!(chunk, OutputChunk::Base64 { .. }));
328 assert_eq!(chunk.into_bytes(), bytes);
329 }
330
331 #[test]
332 fn reopening_appends_after_existing_records() {
333 let directory = tempdir().unwrap();
334 let path = directory.path().join("output.ndjson");
335
336 let writer = RunLogWriter::open(&path).unwrap();
337 writer
338 .append(OutputSource::Agent, None, OutputStream::Stdout, b"one")
339 .unwrap();
340 drop(writer);
341
342 let writer = RunLogWriter::open(&path).unwrap();
343 let sequence = writer
344 .append(OutputSource::Agent, None, OutputStream::Stdout, b"two")
345 .unwrap();
346 assert_eq!(sequence, 2);
347
348 let page = read_page(&path, 0, 10).unwrap();
349 assert_eq!(page.entries.len(), 2);
350 assert_eq!(
351 page.entries[1].chunk,
352 OutputChunk::Utf8 { text: "two".into() }
353 );
354 }
355
356 #[test]
357 fn a_truncated_tail_hides_no_earlier_records() {
358 let directory = tempdir().unwrap();
359 let path = directory.path().join("output.ndjson");
360 let writer = RunLogWriter::open(&path).unwrap();
361 writer
362 .append(OutputSource::Agent, None, OutputStream::Stdout, b"kept")
363 .unwrap();
364 drop(writer);
365
366 use std::io::Write;
367 let mut file = std::fs::OpenOptions::new()
368 .append(true)
369 .open(&path)
370 .unwrap();
371 file.write_all(b"{\"sequence\":2,\"timest").unwrap();
372 drop(file);
373
374 let page = read_page(&path, 0, 10).unwrap();
375 assert_eq!(page.entries.len(), 1);
376 assert!(page.complete);
377
378 let writer = RunLogWriter::open(&path).unwrap();
381 let sequence = writer
382 .append(OutputSource::Agent, None, OutputStream::Stdout, b"next")
383 .unwrap();
384 assert_eq!(sequence, 2);
385
386 let page = read_page(&path, 0, 10).unwrap();
387 assert_eq!(page.entries.len(), 2);
388 assert_eq!(
389 page.entries[1].chunk,
390 OutputChunk::Utf8 {
391 text: "next".into()
392 }
393 );
394 }
395
396 #[test]
397 fn pagination_is_stable_across_sequence_cursors() {
398 let directory = tempdir().unwrap();
399 let path = directory.path().join("output.ndjson");
400 let writer = RunLogWriter::open(&path).unwrap();
401 for index in 0..5 {
402 writer
403 .append(
404 OutputSource::Agent,
405 None,
406 OutputStream::Stdout,
407 format!("chunk {index}").as_bytes(),
408 )
409 .unwrap();
410 }
411
412 let first = read_page(&path, 0, 2).unwrap();
413 assert_eq!(first.entries.len(), 2);
414 assert_eq!(first.next_cursor, 2);
415 assert!(!first.complete);
416
417 let second = read_page(&path, first.next_cursor, 10).unwrap();
418 assert_eq!(second.entries.len(), 3);
419 assert_eq!(second.next_cursor, 5);
420 assert!(second.complete);
421
422 let missing = read_page(&directory.path().join("absent.ndjson"), 0, 10).unwrap();
423 assert!(missing.entries.is_empty() && missing.complete);
424 }
425
426 #[test]
427 fn records_round_trip_through_serde() {
428 let record = super::OutputRecord {
429 sequence: 7,
430 timestamp: "2026-07-13T20:00:01Z".into(),
431 source: OutputSource::Aftercare,
432 stage: Some("test".into()),
433 stream: OutputStream::Stderr,
434 chunk: OutputChunk::Utf8 { text: "x".into() },
435 };
436 let encoded = serde_json::to_value(&record).unwrap();
437 assert_eq!(
438 encoded,
439 json!({
440 "sequence": 7,
441 "timestamp": "2026-07-13T20:00:01Z",
442 "source": "aftercare",
443 "stage": "test",
444 "stream": "stderr",
445 "encoding": "utf8",
446 "text": "x"
447 })
448 );
449 let decoded: super::OutputRecord = serde_json::from_value(encoded).unwrap();
450 assert_eq!(decoded, record);
451 }
452
453 #[test]
454 fn agent_streams_are_reassembled_across_utf8_and_binary_chunks() {
455 let directory = tempdir().unwrap();
456 let path = directory.path().join("output.ndjson");
457 let writer = RunLogWriter::open(&path).unwrap();
458 writer
459 .append(OutputSource::Agent, None, OutputStream::Stderr, b"rate li")
460 .unwrap();
461 writer
462 .append(
463 OutputSource::Aftercare,
464 Some("test"),
465 OutputStream::Stderr,
466 b"must not match",
467 )
468 .unwrap();
469 writer
470 .append(
471 OutputSource::Agent,
472 None,
473 OutputStream::Stdout,
474 &[0xff, b'o', b'k'],
475 )
476 .unwrap();
477 writer
478 .append(OutputSource::Agent, None, OutputStream::Stderr, b"mited")
479 .unwrap();
480 drop(writer);
481
482 use std::io::Write;
483 let mut file = std::fs::OpenOptions::new()
484 .append(true)
485 .open(&path)
486 .unwrap();
487 file.write_all(b"{\"sequence\":99").unwrap();
488
489 let output = read_agent_output(&path).unwrap();
490 assert_eq!(output.stderr, b"rate limited");
491 assert_eq!(output.stdout, [0xff, b'o', b'k']);
492 }
493}