use super::config::LongRunningConfig;
use super::project::{ProjectResult, ProjectStatus, ProjectType};
use crate::bench_harness::subprocess::{apply_env_allowlist, run_with_group_timeout};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct TestTask {
pub name: String,
pub project_type: ProjectType,
pub setup: TaskSetup,
pub prompt: String,
}
#[derive(Debug, Clone)]
pub enum TaskSetup {
RustGreenfield { name: String },
Python,
Go { module: String },
Template { name: String },
}
pub struct LongRunningRunner {
config: LongRunningConfig,
selfware_path: PathBuf,
start_time: Instant,
}
impl LongRunningRunner {
pub fn new(config: LongRunningConfig) -> Result<Self, String> {
config.validate()?;
let selfware_path = find_selfware_binary()?;
Ok(Self {
config,
selfware_path,
start_time: Instant::now(),
})
}
pub fn should_continue(&self) -> bool {
self.start_time.elapsed() < self.config.max_duration
}
pub fn time_remaining(&self) -> Duration {
let elapsed = self.start_time.elapsed();
if elapsed >= self.config.max_duration {
Duration::ZERO
} else {
self.config.max_duration - elapsed
}
}
pub async fn run_task(&self, task: &TestTask, work_dir: &Path) -> ProjectResult {
let task_start = Instant::now();
if let Err(e) = self.setup_project(&task.setup, work_dir).await {
return ProjectResult {
name: task.name.clone(),
status: ProjectStatus::Fail,
duration_secs: 0,
steps: 0,
src_lines: 0,
compiles: false,
tests_passed: 0,
tests_failed: 0,
outcome_label: format!("setup_failed: {}", e),
};
}
let config_toml = self.config.to_selfware_toml();
let _ = std::fs::write(work_dir.join("selfware.toml"), config_toml);
let log_path = work_dir
.parent()
.unwrap()
.join(format!("{}.log", task.name));
let mut cmd = Command::new(&self.selfware_path);
cmd.arg("-c")
.arg(work_dir.join("selfware.toml"))
.arg("-C")
.arg(work_dir)
.arg("--yolo")
.arg("--ascii")
.arg("--no-color")
.arg("-p")
.arg(&task.prompt)
.stderr(Stdio::null());
apply_env_allowlist(&mut cmd);
let timeout_secs = self.config.timeout_per_project_secs;
let outcome = match tokio::task::spawn_blocking(move || {
run_with_group_timeout(cmd, Duration::from_secs(timeout_secs))
})
.await
{
Ok(Ok(outcome)) => outcome,
Ok(Err(e)) => {
return self.create_error_result(&task.name, &format!("spawn error: {}", e));
}
Err(_) => {
return self.create_error_result(&task.name, "task panicked");
}
};
if outcome.timed_out {
return self.create_error_result(&task.name, "timeout");
}
let _ = std::fs::write(&log_path, outcome.stdout.as_bytes());
let duration = task_start.elapsed().as_secs();
let steps = count_steps(outcome.stdout.as_bytes());
let outcome_label = extract_outcome(outcome.stdout.as_bytes());
let (compiles, tests_passed, tests_failed, src_lines) =
self.evaluate_project(&task.project_type, work_dir);
let status = ProjectStatus::from_results(compiles, tests_passed, tests_failed, src_lines);
ProjectResult {
name: task.name.clone(),
status,
duration_secs: duration,
steps,
src_lines,
compiles,
tests_passed,
tests_failed,
outcome_label,
}
}
async fn setup_project(&self, setup: &TaskSetup, dir: &Path) -> std::io::Result<()> {
use super::project::{scaffold_go, scaffold_python, scaffold_rust, scaffold_template};
match setup {
TaskSetup::RustGreenfield { name } => scaffold_rust(name, dir),
TaskSetup::Python => scaffold_python(dir),
TaskSetup::Go { module } => scaffold_go(module, dir),
TaskSetup::Template { name } => {
scaffold_template(name, &self.config.templates_dir, dir)
}
}
}
fn evaluate_project(
&self,
project_type: &ProjectType,
work_dir: &Path,
) -> (bool, usize, usize, usize) {
let compiles = self.check_compiles(project_type, work_dir);
let src_lines = self.count_source_lines(project_type, work_dir);
let (passed, failed) = if compiles {
self.run_tests(project_type, work_dir)
} else {
(0, 0)
};
(compiles, passed, failed, src_lines)
}
fn check_compiles(&self, project_type: &ProjectType, work_dir: &Path) -> bool {
let cmd_parts = project_type.check_command();
if cmd_parts.is_empty() {
return false;
}
let output = Command::new(cmd_parts[0])
.args(&cmd_parts[1..])
.current_dir(work_dir)
.env("XDG_RUNTIME_DIR", "/tmp/xdg-run-selfware")
.output();
match output {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
match project_type {
ProjectType::Rust => stdout.contains("Finished") || !stderr.contains("error"),
ProjectType::Python => output.status.success(),
ProjectType::Go => stdout.contains("ok") || !stderr.contains("build failed"),
ProjectType::Template => stdout.contains("Finished"),
}
}
Err(_) => false,
}
}
fn run_tests(&self, project_type: &ProjectType, work_dir: &Path) -> (usize, usize) {
let cmd_parts = project_type.test_command();
if cmd_parts.is_empty() {
return (0, 0);
}
let output = Command::new(cmd_parts[0])
.args(&cmd_parts[1..])
.current_dir(work_dir)
.env("XDG_RUNTIME_DIR", "/tmp/xdg-run-selfware")
.output();
match output {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout);
match project_type {
ProjectType::Rust => {
let passed: usize = stdout
.lines()
.filter_map(|l| l.split("passed").next())
.filter_map(|l| l.split_whitespace().next_back())
.filter_map(|n| n.parse::<usize>().ok())
.sum();
let failed: usize = stdout
.lines()
.filter_map(|l| l.split("failed").next())
.filter_map(|l| l.split_whitespace().next_back())
.filter_map(|n| n.parse::<usize>().ok())
.sum();
(passed, failed)
}
ProjectType::Python => {
let passed = stdout.matches(" passed").count();
let failed = stdout.matches(" failed").count();
(passed, failed)
}
ProjectType::Go => {
let passed = stdout.matches("--- PASS").count();
let failed = stdout.matches("--- FAIL").count();
(passed, failed)
}
ProjectType::Template => {
let passed: usize = stdout
.lines()
.filter_map(|l| l.split("passed").next())
.filter_map(|l| l.split_whitespace().next_back())
.filter_map(|n| n.parse::<usize>().ok())
.sum();
let failed: usize = stdout
.lines()
.filter_map(|l| l.split("failed").next())
.filter_map(|l| l.split_whitespace().next_back())
.filter_map(|n| n.parse::<usize>().ok())
.sum();
(passed, failed)
}
}
}
Err(_) => (0, 0),
}
}
fn count_source_lines(&self, project_type: &ProjectType, work_dir: &Path) -> usize {
let ext = project_type.source_extension();
if ext.is_empty() {
return 0;
}
let mut total = 0;
if let Ok(entries) = std::fs::read_dir(work_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == ext).unwrap_or(false) {
if let Ok(content) = std::fs::read_to_string(&path) {
total += content.lines().count();
}
}
}
}
if *project_type == ProjectType::Rust || *project_type == ProjectType::Template {
if let Ok(entries) = std::fs::read_dir(work_dir.join("src")) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "rs").unwrap_or(false) {
if let Ok(content) = std::fs::read_to_string(&path) {
total += content.lines().count();
}
}
}
}
}
total
}
fn create_error_result(&self, name: &str, error: &str) -> ProjectResult {
ProjectResult {
name: name.to_string(),
status: ProjectStatus::Fail,
duration_secs: 0,
steps: 0,
src_lines: 0,
compiles: false,
tests_passed: 0,
tests_failed: 0,
outcome_label: error.to_string(),
}
}
}
fn find_selfware_binary() -> Result<PathBuf, String> {
let candidates = [
"./target/release/selfware",
"./target/debug/selfware",
"/usr/local/bin/selfware",
"/usr/bin/selfware",
];
for path in &candidates {
let path = PathBuf::from(path);
if path.exists() {
return Ok(path);
}
}
if let Ok(output) = Command::new("which").arg("selfware").output() {
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(PathBuf::from(path));
}
}
}
Err("Could not find selfware binary. Please build with: cargo build --release".into())
}
fn count_steps(output: &[u8]) -> usize {
let text = String::from_utf8_lossy(output);
text.lines()
.filter(|l| l.contains("Step") && l.contains("Executing"))
.count()
}
fn extract_outcome(output: &[u8]) -> String {
let text = String::from_utf8_lossy(output);
text.lines()
.rfind(|l| l.contains("Outcome:"))
.map(|l| {
l.split("Outcome:")
.nth(1)
.unwrap_or("unknown")
.trim()
.to_string()
})
.unwrap_or_else(|| "none".to_string())
}
#[cfg(test)]
#[path = "../../../tests/unit/bench_harness/long_running/runner/runner_test.rs"]
mod tests;