Skip to main content

runx_runtime/execution/harness/
fixtures.rs

1// rust-style-allow: large-file because harness fixture parsing, typed diagnostics,
2// and expectation normalization stay together for the cutover review surface.
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use runx_contracts::{ClosureDisposition, JsonObject, ReceiptSchema};
8use serde::Deserialize;
9use thiserror::Error;
10
11const RETIRED_RECEIPT_FIELDS: &[&str] =
12    &["kind", "skill_name", "source_type", "graph_name", "owner"];
13
14#[derive(Clone, Debug, PartialEq, Eq)]
15pub struct HarnessFixtureCase {
16    pub name: &'static str,
17    pub fixture_path: &'static str,
18    pub root_oracle_path: &'static str,
19    pub step_oracles: &'static [HarnessFixtureStepOracle],
20}
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct HarnessFixtureStepOracle {
24    pub step_id: &'static str,
25    pub oracle_path: &'static str,
26}
27
28const HARNESS_FIXTURE_CASES: &[HarnessFixtureCase] = &[
29    HarnessFixtureCase {
30        name: "echo-skill",
31        fixture_path: "fixtures/harness/echo-skill.yaml",
32        root_oracle_path: "fixtures/harness/oracle/echo-skill.receipt.json",
33        step_oracles: &[],
34    },
35    HarnessFixtureCase {
36        name: "sequential-graph",
37        fixture_path: "fixtures/harness/sequential-graph.yaml",
38        root_oracle_path: "fixtures/harness/oracle/sequential-graph.receipt.json",
39        step_oracles: &[
40            HarnessFixtureStepOracle {
41                step_id: "first",
42                oracle_path: "fixtures/harness/oracle/sequential-graph.first.json",
43            },
44            HarnessFixtureStepOracle {
45                step_id: "second",
46                oracle_path: "fixtures/harness/oracle/sequential-graph.second.json",
47            },
48        ],
49    },
50];
51
52#[must_use]
53pub fn list_cases() -> &'static [HarnessFixtureCase] {
54    HARNESS_FIXTURE_CASES
55}
56
57#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum HarnessFixtureKind {
60    Skill,
61    Graph,
62    Mcp,
63    A2a,
64    Agent,
65    #[serde(rename = "agent_task")]
66    AgentStep,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
70#[serde(rename_all = "snake_case")]
71pub enum HarnessExpectedStatus {
72    Sealed,
73    Failure,
74    NeedsAgent,
75    PolicyDenied,
76    Escalated,
77}
78
79#[derive(Clone, Debug, PartialEq)]
80pub struct HarnessFixture {
81    pub name: String,
82    pub kind: HarnessFixtureKind,
83    pub target: String,
84    pub runner: Option<String>,
85    pub inputs: JsonObject,
86    pub env: BTreeMap<String, String>,
87    pub caller: JsonObject,
88    pub expect: HarnessExpectation,
89    pub metadata: JsonObject,
90}
91
92#[derive(Clone, Debug, Default, PartialEq)]
93pub struct HarnessExpectation {
94    pub status: Option<HarnessExpectedStatus>,
95    pub receipt: Option<ReceiptExpectation>,
96    pub steps: Vec<String>,
97}
98
99#[derive(Clone, Debug, PartialEq)]
100pub struct ReceiptExpectation {
101    pub schema: ReceiptSchema,
102    pub body_digest: Option<String>,
103    pub receipt_id: Option<String>,
104    pub receipt_digest: Option<String>,
105    pub harness_id: Option<String>,
106    pub state: Option<String>,
107    pub disposition: Option<ClosureDisposition>,
108    pub reason_code: Option<String>,
109    pub act_ids: Vec<String>,
110    pub decision_ids: Vec<String>,
111    pub child_receipt_refs: Vec<String>,
112    pub verification_refs: Vec<String>,
113}
114
115#[derive(Debug, Deserialize)]
116#[serde(deny_unknown_fields)]
117struct RawHarnessFixture {
118    name: String,
119    kind: HarnessFixtureKind,
120    #[serde(default)]
121    target: Option<String>,
122    runner: Option<String>,
123    #[serde(default)]
124    inputs: JsonObject,
125    #[serde(default)]
126    env: BTreeMap<String, String>,
127    #[serde(default)]
128    caller: JsonObject,
129    #[serde(default)]
130    expect: RawHarnessExpectation,
131    #[serde(default)]
132    metadata: JsonObject,
133}
134
135#[derive(Debug, Default, Deserialize)]
136#[serde(deny_unknown_fields)]
137struct RawHarnessExpectation {
138    status: Option<HarnessExpectedStatus>,
139    receipt: Option<RawReceiptExpectation>,
140    #[serde(default)]
141    steps: Vec<String>,
142}
143
144#[derive(Debug, Deserialize)]
145struct RawReceiptExpectation {
146    #[serde(default = "default_receipt_schema")]
147    schema: ReceiptSchema,
148    body_digest: Option<String>,
149    receipt_id: Option<String>,
150    receipt_digest: Option<String>,
151    harness_id: Option<String>,
152    state: Option<String>,
153    disposition: Option<ClosureDisposition>,
154    reason_code: Option<String>,
155    #[serde(default)]
156    act_ids: Vec<String>,
157    #[serde(default)]
158    decision_ids: Vec<String>,
159    #[serde(default)]
160    child_receipt_refs: Vec<String>,
161    #[serde(default)]
162    verification_refs: Vec<String>,
163    #[serde(flatten)]
164    extra: BTreeMap<String, serde::de::IgnoredAny>,
165}
166
167#[derive(Debug, Error)]
168pub enum HarnessFixtureError {
169    #[error("failed to read harness fixture {path}: {source}")]
170    Read {
171        path: PathBuf,
172        source: std::io::Error,
173    },
174    #[error("failed to parse harness fixture YAML: {0}")]
175    Parse(#[from] serde_norway::Error),
176    #[error("harness fixture {field} is required")]
177    Required { field: String },
178    #[error("harness fixture {field} must not be empty")]
179    Empty { field: &'static str },
180    #[error("retired receipt expectation field {field_path}")]
181    RetiredReceiptField { field_path: String },
182    #[error("unknown receipt expectation field {field_path}")]
183    UnknownReceiptField { field_path: String },
184    #[error("harness fixture mode {mode} at {field_path} is not yet supported by the Rust harness")]
185    UnsupportedFixtureMode { mode: String, field_path: String },
186}
187
188pub fn load_harness_fixture(path: impl AsRef<Path>) -> Result<HarnessFixture, HarnessFixtureError> {
189    let path = path.as_ref();
190    let contents = fs::read_to_string(path).map_err(|source| HarnessFixtureError::Read {
191        path: path.to_path_buf(),
192        source,
193    })?;
194    parse_harness_fixture(&contents)
195}
196
197pub fn parse_harness_fixture(contents: &str) -> Result<HarnessFixture, HarnessFixtureError> {
198    let fixture = serde_norway::from_str::<RawHarnessFixture>(contents)?;
199    validate_fixture(fixture)
200}
201
202fn validate_fixture(fixture: RawHarnessFixture) -> Result<HarnessFixture, HarnessFixtureError> {
203    require_non_empty(&fixture.name, "name")?;
204    validate_supported_fixture_kind(&fixture.kind, "kind")?;
205    let target = fixture.target.unwrap_or_default();
206    if !matches!(fixture.kind, HarnessFixtureKind::AgentStep) {
207        require_non_empty(&target, "target")?;
208    }
209    if let Some(runner) = &fixture.runner {
210        require_non_empty(runner, "runner")?;
211    }
212    Ok(HarnessFixture {
213        name: fixture.name,
214        kind: fixture.kind,
215        target,
216        runner: fixture.runner,
217        inputs: fixture.inputs,
218        env: fixture.env,
219        caller: fixture.caller,
220        expect: validate_expectation(fixture.expect)?,
221        metadata: fixture.metadata,
222    })
223}
224
225fn validate_expectation(
226    expectation: RawHarnessExpectation,
227) -> Result<HarnessExpectation, HarnessFixtureError> {
228    Ok(HarnessExpectation {
229        status: expectation.status,
230        receipt: expectation
231            .receipt
232            .map(validate_receipt_expectation)
233            .transpose()?,
234        steps: expectation.steps,
235    })
236}
237
238fn validate_receipt_expectation(
239    receipt: RawReceiptExpectation,
240) -> Result<ReceiptExpectation, HarnessFixtureError> {
241    if let Some(field) = receipt.extra.keys().next() {
242        let field_path = format!("expect.receipt.{field}");
243        if is_retired_receipt_field(field) {
244            return Err(HarnessFixtureError::RetiredReceiptField { field_path });
245        }
246        return Err(HarnessFixtureError::UnknownReceiptField { field_path });
247    }
248    Ok(ReceiptExpectation {
249        schema: receipt.schema,
250        body_digest: receipt.body_digest,
251        receipt_id: receipt.receipt_id,
252        receipt_digest: receipt.receipt_digest,
253        harness_id: receipt.harness_id,
254        state: receipt.state,
255        disposition: receipt.disposition,
256        reason_code: receipt.reason_code,
257        act_ids: receipt.act_ids,
258        decision_ids: receipt.decision_ids,
259        child_receipt_refs: receipt.child_receipt_refs,
260        verification_refs: receipt.verification_refs,
261    })
262}
263
264fn is_retired_receipt_field(field: &str) -> bool {
265    RETIRED_RECEIPT_FIELDS.contains(&field)
266        || field == retired_execution_receipt_field("skill")
267        || field == retired_execution_receipt_field("graph")
268}
269
270fn retired_execution_receipt_field(prefix: &str) -> String {
271    format!("{prefix}_{}", "execution")
272}
273
274fn validate_supported_fixture_kind(
275    kind: &HarnessFixtureKind,
276    field_path: &'static str,
277) -> Result<(), HarnessFixtureError> {
278    match kind {
279        HarnessFixtureKind::Skill
280        | HarnessFixtureKind::Graph
281        | HarnessFixtureKind::A2a
282        | HarnessFixtureKind::Agent
283        | HarnessFixtureKind::AgentStep => Ok(()),
284        HarnessFixtureKind::Mcp => Err(HarnessFixtureError::UnsupportedFixtureMode {
285            mode: fixture_kind_name(kind).to_owned(),
286            field_path: field_path.to_owned(),
287        }),
288    }
289}
290
291pub(crate) fn fixture_kind_name(kind: &HarnessFixtureKind) -> &'static str {
292    match kind {
293        HarnessFixtureKind::Skill => "skill",
294        HarnessFixtureKind::Graph => "graph",
295        HarnessFixtureKind::Mcp => "mcp",
296        HarnessFixtureKind::A2a => "a2a",
297        HarnessFixtureKind::Agent => "agent",
298        HarnessFixtureKind::AgentStep => "agent_task",
299    }
300}
301
302fn require_non_empty(value: &str, field: &'static str) -> Result<(), HarnessFixtureError> {
303    if value.is_empty() {
304        Err(HarnessFixtureError::Empty { field })
305    } else {
306        Ok(())
307    }
308}
309
310fn default_receipt_schema() -> ReceiptSchema {
311    ReceiptSchema::V1
312}
313
314#[cfg(test)]
315mod tests {
316    use super::{
317        HarnessFixtureError, HarnessFixtureKind, ReceiptSchema, parse_harness_fixture,
318        retired_execution_receipt_field,
319    };
320
321    #[test]
322    fn parses_post_cutover_receipt_expectation() -> Result<(), HarnessFixtureError> {
323        let fixture = parse_harness_fixture(
324            r#"
325name: echo-skill
326kind: skill
327target: ../skills/echo
328inputs:
329  message: hello
330expect:
331  status: sealed
332  receipt:
333    schema: runx.receipt.v1
334    body_digest: sha256:test
335    harness_id: echo-skill
336    state: sealed
337    disposition: closed
338    reason_code: harness_replay_passed
339    act_ids:
340      - echo
341"#,
342        )?;
343
344        assert_eq!(fixture.kind, HarnessFixtureKind::Skill);
345        let receipt = fixture
346            .expect
347            .receipt
348            .ok_or(HarnessFixtureError::Required {
349                field: "expect.receipt".to_owned(),
350            })?;
351        assert_eq!(receipt.schema, ReceiptSchema::V1);
352        assert_eq!(receipt.state.as_deref(), Some("sealed"));
353        assert_eq!(receipt.act_ids, vec!["echo"]);
354        Ok(())
355    }
356
357    #[test]
358    fn rejects_retired_receipt_expectation_fields() {
359        for field in [
360            "kind".to_owned(),
361            retired_execution_receipt_field("skill"),
362            retired_execution_receipt_field("graph"),
363        ] {
364            let result = parse_harness_fixture(&format!(
365                r#"
366name: old
367kind: skill
368target: ../skills/echo
369expect:
370  receipt:
371    {field}: value
372"#,
373            ));
374
375            assert!(matches!(
376                result,
377                Err(HarnessFixtureError::RetiredReceiptField { field_path })
378                    if field_path == format!("expect.receipt.{field}")
379            ));
380        }
381    }
382
383    #[test]
384    fn rejects_unsupported_fixture_modes_with_stable_diagnostic() {
385        let result = parse_harness_fixture(
386            r#"
387name: old
388kind: mcp
389target: ../skills/echo
390expect:
391  status: sealed
392"#,
393        );
394
395        assert!(matches!(
396            result,
397            Err(HarnessFixtureError::UnsupportedFixtureMode { mode, field_path })
398                if mode == "mcp" && field_path == "kind"
399        ));
400    }
401
402    #[test]
403    fn rejects_unknown_receipt_expectation_fields() {
404        let result = parse_harness_fixture(
405            r#"
406name: old
407kind: skill
408target: ../skills/echo
409expect:
410  receipt:
411    unexpected: value
412"#,
413        );
414
415        assert!(matches!(
416            result,
417            Err(HarnessFixtureError::UnknownReceiptField { field_path })
418                if field_path == "expect.receipt.unexpected"
419        ));
420    }
421}