1mod 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
293fn 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
473fn 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 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 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 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}