Skip to main content

harn_vm/orchestration/
run_view_fixtures.rs

1//! Production-owned run/session view compatibility fixture engine.
2
3use std::ffi::OsStr;
4use std::fs;
5use std::io;
6use std::path::{Path, PathBuf};
7
8use serde::Serialize;
9use serde_json::Value as JsonValue;
10use thiserror::Error;
11
12use super::{
13    build_run_view_with_options, build_session_view_from_run_views, RunRecord, RunView,
14    RunViewOptions, SessionView, SessionViewOptions, ViewProducer, RUN_VIEW_SCHEMA,
15    SESSION_VIEW_SCHEMA,
16};
17
18const FIXTURE_ROOT: &str = "spec/run-view-fixtures";
19const FIXTURE_PRODUCER_VERSION: &str = "fixture";
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum RunViewFixtureMode {
23    Check,
24    Write,
25}
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub struct RunViewFixtureSummary {
29    pub case_count: usize,
30    pub run_view_count: usize,
31    pub snapshot_count: usize,
32}
33
34#[derive(Debug, Error)]
35pub enum RunViewFixtureError {
36    #[error("failed to {operation} {path}: {source}")]
37    Io {
38        operation: &'static str,
39        path: PathBuf,
40        #[source]
41        source: io::Error,
42    },
43    #[error("failed to decode {path} as a run record: {source}")]
44    Decode {
45        path: PathBuf,
46        #[source]
47        source: serde_json::Error,
48    },
49    #[error("invalid run-view fixture `{case}`: {detail}")]
50    Invalid { case: String, detail: String },
51    #[error("{path} is stale; run `make gen-run-view-fixtures` to refresh")]
52    Stale { path: PathBuf },
53}
54
55/// Check or rewrite every public run/session-view compatibility snapshot.
56///
57/// `repository_root` is explicit so the production CLI, Rust regressions, and
58/// release audit all execute one engine without relying on the builder's
59/// compile-time path or the caller's current directory.
60pub fn sync_run_view_fixtures(
61    repository_root: &Path,
62    mode: RunViewFixtureMode,
63) -> Result<RunViewFixtureSummary, RunViewFixtureError> {
64    let fixture_root = repository_root.join(FIXTURE_ROOT);
65    let cases_root = fixture_root.join("cases");
66    let cases = read_sorted_dirs(&cases_root)?;
67    if cases.is_empty() {
68        return Err(invalid(
69            FIXTURE_ROOT,
70            format!("expected at least one case under {}", cases_root.display()),
71        ));
72    }
73
74    let mut run_view_count = 0;
75    let mut snapshot_count = 0;
76    let mut snapshots = Vec::new();
77    for case_dir in &cases {
78        let case_name = utf8_file_name(case_dir)?;
79        let records_root = case_dir.join("records");
80        let record_paths = read_sorted_json_files(&records_root)?;
81        if record_paths.is_empty() {
82            return Err(invalid(
83                &case_name,
84                format!(
85                    "expected at least one JSON record under {}",
86                    records_root.display()
87                ),
88            ));
89        }
90
91        let mut run_views = Vec::with_capacity(record_paths.len());
92        for record_path in record_paths {
93            let record_name = utf8_file_stem(&record_path)?;
94            let raw = read_text(&record_path)?;
95            let run: RunRecord =
96                serde_json::from_str(&raw).map_err(|source| RunViewFixtureError::Decode {
97                    path: record_path.clone(),
98                    source,
99                })?;
100            let run_path = repo_relative_path(repository_root, &record_path, &case_name)?;
101            let run_view = build_run_view_with_options(
102                &run,
103                RunViewOptions {
104                    producer: fixture_producer(),
105                    run_path: Some(run_path),
106                    ..RunViewOptions::default()
107                },
108            );
109            let snapshot_path = case_dir
110                .join("expected")
111                .join("runs")
112                .join(format!("{record_name}.run_view.json"));
113            snapshots.push((
114                snapshot_path,
115                render_snapshot(&run_view, RUN_VIEW_SCHEMA, &case_name)?,
116            ));
117            run_views.push(run_view);
118            run_view_count += 1;
119            snapshot_count += 1;
120        }
121
122        let session_view = build_session_view_from_run_views(
123            run_views.clone(),
124            SessionViewOptions {
125                producer: fixture_producer(),
126                ..SessionViewOptions::default()
127            },
128        );
129        snapshots.push((
130            case_dir.join("expected").join("session_view.json"),
131            render_snapshot(&session_view, SESSION_VIEW_SCHEMA, &case_name)?,
132        ));
133        assert_case_coverage(&case_name, &run_views, &session_view)?;
134        snapshot_count += 1;
135    }
136
137    for (path, rendered) in snapshots {
138        match mode {
139            RunViewFixtureMode::Check => check_snapshot(&path, &rendered)?,
140            RunViewFixtureMode::Write => write_snapshot(&path, rendered)?,
141        }
142    }
143
144    Ok(RunViewFixtureSummary {
145        case_count: cases.len(),
146        run_view_count,
147        snapshot_count,
148    })
149}
150
151fn fixture_producer() -> ViewProducer {
152    ViewProducer {
153        name: "harn".to_string(),
154        version: FIXTURE_PRODUCER_VERSION.to_string(),
155    }
156}
157
158fn read_sorted_dirs(path: &Path) -> Result<Vec<PathBuf>, RunViewFixtureError> {
159    let entries = fs::read_dir(path).map_err(|source| RunViewFixtureError::Io {
160        operation: "read directory",
161        path: path.to_path_buf(),
162        source,
163    })?;
164    let mut dirs = Vec::new();
165    for entry in entries {
166        let entry = entry.map_err(|source| RunViewFixtureError::Io {
167            operation: "read directory entry in",
168            path: path.to_path_buf(),
169            source,
170        })?;
171        if entry.path().is_dir() {
172            dirs.push(entry.path());
173        }
174    }
175    dirs.sort();
176    Ok(dirs)
177}
178
179fn read_sorted_json_files(path: &Path) -> Result<Vec<PathBuf>, RunViewFixtureError> {
180    let entries = fs::read_dir(path).map_err(|source| RunViewFixtureError::Io {
181        operation: "read directory",
182        path: path.to_path_buf(),
183        source,
184    })?;
185    let mut files = Vec::new();
186    for entry in entries {
187        let entry = entry.map_err(|source| RunViewFixtureError::Io {
188            operation: "read directory entry in",
189            path: path.to_path_buf(),
190            source,
191        })?;
192        if entry.path().is_file() && entry.path().extension() == Some(OsStr::new("json")) {
193            files.push(entry.path());
194        }
195    }
196    files.sort();
197    Ok(files)
198}
199
200fn read_text(path: &Path) -> Result<String, RunViewFixtureError> {
201    fs::read_to_string(path).map_err(|source| RunViewFixtureError::Io {
202        operation: "read",
203        path: path.to_path_buf(),
204        source,
205    })
206}
207
208fn utf8_file_name(path: &Path) -> Result<String, RunViewFixtureError> {
209    path.file_name()
210        .and_then(OsStr::to_str)
211        .map(str::to_owned)
212        .ok_or_else(|| invalid(path.display().to_string(), "path has no UTF-8 file name"))
213}
214
215fn utf8_file_stem(path: &Path) -> Result<String, RunViewFixtureError> {
216    path.file_stem()
217        .and_then(OsStr::to_str)
218        .map(str::to_owned)
219        .ok_or_else(|| invalid(path.display().to_string(), "path has no UTF-8 file stem"))
220}
221
222fn repo_relative_path(
223    repository_root: &Path,
224    path: &Path,
225    case_name: &str,
226) -> Result<String, RunViewFixtureError> {
227    let relative = path.strip_prefix(repository_root).map_err(|error| {
228        invalid(
229            case_name,
230            format!(
231                "{} is not under repository root {}: {error}",
232                path.display(),
233                repository_root.display()
234            ),
235        )
236    })?;
237    Ok(relative
238        .components()
239        .map(|component| component.as_os_str().to_string_lossy())
240        .collect::<Vec<_>>()
241        .join("/"))
242}
243
244fn check_snapshot(path: &Path, rendered: &str) -> Result<(), RunViewFixtureError> {
245    let expected = read_text(path)?;
246    if expected.replace("\r\n", "\n") != rendered {
247        return Err(RunViewFixtureError::Stale {
248            path: path.to_path_buf(),
249        });
250    }
251    Ok(())
252}
253
254fn write_snapshot(path: &Path, rendered: String) -> Result<(), RunViewFixtureError> {
255    let parent = path.parent().ok_or_else(|| {
256        invalid(
257            path.display().to_string(),
258            format!("{} has no parent", path.display()),
259        )
260    })?;
261    fs::create_dir_all(parent).map_err(|source| RunViewFixtureError::Io {
262        operation: "create directory",
263        path: parent.to_path_buf(),
264        source,
265    })?;
266    fs::write(path, rendered).map_err(|source| RunViewFixtureError::Io {
267        operation: "write",
268        path: path.to_path_buf(),
269        source,
270    })
271}
272
273fn render_snapshot<T: Serialize>(
274    value: &T,
275    schema: &str,
276    case_name: &str,
277) -> Result<String, RunViewFixtureError> {
278    let value = serde_json::to_value(value).map_err(|error| {
279        invalid(
280            case_name,
281            format!("failed to serialize projection: {error}"),
282        )
283    })?;
284    assert_projection_metadata(&value, schema, case_name)?;
285    serde_json::to_string_pretty(&value)
286        .map(|json| format!("{json}\n"))
287        .map_err(|error| invalid(case_name, format!("failed to render projection: {error}")))
288}
289
290fn assert_projection_metadata(
291    value: &JsonValue,
292    schema: &str,
293    case_name: &str,
294) -> Result<(), RunViewFixtureError> {
295    ensure_case(case_name, value["schema"] == schema, "schema drifted")?;
296    ensure_case(
297        case_name,
298        value["schema_version"] == 1,
299        "schema_version drifted",
300    )?;
301    ensure_case(
302        case_name,
303        value["producer"]["name"] == "harn",
304        "producer.name drifted",
305    )?;
306    ensure_case(
307        case_name,
308        value["producer"]["version"] == FIXTURE_PRODUCER_VERSION,
309        "producer.version drifted",
310    )
311}
312
313fn assert_case_coverage(
314    case_name: &str,
315    runs: &[RunView],
316    session: &SessionView,
317) -> Result<(), RunViewFixtureError> {
318    match case_name {
319        "legacy-sparse" => {
320            let run = only_run(case_name, runs)?;
321            ensure_case(case_name, run.run.run_id == "run_legacy_sparse", "run id")?;
322            ensure_case(case_name, run.run.session_id.is_none(), "session id")?;
323            ensure_case(case_name, run.run.workflow_id.is_empty(), "workflow id")?;
324            ensure_case(
325                case_name,
326                run.failure.as_ref().map(|failure| failure.status.as_str()) == Some("failed"),
327                "failure status",
328            )
329        }
330        "root-transcript" => {
331            let run = only_run(case_name, runs)?;
332            ensure_case(case_name, run.transcript.present, "transcript presence")?;
333            ensure_case(
334                case_name,
335                run.transcript.source.as_deref() == Some("run_root"),
336                "transcript source",
337            )?;
338            ensure_case(
339                case_name,
340                run.transcript.message_count == 2,
341                "message count",
342            )?;
343            ensure_case(case_name, run.providers.len() == 1, "provider count")
344        }
345        "session-lineage-failure-approval" => {
346            ensure_case(
347                case_name,
348                session.session.session_id.as_deref() == Some("session_lineage"),
349                "session id",
350            )?;
351            ensure_case(
352                case_name,
353                session.session.status == "failed",
354                "session status",
355            )?;
356            ensure_case(case_name, session.session.run_count == 2, "run count")?;
357            ensure_case(
358                case_name,
359                session.pending.approvals.len() == 1,
360                "pending approval count",
361            )?;
362            ensure_case(
363                case_name,
364                runs[0].run.child_runs.len() == 1,
365                "child run count",
366            )?;
367            ensure_case(
368                case_name,
369                runs.iter().any(|run| run.failure.is_some()),
370                "failure coverage",
371            )
372        }
373        "stage-transcript-active-auth" => {
374            let run = only_run(case_name, runs)?;
375            ensure_case(case_name, run.run.status == "running", "run status")?;
376            ensure_case(
377                case_name,
378                run.transcript.source.as_deref() == Some("stages"),
379                "transcript source",
380            )?;
381            ensure_case(
382                case_name,
383                run.transcript.message_count == 2,
384                "message count",
385            )?;
386            ensure_case(case_name, run.pending.auth.len() == 2, "pending auth count")?;
387            ensure_case(
388                case_name,
389                session.session.status == "active",
390                "session status",
391            )
392        }
393        other => Err(invalid(other, "unrecognized fixture case")),
394    }
395}
396
397fn only_run<'a>(case_name: &str, runs: &'a [RunView]) -> Result<&'a RunView, RunViewFixtureError> {
398    ensure_case(case_name, runs.len() == 1, "expected exactly one run")?;
399    Ok(&runs[0])
400}
401
402fn ensure_case(
403    case_name: &str,
404    condition: bool,
405    detail: impl Into<String>,
406) -> Result<(), RunViewFixtureError> {
407    if condition {
408        Ok(())
409    } else {
410        Err(invalid(case_name, detail))
411    }
412}
413
414fn invalid(case: impl Into<String>, detail: impl Into<String>) -> RunViewFixtureError {
415    RunViewFixtureError::Invalid {
416        case: case.into(),
417        detail: detail.into(),
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    #[test]
426    fn stale_snapshot_fails_closed() {
427        let temp = tempfile::tempdir().expect("temporary fixture root");
428        let path = temp.path().join("stale.json");
429        fs::write(&path, "{}\n").expect("seed stale snapshot");
430        let error = check_snapshot(&path, "{\"current\":true}\n")
431            .expect_err("stale fixture must fail closed");
432        assert!(matches!(error, RunViewFixtureError::Stale { path: stale } if stale == path));
433    }
434}