Skip to main content

sloop/
run_log.rs

1//! Per-run NDJSON output capture. Agent and aftercare stdout/stderr are
2//! untrusted evidence: they are stored as ordered chunks, never parsed as
3//! lines and never routed through the dispatcher.
4
5use 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/// One captured chunk. UTF-8 chunks stay readable; anything else round-trips
31/// through base64 rather than being lossily converted.
32#[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/// Append-only writer shared by the stdout and stderr reader threads of one
70/// run. Sequence numbers are capture order across both pipes.
71#[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    /// Opens (creating directories as needed) the run's output log and
83    /// resumes sequence numbering after any records already present, so a
84    /// restarted daemon appends instead of renumbering.
85    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        // A crash can leave a partial record with no trailing newline; the
92        // next record must start on its own line or it would be corrupted too.
93        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    /// Appends one chunk and durably flushes it. Capture must be on disk
105    /// before exit evidence claims it is complete.
106    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            // Keep the previous complete-record boundary after a short write,
137            // especially ENOSPC, so future appends cannot join corrupt JSON.
138            let _ = inner.file.set_len(original_len);
139            return Err(error);
140        }
141        inner.next_sequence += 1;
142        Ok(record.sequence)
143    }
144}
145
146/// True when the file is empty or its last byte is a newline, meaning the
147/// next append starts a fresh record.
148fn 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
164/// The sequence of the last complete record, ignoring a truncated tail left
165/// by a crash; earlier records stay valid evidence.
166fn 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    /// Sequence of the last returned entry; the cursor a future paginated
185    /// call would resume after.
186    pub next_cursor: u64,
187    /// True when the page reached the end of the captured records.
188    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
197/// Reassembles each agent stream from every complete captured record. Test
198/// and merge output is deliberately excluded from vendor classification.
199pub 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
211/// Visits decoded agent chunks in capture order without retaining the whole
212/// log. A malformed or crash-truncated record does not hide earlier evidence.
213pub 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
235/// Reads a finite page of records with `sequence > after`, in order. A
236/// missing file is an empty page: the run may exist before any output does.
237pub 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        // An unparsable line is an incomplete tail, not corruption of the
254        // records before it.
255        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        // The partial record was never complete, so its sequence is free to
379        // reuse, and the new record must land on its own line.
380        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}