Skip to content

Commit 027e3ca

Browse files
authored
Merge pull request #613 from KooshaPari/feat/dual-harness-fixture-adapter
feat(harness_runner): dual-harness shared-3task fixture adapter
2 parents 945b6bb + d7c718e commit 027e3ca

3 files changed

Lines changed: 230 additions & 1 deletion

File tree

crates/harness_runner/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ categories = ["development-tools::testing"]
1111
authors = ["kooshapari"]
1212

1313
[dependencies]
14-
tokio = { version = "1", features = ["process", "io-util", "time", "sync"] }
14+
tokio = { version = "1", features = ["process", "io-util", "time", "sync", "macros", "rt-multi-thread"] }
1515
serde = { version = "1.0", features = ["derive"] }
16+
serde_json = "1.0"
1617
thiserror = "1.0"
1718
tracing = "0.1"
1819

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
// SPDX-License-Identifier: MIT OR Apache-2.0
2+
// Copyright (c) 2026 Phenotype org (heliosCLI)
3+
4+
//! Dual-harness shared fixture adapter (Planify2 × helios-cli).
5+
//!
6+
//! Loads `shared-3task.v1.json` and executes the `helios_cli` adapter specs
7+
//! via [`crate::Runner`]. Traces to FR-DH-001.
8+
9+
use crate::{RunError, Runner, RunnerConfig};
10+
use serde::Deserialize;
11+
use std::collections::HashMap;
12+
use std::path::{Path, PathBuf};
13+
14+
/// Fixture root document (`pheno.dual_harness.fixture.v1`).
15+
#[derive(Debug, Clone, Deserialize)]
16+
pub struct DualHarnessFixture {
17+
pub schema_version: String,
18+
pub fixture_id: String,
19+
pub tasks: Vec<FixtureTask>,
20+
}
21+
22+
#[derive(Debug, Clone, Deserialize)]
23+
pub struct FixtureTask {
24+
pub task_id: String,
25+
pub title: String,
26+
pub kind: String,
27+
pub acceptance: Acceptance,
28+
pub adapters: HashMap<String, AdapterSpec>,
29+
}
30+
31+
#[derive(Debug, Clone, Deserialize)]
32+
pub struct Acceptance {
33+
pub exit_code: Option<i32>,
34+
pub stdout_contains: Option<String>,
35+
pub stdout_path_prefix_env: Option<String>,
36+
pub must_error: Option<bool>,
37+
pub error_class: Option<String>,
38+
pub timeout_secs: Option<u64>,
39+
}
40+
41+
#[derive(Debug, Clone, Deserialize)]
42+
pub struct AdapterSpec {
43+
pub cmd: String,
44+
#[serde(default)]
45+
pub args: Vec<String>,
46+
pub working_dir_env: Option<String>,
47+
pub timeout_secs: Option<u64>,
48+
}
49+
50+
/// Outcome of one fixture task under the helios adapter.
51+
#[derive(Debug, Clone)]
52+
pub struct TaskOutcome {
53+
pub task_id: String,
54+
pub passed: bool,
55+
pub detail: String,
56+
}
57+
58+
/// Errors while loading or interpreting the fixture JSON.
59+
#[derive(Debug, thiserror::Error)]
60+
pub enum FixtureError {
61+
#[error("IO error: {0}")]
62+
Io(#[from] std::io::Error),
63+
#[error("JSON error: {0}")]
64+
Json(#[from] serde_json::Error),
65+
#[error("fixture schema unsupported: {0}")]
66+
Schema(String),
67+
#[error("task {0} missing helios_cli adapter")]
68+
MissingAdapter(String),
69+
#[error("DUAL_HARNESS_WORKDIR unset (required for task {0})")]
70+
WorkdirUnset(String),
71+
}
72+
73+
/// Load a dual-harness fixture from disk.
74+
pub fn load_fixture(path: &Path) -> Result<DualHarnessFixture, FixtureError> {
75+
let raw = std::fs::read_to_string(path)?;
76+
let fixture: DualHarnessFixture = serde_json::from_str(&raw)?;
77+
if fixture.schema_version != "pheno.dual_harness.fixture.v1" {
78+
return Err(FixtureError::Schema(fixture.schema_version));
79+
}
80+
Ok(fixture)
81+
}
82+
83+
/// Default path to the pheno-harness shared-3task fixture (repos layout).
84+
pub fn default_shared_3task_path() -> PathBuf {
85+
// crates/harness_runner → helios worktree → worktrees → repos
86+
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
87+
.ancestors()
88+
.nth(5)
89+
.map(|repos| {
90+
repos
91+
.join("pheno-harness")
92+
.join("plans")
93+
.join("2026-07-22-dual-harness-matrix")
94+
.join("fixtures")
95+
.join("shared-3task.v1.json")
96+
})
97+
.unwrap_or_else(|| PathBuf::from("shared-3task.v1.json"))
98+
}
99+
100+
/// Run all helios_cli adapter tasks; returns per-task outcomes.
101+
pub async fn run_helios_fixture(
102+
fixture: &DualHarnessFixture,
103+
) -> Result<Vec<TaskOutcome>, FixtureError> {
104+
let mut out = Vec::with_capacity(fixture.tasks.len());
105+
for task in &fixture.tasks {
106+
out.push(run_one_helios_task(task).await?);
107+
}
108+
Ok(out)
109+
}
110+
111+
async fn run_one_helios_task(task: &FixtureTask) -> Result<TaskOutcome, FixtureError> {
112+
let adapter = task
113+
.adapters
114+
.get("helios_cli")
115+
.ok_or_else(|| FixtureError::MissingAdapter(task.task_id.clone()))?;
116+
117+
let mut config = RunnerConfig::default();
118+
if let Some(secs) = adapter.timeout_secs.or(task.acceptance.timeout_secs) {
119+
config.timeout_secs = Some(secs);
120+
}
121+
if let Some(env_key) = &adapter.working_dir_env {
122+
let dir = std::env::var(env_key).map_err(|_| FixtureError::WorkdirUnset(task.task_id.clone()))?;
123+
config.working_dir = Some(dir);
124+
}
125+
126+
let runner = Runner::with_config(config);
127+
let arg_refs: Vec<&str> = adapter.args.iter().map(String::as_str).collect();
128+
let result = runner.run(&adapter.cmd, &arg_refs).await;
129+
130+
let passed = match (&task.acceptance, result) {
131+
(
132+
Acceptance {
133+
must_error: Some(true),
134+
error_class: Some(class),
135+
..
136+
},
137+
Err(RunError::Timeout(_)),
138+
) if class == "timeout" => true,
139+
(acceptance, Ok(run)) => {
140+
let mut ok = true;
141+
if let Some(code) = acceptance.exit_code {
142+
ok &= run.exit_code == Some(code);
143+
}
144+
if let Some(needle) = &acceptance.stdout_contains {
145+
ok &= run.stdout.contains(needle);
146+
}
147+
if let Some(env_key) = &acceptance.stdout_path_prefix_env {
148+
let prefix = std::env::var(env_key).unwrap_or_default();
149+
let stdout_path = PathBuf::from(run.stdout.trim());
150+
let prefix_path = PathBuf::from(&prefix);
151+
let stdout_canon = std::fs::canonicalize(&stdout_path).unwrap_or(stdout_path);
152+
let prefix_canon = std::fs::canonicalize(&prefix_path).unwrap_or(prefix_path);
153+
ok &= !prefix.is_empty()
154+
&& stdout_canon
155+
.to_string_lossy()
156+
.starts_with(prefix_canon.to_string_lossy().as_ref());
157+
}
158+
if acceptance.must_error == Some(true) {
159+
ok = false;
160+
}
161+
ok
162+
}
163+
(_, Err(e)) => {
164+
return Ok(TaskOutcome {
165+
task_id: task.task_id.clone(),
166+
passed: false,
167+
detail: format!("run error: {e}"),
168+
});
169+
}
170+
};
171+
172+
Ok(TaskOutcome {
173+
task_id: task.task_id.clone(),
174+
passed,
175+
detail: if passed {
176+
"ok".into()
177+
} else {
178+
"acceptance failed".into()
179+
},
180+
})
181+
}
182+
183+
#[cfg(test)]
184+
mod tests {
185+
use super::*;
186+
use std::time::Duration;
187+
188+
/// Traces to: FR-DH-001
189+
#[tokio::test]
190+
async fn shared_3task_fixture_passes_on_helios() {
191+
let path = std::env::var("DUAL_HARNESS_FIXTURE")
192+
.map(PathBuf::from)
193+
.unwrap_or_else(|_| default_shared_3task_path());
194+
if !path.is_file() {
195+
// Skip when fixture not present in this checkout layout.
196+
eprintln!("skip: fixture missing at {}", path.display());
197+
return;
198+
}
199+
let work = tempfile_workdir();
200+
std::env::set_var("DUAL_HARNESS_WORKDIR", &work);
201+
let fixture = load_fixture(&path).expect("load fixture");
202+
assert_eq!(fixture.tasks.len(), 3);
203+
let outcomes = run_helios_fixture(&fixture).await.expect("run");
204+
for o in &outcomes {
205+
assert!(o.passed, "{}: {}", o.task_id, o.detail);
206+
}
207+
let _ = std::fs::remove_dir_all(&work);
208+
}
209+
210+
fn tempfile_workdir() -> PathBuf {
211+
let dir = std::env::temp_dir().join(format!(
212+
"dual-harness-{}",
213+
std::time::SystemTime::now()
214+
.duration_since(std::time::UNIX_EPOCH)
215+
.unwrap_or(Duration::from_secs(0))
216+
.as_nanos()
217+
));
218+
std::fs::create_dir_all(&dir).expect("mkdir");
219+
dir
220+
}
221+
}

crates/harness_runner/src/lib.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44
//! Runner module - Optimized process execution
55
//! Features: Timeout, streaming, environment isolation
66
7+
mod dual_harness;
8+
9+
pub use dual_harness::{
10+
default_shared_3task_path, load_fixture, run_helios_fixture, AdapterSpec, DualHarnessFixture,
11+
FixtureError, FixtureTask, TaskOutcome,
12+
};
13+
714
use std::process::Stdio;
815
use std::time::{Duration, Instant};
916
use thiserror::Error;

0 commit comments

Comments
 (0)