Skip to main content

runx_runtime/execution/harness/
runner.rs

1// rust-style-allow: large-file because harness replay owns fixture loading,
2// adapter invocation, receipt assertion, and graph replay sealing as one
3// deterministic proof path until MCP replay creates a separate module boundary.
4
5mod dispositions;
6
7use std::collections::BTreeMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use dispositions::{
12    agent_answer_disposition, agent_task_output, disposition_from_expected_status,
13    disposition_suffix, named_reason_code, process_reason_code, required_string_metadata,
14    skill_output_object, string_metadata,
15};
16use runx_contracts::{
17    ClosureDisposition, ExecutionEvent, JsonObject, JsonValue, Receipt, ResolutionRequest,
18    ResolutionResponse, ResolutionResponseActor,
19};
20use runx_core::state_machine::StepAdmissionWitness;
21use runx_parser::{
22    SkillRunnerDefinition, SkillRunnerManifest, parse_runner_manifest_yaml,
23    validate_runner_manifest,
24};
25use thiserror::Error;
26
27use super::super::graph::{load_skill, materialize_graph_inputs};
28use super::assertions::{assert_expectations, status_from_disposition};
29use super::fixtures::{
30    HarnessExpectedStatus, HarnessFixture, HarnessFixtureError, HarnessFixtureKind,
31    fixture_kind_name, load_harness_fixture,
32};
33use crate::RuntimeError;
34use crate::adapter::{InvocationStatus, SkillAdapter, SkillInvocation, SkillOutput};
35use crate::agent_invocation::{AgentActInvocationSourceType, agent_act_invocation_id};
36use crate::effects::RuntimeEffectRegistry;
37use crate::execution::runner::{GraphRun, Runtime, RuntimeOptions, StepRun};
38use crate::host::Host;
39use crate::receipts::{
40    GraphClosure, StepReceiptWithDisposition, graph_receipt_with_disposition_and_policy,
41    step_receipt_with_disposition_and_policy,
42};
43
44#[derive(Clone, Debug)]
45pub struct HarnessReplayOutput {
46    pub fixture: HarnessFixture,
47    pub status: HarnessExpectedStatus,
48    pub receipt: Receipt,
49    pub step_receipts: Vec<Receipt>,
50    pub steps: Vec<StepRun>,
51    pub skill_output: Option<SkillOutput>,
52}
53
54#[derive(Debug, Error)]
55pub enum HarnessReplayError {
56    #[error(transparent)]
57    Fixture(#[from] HarnessFixtureError),
58    #[error(transparent)]
59    Runtime(#[from] RuntimeError),
60    #[error("harness fixture target {target} has no parent directory")]
61    TargetWithoutParent { target: PathBuf },
62    #[error("harness expectation mismatch at {field}: expected {expected}, actual {actual}")]
63    Mismatch {
64        field: &'static str,
65        expected: String,
66        actual: String,
67    },
68    #[error(
69        "{message}; receipt={receipt_id}; disposition={disposition}; reason={reason_code}; summary={summary}; skill_stdout={skill_stdout}; skill_stderr={skill_stderr}"
70    )]
71    ExpectationFailed {
72        message: String,
73        receipt_id: String,
74        disposition: String,
75        reason_code: String,
76        summary: String,
77        skill_stdout: String,
78        skill_stderr: String,
79    },
80    #[error("receipt digest failed: {message}")]
81    ReceiptDigest { message: String },
82    #[error("receipt proof failed for {receipt_id}: {findings}")]
83    ReceiptProofInvalid {
84        receipt_id: String,
85        findings: String,
86    },
87    #[error("harness fixture mode {mode} at {field_path} is not yet supported by the Rust harness")]
88    UnsupportedFixtureMode { mode: String, field_path: String },
89    #[error("invalid harness replay metadata at {field}: {message}")]
90    InvalidReplayMetadata { field: String, message: String },
91    #[error(
92        "native cli-tool harness replay is unavailable because runx-runtime was built without the cli-tool feature"
93    )]
94    CliToolFeatureDisabled,
95}
96
97impl From<crate::execution::skill_front::SkillRunError> for HarnessReplayError {
98    fn from(error: crate::execution::skill_front::SkillRunError) -> Self {
99        use crate::execution::skill_front::SkillRunError;
100        match error {
101            SkillRunError::Runtime(error) => HarnessReplayError::Runtime(error),
102            other => HarnessReplayError::Runtime(RuntimeError::ReceiptInvalid {
103                message: other.to_string(),
104            }),
105        }
106    }
107}
108
109pub fn run_harness_fixture(
110    fixture_path: impl AsRef<Path>,
111) -> Result<HarnessReplayOutput, HarnessReplayError> {
112    #[cfg(feature = "cli-tool")]
113    {
114        run_harness_fixture_with_adapter(
115            fixture_path,
116            crate::execution::skill_front::SkillRunGraphAdapter::default(),
117            fixture_runtime_options()?,
118        )
119    }
120    #[cfg(not(feature = "cli-tool"))]
121    {
122        let _ = fixture_path;
123        Err(HarnessReplayError::CliToolFeatureDisabled)
124    }
125}
126
127#[cfg(feature = "cli-tool")]
128pub fn run_harness_fixture_with_env(
129    fixture_path: impl AsRef<Path>,
130    env: BTreeMap<String, String>,
131) -> Result<HarnessReplayOutput, HarnessReplayError> {
132    run_harness_fixture_with_adapter(
133        fixture_path,
134        crate::execution::skill_front::SkillRunGraphAdapter::default(),
135        fixture_runtime_options_from_env(env)?,
136    )
137}
138
139#[cfg(not(feature = "cli-tool"))]
140pub fn run_harness_fixture_with_env(
141    fixture_path: impl AsRef<Path>,
142    env: BTreeMap<String, String>,
143) -> Result<HarnessReplayOutput, HarnessReplayError> {
144    let _ = fixture_path;
145    let _ = env;
146    Err(HarnessReplayError::CliToolFeatureDisabled)
147}
148
149#[cfg(feature = "cli-tool")]
150pub fn run_harness_fixture_cli_tool(
151    fixture_path: impl AsRef<Path>,
152) -> Result<HarnessReplayOutput, HarnessReplayError> {
153    run_harness_fixture_with_adapter(
154        fixture_path,
155        crate::execution::skill_front::SkillRunGraphAdapter::default(),
156        fixture_runtime_options()?,
157    )
158}
159
160#[cfg(feature = "cli-tool")]
161fn fixture_runtime_options() -> Result<RuntimeOptions, HarnessReplayError> {
162    fixture_runtime_options_from_env(crate::services::process_env_snapshot())
163}
164
165#[cfg(feature = "cli-tool")]
166fn fixture_runtime_options_from_env(
167    env: BTreeMap<String, String>,
168) -> Result<RuntimeOptions, HarnessReplayError> {
169    Ok(RuntimeOptions {
170        created_at: crate::time::DEFAULT_CREATED_AT.to_owned(),
171        ..RuntimeOptions::from_env(env)?
172    })
173}
174
175pub fn run_harness_fixture_with_adapter<A>(
176    fixture_path: impl AsRef<Path>,
177    adapter: A,
178    options: RuntimeOptions,
179) -> Result<HarnessReplayOutput, HarnessReplayError>
180where
181    A: SkillAdapter,
182{
183    let fixture_path = fixture_path.as_ref();
184    let fixture = load_harness_fixture(fixture_path)?;
185    let target_path = resolve_target_path(fixture_path, &fixture.target)?;
186    let receipt_signature = options.receipt_signature.clone();
187    let output = match fixture.kind {
188        HarnessFixtureKind::Skill | HarnessFixtureKind::A2a | HarnessFixtureKind::Agent => {
189            run_skill_fixture(&fixture, target_path, adapter, options)?
190        }
191        HarnessFixtureKind::AgentStep => run_agent_task_fixture(&fixture, options)?,
192        HarnessFixtureKind::Graph if is_fixture_replay_graph(&fixture) => {
193            run_graph_replay_fixture(&fixture, options)?
194        }
195        HarnessFixtureKind::Graph => run_graph_fixture(&fixture, &target_path, adapter, options)?,
196        HarnessFixtureKind::Mcp => {
197            return Err(HarnessReplayError::UnsupportedFixtureMode {
198                mode: fixture_kind_name(&fixture.kind).to_owned(),
199                field_path: "kind".to_owned(),
200            });
201        }
202    };
203    assert_expectations(&output, receipt_signature.signature_policy())
204        .map_err(|error| expectation_error_with_output(error, &output))?;
205    Ok(output)
206}
207
208fn expectation_error_with_output(
209    error: HarnessReplayError,
210    output: &HarnessReplayOutput,
211) -> HarnessReplayError {
212    HarnessReplayError::ExpectationFailed {
213        message: error.to_string(),
214        receipt_id: output.receipt.id.to_string(),
215        disposition: format!("{:?}", output.receipt.seal.disposition),
216        reason_code: output.receipt.seal.reason_code.to_string(),
217        summary: truncate_diagnostic(&output.receipt.seal.summary),
218        skill_stdout: output
219            .skill_output
220            .as_ref()
221            .map(|skill_output| truncate_diagnostic(&skill_output.stdout))
222            .unwrap_or_default(),
223        skill_stderr: output
224            .skill_output
225            .as_ref()
226            .map(|skill_output| truncate_diagnostic(&skill_output.stderr))
227            .unwrap_or_default(),
228    }
229}
230
231fn truncate_diagnostic(value: &str) -> String {
232    const LIMIT: usize = 800;
233    let trimmed = value.trim();
234    if trimmed.len() <= LIMIT {
235        return trimmed.to_owned();
236    }
237    format!("{}...[truncated]", &trimmed[..LIMIT])
238}
239
240fn run_agent_task_fixture(
241    fixture: &HarnessFixture,
242    options: RuntimeOptions,
243) -> Result<HarnessReplayOutput, HarnessReplayError> {
244    let replay_name = fixture.runner.as_deref().unwrap_or(&fixture.name);
245    let request_id = format!("agent_task.{replay_name}.output");
246    let output = agent_task_output(fixture, &request_id)?;
247    let disposition = fixture
248        .expect
249        .status
250        .as_ref()
251        .map(disposition_from_expected_status)
252        .unwrap_or_else(|| {
253            if output.succeeded() {
254                ClosureDisposition::Closed
255            } else {
256                ClosureDisposition::Failed
257            }
258        });
259    let receipt = step_receipt_with_disposition_and_policy(
260        StepReceiptWithDisposition {
261            graph_name: &fixture.name,
262            step_id: &fixture.name,
263            attempt: 1,
264            output: &output,
265            created_at: &options.created_at,
266            disposition: disposition.clone(),
267            reason_code: process_reason_code(&disposition),
268            summary: format!("agent-task {} completed", fixture.name),
269        },
270        options.signature_policy(),
271    )?;
272    Ok(HarnessReplayOutput {
273        fixture: fixture.clone(),
274        status: status_from_disposition(&receipt.seal.disposition),
275        receipt,
276        step_receipts: Vec::new(),
277        steps: Vec::new(),
278        skill_output: Some(output),
279    })
280}
281
282#[derive(Clone, Debug)]
283struct GraphReplayStep {
284    step_id: String,
285    task: String,
286    request_id: String,
287}
288
289fn is_fixture_replay_graph(fixture: &HarnessFixture) -> bool {
290    string_metadata(fixture, "graph_shape") == Some("fixture_replay")
291}
292
293// rust-style-allow: long-function because graph replay receipt assembly keeps
294// step runs, closure disposition, and parent receipt sealing in one invariant.
295fn run_graph_replay_fixture(
296    fixture: &HarnessFixture,
297    options: RuntimeOptions,
298) -> Result<HarnessReplayOutput, HarnessReplayError> {
299    let mut runs = Vec::new();
300    for replay_step in graph_replay_steps(fixture)? {
301        let output = agent_task_output(fixture, &replay_step.request_id)?;
302        let disposition = if output.succeeded() {
303            ClosureDisposition::Closed
304        } else {
305            ClosureDisposition::Deferred
306        };
307        let receipt = step_receipt_with_disposition_and_policy(
308            StepReceiptWithDisposition {
309                graph_name: &fixture.name,
310                step_id: &replay_step.step_id,
311                attempt: 1,
312                output: &output,
313                created_at: &options.created_at,
314                disposition: disposition.clone(),
315                reason_code: process_reason_code(&disposition),
316                summary: if output.succeeded() {
317                    format!("agent-task {} replayed", replay_step.task)
318                } else {
319                    output.stderr.clone()
320                },
321            },
322            options.signature_policy(),
323        )?;
324        let outputs = skill_output_object(&output);
325        let succeeded = output.succeeded();
326        let admission_witness =
327            StepAdmissionWitness::local_runtime(&replay_step.step_id, receipt.id.as_str());
328        runs.push(StepRun {
329            step_id: replay_step.step_id,
330            attempt: 1,
331            skill: replay_step.task.clone(),
332            runner: Some(replay_step.task),
333            fanout_group: None,
334            output,
335            outputs,
336            receipt,
337            admission_witness,
338        });
339        if !succeeded {
340            break;
341        }
342    }
343    if runs.is_empty() {
344        return Err(HarnessReplayError::InvalidReplayMetadata {
345            field: "metadata.graph_replay_steps".to_owned(),
346            message: "at least one replay step is required".to_owned(),
347        });
348    }
349    let disposition = fixture
350        .expect
351        .status
352        .as_ref()
353        .map(disposition_from_expected_status)
354        .unwrap_or_else(|| {
355            if runs.iter().all(|run| run.output.succeeded()) {
356                ClosureDisposition::Closed
357            } else {
358                ClosureDisposition::Deferred
359            }
360        });
361    let receipt = graph_receipt_with_disposition_and_policy(
362        &fixture.name,
363        &mut runs,
364        Vec::new(),
365        &options.created_at,
366        GraphClosure {
367            disposition: disposition.clone(),
368            reason_code: named_reason_code(&fixture.name, &disposition),
369            summary: format!("graph {} replayed through fixture harness", fixture.name),
370        },
371        RuntimeEffectRegistry::default(),
372        options.signature_policy(),
373    )?;
374    let step_receipts = runs
375        .iter()
376        .map(|run| run.receipt.clone())
377        .collect::<Vec<_>>();
378    let skill_output = runs
379        .iter()
380        .rev()
381        .find(|run| run.output.succeeded())
382        .or_else(|| runs.last())
383        .map(|run| run.output.clone());
384    Ok(HarnessReplayOutput {
385        fixture: fixture.clone(),
386        status: status_from_disposition(&receipt.seal.disposition),
387        receipt,
388        step_receipts,
389        steps: runs,
390        skill_output,
391    })
392}
393
394fn graph_replay_steps(
395    fixture: &HarnessFixture,
396) -> Result<Vec<GraphReplayStep>, HarnessReplayError> {
397    let Some(JsonValue::Array(raw_steps)) = fixture.metadata.get("graph_replay_steps") else {
398        return Err(HarnessReplayError::InvalidReplayMetadata {
399            field: "metadata.graph_replay_steps".to_owned(),
400            message: "array is required for fixture replay graphs".to_owned(),
401        });
402    };
403    raw_steps
404        .iter()
405        .enumerate()
406        .map(|(index, raw_step)| {
407            let JsonValue::Object(step) = raw_step else {
408                return Err(HarnessReplayError::InvalidReplayMetadata {
409                    field: format!("metadata.graph_replay_steps.{index}"),
410                    message: "object is required".to_owned(),
411                });
412            };
413            let step_id = required_string_metadata(
414                step,
415                &format!("metadata.graph_replay_steps.{index}.step_id"),
416                "step_id",
417            )?;
418            let task = required_string_metadata(
419                step,
420                &format!("metadata.graph_replay_steps.{index}.task"),
421                "task",
422            )?;
423            Ok(GraphReplayStep {
424                request_id: format!("agent_task.{task}.output"),
425                step_id,
426                task,
427            })
428        })
429        .collect()
430}
431
432fn run_skill_fixture<A>(
433    fixture: &HarnessFixture,
434    skill_dir: PathBuf,
435    adapter: A,
436    options: RuntimeOptions,
437) -> Result<HarnessReplayOutput, HarnessReplayError>
438where
439    A: SkillAdapter,
440{
441    let (skill_name, runner, invocation) = skill_fixture_invocation(fixture, skill_dir, &options)?;
442    if invocation.source.source_type == runx_parser::SourceKind::Graph {
443        if is_fixture_replay_graph(fixture) {
444            return run_graph_replay_fixture(fixture, options);
445        }
446        return run_graph_skill_fixture(fixture, skill_name, runner, invocation, adapter, options);
447    }
448    let (skill_output, disposition, reason_code, summary) =
449        run_skill_invocation(fixture, invocation, adapter)?;
450    let receipt = step_receipt_with_disposition_and_policy(
451        StepReceiptWithDisposition {
452            graph_name: &fixture.name,
453            step_id: &skill_name,
454            attempt: 1,
455            output: &skill_output,
456            created_at: &options.created_at,
457            disposition: disposition.clone(),
458            reason_code,
459            summary,
460        },
461        options.signature_policy(),
462    )?;
463    Ok(HarnessReplayOutput {
464        fixture: fixture.clone(),
465        status: status_from_disposition(&receipt.seal.disposition),
466        receipt,
467        step_receipts: Vec::new(),
468        steps: Vec::new(),
469        skill_output: Some(skill_output),
470    })
471}
472
473// rust-style-allow: long-function because the fixture graph turn keeps materialize,
474// run, and act-receipt minting in one path so it seals under the same instant and
475// signature policy as the production and inline fronts.
476fn run_graph_skill_fixture<A>(
477    fixture: &HarnessFixture,
478    skill_name: String,
479    runner: Option<SkillRunnerDefinition>,
480    invocation: SkillInvocation,
481    adapter: A,
482    mut options: RuntimeOptions,
483) -> Result<HarnessReplayOutput, HarnessReplayError>
484where
485    A: SkillAdapter,
486{
487    let graph = invocation
488        .source
489        .graph
490        .clone()
491        .ok_or_else(|| RuntimeError::UnsupportedSource {
492            source_kind: "graph runner without source.graph".to_owned(),
493        })?;
494    let graph = materialize_graph_inputs(graph, &invocation.inputs);
495    overlay_harness_env(&mut options, &invocation.env);
496    let run_id = options
497        .env
498        .entry(crate::execution::runner::RUNX_RUN_ID_ENV.to_owned())
499        .or_insert_with(|| format!("harness-{}", fixture.name))
500        .clone();
501    // Capture the deterministic seal inputs before `options` moves into the
502    // runtime, so an `act:`-declaring runner can mint its domain receipt from the
503    // same instant and signature policy the graph trace sealed under.
504    let created_at = options.created_at.clone();
505    let signature_config = options.receipt_signature.clone();
506    let runtime = Runtime::new(adapter, options);
507    let mut host = FixtureHost::new(fixture);
508    let graph_run = runtime.run_graph_with_host(&invocation.skill_directory, graph, &mut host)?;
509    // When the runner declares an `act:` block, seal the turn's primary receipt as
510    // its domain act through the SAME production minting entry, so the fixture and
511    // production/inline paths emit identical receipts. The graph trace receipt and
512    // per-step receipts remain as the execution trace. Borrow `graph_run` before
513    // `replay_output_from_graph` consumes it by value.
514    let minted = match runner.as_ref() {
515        Some(runner) => crate::execution::skill_front::graph_domain_act_receipt(
516            runner,
517            &invocation.inputs,
518            &graph_run,
519            &run_id,
520            &created_at,
521            &signature_config,
522        )?,
523        None => None,
524    };
525    let mut output = replay_output_from_graph(fixture, graph_run);
526    if let Some(domain_receipt) = minted {
527        output.receipt = domain_receipt;
528    }
529    if output.skill_output.is_none() {
530        output.skill_output = output
531            .steps
532            .iter()
533            .rev()
534            .find(|run| run.output.succeeded())
535            .or_else(|| output.steps.last())
536            .map(|run| run.output.clone());
537    }
538    if output.steps.is_empty() {
539        return Err(RuntimeError::UnsupportedSource {
540            source_kind: format!("graph runner {skill_name} produced no steps"),
541        }
542        .into());
543    }
544    Ok(output)
545}
546
547fn skill_fixture_invocation(
548    fixture: &HarnessFixture,
549    skill_dir: PathBuf,
550    options: &RuntimeOptions,
551) -> Result<(String, Option<SkillRunnerDefinition>, SkillInvocation), HarnessReplayError> {
552    let skill = load_skill(&skill_dir)?;
553    let runner = load_harness_runner(&skill_dir, fixture.runner.as_deref())?;
554    let mut env = options.env.clone();
555    env.extend(fixture.env.clone());
556    let skill_name = if fixture.runner.is_some() {
557        runner
558            .as_ref()
559            .map_or_else(|| skill.name.clone(), |runner| runner.name.clone())
560    } else {
561        skill.name.clone()
562    };
563    let source = runner
564        .as_ref()
565        .map_or_else(|| skill.source.clone(), |runner| runner.source.clone());
566    let invocation = SkillInvocation {
567        skill_name: skill_name.clone(),
568        source,
569        inputs: fixture.inputs.clone(),
570        resolved_inputs: JsonObject::new(),
571        current_context: Vec::new(),
572        skill_directory: skill_dir,
573        env,
574        credential_delivery: crate::credentials::CredentialDelivery::none(),
575    };
576    Ok((skill_name, runner, invocation))
577}
578
579fn run_skill_invocation<A>(
580    fixture: &HarnessFixture,
581    invocation: SkillInvocation,
582    adapter: A,
583) -> Result<(SkillOutput, ClosureDisposition, String, String), HarnessReplayError>
584where
585    A: SkillAdapter,
586{
587    let skill_name = invocation.skill_name.clone();
588    let (skill_output, disposition, reason_code, summary) =
589        match invocation.source.source_type.as_str() {
590            "agent" | "agent-task" => replay_agent_skill_fixture(fixture, &invocation)?,
591            _ => {
592                let output = adapter.invoke(invocation)?;
593                let disposition = if output.succeeded() {
594                    ClosureDisposition::Closed
595                } else {
596                    ClosureDisposition::Failed
597                };
598                let reason_code = process_reason_code(&disposition);
599                let summary = format!("step {skill_name} completed");
600                (output, disposition, reason_code, summary)
601            }
602        };
603    Ok((skill_output, disposition, reason_code, summary))
604}
605
606fn load_harness_runner(
607    skill_dir: &Path,
608    requested_runner: Option<&str>,
609) -> Result<Option<SkillRunnerDefinition>, HarnessReplayError> {
610    let manifest_path = skill_dir.join("X.yaml");
611    if !manifest_path.exists() {
612        if let Some(runner) = requested_runner {
613            return Err(RuntimeError::UnsupportedRunnerSelection {
614                runner: runner.to_owned(),
615            }
616            .into());
617        }
618        return Ok(None);
619    }
620    let source = fs::read_to_string(&manifest_path).map_err(|source| {
621        RuntimeError::io(format!("reading {}", manifest_path.display()), source)
622    })?;
623    let parsed = parse_runner_manifest_yaml(&source).map_err(RuntimeError::from)?;
624    let manifest = validate_runner_manifest(parsed).map_err(RuntimeError::from)?;
625    select_harness_runner(&manifest, requested_runner)
626        .cloned()
627        .map(Some)
628}
629
630fn select_harness_runner<'a>(
631    manifest: &'a SkillRunnerManifest,
632    requested_runner: Option<&str>,
633) -> Result<&'a SkillRunnerDefinition, HarnessReplayError> {
634    if let Some(runner) = requested_runner {
635        return manifest.runners.get(runner).ok_or_else(|| {
636            RuntimeError::UnsupportedRunnerSelection {
637                runner: runner.to_owned(),
638            }
639            .into()
640        });
641    }
642    let defaults = manifest
643        .runners
644        .values()
645        .filter(|runner| runner.default)
646        .collect::<Vec<_>>();
647    match defaults.as_slice() {
648        [runner] => Ok(*runner),
649        [] if manifest.runners.len() == 1 => manifest.runners.values().next().ok_or_else(|| {
650            RuntimeError::UnsupportedRunnerSelection {
651                runner: "default".to_owned(),
652            }
653            .into()
654        }),
655        [] => Err(RuntimeError::UnsupportedRunnerSelection {
656            runner: "default".to_owned(),
657        }
658        .into()),
659        _ => Err(RuntimeError::UnsupportedRunnerSelection {
660            runner: "default".to_owned(),
661        }
662        .into()),
663    }
664}
665
666fn replay_agent_skill_fixture(
667    fixture: &HarnessFixture,
668    invocation: &SkillInvocation,
669) -> Result<(SkillOutput, ClosureDisposition, String, String), HarnessReplayError> {
670    let source_type =
671        AgentActInvocationSourceType::from_contract_value(invocation.source.source_type.as_str())
672            .ok_or_else(|| RuntimeError::UnsupportedAdapter {
673            adapter_type: invocation.source.source_type.as_str().to_owned(),
674        })?;
675    let request_id = agent_act_invocation_id(invocation, source_type);
676    let mut metadata = JsonObject::new();
677    metadata.insert(
678        "agent_request_id".to_owned(),
679        JsonValue::String(request_id.clone()),
680    );
681    let Some(answer) = fixture_answer(fixture, "answers", &request_id, &request_id) else {
682        return Ok((
683            SkillOutput {
684                status: InvocationStatus::Failure,
685                stdout: String::new(),
686                stderr: format!("missing replay answer for {request_id}"),
687                exit_code: None,
688                duration_ms: 0,
689                metadata,
690            },
691            ClosureDisposition::Deferred,
692            "agent_act_deferred".to_owned(),
693            format!("agent act {request_id} is awaiting replay answer"),
694        ));
695    };
696    let stdout = serde_json::to_string(answer).map_err(|source| RuntimeError::Json {
697        context: format!("serializing replay answer {request_id}"),
698        source,
699    })?;
700    let disposition = agent_answer_disposition(answer)?;
701    let succeeded = disposition == ClosureDisposition::Closed;
702    Ok((
703        SkillOutput {
704            status: if succeeded {
705                InvocationStatus::Success
706            } else {
707                InvocationStatus::Failure
708            },
709            stdout,
710            stderr: if succeeded {
711                String::new()
712            } else {
713                format!("agent act closed with {}", disposition_suffix(&disposition))
714            },
715            exit_code: succeeded.then_some(0),
716            duration_ms: 0,
717            metadata,
718        },
719        disposition.clone(),
720        format!("agent_act_{}", disposition_suffix(&disposition)),
721        format!("agent act closed with {}", disposition_suffix(&disposition)),
722    ))
723}
724
725fn run_graph_fixture<A>(
726    fixture: &HarnessFixture,
727    graph_path: &Path,
728    adapter: A,
729    mut options: RuntimeOptions,
730) -> Result<HarnessReplayOutput, HarnessReplayError>
731where
732    A: SkillAdapter,
733{
734    overlay_harness_env(&mut options, &fixture.env);
735    // Harness graph replays need a deterministic run_id so per-run governance
736    // can resolve one, mirroring the production graph runner. Derived from the
737    // graph so receipts stay reproducible; an explicit fixture env value still
738    // wins.
739    options
740        .env
741        .entry(crate::execution::runner::RUNX_RUN_ID_ENV.to_owned())
742        .or_insert_with(|| {
743            let stem = graph_path
744                .file_stem()
745                .and_then(|stem| stem.to_str())
746                .unwrap_or("graph");
747            format!("harness-{stem}")
748        });
749    let runtime = Runtime::new(adapter, options);
750    let mut host = FixtureHost::new(fixture);
751    let graph_run = runtime.run_graph_file_for_harness(graph_path, &mut host)?;
752    let output = replay_output_from_graph(fixture, graph_run);
753    Ok(output)
754}
755
756struct FixtureHost<'a> {
757    fixture: &'a HarnessFixture,
758}
759
760impl<'a> FixtureHost<'a> {
761    fn new(fixture: &'a HarnessFixture) -> Self {
762        Self { fixture }
763    }
764}
765
766impl Host for FixtureHost<'_> {
767    fn report(&mut self, _event: ExecutionEvent) -> Result<(), RuntimeError> {
768        Ok(())
769    }
770
771    fn resolve(
772        &mut self,
773        request: ResolutionRequest,
774    ) -> Result<Option<ResolutionResponse>, RuntimeError> {
775        match request {
776            ResolutionRequest::Approval { id, gate } => {
777                fixture_approval_response(self.fixture, &id, &gate.id)
778            }
779            ResolutionRequest::AgentAct { id, .. } => {
780                fixture_agent_act_response(self.fixture, id.as_str())
781            }
782            ResolutionRequest::Input { .. } => Ok(None),
783        }
784    }
785}
786
787fn fixture_agent_act_response(
788    fixture: &HarnessFixture,
789    request_id: &str,
790) -> Result<Option<ResolutionResponse>, RuntimeError> {
791    let Some(answer) = fixture_answer(fixture, "answers", request_id, request_id) else {
792        return Ok(None);
793    };
794    Ok(Some(ResolutionResponse {
795        actor: ResolutionResponseActor::Agent,
796        payload: answer.clone(),
797    }))
798}
799
800fn fixture_approval_response(
801    fixture: &HarnessFixture,
802    request_id: &str,
803    gate_id: &str,
804) -> Result<Option<ResolutionResponse>, RuntimeError> {
805    let Some(answer) = fixture_answer(fixture, "approvals", gate_id, request_id)
806        .or_else(|| fixture_answer(fixture, "answers", request_id, gate_id))
807    else {
808        return Ok(None);
809    };
810    let approved = fixture_bool_answer(answer, request_id, gate_id)?;
811    Ok(Some(ResolutionResponse {
812        actor: fixture_answer_actor(answer, request_id, gate_id)?,
813        payload: JsonValue::Bool(approved),
814    }))
815}
816
817fn fixture_answer<'a>(
818    fixture: &'a HarnessFixture,
819    group: &str,
820    primary_key: &str,
821    secondary_key: &str,
822) -> Option<&'a JsonValue> {
823    fixture
824        .caller
825        .get(group)
826        .and_then(JsonValue::as_object)
827        .and_then(|answers| {
828            answers
829                .get(primary_key)
830                .or_else(|| answers.get(secondary_key))
831        })
832}
833
834fn fixture_bool_answer(
835    answer: &JsonValue,
836    request_id: &str,
837    gate_id: &str,
838) -> Result<bool, RuntimeError> {
839    match answer {
840        JsonValue::Bool(value) => Ok(*value),
841        JsonValue::Object(object) => match object.get("approved").or_else(|| object.get("payload"))
842        {
843            Some(JsonValue::Bool(value)) => Ok(*value),
844            Some(_) | None => Err(invalid_fixture_answer(request_id, gate_id)),
845        },
846        JsonValue::Null | JsonValue::Number(_) | JsonValue::String(_) | JsonValue::Array(_) => {
847            Err(invalid_fixture_answer(request_id, gate_id))
848        }
849    }
850}
851
852fn fixture_answer_actor(
853    answer: &JsonValue,
854    request_id: &str,
855    gate_id: &str,
856) -> Result<ResolutionResponseActor, RuntimeError> {
857    let Some(actor) = answer.as_object().and_then(|object| object.get("actor")) else {
858        return Ok(ResolutionResponseActor::Human);
859    };
860    match actor {
861        JsonValue::String(value) if value == "human" => Ok(ResolutionResponseActor::Human),
862        JsonValue::String(value) if value == "agent" => Ok(ResolutionResponseActor::Agent),
863        _ => Err(RuntimeError::ReceiptInvalid {
864            message: format!(
865                "harness fixture approval answer for request {request_id} gate {gate_id} has invalid actor"
866            ),
867        }),
868    }
869}
870
871fn invalid_fixture_answer(request_id: &str, gate_id: &str) -> RuntimeError {
872    RuntimeError::ReceiptInvalid {
873        message: format!(
874            "harness fixture approval answer for request {request_id} gate {gate_id} must be a boolean or object with a boolean approved field"
875        ),
876    }
877}
878
879fn replay_output_from_graph(fixture: &HarnessFixture, graph_run: GraphRun) -> HarnessReplayOutput {
880    let step_receipts = graph_run
881        .steps
882        .iter()
883        .map(|step| step.receipt.clone())
884        .collect::<Vec<_>>();
885    HarnessReplayOutput {
886        fixture: fixture.clone(),
887        status: status_from_disposition(&graph_run.receipt.seal.disposition),
888        receipt: graph_run.receipt,
889        step_receipts,
890        steps: graph_run.steps,
891        skill_output: None,
892    }
893}
894
895fn resolve_target_path(fixture_path: &Path, target: &str) -> Result<PathBuf, HarnessReplayError> {
896    let Some(parent) = fixture_path.parent() else {
897        return Err(HarnessReplayError::TargetWithoutParent {
898            target: fixture_path.to_path_buf(),
899        });
900    };
901    Ok(parent.join(target))
902}
903
904fn overlay_harness_env(options: &mut RuntimeOptions, env: &BTreeMap<String, String>) {
905    options.env.extend(env.clone());
906}
907
908#[cfg(test)]
909mod tests {
910    use super::overlay_harness_env;
911    use crate::credentials::CredentialDelivery;
912    use crate::effects::RuntimeEffectRegistry;
913    use crate::execution::runner::RuntimeOptions;
914    use crate::receipts::RuntimeReceiptSignatureConfig;
915    use std::collections::BTreeMap;
916
917    #[test]
918    fn overlay_harness_env_preserves_operator_env_and_allows_fixture_override() {
919        let mut options = RuntimeOptions {
920            created_at: "2026-05-18T00:00:00Z".to_owned(),
921            env: BTreeMap::from([
922                (
923                    "RUNX_SANDBOX_ALLOW_DECLARED_POLICY_ONLY".to_owned(),
924                    "local".to_owned(),
925                ),
926                ("FIXTURE_OVERRIDE".to_owned(), "operator".to_owned()),
927            ]),
928            receipt_signature: RuntimeReceiptSignatureConfig::local_development(),
929            effects: RuntimeEffectRegistry::default(),
930            credential_delivery: CredentialDelivery::none(),
931        };
932        let fixture_env = BTreeMap::from([
933            ("FIXTURE_OVERRIDE".to_owned(), "fixture".to_owned()),
934            ("FIXTURE_ONLY".to_owned(), "fixture".to_owned()),
935        ]);
936
937        overlay_harness_env(&mut options, &fixture_env);
938
939        assert_eq!(
940            options.env.get("RUNX_SANDBOX_ALLOW_DECLARED_POLICY_ONLY"),
941            Some(&"local".to_owned())
942        );
943        assert_eq!(
944            options.env.get("FIXTURE_OVERRIDE"),
945            Some(&"fixture".to_owned())
946        );
947        assert_eq!(options.env.get("FIXTURE_ONLY"), Some(&"fixture".to_owned()));
948    }
949}