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, execution phase, and a
7//! virtual-time stamp. The tape is what the [`fidelity`] oracle compares;
8//! it is what `harn test-bench replay` reads to drive a deterministic
9//! re-run.
10//!
11//! [`fidelity`]: super::fidelity
12//!
13//! ## File layout
14//!
15//! ```text
16//! tape.tape       # NDJSON: one header line + one record line per event
17//! tape.cas/       # content-addressed sidecar (BLAKE3 hex names)
18//! ```
19//!
20//! Small payloads are serialized inline. Anything over [`MAX_INLINE_BYTES`]
21//! lands in `tape.cas/<blake3>` and the record carries `{"cas": "<blake3>"}`.
22//! That keeps the main stream diffable when the only thing that changes
23//! is a multi-MB LLM response.
24//!
25//! ## Versioning
26//!
27//! Every tape carries a `version` integer in its header. The current
28//! schema is [`TAPE_FORMAT_VERSION`]. Loaders accept anything `<=` the
29//! current version and emit a structured error for newer tapes; this
30//! gives us room to add record kinds (under `#[serde(other)]`) without
31//! silently breaking older runners.
32//!
33//! ## Recording
34//!
35//! Recording is opt-in: the testbench installs a thread-local
36//! [`TapeRecorder`] when `Testbench::tape = TapeConfig::Emit { path }`.
37//! Every host-capability axis that already has a record path
38//! ([`super::process_tape`], [`super::overlay_fs`], [`crate::llm::mock`],
39//! [`crate::clock_mock`]) calls into this module to push a record. When
40//! no recorder is installed, the helpers are no-ops — production code
41//! pays nothing.
42
43use std::cell::RefCell;
44use std::collections::BTreeMap;
45use std::path::{Path, PathBuf};
46use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
47use std::sync::{Arc, Mutex};
48
49use serde::{Deserialize, Serialize};
50
51use crate::clock_mock;
52
53/// Format version of the on-disk tape representation. Bump on any
54/// breaking change. Loaders refuse tapes with a higher version.
55pub const TAPE_FORMAT_VERSION: u32 = 1;
56
57/// Records whose serialized payload exceeds this size are spilled into
58/// the content-addressed sidecar. Picked to be larger than typical
59/// stdout/file-read sizes but smaller than full LLM responses, so the
60/// main NDJSON stream stays diffable.
61pub const MAX_INLINE_BYTES: usize = 4 * 1024;
62
63/// Header line written first in every tape file. Captures the metadata a
64/// fidelity-checker needs to interpret the records that follow.
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
66pub struct TapeHeader {
67    pub version: u32,
68    /// Crate version of the producer (`harn-vm` `CARGO_PKG_VERSION`).
69    /// Surfaced so a fidelity report can attribute divergences across
70    /// runtime upgrades.
71    pub harn_version: String,
72    /// UNIX-epoch milliseconds the script was launched at — i.e. the
73    /// initial value of the testbench's paused clock. `null` when the
74    /// run used the real clock.
75    #[serde(default)]
76    pub started_at_unix_ms: Option<i64>,
77    /// Path passed to `harn test-bench run`. Informational only; replays
78    /// resolve scripts via the CLI argument, not this field.
79    #[serde(default)]
80    pub script_path: Option<String>,
81    /// Positional arguments forwarded to the script (post `--`). Captured
82    /// so two re-runs that differ only in argv are distinguishable.
83    #[serde(default)]
84    pub argv: Vec<String>,
85}
86
87impl TapeHeader {
88    pub fn current(
89        started_at_unix_ms: Option<i64>,
90        script_path: Option<String>,
91        argv: Vec<String>,
92    ) -> Self {
93        Self {
94            version: TAPE_FORMAT_VERSION,
95            harn_version: env!("CARGO_PKG_VERSION").to_string(),
96            started_at_unix_ms,
97            script_path,
98            argv,
99        }
100    }
101}
102
103/// One on-disk line of the tape. Wrapping the header and record kinds
104/// behind a single tagged enum lets us write the whole file as
105/// homogeneous NDJSON.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107#[serde(tag = "type", rename_all = "snake_case")]
108enum TapeLine {
109    Header(TapeHeader),
110    Record(TapeRecord),
111}
112
113/// One captured non-deterministic event. The variant carries the record
114/// payload; the wrapping [`TapeRecord`] adds the metadata every variant
115/// shares.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct TapeRecord {
118    /// Monotonic logical sequence number assigned at record time.
119    pub seq: u64,
120    /// Execution phase that produced the record. The fidelity oracle
121    /// uses this to keep script-visible boundaries strict while letting
122    /// runtime finalization evolve without regenerating user fixtures.
123    #[serde(default)]
124    pub phase: TapePhase,
125    /// Wall-clock value (UNIX-epoch ms) observed at record time. Reads
126    /// from the unified mock clock when one is installed.
127    pub virtual_time_ms: i64,
128    /// Monotonic ms since the testbench was activated. Independent of
129    /// `virtual_time_ms` so a paused clock that never advances still
130    /// produces an ordered stream.
131    pub monotonic_ms: i64,
132    /// The actual event.
133    pub kind: TapeRecordKind,
134}
135
136/// Coarse execution phase for host-boundary tape records.
137#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
138#[serde(rename_all = "snake_case")]
139pub enum TapePhase {
140    /// Records produced while evaluating the user-authored script body.
141    #[default]
142    UserScript,
143    /// Records produced while the runtime drains finish/resume/finalizer
144    /// lifecycle work after the script body has yielded its result.
145    RuntimeFinalize,
146}
147
148impl TapePhase {
149    fn as_u8(self) -> u8 {
150        match self {
151            Self::UserScript => 0,
152            Self::RuntimeFinalize => 1,
153        }
154    }
155
156    fn from_u8(value: u8) -> Self {
157        match value {
158            1 => Self::RuntimeFinalize,
159            _ => Self::UserScript,
160        }
161    }
162
163    pub fn label(self) -> &'static str {
164        match self {
165            Self::UserScript => "user_script",
166            Self::RuntimeFinalize => "runtime_finalize",
167        }
168    }
169}
170
171/// Discriminated union of every record kind the v1 tape captures. New
172/// kinds can be added without breaking older readers (`serde(other)`
173/// support is intentional — unknown variants surface as
174/// [`TapeRecordKind::Unknown`] so a fidelity check still runs).
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[serde(tag = "kind", rename_all = "snake_case")]
177pub enum TapeRecordKind {
178    /// Script read the wall-clock or monotonic clock. The captured value
179    /// is what the script actually saw, so a re-run that drifts (e.g.
180    /// because the operator forgot `--clock paused`) produces a
181    /// different content hash and the fidelity oracle flags it.
182    ClockRead { source: ClockSource, value_ms: i64 },
183    /// Script slept (or otherwise advanced the unified mock clock) by
184    /// `duration_ms`. The recorded virtual time is post-advance.
185    ClockSleep { duration_ms: u64 },
186    /// LLM call. `request_digest` is a deterministic hash of the call's
187    /// matchable surface (messages + system + tools + tool_choice +
188    /// thinking). `response` is the recorded mock — inline JSON for
189    /// small payloads, a CAS reference for large ones.
190    LlmCall {
191        request_digest: String,
192        response: TapePayload,
193    },
194    /// Filesystem read against the testbench overlay. The content hash
195    /// lets fidelity checks reason about read consistency without
196    /// inlining every byte.
197    FileRead {
198        path: String,
199        content_hash: String,
200        len_bytes: u64,
201    },
202    /// Filesystem write into the testbench overlay.
203    FileWrite {
204        path: String,
205        content_hash: String,
206        len_bytes: u64,
207    },
208    /// Filesystem delete in the overlay layer.
209    FileDelete { path: String },
210    /// Subprocess spawn captured by [`super::process_tape`]. Stdout and
211    /// stderr are stored under `stdout_payload`/`stderr_payload` so the
212    /// large blobs land in CAS rather than the NDJSON line.
213    ProcessSpawn {
214        program: String,
215        args: Vec<String>,
216        cwd: Option<String>,
217        exit_code: i32,
218        duration_ms: u64,
219        stdout_payload: TapePayload,
220        stderr_payload: TapePayload,
221    },
222    /// MCP JSON-RPC exchange observed by Harn's MCP client. The request
223    /// and response payloads are redacted before they are written so
224    /// cassettes and unified tapes share the same privacy boundary.
225    McpJsonRpc {
226        server: String,
227        method: String,
228        request_digest: String,
229        response_digest: String,
230        latency_ms: u64,
231        request_payload: TapePayload,
232        response_payload: TapePayload,
233    },
234    /// Asynchronous model-job lifecycle event (`harn.model_job_event.v1`)
235    /// observed through the host event bridge. Asset digests named in the
236    /// event payload let fidelity checks reason about media lineage without
237    /// calling a live model.
238    ModelJob {
239        job_id: String,
240        request_id: String,
241        backend: String,
242        state: String,
243        event_kind: String,
244        event: TapePayload,
245        asset_digests: Vec<String>,
246    },
247    /// Catch-all for record kinds emitted by a newer producer. Lets
248    /// older fidelity checkers compare what they understand and flag
249    /// the rest as `Unknown` divergence rather than refusing to load.
250    #[serde(other)]
251    Unknown,
252}
253
254impl TapeRecordKind {
255    /// Stable, snake_case label for this kind. Mirrors the `kind` tag
256    /// `serde` writes to disk so display-side code (CLI summaries,
257    /// report headers, error messages) is consistent with the wire
258    /// format without re-deriving the string each call site.
259    pub fn label(&self) -> &'static str {
260        match self {
261            Self::ClockRead { .. } => "clock_read",
262            Self::ClockSleep { .. } => "clock_sleep",
263            Self::LlmCall { .. } => "llm_call",
264            Self::FileRead { .. } => "file_read",
265            Self::FileWrite { .. } => "file_write",
266            Self::FileDelete { .. } => "file_delete",
267            Self::ProcessSpawn { .. } => "process_spawn",
268            Self::McpJsonRpc { .. } => "mcp_json_rpc",
269            Self::ModelJob { .. } => "model_job",
270            Self::Unknown => "unknown",
271        }
272    }
273}
274
275/// Which face of the unified clock the script read. Captured so a
276/// fidelity report can attribute drift back to the right axis.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(rename_all = "snake_case")]
279pub enum ClockSource {
280    Wall,
281    Monotonic,
282}
283
284/// On-disk representation of a record payload. Inline for small values,
285/// CAS-by-hash for anything over [`MAX_INLINE_BYTES`].
286#[derive(Debug, Clone, Serialize, Deserialize)]
287#[serde(untagged)]
288pub enum TapePayload {
289    /// Inline UTF-8 text payload. Carries a content hash so a fidelity
290    /// check can compare without re-hashing.
291    Inline { content_hash: String, text: String },
292    /// CAS-stored payload. The bytes live at `<tape>.cas/<content_hash>`.
293    Cas {
294        content_hash: String,
295        len_bytes: u64,
296    },
297}
298
299impl TapePayload {
300    pub fn content_hash(&self) -> &str {
301        match self {
302            Self::Inline { content_hash, .. } | Self::Cas { content_hash, .. } => content_hash,
303        }
304    }
305
306    pub fn len_bytes(&self) -> u64 {
307        match self {
308            Self::Inline { text, .. } => text.len() as u64,
309            Self::Cas { len_bytes, .. } => *len_bytes,
310        }
311    }
312}
313
314/// Compute a stable BLAKE3 hex digest for a byte slice. Centralized so
315/// every record path keys CAS lookups identically.
316pub fn content_hash(bytes: &[u8]) -> String {
317    blake3::hash(bytes).to_hex().to_string()
318}
319
320/// Build a [`TapePayload`] from raw bytes, spilling to the sidecar map
321/// when the body is large enough to clutter the NDJSON.
322fn build_payload(bytes: Vec<u8>, cas: &mut BTreeMap<String, Vec<u8>>) -> TapePayload {
323    let hash = content_hash(&bytes);
324    if bytes.len() > MAX_INLINE_BYTES {
325        let len_bytes = bytes.len() as u64;
326        cas.entry(hash.clone()).or_insert(bytes);
327        TapePayload::Cas {
328            content_hash: hash,
329            len_bytes,
330        }
331    } else {
332        let text = match String::from_utf8(bytes) {
333            Ok(text) => text,
334            Err(error) => {
335                // Non-utf8 bytes still need to round-trip. Stash the raw
336                // bytes in CAS and inline a sentinel so a fidelity diff
337                // is still meaningful.
338                let bytes = error.into_bytes();
339                let len_bytes = bytes.len() as u64;
340                cas.entry(hash.clone()).or_insert(bytes);
341                return TapePayload::Cas {
342                    content_hash: hash,
343                    len_bytes,
344                };
345            }
346        };
347        TapePayload::Inline {
348            content_hash: hash,
349            text,
350        }
351    }
352}
353
354/// In-memory tape: header + ordered record list + sidecar bytes pending
355/// flush to disk. Built by [`TapeRecorder`] during a record run; loaded
356/// by [`EventTape::load`] for replay or fidelity comparison.
357#[derive(Debug, Clone)]
358pub struct EventTape {
359    pub header: TapeHeader,
360    pub records: Vec<TapeRecord>,
361    /// Content-addressed bodies. Populated either by the recorder (in
362    /// memory until [`EventTape::persist`]) or by [`EventTape::load`]
363    /// (read back from `<tape>.cas/`).
364    cas: BTreeMap<String, Vec<u8>>,
365}
366
367impl EventTape {
368    pub fn new(header: TapeHeader) -> Self {
369        Self {
370            header,
371            records: Vec::new(),
372            cas: BTreeMap::new(),
373        }
374    }
375
376    /// Resolve a payload to its full bytes. Inline payloads return their
377    /// text; CAS payloads look up the sidecar.
378    pub fn resolve_payload(&self, payload: &TapePayload) -> Result<Vec<u8>, String> {
379        match payload {
380            TapePayload::Inline { text, .. } => Ok(text.as_bytes().to_vec()),
381            TapePayload::Cas { content_hash, .. } => self
382                .cas
383                .get(content_hash)
384                .cloned()
385                .ok_or_else(|| format!("tape CAS missing entry for {content_hash}")),
386        }
387    }
388
389    /// Total CAS payload count. Useful for diagnostics and tests.
390    pub fn cas_len(&self) -> usize {
391        self.cas.len()
392    }
393
394    /// Persist the tape (NDJSON + sidecar) to `path`. The sidecar lives
395    /// at `<path>.cas/`; the parent directory is created if needed.
396    pub fn persist(&self, path: &Path) -> Result<(), String> {
397        if let Some(parent) = path.parent() {
398            if !parent.as_os_str().is_empty() {
399                std::fs::create_dir_all(parent)
400                    .map_err(|err| format!("mkdir {}: {err}", parent.display()))?;
401            }
402        }
403
404        let mut body = String::new();
405        let header_line = serde_json::to_string(&TapeLine::Header(self.header.clone()))
406            .map_err(|err| format!("serialize tape header: {err}"))?;
407        body.push_str(&header_line);
408        body.push('\n');
409        for record in &self.records {
410            let line = serde_json::to_string(&TapeLine::Record(record.clone()))
411                .map_err(|err| format!("serialize tape record: {err}"))?;
412            body.push_str(&line);
413            body.push('\n');
414        }
415        std::fs::write(path, body).map_err(|err| format!("write {}: {err}", path.display()))?;
416
417        if !self.cas.is_empty() {
418            let cas_dir = cas_dir_for(path);
419            std::fs::create_dir_all(&cas_dir)
420                .map_err(|err| format!("mkdir {}: {err}", cas_dir.display()))?;
421            for (hash, bytes) in &self.cas {
422                let entry = cas_dir.join(hash);
423                std::fs::write(&entry, bytes)
424                    .map_err(|err| format!("write {}: {err}", entry.display()))?;
425            }
426        }
427        Ok(())
428    }
429
430    /// Load a tape from `path`. Reads the NDJSON body and lazily fetches
431    /// any referenced CAS entries from `<path>.cas/`.
432    pub fn load(path: &Path) -> Result<Self, String> {
433        let body = std::fs::read_to_string(path)
434            .map_err(|err| format!("read {}: {err}", path.display()))?;
435        let mut lines = body.lines();
436        let first_line = lines
437            .next()
438            .ok_or_else(|| format!("empty tape file: {}", path.display()))?;
439        let header_line: TapeLine = serde_json::from_str(first_line)
440            .map_err(|err| format!("parse tape header in {}: {err}", path.display()))?;
441        let header = match header_line {
442            TapeLine::Header(header) => header,
443            TapeLine::Record(_) => {
444                return Err(format!(
445                    "tape {} is missing its header (first line is a record)",
446                    path.display()
447                ))
448            }
449        };
450        if header.version > TAPE_FORMAT_VERSION {
451            return Err(format!(
452                "tape {} declares version {} but this runtime supports up to {TAPE_FORMAT_VERSION}",
453                path.display(),
454                header.version
455            ));
456        }
457        let mut records = Vec::new();
458        for (idx, line) in lines.enumerate() {
459            let trimmed = line.trim();
460            if trimmed.is_empty() {
461                continue;
462            }
463            let parsed: TapeLine = serde_json::from_str(trimmed).map_err(|err| {
464                format!(
465                    "parse tape record at line {} in {}: {err}",
466                    idx + 2,
467                    path.display()
468                )
469            })?;
470            match parsed {
471                TapeLine::Record(record) => records.push(record),
472                TapeLine::Header(_) => {
473                    return Err(format!(
474                        "tape {} contains a second header at line {}",
475                        path.display(),
476                        idx + 2
477                    ))
478                }
479            }
480        }
481
482        let mut cas = BTreeMap::new();
483        let cas_dir = cas_dir_for(path);
484        if cas_dir.is_dir() {
485            for record in &records {
486                visit_payloads(&record.kind, |payload| {
487                    if let TapePayload::Cas { content_hash, .. } = payload {
488                        if cas.contains_key(content_hash) {
489                            return;
490                        }
491                        let entry = cas_dir.join(content_hash);
492                        if let Ok(bytes) = std::fs::read(&entry) {
493                            cas.insert(content_hash.clone(), bytes);
494                        }
495                    }
496                });
497            }
498        }
499        Ok(Self {
500            header,
501            records,
502            cas,
503        })
504    }
505}
506
507fn cas_dir_for(tape_path: &Path) -> PathBuf {
508    let mut buf = tape_path.as_os_str().to_owned();
509    buf.push(".cas");
510    PathBuf::from(buf)
511}
512
513fn visit_payloads(kind: &TapeRecordKind, mut visit: impl FnMut(&TapePayload)) {
514    match kind {
515        TapeRecordKind::LlmCall { response, .. } => visit(response),
516        TapeRecordKind::ProcessSpawn {
517            stdout_payload,
518            stderr_payload,
519            ..
520        } => {
521            visit(stdout_payload);
522            visit(stderr_payload);
523        }
524        TapeRecordKind::McpJsonRpc {
525            request_payload,
526            response_payload,
527            ..
528        } => {
529            visit(request_payload);
530            visit(response_payload);
531        }
532        TapeRecordKind::ModelJob { event, .. } => visit(event),
533        TapeRecordKind::ClockRead { .. }
534        | TapeRecordKind::ClockSleep { .. }
535        | TapeRecordKind::FileRead { .. }
536        | TapeRecordKind::FileWrite { .. }
537        | TapeRecordKind::FileDelete { .. }
538        | TapeRecordKind::Unknown => {}
539    }
540}
541
542/// Recorder consulted by every host-capability axis. When installed as
543/// the [`active_recorder`], each axis's record path also pushes a
544/// [`TapeRecord`] here so the unified tape stays in sync without
545/// re-routing every capability through this module.
546#[derive(Debug)]
547pub struct TapeRecorder {
548    next_seq: AtomicU64,
549    phase: AtomicU8,
550    started_at: clock_mock::ClockInstant,
551    inner: Mutex<RecorderInner>,
552}
553
554#[derive(Debug, Default)]
555struct RecorderInner {
556    records: Vec<TapeRecord>,
557    cas: BTreeMap<String, Vec<u8>>,
558}
559
560impl Default for TapeRecorder {
561    fn default() -> Self {
562        Self::new()
563    }
564}
565
566impl TapeRecorder {
567    pub fn new() -> Self {
568        Self {
569            next_seq: AtomicU64::new(0),
570            phase: AtomicU8::new(TapePhase::UserScript.as_u8()),
571            started_at: clock_mock::instant_now(),
572            inner: Mutex::new(RecorderInner::default()),
573        }
574    }
575
576    /// Append a record built from `kind`. The recorder stamps the seq
577    /// number and timing metadata; callers only worry about the payload.
578    pub fn record(&self, kind: TapeRecordKind) {
579        let virtual_time_ms = clock_mock::now_ms();
580        let monotonic_ms = clock_mock::instant_now()
581            .duration_since(self.started_at)
582            .as_millis()
583            .min(i64::MAX as u128) as i64;
584        self.record_at(kind, virtual_time_ms, monotonic_ms);
585    }
586
587    fn record_at(&self, kind: TapeRecordKind, virtual_time_ms: i64, monotonic_ms: i64) {
588        let seq = self.next_seq.fetch_add(1, Ordering::SeqCst);
589        let record = TapeRecord {
590            seq,
591            phase: TapePhase::from_u8(self.phase.load(Ordering::SeqCst)),
592            virtual_time_ms,
593            monotonic_ms,
594            kind,
595        };
596        self.inner
597            .lock()
598            .expect("tape recorder mutex poisoned")
599            .records
600            .push(record);
601    }
602
603    fn swap_phase(&self, phase: TapePhase) -> TapePhase {
604        TapePhase::from_u8(self.phase.swap(phase.as_u8(), Ordering::SeqCst))
605    }
606
607    /// Convenience wrapper: build a [`TapePayload`] from `bytes` (spilling
608    /// to CAS as needed) and register the bytes for persistence. Used by
609    /// axes that have raw bodies on hand (subprocess stdout, LLM
610    /// response JSON, file content).
611    pub fn payload_from_bytes(&self, bytes: Vec<u8>) -> TapePayload {
612        let mut inner = self.inner.lock().expect("tape recorder mutex poisoned");
613        build_payload(bytes, &mut inner.cas)
614    }
615
616    /// Snapshot the tape into a self-contained [`EventTape`]. Consumes
617    /// the recorder's CAS by `clone()` so a recorder can be sampled
618    /// mid-run for diagnostics — production callers usually move into
619    /// `into_tape` instead.
620    pub fn snapshot(&self, header: TapeHeader) -> EventTape {
621        let inner = self.inner.lock().expect("tape recorder mutex poisoned");
622        EventTape {
623            header,
624            records: inner.records.clone(),
625            cas: inner.cas.clone(),
626        }
627    }
628}
629
630thread_local! {
631    static ACTIVE_RECORDER: RefCell<Option<Arc<TapeRecorder>>> = const { RefCell::new(None) };
632}
633
634/// RAII guard returned by [`install_recorder`]. Restores the previous
635/// recorder (if any) on drop so nested testbench sessions stay sane.
636pub struct TapeRecorderGuard {
637    previous: Option<Arc<TapeRecorder>>,
638}
639
640impl Drop for TapeRecorderGuard {
641    fn drop(&mut self) {
642        let prev = self.previous.take();
643        ACTIVE_RECORDER.with(|slot| {
644            *slot.borrow_mut() = prev;
645        });
646    }
647}
648
649pub fn install_recorder(recorder: Arc<TapeRecorder>) -> TapeRecorderGuard {
650    let previous = ACTIVE_RECORDER.with(|slot| slot.replace(Some(recorder)));
651    TapeRecorderGuard { previous }
652}
653
654/// Currently installed recorder, if any. Production callers stay
655/// untouched because nothing installs a recorder outside testbench mode.
656pub fn active_recorder() -> Option<Arc<TapeRecorder>> {
657    ACTIVE_RECORDER.with(|slot| slot.borrow().clone())
658}
659
660/// Record a model-job lifecycle event in the active unified tape.
661///
662/// Called from the host event bridge so fake, replay, and live backends share
663/// one cassette shape. Missing recorders are a no-op.
664pub fn record_model_job_event(payload: &serde_json::Value) {
665    let Some(recorder) = active_recorder() else {
666        return;
667    };
668    let policy = crate::redact::current_policy();
669    let redacted = policy.redact_json(payload);
670    let event_bytes = serde_json::to_vec(&redacted).unwrap_or_default();
671    let event = recorder.payload_from_bytes(event_bytes);
672    let job_id = redacted
673        .get("job_id")
674        .and_then(|value| value.as_str())
675        .unwrap_or("")
676        .to_string();
677    let request_id = redacted
678        .get("request_id")
679        .and_then(|value| value.as_str())
680        .unwrap_or("")
681        .to_string();
682    let backend = redacted
683        .get("backend")
684        .and_then(|value| value.as_str())
685        .unwrap_or("")
686        .to_string();
687    let state = redacted
688        .get("state")
689        .and_then(|value| value.as_str())
690        .unwrap_or("")
691        .to_string();
692    let event_kind = redacted
693        .get("kind")
694        .and_then(|value| value.as_str())
695        .unwrap_or("")
696        .to_string();
697    let mut asset_digests = Vec::new();
698    if let Some(assets) = redacted.get("assets").and_then(|value| value.as_array()) {
699        for asset in assets {
700            if let Some(digest) = asset
701                .get("sha256")
702                .and_then(|value| value.as_str())
703                .filter(|value| !value.is_empty())
704            {
705                asset_digests.push(digest.to_string());
706            } else if let Some(uri) = asset.get("uri").and_then(|value| value.as_str()) {
707                if let Some(digest) = uri.strip_prefix("asset://sha256/") {
708                    if !digest.is_empty() {
709                        asset_digests.push(digest.to_string());
710                    }
711                }
712            }
713        }
714    }
715    if let Some(digest) = redacted
716        .get("asset_digest")
717        .and_then(|value| value.as_str())
718        .filter(|value| !value.is_empty())
719    {
720        asset_digests.push(digest.to_string());
721    }
722    asset_digests.sort();
723    asset_digests.dedup();
724    recorder.record(TapeRecordKind::ModelJob {
725        job_id,
726        request_id,
727        backend,
728        state,
729        event_kind,
730        event,
731        asset_digests,
732    });
733}
734
735/// Record an MCP JSON-RPC exchange in the active unified tape, if one
736/// is installed. Payloads are redacted here so every caller gets the
737/// same privacy behavior as MCP cassettes.
738pub fn record_mcp_json_rpc(
739    server: &str,
740    method: &str,
741    request: &serde_json::Value,
742    response: &serde_json::Value,
743    latency_ms: u64,
744) {
745    let Some(recorder) = active_recorder() else {
746        return;
747    };
748    let policy = crate::redact::current_policy();
749    let request = policy.redact_json(request);
750    let response = policy.redact_json(response);
751    let request_bytes = serde_json::to_vec(&request).unwrap_or_default();
752    let response_bytes = serde_json::to_vec(&response).unwrap_or_default();
753    let request_digest = content_hash(&request_bytes);
754    let response_digest = content_hash(&response_bytes);
755    let request_payload = recorder.payload_from_bytes(request_bytes);
756    let response_payload = recorder.payload_from_bytes(response_bytes);
757    recorder.record(TapeRecordKind::McpJsonRpc {
758        server: server.to_string(),
759        method: method.to_string(),
760        request_digest,
761        response_digest,
762        latency_ms,
763        request_payload,
764        response_payload,
765    });
766}
767
768/// RAII guard that temporarily changes the phase stamped onto records
769/// from the active recorder.
770pub struct TapePhaseGuard {
771    recorder: Arc<TapeRecorder>,
772    previous: TapePhase,
773}
774
775impl Drop for TapePhaseGuard {
776    fn drop(&mut self) {
777        self.recorder.swap_phase(self.previous);
778    }
779}
780
781/// Enter `phase` for subsequent records emitted by the active recorder.
782/// Returns `None` when tape recording is off.
783pub fn enter_phase(phase: TapePhase) -> Option<TapePhaseGuard> {
784    let recorder = active_recorder()?;
785    let previous = recorder.swap_phase(phase);
786    Some(TapePhaseGuard { recorder, previous })
787}
788
789/// Push a record if a recorder is active. The closure is only evaluated
790/// when recording is on, so the per-axis hooks pay nothing in production.
791pub fn with_active_recorder<F>(build: F)
792where
793    F: FnOnce(&Arc<TapeRecorder>) -> Option<TapeRecordKind>,
794{
795    let Some(recorder) = active_recorder() else {
796        return;
797    };
798    if let Some(kind) = build(&recorder) {
799        recorder.record(kind);
800    }
801}
802
803/// Push a record stamped by an exact capability-owned clock.
804///
805/// The unified testbench clock remains the default for ambient runtime axes,
806/// while nominal Harness clocks use this path so an attenuated test clock does
807/// not leak into unrelated VMs or lose fidelity metadata.
808pub fn with_active_recorder_clock<F>(clock: &dyn harn_clock::Clock, build: F)
809where
810    F: FnOnce(&Arc<TapeRecorder>) -> Option<TapeRecordKind>,
811{
812    let Some(recorder) = active_recorder() else {
813        return;
814    };
815    if let Some(kind) = build(&recorder) {
816        recorder.record_at(kind, harn_clock::now_wall_ms(clock), clock.monotonic_ms());
817    }
818}
819
820#[cfg(test)]
821mod tests {
822    use super::*;
823    use tempfile::TempDir;
824
825    fn small_record(seq: u64, dur: u64) -> TapeRecord {
826        TapeRecord {
827            seq,
828            phase: TapePhase::UserScript,
829            virtual_time_ms: seq as i64 * 1000,
830            monotonic_ms: seq as i64 * 1000,
831            kind: TapeRecordKind::ClockSleep { duration_ms: dur },
832        }
833    }
834
835    #[test]
836    fn round_trip_inline_records() {
837        let temp = TempDir::new().unwrap();
838        let path = temp.path().join("run.tape");
839        let mut tape = EventTape::new(TapeHeader::current(
840            Some(1_700_000_000_000),
841            Some("script.harn".to_string()),
842            vec!["a".into()],
843        ));
844        tape.records.push(small_record(0, 250));
845        tape.records.push(small_record(1, 750));
846        tape.persist(&path).unwrap();
847
848        let loaded = EventTape::load(&path).unwrap();
849        assert_eq!(loaded.header.version, TAPE_FORMAT_VERSION);
850        assert_eq!(loaded.header.argv, vec!["a".to_string()]);
851        assert_eq!(loaded.records.len(), 2);
852        match &loaded.records[0].kind {
853            TapeRecordKind::ClockSleep { duration_ms } => assert_eq!(*duration_ms, 250),
854            other => panic!("unexpected: {other:?}"),
855        }
856    }
857
858    #[test]
859    fn recorder_phase_guard_stamps_and_restores() {
860        let recorder = Arc::new(TapeRecorder::new());
861        let _recorder_guard = install_recorder(Arc::clone(&recorder));
862
863        with_active_recorder(|_| Some(TapeRecordKind::ClockSleep { duration_ms: 1 }));
864        {
865            let _phase_guard = enter_phase(TapePhase::RuntimeFinalize).unwrap();
866            with_active_recorder(|_| Some(TapeRecordKind::ClockSleep { duration_ms: 2 }));
867        }
868        with_active_recorder(|_| Some(TapeRecordKind::ClockSleep { duration_ms: 3 }));
869
870        let tape = recorder.snapshot(TapeHeader::current(None, None, Vec::new()));
871        let phases = tape
872            .records
873            .iter()
874            .map(|record| record.phase)
875            .collect::<Vec<_>>();
876        assert_eq!(
877            phases,
878            vec![
879                TapePhase::UserScript,
880                TapePhase::RuntimeFinalize,
881                TapePhase::UserScript
882            ]
883        );
884    }
885
886    #[test]
887    fn large_payloads_spill_to_cas_and_round_trip() {
888        let temp = TempDir::new().unwrap();
889        let path = temp.path().join("run.tape");
890        let mut tape = EventTape::new(TapeHeader::current(None, None, Vec::new()));
891        let big = vec![b'x'; MAX_INLINE_BYTES + 32];
892        let payload = build_payload(big.clone(), &mut tape.cas);
893        let hash = payload.content_hash().to_string();
894        let kind = TapeRecordKind::ProcessSpawn {
895            program: "/bin/echo".to_string(),
896            args: vec!["x".to_string()],
897            cwd: None,
898            exit_code: 0,
899            duration_ms: 1,
900            stdout_payload: payload,
901            stderr_payload: build_payload(Vec::new(), &mut tape.cas),
902        };
903        tape.records.push(TapeRecord {
904            seq: 0,
905            phase: TapePhase::UserScript,
906            virtual_time_ms: 0,
907            monotonic_ms: 0,
908            kind,
909        });
910        tape.persist(&path).unwrap();
911
912        // CAS sidecar exists.
913        assert!(path.with_extension("tape.cas").exists() || cas_dir_for(&path).exists());
914        let cas_dir = cas_dir_for(&path);
915        assert!(cas_dir.join(&hash).exists());
916
917        let loaded = EventTape::load(&path).unwrap();
918        let resolved = match &loaded.records[0].kind {
919            TapeRecordKind::ProcessSpawn { stdout_payload, .. } => {
920                loaded.resolve_payload(stdout_payload).unwrap()
921            }
922            other => panic!("unexpected: {other:?}"),
923        };
924        assert_eq!(resolved.len(), big.len());
925    }
926
927    #[test]
928    fn rejects_newer_version() {
929        let temp = TempDir::new().unwrap();
930        let path = temp.path().join("future.tape");
931        std::fs::write(
932            &path,
933            r#"{"type":"header","version":99,"harn_version":"x","started_at_unix_ms":null,"script_path":null,"argv":[]}
934"#,
935        )
936        .unwrap();
937        let err = EventTape::load(&path).unwrap_err();
938        assert!(err.contains("version 99"), "{err}");
939    }
940
941    #[test]
942    fn recorder_assigns_monotonic_seq() {
943        let recorder = Arc::new(TapeRecorder::new());
944        recorder.record(TapeRecordKind::ClockSleep { duration_ms: 1 });
945        recorder.record(TapeRecordKind::ClockSleep { duration_ms: 2 });
946        let snapshot = recorder.snapshot(TapeHeader::current(None, None, Vec::new()));
947        assert_eq!(snapshot.records[0].seq, 0);
948        assert_eq!(snapshot.records[1].seq, 1);
949    }
950
951    #[test]
952    fn records_model_job_events_with_asset_digests() {
953        let recorder = Arc::new(TapeRecorder::new());
954        let _guard = install_recorder(Arc::clone(&recorder));
955        record_model_job_event(&serde_json::json!({
956            "schema": "harn.model_job_event.v1",
957            "kind": "output",
958            "job_id": "job-1",
959            "request_id": "req-1",
960            "backend": "fixture",
961            "state": "succeeded",
962            "assets": [
963                {
964                    "uri": "asset://sha256/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
965                    "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
966                }
967            ]
968        }));
969        let snapshot = recorder.snapshot(TapeHeader::current(None, None, Vec::new()));
970        assert_eq!(snapshot.records.len(), 1);
971        match &snapshot.records[0].kind {
972            TapeRecordKind::ModelJob {
973                job_id,
974                event_kind,
975                asset_digests,
976                ..
977            } => {
978                assert_eq!(job_id, "job-1");
979                assert_eq!(event_kind, "output");
980                assert_eq!(
981                    asset_digests,
982                    &vec![
983                        "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
984                            .to_string()
985                    ]
986                );
987            }
988            other => panic!("unexpected tape kind: {other:?}"),
989        }
990    }
991}