Skip to main content

harn_vm/testbench/
tape.rs

1//! Unified event tape for the testbench.
2//!
3//! A tape is the canonical artifact behind `harn test-bench --emit-tape`.
4//! Every non-deterministic input the script consumed — clock advances,
5//! LLM responses, FS reads/writes, subprocess spawns — lands as a typed
6//! [`TapeRecord`] with a logical sequence number and a virtual-time
7//! stamp. The tape is what the [`fidelity`] oracle compares; it is what
8//! `harn test-bench replay` reads to drive a deterministic re-run.
9//!
10//! [`fidelity`]: super::fidelity
11//!
12//! ## File layout
13//!
14//! ```text
15//! tape.tape       # NDJSON: one header line + one record line per event
16//! tape.cas/       # content-addressed sidecar (BLAKE3 hex names)
17//! ```
18//!
19//! Small payloads are serialized inline. Anything over [`MAX_INLINE_BYTES`]
20//! lands in `tape.cas/<blake3>` and the record carries `{"cas": "<blake3>"}`.
21//! That keeps the main stream diffable when the only thing that changes
22//! is a multi-MB LLM response.
23//!
24//! ## Versioning
25//!
26//! Every tape carries a `version` integer in its header. The current
27//! schema is [`TAPE_FORMAT_VERSION`]. Loaders accept anything `<=` the
28//! current version and emit a structured error for newer tapes; this
29//! gives us room to add record kinds (under `#[serde(other)]`) without
30//! silently breaking older runners.
31//!
32//! ## Recording
33//!
34//! Recording is opt-in: the testbench installs a thread-local
35//! [`TapeRecorder`] when `Testbench::tape = TapeConfig::Emit { path }`.
36//! Every host-capability axis that already has a record path
37//! ([`super::process_tape`], [`super::overlay_fs`], [`crate::llm::mock`],
38//! [`crate::clock_mock`]) calls into this module to push a record. When
39//! no recorder is installed, the helpers are no-ops — production code
40//! pays nothing.
41
42use std::cell::RefCell;
43use std::collections::BTreeMap;
44use std::path::{Path, PathBuf};
45use std::sync::atomic::{AtomicU64, Ordering};
46use std::sync::{Arc, Mutex};
47
48use serde::{Deserialize, Serialize};
49
50use crate::clock_mock;
51
52/// Format version of the on-disk tape representation. Bump on any
53/// breaking change. Loaders refuse tapes with a higher version.
54pub const TAPE_FORMAT_VERSION: u32 = 1;
55
56/// Records whose serialized payload exceeds this size are spilled into
57/// the content-addressed sidecar. Picked to be larger than typical
58/// stdout/file-read sizes but smaller than full LLM responses, so the
59/// main NDJSON stream stays diffable.
60pub const MAX_INLINE_BYTES: usize = 4 * 1024;
61
62/// Header line written first in every tape file. Captures the metadata a
63/// fidelity-checker needs to interpret the records that follow.
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65pub struct TapeHeader {
66    pub version: u32,
67    /// Crate version of the producer (`harn-vm` `CARGO_PKG_VERSION`).
68    /// Surfaced so a fidelity report can attribute divergences across
69    /// runtime upgrades.
70    pub harn_version: String,
71    /// UNIX-epoch milliseconds the script was launched at — i.e. the
72    /// initial value of the testbench's paused clock. `null` when the
73    /// run used the real clock.
74    #[serde(default)]
75    pub started_at_unix_ms: Option<i64>,
76    /// Path passed to `harn test-bench run`. Informational only; replays
77    /// resolve scripts via the CLI argument, not this field.
78    #[serde(default)]
79    pub script_path: Option<String>,
80    /// Positional arguments forwarded to the script (post `--`). Captured
81    /// so two re-runs that differ only in argv are distinguishable.
82    #[serde(default)]
83    pub argv: Vec<String>,
84}
85
86impl TapeHeader {
87    pub fn current(
88        started_at_unix_ms: Option<i64>,
89        script_path: Option<String>,
90        argv: Vec<String>,
91    ) -> Self {
92        Self {
93            version: TAPE_FORMAT_VERSION,
94            harn_version: env!("CARGO_PKG_VERSION").to_string(),
95            started_at_unix_ms,
96            script_path,
97            argv,
98        }
99    }
100}
101
102/// One on-disk line of the tape. Wrapping the header and record kinds
103/// behind a single tagged enum lets us write the whole file as
104/// homogeneous NDJSON.
105#[derive(Debug, Clone, Serialize, Deserialize)]
106#[serde(tag = "type", rename_all = "snake_case")]
107enum TapeLine {
108    Header(TapeHeader),
109    Record(TapeRecord),
110}
111
112/// One captured non-deterministic event. The variant carries the record
113/// payload; the wrapping [`TapeRecord`] adds the metadata every variant
114/// shares.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct TapeRecord {
117    /// Monotonic logical sequence number assigned at record time.
118    pub seq: u64,
119    /// Wall-clock value (UNIX-epoch ms) observed at record time. Reads
120    /// from the unified mock clock when one is installed.
121    pub virtual_time_ms: i64,
122    /// Monotonic ms since the testbench was activated. Independent of
123    /// `virtual_time_ms` so a paused clock that never advances still
124    /// produces an ordered stream.
125    pub monotonic_ms: i64,
126    /// The actual event.
127    pub kind: TapeRecordKind,
128}
129
130/// Discriminated union of every record kind the v1 tape captures. New
131/// kinds can be added without breaking older readers (`serde(other)`
132/// support is intentional — unknown variants surface as
133/// [`TapeRecordKind::Unknown`] so a fidelity check still runs).
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(tag = "kind", rename_all = "snake_case")]
136pub enum TapeRecordKind {
137    /// Script read the wall-clock or monotonic clock. The captured value
138    /// is what the script actually saw, so a re-run that drifts (e.g.
139    /// because the operator forgot `--clock paused`) produces a
140    /// different content hash and the fidelity oracle flags it.
141    ClockRead { source: ClockSource, value_ms: i64 },
142    /// Script slept (or otherwise advanced the unified mock clock) by
143    /// `duration_ms`. The recorded virtual time is post-advance.
144    ClockSleep { duration_ms: u64 },
145    /// LLM call. `request_digest` is a deterministic hash of the call's
146    /// matchable surface (messages + system + tools + tool_choice +
147    /// thinking). `response` is the recorded mock — inline JSON for
148    /// small payloads, a CAS reference for large ones.
149    LlmCall {
150        request_digest: String,
151        response: TapePayload,
152    },
153    /// Filesystem read against the testbench overlay. The content hash
154    /// lets fidelity checks reason about read consistency without
155    /// inlining every byte.
156    FileRead {
157        path: String,
158        content_hash: String,
159        len_bytes: u64,
160    },
161    /// Filesystem write into the testbench overlay.
162    FileWrite {
163        path: String,
164        content_hash: String,
165        len_bytes: u64,
166    },
167    /// Filesystem delete in the overlay layer.
168    FileDelete { path: String },
169    /// Subprocess spawn captured by [`super::process_tape`]. Stdout and
170    /// stderr are stored under `stdout_payload`/`stderr_payload` so the
171    /// large blobs land in CAS rather than the NDJSON line.
172    ProcessSpawn {
173        program: String,
174        args: Vec<String>,
175        cwd: Option<String>,
176        exit_code: i32,
177        duration_ms: u64,
178        stdout_payload: TapePayload,
179        stderr_payload: TapePayload,
180    },
181    /// Catch-all for record kinds emitted by a newer producer. Lets
182    /// older fidelity checkers compare what they understand and flag
183    /// the rest as `Unknown` divergence rather than refusing to load.
184    #[serde(other)]
185    Unknown,
186}
187
188impl TapeRecordKind {
189    /// Stable, snake_case label for this kind. Mirrors the `kind` tag
190    /// `serde` writes to disk so display-side code (CLI summaries,
191    /// report headers, error messages) is consistent with the wire
192    /// format without re-deriving the string each call site.
193    pub fn label(&self) -> &'static str {
194        match self {
195            Self::ClockRead { .. } => "clock_read",
196            Self::ClockSleep { .. } => "clock_sleep",
197            Self::LlmCall { .. } => "llm_call",
198            Self::FileRead { .. } => "file_read",
199            Self::FileWrite { .. } => "file_write",
200            Self::FileDelete { .. } => "file_delete",
201            Self::ProcessSpawn { .. } => "process_spawn",
202            Self::Unknown => "unknown",
203        }
204    }
205}
206
207/// Which face of the unified clock the script read. Captured so a
208/// fidelity report can attribute drift back to the right axis.
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum ClockSource {
212    Wall,
213    Monotonic,
214}
215
216/// On-disk representation of a record payload. Inline for small values,
217/// CAS-by-hash for anything over [`MAX_INLINE_BYTES`].
218#[derive(Debug, Clone, Serialize, Deserialize)]
219#[serde(untagged)]
220pub enum TapePayload {
221    /// Inline UTF-8 text payload. Carries a content hash so a fidelity
222    /// check can compare without re-hashing.
223    Inline { content_hash: String, text: String },
224    /// CAS-stored payload. The bytes live at `<tape>.cas/<content_hash>`.
225    Cas {
226        content_hash: String,
227        len_bytes: u64,
228    },
229}
230
231impl TapePayload {
232    pub fn content_hash(&self) -> &str {
233        match self {
234            Self::Inline { content_hash, .. } | Self::Cas { content_hash, .. } => content_hash,
235        }
236    }
237
238    pub fn len_bytes(&self) -> u64 {
239        match self {
240            Self::Inline { text, .. } => text.len() as u64,
241            Self::Cas { len_bytes, .. } => *len_bytes,
242        }
243    }
244}
245
246/// Compute a stable BLAKE3 hex digest for a byte slice. Centralized so
247/// every record path keys CAS lookups identically.
248pub fn content_hash(bytes: &[u8]) -> String {
249    blake3::hash(bytes).to_hex().to_string()
250}
251
252/// Build a [`TapePayload`] from raw bytes, spilling to the sidecar map
253/// when the body is large enough to clutter the NDJSON.
254fn build_payload(bytes: Vec<u8>, cas: &mut BTreeMap<String, Vec<u8>>) -> TapePayload {
255    let hash = content_hash(&bytes);
256    if bytes.len() > MAX_INLINE_BYTES {
257        let len_bytes = bytes.len() as u64;
258        cas.entry(hash.clone()).or_insert(bytes);
259        TapePayload::Cas {
260            content_hash: hash,
261            len_bytes,
262        }
263    } else {
264        let text = match String::from_utf8(bytes) {
265            Ok(text) => text,
266            Err(error) => {
267                // Non-utf8 bytes still need to round-trip. Stash the raw
268                // bytes in CAS and inline a sentinel so a fidelity diff
269                // is still meaningful.
270                let bytes = error.into_bytes();
271                let len_bytes = bytes.len() as u64;
272                cas.entry(hash.clone()).or_insert(bytes);
273                return TapePayload::Cas {
274                    content_hash: hash,
275                    len_bytes,
276                };
277            }
278        };
279        TapePayload::Inline {
280            content_hash: hash,
281            text,
282        }
283    }
284}
285
286/// In-memory tape: header + ordered record list + sidecar bytes pending
287/// flush to disk. Built by [`TapeRecorder`] during a record run; loaded
288/// by [`EventTape::load`] for replay or fidelity comparison.
289#[derive(Debug, Clone)]
290pub struct EventTape {
291    pub header: TapeHeader,
292    pub records: Vec<TapeRecord>,
293    /// Content-addressed bodies. Populated either by the recorder (in
294    /// memory until [`EventTape::persist`]) or by [`EventTape::load`]
295    /// (read back from `<tape>.cas/`).
296    cas: BTreeMap<String, Vec<u8>>,
297}
298
299impl EventTape {
300    pub fn new(header: TapeHeader) -> Self {
301        Self {
302            header,
303            records: Vec::new(),
304            cas: BTreeMap::new(),
305        }
306    }
307
308    /// Resolve a payload to its full bytes. Inline payloads return their
309    /// text; CAS payloads look up the sidecar.
310    pub fn resolve_payload(&self, payload: &TapePayload) -> Result<Vec<u8>, String> {
311        match payload {
312            TapePayload::Inline { text, .. } => Ok(text.as_bytes().to_vec()),
313            TapePayload::Cas { content_hash, .. } => self
314                .cas
315                .get(content_hash)
316                .cloned()
317                .ok_or_else(|| format!("tape CAS missing entry for {content_hash}")),
318        }
319    }
320
321    /// Total CAS payload count. Useful for diagnostics and tests.
322    pub fn cas_len(&self) -> usize {
323        self.cas.len()
324    }
325
326    /// Persist the tape (NDJSON + sidecar) to `path`. The sidecar lives
327    /// at `<path>.cas/`; the parent directory is created if needed.
328    pub fn persist(&self, path: &Path) -> Result<(), String> {
329        if let Some(parent) = path.parent() {
330            if !parent.as_os_str().is_empty() {
331                std::fs::create_dir_all(parent)
332                    .map_err(|err| format!("mkdir {}: {err}", parent.display()))?;
333            }
334        }
335
336        let mut body = String::new();
337        let header_line = serde_json::to_string(&TapeLine::Header(self.header.clone()))
338            .map_err(|err| format!("serialize tape header: {err}"))?;
339        body.push_str(&header_line);
340        body.push('\n');
341        for record in &self.records {
342            let line = serde_json::to_string(&TapeLine::Record(record.clone()))
343                .map_err(|err| format!("serialize tape record: {err}"))?;
344            body.push_str(&line);
345            body.push('\n');
346        }
347        std::fs::write(path, body).map_err(|err| format!("write {}: {err}", path.display()))?;
348
349        if !self.cas.is_empty() {
350            let cas_dir = cas_dir_for(path);
351            std::fs::create_dir_all(&cas_dir)
352                .map_err(|err| format!("mkdir {}: {err}", cas_dir.display()))?;
353            for (hash, bytes) in &self.cas {
354                let entry = cas_dir.join(hash);
355                std::fs::write(&entry, bytes)
356                    .map_err(|err| format!("write {}: {err}", entry.display()))?;
357            }
358        }
359        Ok(())
360    }
361
362    /// Load a tape from `path`. Reads the NDJSON body and lazily fetches
363    /// any referenced CAS entries from `<path>.cas/`.
364    pub fn load(path: &Path) -> Result<Self, String> {
365        let body = std::fs::read_to_string(path)
366            .map_err(|err| format!("read {}: {err}", path.display()))?;
367        let mut lines = body.lines();
368        let first_line = lines
369            .next()
370            .ok_or_else(|| format!("empty tape file: {}", path.display()))?;
371        let header_line: TapeLine = serde_json::from_str(first_line)
372            .map_err(|err| format!("parse tape header in {}: {err}", path.display()))?;
373        let header = match header_line {
374            TapeLine::Header(header) => header,
375            TapeLine::Record(_) => {
376                return Err(format!(
377                    "tape {} is missing its header (first line is a record)",
378                    path.display()
379                ))
380            }
381        };
382        if header.version > TAPE_FORMAT_VERSION {
383            return Err(format!(
384                "tape {} declares version {} but this runtime supports up to {TAPE_FORMAT_VERSION}",
385                path.display(),
386                header.version
387            ));
388        }
389        let mut records = Vec::new();
390        for (idx, line) in lines.enumerate() {
391            let trimmed = line.trim();
392            if trimmed.is_empty() {
393                continue;
394            }
395            let parsed: TapeLine = serde_json::from_str(trimmed).map_err(|err| {
396                format!(
397                    "parse tape record at line {} in {}: {err}",
398                    idx + 2,
399                    path.display()
400                )
401            })?;
402            match parsed {
403                TapeLine::Record(record) => records.push(record),
404                TapeLine::Header(_) => {
405                    return Err(format!(
406                        "tape {} contains a second header at line {}",
407                        path.display(),
408                        idx + 2
409                    ))
410                }
411            }
412        }
413
414        let mut cas = BTreeMap::new();
415        let cas_dir = cas_dir_for(path);
416        if cas_dir.is_dir() {
417            for record in &records {
418                visit_payloads(&record.kind, |payload| {
419                    if let TapePayload::Cas { content_hash, .. } = payload {
420                        if cas.contains_key(content_hash) {
421                            return;
422                        }
423                        let entry = cas_dir.join(content_hash);
424                        if let Ok(bytes) = std::fs::read(&entry) {
425                            cas.insert(content_hash.clone(), bytes);
426                        }
427                    }
428                });
429            }
430        }
431        Ok(Self {
432            header,
433            records,
434            cas,
435        })
436    }
437}
438
439fn cas_dir_for(tape_path: &Path) -> PathBuf {
440    let mut buf = tape_path.as_os_str().to_owned();
441    buf.push(".cas");
442    PathBuf::from(buf)
443}
444
445fn visit_payloads(kind: &TapeRecordKind, mut visit: impl FnMut(&TapePayload)) {
446    match kind {
447        TapeRecordKind::LlmCall { response, .. } => visit(response),
448        TapeRecordKind::ProcessSpawn {
449            stdout_payload,
450            stderr_payload,
451            ..
452        } => {
453            visit(stdout_payload);
454            visit(stderr_payload);
455        }
456        TapeRecordKind::ClockRead { .. }
457        | TapeRecordKind::ClockSleep { .. }
458        | TapeRecordKind::FileRead { .. }
459        | TapeRecordKind::FileWrite { .. }
460        | TapeRecordKind::FileDelete { .. }
461        | TapeRecordKind::Unknown => {}
462    }
463}
464
465/// Recorder consulted by every host-capability axis. When installed as
466/// the [`active_recorder`], each axis's record path also pushes a
467/// [`TapeRecord`] here so the unified tape stays in sync without
468/// re-routing every capability through this module.
469#[derive(Debug)]
470pub struct TapeRecorder {
471    next_seq: AtomicU64,
472    started_at: clock_mock::ClockInstant,
473    inner: Mutex<RecorderInner>,
474}
475
476#[derive(Debug, Default)]
477struct RecorderInner {
478    records: Vec<TapeRecord>,
479    cas: BTreeMap<String, Vec<u8>>,
480}
481
482impl Default for TapeRecorder {
483    fn default() -> Self {
484        Self::new()
485    }
486}
487
488impl TapeRecorder {
489    pub fn new() -> Self {
490        Self {
491            next_seq: AtomicU64::new(0),
492            started_at: clock_mock::instant_now(),
493            inner: Mutex::new(RecorderInner::default()),
494        }
495    }
496
497    /// Append a record built from `kind`. The recorder stamps the seq
498    /// number and timing metadata; callers only worry about the payload.
499    pub fn record(&self, kind: TapeRecordKind) {
500        let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
501        let virtual_time_ms = clock_mock::now_ms();
502        let monotonic_ms = clock_mock::instant_now()
503            .duration_since(self.started_at)
504            .as_millis()
505            .min(i64::MAX as u128) as i64;
506        let record = TapeRecord {
507            seq,
508            virtual_time_ms,
509            monotonic_ms,
510            kind,
511        };
512        self.inner
513            .lock()
514            .expect("tape recorder mutex poisoned")
515            .records
516            .push(record);
517    }
518
519    /// Convenience wrapper: build a [`TapePayload`] from `bytes` (spilling
520    /// to CAS as needed) and register the bytes for persistence. Used by
521    /// axes that have raw bodies on hand (subprocess stdout, LLM
522    /// response JSON, file content).
523    pub fn payload_from_bytes(&self, bytes: Vec<u8>) -> TapePayload {
524        let mut inner = self.inner.lock().expect("tape recorder mutex poisoned");
525        build_payload(bytes, &mut inner.cas)
526    }
527
528    /// Snapshot the tape into a self-contained [`EventTape`]. Consumes
529    /// the recorder's CAS by `clone()` so a recorder can be sampled
530    /// mid-run for diagnostics — production callers usually move into
531    /// `into_tape` instead.
532    pub fn snapshot(&self, header: TapeHeader) -> EventTape {
533        let inner = self.inner.lock().expect("tape recorder mutex poisoned");
534        EventTape {
535            header,
536            records: inner.records.clone(),
537            cas: inner.cas.clone(),
538        }
539    }
540}
541
542thread_local! {
543    static ACTIVE_RECORDER: RefCell<Option<Arc<TapeRecorder>>> = const { RefCell::new(None) };
544}
545
546/// RAII guard returned by [`install_recorder`]. Restores the previous
547/// recorder (if any) on drop so nested testbench sessions stay sane.
548pub struct TapeRecorderGuard {
549    previous: Option<Arc<TapeRecorder>>,
550}
551
552impl Drop for TapeRecorderGuard {
553    fn drop(&mut self) {
554        let prev = self.previous.take();
555        ACTIVE_RECORDER.with(|slot| {
556            *slot.borrow_mut() = prev;
557        });
558    }
559}
560
561pub fn install_recorder(recorder: Arc<TapeRecorder>) -> TapeRecorderGuard {
562    let previous = ACTIVE_RECORDER.with(|slot| slot.replace(Some(recorder)));
563    TapeRecorderGuard { previous }
564}
565
566/// Currently installed recorder, if any. Production callers stay
567/// untouched because nothing installs a recorder outside testbench mode.
568pub fn active_recorder() -> Option<Arc<TapeRecorder>> {
569    ACTIVE_RECORDER.with(|slot| slot.borrow().clone())
570}
571
572/// Push a record if a recorder is active. The closure is only evaluated
573/// when recording is on, so the per-axis hooks pay nothing in production.
574pub fn with_active_recorder<F>(build: F)
575where
576    F: FnOnce(&Arc<TapeRecorder>) -> Option<TapeRecordKind>,
577{
578    let Some(recorder) = active_recorder() else {
579        return;
580    };
581    if let Some(kind) = build(&recorder) {
582        recorder.record(kind);
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use tempfile::TempDir;
590
591    fn small_record(seq: u64, dur: u64) -> TapeRecord {
592        TapeRecord {
593            seq,
594            virtual_time_ms: seq as i64 * 1000,
595            monotonic_ms: seq as i64 * 1000,
596            kind: TapeRecordKind::ClockSleep { duration_ms: dur },
597        }
598    }
599
600    #[test]
601    fn round_trip_inline_records() {
602        let temp = TempDir::new().unwrap();
603        let path = temp.path().join("run.tape");
604        let mut tape = EventTape::new(TapeHeader::current(
605            Some(1_700_000_000_000),
606            Some("script.harn".to_string()),
607            vec!["a".into()],
608        ));
609        tape.records.push(small_record(0, 250));
610        tape.records.push(small_record(1, 750));
611        tape.persist(&path).unwrap();
612
613        let loaded = EventTape::load(&path).unwrap();
614        assert_eq!(loaded.header.version, TAPE_FORMAT_VERSION);
615        assert_eq!(loaded.header.argv, vec!["a".to_string()]);
616        assert_eq!(loaded.records.len(), 2);
617        match &loaded.records[0].kind {
618            TapeRecordKind::ClockSleep { duration_ms } => assert_eq!(*duration_ms, 250),
619            other => panic!("unexpected: {other:?}"),
620        }
621    }
622
623    #[test]
624    fn large_payloads_spill_to_cas_and_round_trip() {
625        let temp = TempDir::new().unwrap();
626        let path = temp.path().join("run.tape");
627        let mut tape = EventTape::new(TapeHeader::current(None, None, Vec::new()));
628        let big = vec![b'x'; MAX_INLINE_BYTES + 32];
629        let payload = build_payload(big.clone(), &mut tape.cas);
630        let hash = payload.content_hash().to_string();
631        let kind = TapeRecordKind::ProcessSpawn {
632            program: "/bin/echo".to_string(),
633            args: vec!["x".to_string()],
634            cwd: None,
635            exit_code: 0,
636            duration_ms: 1,
637            stdout_payload: payload,
638            stderr_payload: build_payload(Vec::new(), &mut tape.cas),
639        };
640        tape.records.push(TapeRecord {
641            seq: 0,
642            virtual_time_ms: 0,
643            monotonic_ms: 0,
644            kind,
645        });
646        tape.persist(&path).unwrap();
647
648        // CAS sidecar exists.
649        assert!(path.with_extension("tape.cas").exists() || cas_dir_for(&path).exists());
650        let cas_dir = cas_dir_for(&path);
651        assert!(cas_dir.join(&hash).exists());
652
653        let loaded = EventTape::load(&path).unwrap();
654        let resolved = match &loaded.records[0].kind {
655            TapeRecordKind::ProcessSpawn { stdout_payload, .. } => {
656                loaded.resolve_payload(stdout_payload).unwrap()
657            }
658            other => panic!("unexpected: {other:?}"),
659        };
660        assert_eq!(resolved.len(), big.len());
661    }
662
663    #[test]
664    fn rejects_newer_version() {
665        let temp = TempDir::new().unwrap();
666        let path = temp.path().join("future.tape");
667        std::fs::write(
668            &path,
669            r#"{"type":"header","version":99,"harn_version":"x","started_at_unix_ms":null,"script_path":null,"argv":[]}
670"#,
671        )
672        .unwrap();
673        let err = EventTape::load(&path).unwrap_err();
674        assert!(err.contains("version 99"), "{err}");
675    }
676
677    #[test]
678    fn recorder_assigns_monotonic_seq() {
679        let recorder = Arc::new(TapeRecorder::new());
680        recorder.record(TapeRecordKind::ClockSleep { duration_ms: 1 });
681        recorder.record(TapeRecordKind::ClockSleep { duration_ms: 2 });
682        let snapshot = recorder.snapshot(TapeHeader::current(None, None, Vec::new()));
683        assert_eq!(snapshot.records[0].seq, 0);
684        assert_eq!(snapshot.records[1].seq, 1);
685    }
686}