1use 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
19pub const PAGE_LIMIT: usize = 64;
21
22pub 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 Agent,
31 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#[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 #[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#[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 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 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 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 #[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 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#[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
200pub 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
223fn 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
270fn 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
287fn 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 pub next_cursor: u64,
310 pub complete: bool,
312 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
324pub 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
338pub 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
362pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
413pub struct StageFilter {
414 pub stage: String,
415 pub attempt: Option<u32>,
420 pub agent_fallback: bool,
421}
422
423impl StageFilter {
424 fn accepts(&self, record: &OutputRecord) -> bool {
425 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
443pub struct PageQuery {
444 pub after: u64,
446 pub limit: usize,
448 pub stage: Option<StageFilter>,
450 pub tail: Option<usize>,
453}
454
455pub 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
468pub 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 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 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 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 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 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 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 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 #[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 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 #[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 #[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 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}