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        inner.file.write_all(&line)?;
131        inner.file.sync_data()?;
132        inner.next_sequence += 1;
133        Ok(record.sequence)
134    }
135}
136
137/// True when the file is empty or its last byte is a newline, meaning the
138/// next append starts a fresh record.
139fn 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
155/// The sequence of the last complete record, ignoring a truncated tail left
156/// by a crash; earlier records stay valid evidence.
157fn 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    /// Sequence of the last returned entry; the cursor a future paginated
176    /// call would resume after.
177    pub next_cursor: u64,
178    /// True when the page reached the end of the captured records.
179    pub complete: bool,
180}
181
182/// Reads a finite page of records with `sequence > after`, in order. A
183/// missing file is an empty page: the run may exist before any output does.
184pub 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        // An unparsable line is an incomplete tail, not corruption of the
201        // records before it.
202        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        // The partial record was never complete, so its sequence is free to
324        // reuse, and the new record must land on its own line.
325        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}