use super::{
InlineHarnessReport, SkillRunError, SkillRunOverrides, execute_skill_run_with_overrides,
};
use std::collections::BTreeMap;
use std::path::Path;
use runx_contracts::{JsonObject, JsonValue};
use runx_parser::{HarnessCallerFixture, RunnerHarnessCase, SkillRunnerManifest};
use crate::RuntimeError;
use crate::effects::RuntimeEffectRegistry;
use crate::execution::orchestrator::SkillRunRequest;
use super::runner_manifest::{load_runner_manifest, resolve_skill_dir, selected_runner};
pub(crate) fn run_inline_harness_with_effects(
skill_path: &Path,
receipt_dir: Option<&Path>,
env: Option<&BTreeMap<String, String>>,
effects: &RuntimeEffectRegistry,
) -> Result<InlineHarnessReport, SkillRunError> {
let skill_dir = resolve_skill_dir(skill_path)?;
let manifest = load_runner_manifest(&skill_dir)?;
let Some(harness) = manifest.harness.as_ref() else {
return Ok(InlineHarnessReport::not_declared());
};
if harness.cases.is_empty() {
return Ok(InlineHarnessReport::not_declared());
}
let cwd = std::env::current_dir()
.map_err(|source| RuntimeError::io("resolving cwd for inline harness", source))?;
let mut assertion_errors = Vec::new();
let mut case_names = Vec::with_capacity(harness.cases.len());
let mut receipt_ids = Vec::new();
let mut graph_case_count = 0;
for case in &harness.cases {
case_names.push(case.name.clone());
let outcome =
run_inline_harness_case(&skill_dir, receipt_dir, env, &manifest, case, &cwd, effects);
if outcome.is_graph {
graph_case_count += 1;
}
if let Some(receipt_id) = outcome.receipt_id {
receipt_ids.push(receipt_id);
}
if let Some(error) = outcome.assertion_error {
assertion_errors.push(error);
}
}
let status = if assertion_errors.is_empty() {
"passed"
} else {
"failed"
};
Ok(InlineHarnessReport {
assertion_error_count: assertion_errors.len(),
status,
case_count: harness.cases.len(),
assertion_errors,
case_names,
receipt_ids,
graph_case_count,
})
}
struct InlineHarnessCaseOutcome {
is_graph: bool,
receipt_id: Option<String>,
assertion_error: Option<String>,
}
fn run_inline_harness_case(
skill_dir: &Path,
receipt_dir: Option<&Path>,
env: Option<&BTreeMap<String, String>>,
manifest: &SkillRunnerManifest,
case: &RunnerHarnessCase,
cwd: &Path,
effects: &RuntimeEffectRegistry,
) -> InlineHarnessCaseOutcome {
let is_graph = match selected_runner(manifest, case.runner.as_deref()) {
Ok(runner) => runner.source.source_type == runx_parser::SourceKind::Graph,
Err(error) => return inline_harness_case_error(&case.name, error),
};
let request = inline_harness_case_request(skill_dir, receipt_dir, env, case, cwd);
let overrides = SkillRunOverrides {
runner: case.runner.clone(),
seeded_answers: seeded_answers_from_caller(&case.caller),
};
match execute_skill_run_with_overrides(&request, &overrides, effects) {
Ok(output) => InlineHarnessCaseOutcome {
is_graph,
receipt_id: receipt_id_from_output(&output),
assertion_error: inline_harness_expectation_error(case, &output),
},
Err(error) => InlineHarnessCaseOutcome {
is_graph,
receipt_id: None,
assertion_error: Some(format!("{}: {error}", case.name)),
},
}
}
fn inline_harness_case_request(
skill_dir: &Path,
receipt_dir: Option<&Path>,
env: Option<&BTreeMap<String, String>>,
case: &RunnerHarnessCase,
cwd: &Path,
) -> SkillRunRequest {
let mut env: BTreeMap<String, String> =
env.cloned().unwrap_or_else(|| std::env::vars().collect());
env.extend(case.env.clone());
SkillRunRequest {
skill_path: skill_dir.to_path_buf(),
receipt_dir: receipt_dir.map(Path::to_path_buf),
run_id: None,
answers_path: None,
inputs: case.inputs.clone(),
env,
cwd: cwd.to_path_buf(),
local_credential: None,
}
}
fn inline_harness_case_error(
case_name: &str,
error: impl std::fmt::Display,
) -> InlineHarnessCaseOutcome {
InlineHarnessCaseOutcome {
is_graph: false,
receipt_id: None,
assertion_error: Some(format!("{case_name}: {error}")),
}
}
fn receipt_id_from_output(output: &JsonValue) -> Option<String> {
output
.as_object()
.and_then(|object| object.get("receipt_id"))
.and_then(JsonValue::as_str)
.map(str::to_owned)
}
fn inline_harness_expectation_error(
case: &RunnerHarnessCase,
output: &JsonValue,
) -> Option<String> {
let expected = case.expect.status.as_deref()?;
let actual = inline_harness_actual_status(output);
(actual != expected).then(|| format!("{}: expected status {expected}, got {actual}", case.name))
}
fn seeded_answers_from_caller(caller: &HarnessCallerFixture) -> Option<JsonObject> {
let mut merged = caller.answers.clone().unwrap_or_default();
if let Some(approvals) = &caller.approvals {
for (gate, approved) in approvals {
merged
.entry(gate.clone())
.or_insert_with(|| JsonValue::Bool(*approved));
}
}
if merged.is_empty() {
None
} else {
Some(merged)
}
}
fn inline_harness_actual_status(output: &JsonValue) -> &'static str {
let Some(object) = output.as_object() else {
return "sealed";
};
if object.get("status").and_then(JsonValue::as_str) == Some("needs_agent") {
return "needs_agent";
}
let disposition = object
.get("closure")
.and_then(JsonValue::as_object)
.and_then(|closure| closure.get("disposition"))
.and_then(JsonValue::as_str);
match disposition {
Some("deferred") => "needs_agent",
Some("blocked") => "policy_denied",
Some("declined" | "failed" | "killed" | "timed_out" | "superseded") => "failure",
_ => "sealed",
}
}