Skip to main content

sloop/
run_log.rs

1//! Per-run NDJSON output capture. Agent and stage 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::collections::VecDeque;
6use std::fs::{self, File, OpenOptions};
7use std::io::{self, BufRead, BufReader, Read, Seek, SeekFrom, Write};
8use std::path::Path;
9use std::sync::{Arc, Mutex};
10
11use base64::Engine;
12use base64::engine::general_purpose::STANDARD as BASE64;
13use serde::{Deserialize, Serialize};
14use time::OffsetDateTime;
15use time::format_description::well_known::Rfc3339;
16
17use crate::clock::format_timestamp;
18
19/// One socket page of records, and the window a bare `sloop logs` tails.
20pub const PAGE_LIMIT: usize = 64;
21
22/// An explicit `tail` may ask for more than one default page — that is the
23/// point of asking — but not for an unbounded slice of an untrusted log.
24pub const TAIL_LIMIT: usize = 1000;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum OutputSource {
29    /// A supervised agent process.
30    Agent,
31    /// A stage process the daemon ran itself.
32    Stage,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum OutputStream {
38    Stdout,
39    Stderr,
40}
41
42/// One captured chunk. UTF-8 chunks stay readable; anything else round-trips
43/// through base64 rather than being lossily converted.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(tag = "encoding", rename_all = "snake_case")]
46pub enum OutputChunk {
47    Utf8 { text: String },
48    Base64 { data: String },
49}
50
51impl OutputChunk {
52    pub fn from_bytes(bytes: &[u8]) -> Self {
53        match std::str::from_utf8(bytes) {
54            Ok(text) => Self::Utf8 { text: text.into() },
55            Err(_) => Self::Base64 {
56                data: BASE64.encode(bytes),
57            },
58        }
59    }
60
61    pub fn into_bytes(self) -> Vec<u8> {
62        match self {
63            Self::Utf8 { text } => text.into_bytes(),
64            Self::Base64 { data } => BASE64.decode(data).unwrap_or_default(),
65        }
66    }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct OutputRecord {
71    pub sequence: u64,
72    pub timestamp: String,
73    pub source: OutputSource,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub stage: Option<String>,
76    /// Which execution of `stage` produced the chunk. A backward edge re-runs
77    /// a stage, so the stage name alone no longer identifies whose output this
78    /// is. Absent on records captured before re-runs existed.
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub attempt: Option<u32>,
81    pub stream: OutputStream,
82    #[serde(flatten)]
83    pub chunk: OutputChunk,
84}
85
86/// Append-only writer shared by the stdout and stderr reader threads of one
87/// run. Sequence numbers are capture order across both pipes.
88#[derive(Clone)]
89pub struct RunLogWriter {
90    inner: Arc<Mutex<Inner>>,
91}
92
93struct Inner {
94    file: File,
95    next_sequence: u64,
96}
97
98impl RunLogWriter {
99    /// Opens (creating directories as needed) the run's output log and
100    /// resumes sequence numbering after any records already present, so a
101    /// restarted daemon appends instead of renumbering.
102    pub fn open(path: &Path) -> io::Result<Self> {
103        if let Some(parent) = path.parent() {
104            fs::create_dir_all(parent)?;
105        }
106        let next_sequence = last_sequence(path)?.map_or(1, |last| last + 1);
107        let mut file = OpenOptions::new().create(true).append(true).open(path)?;
108        // A crash can leave a partial record with no trailing newline; the
109        // next record must start on its own line or it would be corrupted too.
110        if !ends_with_newline(path)? {
111            file.write_all(b"\n")?;
112        }
113        Ok(Self {
114            inner: Arc::new(Mutex::new(Inner {
115                file,
116                next_sequence,
117            })),
118        })
119    }
120
121    /// Appends one chunk and durably flushes it. Capture must be on disk
122    /// before exit evidence claims it is complete.
123    pub fn append(
124        &self,
125        source: OutputSource,
126        stage: Option<&str>,
127        attempt: Option<u32>,
128        stream: OutputStream,
129        bytes: &[u8],
130    ) -> io::Result<u64> {
131        let timestamp_ms = OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000;
132        self.append_at(
133            i64::try_from(timestamp_ms).unwrap_or(0),
134            source,
135            stage,
136            attempt,
137            stream,
138            bytes,
139        )
140    }
141
142    /// Appends a chunk with a caller-supplied clock instant. Agent output uses
143    /// the daemon clock so scheduler deadlines and persisted output agree in
144    /// tests as well as production.
145    #[allow(clippy::too_many_arguments)]
146    pub fn append_at(
147        &self,
148        timestamp_ms: i64,
149        source: OutputSource,
150        stage: Option<&str>,
151        attempt: Option<u32>,
152        stream: OutputStream,
153        bytes: &[u8],
154    ) -> io::Result<u64> {
155        let timestamp =
156            format_timestamp(timestamp_ms).unwrap_or_else(|| "1970-01-01T00:00:00Z".into());
157        let mut inner = self
158            .inner
159            .lock()
160            .map_err(|_| io::Error::other("run log lock poisoned"))?;
161        let record = OutputRecord {
162            sequence: inner.next_sequence,
163            timestamp,
164            source,
165            stage: stage.map(str::to_owned),
166            attempt,
167            stream,
168            chunk: OutputChunk::from_bytes(bytes),
169        };
170        let mut line = serde_json::to_vec(&record).map_err(io::Error::other)?;
171        line.push(b'\n');
172        let original_len = inner.file.metadata()?.len();
173        if let Err(error) = inner
174            .file
175            .write_all(&line)
176            .and_then(|()| inner.file.sync_data())
177        {
178            // Keep the previous complete-record boundary after a short write,
179            // especially ENOSPC, so future appends cannot join corrupt JSON.
180            let _ = inner.file.set_len(original_len);
181            return Err(error);
182        }
183        inner.next_sequence += 1;
184        Ok(record.sequence)
185    }
186}
187
188/// The reusable output-silence signal for one running agent stage. The file is
189/// authoritative: reopening after daemon restart reconstructs both the last
190/// append instant and the silence episode from its final complete agent record.
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
192pub struct OutputStaleness {
193    pub last_sequence: Option<u64>,
194    pub last_output_at_ms: i64,
195    pub silent_for_ms: i64,
196    pub deadline_ms: i64,
197    pub stalled: bool,
198}
199
200/// Measures agent-output silence from the last complete record, falling back
201/// to the durable stage start when the agent has not written anything yet.
202pub fn output_staleness(
203    path: &Path,
204    started_at_ms: i64,
205    now_ms: i64,
206    report_after_ms: i64,
207) -> io::Result<OutputStaleness> {
208    let last = last_agent_output(path)?;
209    let (last_sequence, last_output_at_ms) = last
210        .map(|(sequence, at_ms)| (Some(sequence), at_ms))
211        .unwrap_or((None, started_at_ms));
212    let silent_for_ms = now_ms.saturating_sub(last_output_at_ms).max(0);
213    let deadline_ms = last_output_at_ms.saturating_add(report_after_ms);
214    Ok(OutputStaleness {
215        last_sequence,
216        last_output_at_ms,
217        silent_for_ms,
218        deadline_ms,
219        stalled: now_ms >= deadline_ms,
220    })
221}
222
223/// Reads backward so frequent scheduler recomputations cost at most the final
224/// record in the common case rather than repeatedly scanning a large run log.
225fn last_agent_output(path: &Path) -> io::Result<Option<(u64, i64)>> {
226    const BLOCK: usize = 8 * 1024;
227    let mut file = match File::open(path) {
228        Ok(file) => file,
229        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
230        Err(error) => return Err(error),
231    };
232    let mut position = file.metadata()?.len();
233    let mut suffix = Vec::new();
234    while position > 0 {
235        let start = position.saturating_sub(BLOCK as u64);
236        let mut block = vec![0; usize::try_from(position - start).unwrap_or(BLOCK)];
237        file.seek(SeekFrom::Start(start))?;
238        file.read_exact(&mut block)?;
239        block.extend_from_slice(&suffix);
240
241        let mut end = block.len();
242        while let Some(newline) = block[..end].iter().rposition(|byte| *byte == b'\n') {
243            if let Some(last) = agent_output_record(&block[newline + 1..end]) {
244                return Ok(Some(last));
245            }
246            end = newline;
247        }
248        suffix = block[..end].to_vec();
249        position = start;
250    }
251    Ok(agent_output_record(&suffix))
252}
253
254fn agent_output_record(line: &[u8]) -> Option<(u64, i64)> {
255    if line.is_empty() {
256        return None;
257    }
258    let record = serde_json::from_slice::<OutputRecord>(line).ok()?;
259    (record.source == OutputSource::Agent)
260        .then(|| parse_timestamp_ms(&record.timestamp).map(|at_ms| (record.sequence, at_ms)))?
261}
262
263fn parse_timestamp_ms(timestamp: &str) -> Option<i64> {
264    let nanos = OffsetDateTime::parse(timestamp, &Rfc3339)
265        .ok()?
266        .unix_timestamp_nanos();
267    i64::try_from(nanos / 1_000_000).ok()
268}
269
270/// True when the file is empty or its last byte is a newline, meaning the
271/// next append starts a fresh record.
272fn ends_with_newline(path: &Path) -> io::Result<bool> {
273    let mut file = match File::open(path) {
274        Ok(file) => file,
275        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(true),
276        Err(error) => return Err(error),
277    };
278    if file.metadata()?.len() == 0 {
279        return Ok(true);
280    }
281    file.seek(SeekFrom::End(-1))?;
282    let mut last = [0u8; 1];
283    file.read_exact(&mut last)?;
284    Ok(last[0] == b'\n')
285}
286
287/// The sequence of the last complete record, ignoring a truncated tail left
288/// by a crash; earlier records stay valid evidence.
289fn last_sequence(path: &Path) -> io::Result<Option<u64>> {
290    let file = match File::open(path) {
291        Ok(file) => file,
292        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
293        Err(error) => return Err(error),
294    };
295    let mut last = None;
296    for line in BufReader::new(file).lines() {
297        if let Ok(record) = serde_json::from_str::<OutputRecord>(&line?) {
298            last = Some(record.sequence);
299        }
300    }
301    Ok(last)
302}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
305pub struct OutputPage {
306    pub entries: Vec<OutputRecord>,
307    /// Sequence of the last returned entry; the cursor a future paginated
308    /// call would resume after.
309    pub next_cursor: u64,
310    /// True when the page reached the end of the captured records.
311    pub complete: bool,
312    /// Matching records a trailing window dropped off the front. `complete`
313    /// cannot carry this: a tail reads to the end, so it is always complete
314    /// however much older output it discarded.
315    pub elided: usize,
316}
317
318#[derive(Debug, Clone, Default, PartialEq, Eq)]
319pub struct AgentOutput {
320    pub stdout: Vec<u8>,
321    pub stderr: Vec<u8>,
322}
323
324/// Reassembles each agent stream from every complete captured record. Test
325/// and merge output is deliberately excluded from vendor classification.
326pub fn read_agent_output(path: &Path) -> io::Result<AgentOutput> {
327    let mut output = AgentOutput::default();
328    visit_agent_output(path, |stream, bytes| {
329        let destination = match stream {
330            OutputStream::Stdout => &mut output.stdout,
331            OutputStream::Stderr => &mut output.stderr,
332        };
333        destination.extend_from_slice(bytes);
334    })?;
335    Ok(output)
336}
337
338/// Visits decoded agent chunks in capture order without retaining the whole
339/// log. A malformed or crash-truncated record does not hide earlier evidence.
340pub fn visit_agent_output(
341    path: &Path,
342    mut visitor: impl FnMut(OutputStream, &[u8]),
343) -> io::Result<()> {
344    let file = match File::open(path) {
345        Ok(file) => file,
346        Err(error) if error.kind() == io::ErrorKind::NotFound => {
347            return Ok(());
348        }
349        Err(error) => return Err(error),
350    };
351    for line in BufReader::new(file).lines() {
352        if let Ok(record) = serde_json::from_str::<OutputRecord>(&line?)
353            && record.source == OutputSource::Agent
354        {
355            let bytes = record.chunk.into_bytes();
356            visitor(record.stream, &bytes);
357        }
358    }
359    Ok(())
360}
361
362/// The trailing `lines` lines one stage execution captured, decoded as text.
363///
364/// This is the only read that narrows to a single *execution* rather than a
365/// stage: it feeds the "previous attempt failed" block a re-entered agent
366/// stage is prompted with, and quoting a different attempt's output there
367/// would send the agent after the wrong failure. Records written before
368/// attempts were tagged carry none and are claimed by the first attempt, which
369/// is the only one such a run ever had.
370pub fn stage_output_tail(
371    path: &Path,
372    stage: &str,
373    attempt: u32,
374    lines: usize,
375) -> io::Result<String> {
376    let file = match File::open(path) {
377        Ok(file) => file,
378        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(String::new()),
379        Err(error) => return Err(error),
380    };
381    let mut captured = Vec::new();
382    for line in BufReader::new(file).lines() {
383        let Ok(record) = serde_json::from_str::<OutputRecord>(&line?) else {
384            continue;
385        };
386        if record.stage.as_deref() != Some(stage) {
387            continue;
388        }
389        if record.attempt.unwrap_or(1) != attempt {
390            continue;
391        }
392        captured.extend_from_slice(&record.chunk.into_bytes());
393    }
394    // Chunks are pipe reads, not lines, so the text is only split into lines
395    // after reassembly.
396    let text = String::from_utf8_lossy(&captured);
397    let text = text.strip_suffix('\n').unwrap_or(&text);
398    if text.is_empty() {
399        return Ok(String::new());
400    }
401    let total = text.lines().count();
402    Ok(text
403        .lines()
404        .skip(total.saturating_sub(lines))
405        .collect::<Vec<_>>()
406        .join("\n"))
407}
408
409/// Selects the records belonging to one flow stage. Stage records carry
410/// their stage name; agent records captured before stages were tagged carry
411/// none, so `agent_fallback` lets the flow's agent stage claim them.
412#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct StageFilter {
414    pub stage: String,
415    /// One execution of the stage, or every execution when absent. A backward
416    /// edge re-enters a stage, and past that point the name alone selects two
417    /// different runs of the same command interleaved in one page — which is
418    /// exactly the page nobody wants to read.
419    pub attempt: Option<u32>,
420    pub agent_fallback: bool,
421}
422
423impl StageFilter {
424    fn accepts(&self, record: &OutputRecord) -> bool {
425        // Output captured before attempts were tagged belongs to the only
426        // execution such a run ever had, so it answers to `#1`.
427        if self
428            .attempt
429            .is_some_and(|attempt| record.attempt.unwrap_or(1) != attempt)
430        {
431            return false;
432        }
433        match record.stage.as_deref() {
434            Some(stage) => stage == self.stage,
435            None => self.agent_fallback && record.source == OutputSource::Agent,
436        }
437    }
438}
439
440/// One read of the captured log: everything after a cursor, optionally
441/// narrowed to a stage and trimmed to a trailing window.
442#[derive(Debug, Clone, Default, PartialEq, Eq)]
443pub struct PageQuery {
444    /// Only records with `sequence > after`.
445    pub after: u64,
446    /// Hard cap on returned entries; also the cap on `tail`.
447    pub limit: usize,
448    /// Only records this filter accepts.
449    pub stage: Option<StageFilter>,
450    /// Keep the last N accepted records instead of the first N. Reading runs
451    /// to the end of the file, so the page is always complete.
452    pub tail: Option<usize>,
453}
454
455/// Reads a finite page of records with `sequence > after`, in order. A
456/// missing file is an empty page: the run may exist before any output does.
457pub fn read_page(path: &Path, after: u64, limit: usize) -> io::Result<OutputPage> {
458    read_filtered_page(
459        path,
460        &PageQuery {
461            after,
462            limit,
463            ..PageQuery::default()
464        },
465    )
466}
467
468/// Reads one page under a filter. `next_cursor` advances past every record
469/// the read *consumed*, not just the ones it returned: a filtered-out record
470/// is still evidence that has been examined, so a follower resuming from the
471/// cursor neither replays it nor stalls behind it.
472pub fn read_filtered_page(path: &Path, query: &PageQuery) -> io::Result<OutputPage> {
473    let file = match File::open(path) {
474        Ok(file) => file,
475        Err(error) if error.kind() == io::ErrorKind::NotFound => {
476            return Ok(OutputPage {
477                entries: Vec::new(),
478                next_cursor: query.after,
479                complete: true,
480                elided: 0,
481            });
482        }
483        Err(error) => return Err(error),
484    };
485
486    // A tail keeps the newest accepted records, so it must read to the end;
487    // the trailing window is bounded by the caller's limit either way.
488    let window = query.tail.map_or(query.limit, |tail| tail.min(query.limit));
489    let mut entries = VecDeque::new();
490    let mut next_cursor = query.after;
491    let mut complete = true;
492    let mut elided = 0;
493    for line in BufReader::new(file).lines() {
494        // An unparsable line is an incomplete tail, not corruption of the
495        // records before it.
496        let Ok(record) = serde_json::from_str::<OutputRecord>(&line?) else {
497            continue;
498        };
499        if record.sequence <= query.after {
500            continue;
501        }
502        if query.tail.is_none() && entries.len() == query.limit {
503            complete = false;
504            break;
505        }
506        next_cursor = record.sequence;
507        if query.stage.as_ref().is_some_and(|f| !f.accepts(&record)) {
508            continue;
509        }
510        entries.push_back(record);
511        if entries.len() > window {
512            entries.pop_front();
513            elided += 1;
514        }
515    }
516    Ok(OutputPage {
517        entries: entries.into(),
518        next_cursor,
519        complete,
520        elided,
521    })
522}
523
524#[cfg(test)]
525mod tests {
526    use serde_json::{Value, json};
527    use tempfile::tempdir;
528
529    use super::{
530        OutputChunk, OutputSource, OutputStream, PageQuery, RunLogWriter, StageFilter,
531        output_staleness, read_agent_output, read_filtered_page, read_page,
532    };
533
534    /// Writes one record per call, so a test controls entry boundaries the
535    /// way pipe reads do at runtime.
536    fn write_records(path: &std::path::Path, records: &[(OutputSource, Option<&str>, &str)]) {
537        let writer = RunLogWriter::open(path).unwrap();
538        for (source, stage, text) in records {
539            writer
540                .append(
541                    *source,
542                    *stage,
543                    Some(1),
544                    OutputStream::Stdout,
545                    text.as_bytes(),
546                )
547                .unwrap();
548        }
549    }
550
551    fn texts(page: &super::OutputPage) -> Vec<String> {
552        page.entries
553            .iter()
554            .map(|record| String::from_utf8(record.chunk.clone().into_bytes()).unwrap())
555            .collect()
556    }
557
558    #[test]
559    fn a_stage_filter_selects_that_stage_and_leaves_the_cursor_past_what_it_skipped() {
560        let directory = tempdir().unwrap();
561        let path = directory.path().join("output.ndjson");
562        write_records(
563            &path,
564            &[
565                (OutputSource::Agent, Some("build"), "built"),
566                (OutputSource::Stage, Some("test"), "tested"),
567                (OutputSource::Stage, Some("merge"), "merged"),
568            ],
569        );
570
571        let page = read_filtered_page(
572            &path,
573            &PageQuery {
574                limit: 10,
575                stage: Some(StageFilter {
576                    stage: "test".into(),
577                    attempt: None,
578                    agent_fallback: false,
579                }),
580                ..PageQuery::default()
581            },
582        )
583        .unwrap();
584
585        assert_eq!(texts(&page), ["tested"]);
586        // Records 3 was examined and rejected, so a follower resuming here
587        // neither replays it nor re-reads it on every poll.
588        assert_eq!(page.next_cursor, 3);
589        assert!(page.complete);
590    }
591
592    #[test]
593    fn the_agent_fallback_claims_records_captured_before_stages_were_tagged() {
594        let directory = tempdir().unwrap();
595        let path = directory.path().join("output.ndjson");
596        write_records(
597            &path,
598            &[
599                (OutputSource::Agent, None, "legacy agent"),
600                (OutputSource::Stage, None, "legacy untagged"),
601                (OutputSource::Agent, Some("build"), "tagged agent"),
602            ],
603        );
604        let filter = |agent_fallback| PageQuery {
605            limit: 10,
606            stage: Some(StageFilter {
607                stage: "build".into(),
608                attempt: None,
609                agent_fallback,
610            }),
611            ..PageQuery::default()
612        };
613
614        let claimed = read_filtered_page(&path, &filter(true)).unwrap();
615        assert_eq!(texts(&claimed), ["legacy agent", "tagged agent"]);
616
617        // Without the fallback only the tagged record matches: an untagged
618        // record names no stage and must not be invented into one.
619        let literal = read_filtered_page(&path, &filter(false)).unwrap();
620        assert_eq!(texts(&literal), ["tagged agent"]);
621    }
622
623    #[test]
624    fn a_tail_keeps_the_newest_matching_records_and_still_reaches_the_end() {
625        let directory = tempdir().unwrap();
626        let path = directory.path().join("output.ndjson");
627        let mut records = Vec::new();
628        for index in 0..6 {
629            records.push((OutputSource::Stage, Some("test"), format!("t{index}")));
630            records.push((OutputSource::Agent, Some("build"), format!("b{index}")));
631        }
632        write_records(
633            &path,
634            &records
635                .iter()
636                .map(|(source, stage, text)| (*source, *stage, text.as_str()))
637                .collect::<Vec<_>>(),
638        );
639
640        let page = read_filtered_page(
641            &path,
642            &PageQuery {
643                limit: 64,
644                stage: Some(StageFilter {
645                    stage: "test".into(),
646                    attempt: None,
647                    agent_fallback: false,
648                }),
649                tail: Some(2),
650                ..PageQuery::default()
651            },
652        )
653        .unwrap();
654
655        assert_eq!(texts(&page), ["t4", "t5"]);
656        // A tail reads to the end of the file, so the page is complete and
657        // the cursor is past every record — including the ones it dropped.
658        assert!(page.complete);
659        assert_eq!(page.next_cursor, 12);
660    }
661
662    #[test]
663    fn a_tail_larger_than_the_log_returns_everything_and_never_exceeds_the_limit() {
664        let directory = tempdir().unwrap();
665        let path = directory.path().join("output.ndjson");
666        write_records(
667            &path,
668            &[
669                (OutputSource::Agent, Some("build"), "one"),
670                (OutputSource::Agent, Some("build"), "two"),
671            ],
672        );
673
674        let all = read_filtered_page(
675            &path,
676            &PageQuery {
677                limit: 64,
678                tail: Some(50),
679                ..PageQuery::default()
680            },
681        )
682        .unwrap();
683        assert_eq!(texts(&all), ["one", "two"]);
684
685        // The limit outranks the tail; a caller cannot page past it.
686        let capped = read_filtered_page(
687            &path,
688            &PageQuery {
689                limit: 1,
690                tail: Some(50),
691                ..PageQuery::default()
692            },
693        )
694        .unwrap();
695        assert_eq!(texts(&capped), ["two"]);
696    }
697
698    /// The block a re-entered stage is prompted with quotes one execution, so
699    /// the tail must never mix a stage's attempts together.
700    #[test]
701    fn a_stage_tail_selects_one_execution_and_keeps_its_last_lines() {
702        let directory = tempdir().unwrap();
703        let path = directory.path().join("output.ndjson");
704        let writer = RunLogWriter::open(&path).unwrap();
705        for attempt in 1..=2 {
706            for index in 0..4 {
707                writer
708                    .append(
709                        OutputSource::Stage,
710                        Some("test"),
711                        Some(attempt),
712                        OutputStream::Stdout,
713                        format!("a{attempt} line {index}\n").as_bytes(),
714                    )
715                    .unwrap();
716            }
717        }
718        writer
719            .append(
720                OutputSource::Agent,
721                Some("build"),
722                Some(1),
723                OutputStream::Stdout,
724                b"not the test stage\n",
725            )
726            .unwrap();
727        drop(writer);
728
729        assert_eq!(
730            super::stage_output_tail(&path, "test", 1, 2).unwrap(),
731            "a1 line 2\na1 line 3"
732        );
733        assert_eq!(
734            super::stage_output_tail(&path, "test", 2, 100).unwrap(),
735            "a2 line 0\na2 line 1\na2 line 2\na2 line 3"
736        );
737        // A stage that never ran, and a run with no output at all, are both
738        // simply nothing to quote rather than an error.
739        assert_eq!(super::stage_output_tail(&path, "test", 3, 10).unwrap(), "");
740        assert_eq!(
741            super::stage_output_tail(&directory.path().join("absent.ndjson"), "test", 1, 10)
742                .unwrap(),
743            ""
744        );
745    }
746
747    /// Chunks are pipe reads, not lines: a line split across two records is
748    /// one line in the tail, and the count applies after reassembly.
749    #[test]
750    fn a_stage_tail_counts_lines_after_reassembling_chunks() {
751        let directory = tempdir().unwrap();
752        let path = directory.path().join("output.ndjson");
753        let writer = RunLogWriter::open(&path).unwrap();
754        for chunk in ["one\ntw", "o\nthr", "ee\n"] {
755            writer
756                .append(
757                    OutputSource::Stage,
758                    Some("test"),
759                    Some(1),
760                    OutputStream::Stdout,
761                    chunk.as_bytes(),
762                )
763                .unwrap();
764        }
765        drop(writer);
766
767        assert_eq!(
768            super::stage_output_tail(&path, "test", 1, 10).unwrap(),
769            "one\ntwo\nthree"
770        );
771        assert_eq!(
772            super::stage_output_tail(&path, "test", 1, 1).unwrap(),
773            "three"
774        );
775    }
776
777    /// Output captured before attempts were tagged belongs to the only
778    /// execution such a run ever had.
779    #[test]
780    fn untagged_output_is_claimed_by_the_first_attempt() {
781        let directory = tempdir().unwrap();
782        let path = directory.path().join("output.ndjson");
783        let writer = RunLogWriter::open(&path).unwrap();
784        writer
785            .append(
786                OutputSource::Stage,
787                Some("test"),
788                None,
789                OutputStream::Stdout,
790                b"legacy\n",
791            )
792            .unwrap();
793        drop(writer);
794
795        assert_eq!(
796            super::stage_output_tail(&path, "test", 1, 10).unwrap(),
797            "legacy"
798        );
799        assert_eq!(super::stage_output_tail(&path, "test", 2, 10).unwrap(), "");
800    }
801
802    #[test]
803    fn utf8_and_binary_chunks_serialize_to_the_documented_shapes() {
804        let writer_dir = tempdir().unwrap();
805        let path = writer_dir.path().join("runs/R1/output.ndjson");
806        let writer = RunLogWriter::open(&path).unwrap();
807
808        writer
809            .append(
810                OutputSource::Agent,
811                None,
812                None,
813                OutputStream::Stdout,
814                b"hello\n",
815            )
816            .unwrap();
817        writer
818            .append(
819                OutputSource::Stage,
820                Some("test"),
821                None,
822                OutputStream::Stderr,
823                &[0xff, 0x00],
824            )
825            .unwrap();
826
827        let contents = std::fs::read_to_string(&path).unwrap();
828        let records: Vec<Value> = contents
829            .lines()
830            .map(|line| serde_json::from_str(line).unwrap())
831            .collect();
832
833        assert_eq!(records[0]["sequence"], 1);
834        assert_eq!(records[0]["source"], "agent");
835        assert_eq!(records[0]["stream"], "stdout");
836        assert_eq!(records[0]["encoding"], "utf8");
837        assert_eq!(records[0]["text"], "hello\n");
838        assert_eq!(records[0].get("stage"), None);
839        assert!(records[0]["timestamp"].as_str().unwrap().ends_with('Z'));
840
841        assert_eq!(records[1]["sequence"], 2);
842        assert_eq!(records[1]["source"], "stage");
843        assert_eq!(records[1]["stage"], "test");
844        assert_eq!(records[1]["encoding"], "base64");
845        assert_eq!(records[1]["data"], "/wA=");
846    }
847
848    #[test]
849    fn binary_chunks_round_trip_without_loss() {
850        let bytes = [0xff, 0xfe, 0x00, 0x41];
851        let chunk = OutputChunk::from_bytes(&bytes);
852        assert!(matches!(chunk, OutputChunk::Base64 { .. }));
853        assert_eq!(chunk.into_bytes(), bytes);
854    }
855
856    #[test]
857    fn reopening_appends_after_existing_records() {
858        let directory = tempdir().unwrap();
859        let path = directory.path().join("output.ndjson");
860
861        let writer = RunLogWriter::open(&path).unwrap();
862        writer
863            .append(
864                OutputSource::Agent,
865                None,
866                None,
867                OutputStream::Stdout,
868                b"one",
869            )
870            .unwrap();
871        drop(writer);
872
873        let writer = RunLogWriter::open(&path).unwrap();
874        let sequence = writer
875            .append(
876                OutputSource::Agent,
877                None,
878                None,
879                OutputStream::Stdout,
880                b"two",
881            )
882            .unwrap();
883        assert_eq!(sequence, 2);
884
885        let page = read_page(&path, 0, 10).unwrap();
886        assert_eq!(page.entries.len(), 2);
887        assert_eq!(
888            page.entries[1].chunk,
889            OutputChunk::Utf8 { text: "two".into() }
890        );
891    }
892
893    #[test]
894    fn staleness_uses_the_last_complete_agent_record_across_reopen() {
895        let directory = tempdir().unwrap();
896        let path = directory.path().join("output.ndjson");
897        let writer = RunLogWriter::open(&path).unwrap();
898        writer
899            .append_at(
900                100_000,
901                OutputSource::Agent,
902                Some("build"),
903                None,
904                OutputStream::Stdout,
905                b"first",
906            )
907            .unwrap();
908        writer
909            .append_at(
910                200_000,
911                OutputSource::Stage,
912                Some("test"),
913                None,
914                OutputStream::Stdout,
915                b"ignored",
916            )
917            .unwrap();
918        drop(writer);
919
920        let stale = output_staleness(&path, 50_000, 160_000, 60_000).unwrap();
921        assert_eq!(stale.last_sequence, Some(1));
922        assert_eq!(stale.last_output_at_ms, 100_000);
923        assert_eq!(stale.silent_for_ms, 60_000);
924        assert!(stale.stalled);
925
926        let writer = RunLogWriter::open(&path).unwrap();
927        writer
928            .append_at(
929                160_000,
930                OutputSource::Agent,
931                Some("build"),
932                None,
933                OutputStream::Stdout,
934                b"resumed",
935            )
936            .unwrap();
937        drop(writer);
938        let resumed = output_staleness(&path, 50_000, 160_000, 60_000).unwrap();
939        assert_eq!(resumed.last_sequence, Some(3));
940        assert_eq!(resumed.silent_for_ms, 0);
941        assert!(!resumed.stalled);
942    }
943
944    #[test]
945    fn a_truncated_tail_hides_no_earlier_records() {
946        let directory = tempdir().unwrap();
947        let path = directory.path().join("output.ndjson");
948        let writer = RunLogWriter::open(&path).unwrap();
949        writer
950            .append(
951                OutputSource::Agent,
952                None,
953                None,
954                OutputStream::Stdout,
955                b"kept",
956            )
957            .unwrap();
958        drop(writer);
959
960        use std::io::Write;
961        let mut file = std::fs::OpenOptions::new()
962            .append(true)
963            .open(&path)
964            .unwrap();
965        file.write_all(b"{\"sequence\":2,\"timest").unwrap();
966        drop(file);
967
968        let page = read_page(&path, 0, 10).unwrap();
969        assert_eq!(page.entries.len(), 1);
970        assert!(page.complete);
971
972        // The partial record was never complete, so its sequence is free to
973        // reuse, and the new record must land on its own line.
974        let writer = RunLogWriter::open(&path).unwrap();
975        let sequence = writer
976            .append(
977                OutputSource::Agent,
978                None,
979                None,
980                OutputStream::Stdout,
981                b"next",
982            )
983            .unwrap();
984        assert_eq!(sequence, 2);
985
986        let page = read_page(&path, 0, 10).unwrap();
987        assert_eq!(page.entries.len(), 2);
988        assert_eq!(
989            page.entries[1].chunk,
990            OutputChunk::Utf8 {
991                text: "next".into()
992            }
993        );
994    }
995
996    #[test]
997    fn pagination_is_stable_across_sequence_cursors() {
998        let directory = tempdir().unwrap();
999        let path = directory.path().join("output.ndjson");
1000        let writer = RunLogWriter::open(&path).unwrap();
1001        for index in 0..5 {
1002            writer
1003                .append(
1004                    OutputSource::Agent,
1005                    None,
1006                    None,
1007                    OutputStream::Stdout,
1008                    format!("chunk {index}").as_bytes(),
1009                )
1010                .unwrap();
1011        }
1012
1013        let first = read_page(&path, 0, 2).unwrap();
1014        assert_eq!(first.entries.len(), 2);
1015        assert_eq!(first.next_cursor, 2);
1016        assert!(!first.complete);
1017
1018        let second = read_page(&path, first.next_cursor, 10).unwrap();
1019        assert_eq!(second.entries.len(), 3);
1020        assert_eq!(second.next_cursor, 5);
1021        assert!(second.complete);
1022
1023        let missing = read_page(&directory.path().join("absent.ndjson"), 0, 10).unwrap();
1024        assert!(missing.entries.is_empty() && missing.complete);
1025    }
1026
1027    #[test]
1028    fn records_round_trip_through_serde() {
1029        let record = super::OutputRecord {
1030            sequence: 7,
1031            timestamp: "2026-07-13T20:00:01Z".into(),
1032            source: OutputSource::Stage,
1033            stage: Some("test".into()),
1034            attempt: Some(2),
1035            stream: OutputStream::Stderr,
1036            chunk: OutputChunk::Utf8 { text: "x".into() },
1037        };
1038        let encoded = serde_json::to_value(&record).unwrap();
1039        assert_eq!(
1040            encoded,
1041            json!({
1042                "sequence": 7,
1043                "timestamp": "2026-07-13T20:00:01Z",
1044                "source": "stage",
1045                "stage": "test",
1046                "attempt": 2,
1047                "stream": "stderr",
1048                "encoding": "utf8",
1049                "text": "x"
1050            })
1051        );
1052        let decoded: super::OutputRecord = serde_json::from_value(encoded).unwrap();
1053        assert_eq!(decoded, record);
1054    }
1055
1056    #[test]
1057    fn agent_streams_are_reassembled_across_utf8_and_binary_chunks() {
1058        let directory = tempdir().unwrap();
1059        let path = directory.path().join("output.ndjson");
1060        let writer = RunLogWriter::open(&path).unwrap();
1061        writer
1062            .append(
1063                OutputSource::Agent,
1064                None,
1065                None,
1066                OutputStream::Stderr,
1067                b"rate li",
1068            )
1069            .unwrap();
1070        writer
1071            .append(
1072                OutputSource::Stage,
1073                Some("test"),
1074                None,
1075                OutputStream::Stderr,
1076                b"must not match",
1077            )
1078            .unwrap();
1079        writer
1080            .append(
1081                OutputSource::Agent,
1082                None,
1083                None,
1084                OutputStream::Stdout,
1085                &[0xff, b'o', b'k'],
1086            )
1087            .unwrap();
1088        writer
1089            .append(
1090                OutputSource::Agent,
1091                None,
1092                None,
1093                OutputStream::Stderr,
1094                b"mited",
1095            )
1096            .unwrap();
1097        drop(writer);
1098
1099        use std::io::Write;
1100        let mut file = std::fs::OpenOptions::new()
1101            .append(true)
1102            .open(&path)
1103            .unwrap();
1104        file.write_all(b"{\"sequence\":99").unwrap();
1105
1106        let output = read_agent_output(&path).unwrap();
1107        assert_eq!(output.stderr, b"rate limited");
1108        assert_eq!(output.stdout, [0xff, b'o', b'k']);
1109    }
1110}