1use std::collections::HashSet;
24use std::fs;
25use std::path::{Path, PathBuf};
26use std::process;
27use std::thread;
28
29use harn_vm::testbench::annotations::{
30 annotations_for_record, validate_against_tape, AnnotationKind, AnnotationTape,
31};
32use harn_vm::testbench::fidelity::{compare, FidelityMode, FidelityReport};
33use harn_vm::testbench::overlay_fs::{render_unified_diff, DiffEntry, DiffKind};
34use harn_vm::testbench::tape::EventTape;
35use harn_vm::testbench::{
36 ClockConfig, FilesystemConfig, LlmConfig, NetworkConfig, SubprocessConfig, TapeConfig,
37 Testbench,
38};
39
40use crate::cli::{
41 TestBenchCommand, TestBenchExportAnnotationsArgs, TestBenchFidelityArgs, TestBenchReplayArgs,
42 TestBenchRunArgs, TestBenchValidateAnnotationsArgs,
43};
44use crate::commands::run::{execute_run, CliLlmMockMode, RunOutcome, RunProfileOptions};
45use crate::CLI_RUNTIME_STACK_SIZE;
46
47const DEFAULT_TESTBENCH_START_MS: i64 = 1_767_225_600_000;
52
53enum ReplaySource {
55 ReRun,
57 Tape(String),
59}
60
61pub(crate) async fn run(command: TestBenchCommand) {
62 let outcome = match command {
63 TestBenchCommand::Run(args) => run_args(args).await,
64 TestBenchCommand::Replay(args) => replay_args(args).await,
65 TestBenchCommand::Fidelity(args) => fidelity_args(args).await,
66 TestBenchCommand::ValidateAnnotations(args) => validate_annotations_args(args),
67 TestBenchCommand::ExportAnnotations(args) => export_annotations_args(args),
68 };
69 flush_outcome(outcome);
70}
71
72async fn run_args(args: TestBenchRunArgs) -> RunOutcome {
73 let bench = match build_testbench(&args) {
74 Ok(bench) => bench,
75 Err(message) => return error_outcome(message),
76 };
77 let llm_mode = match build_llm_mode(&args) {
78 Ok(mode) => mode,
79 Err(message) => return error_outcome(message),
80 };
81 match args.runtime.as_str() {
82 "paused-tokio" | "" => run_with_bench(args, bench, llm_mode).await,
83 "des" => run_with_des_runtime(args, bench, llm_mode).await,
84 other => error_outcome(format!(
85 "--runtime must be `paused-tokio` or `des`, got `{other}`"
86 )),
87 }
88}
89
90async fn run_with_bench(
93 args: TestBenchRunArgs,
94 bench: Testbench,
95 llm_mode: CliLlmMockMode,
96) -> RunOutcome {
97 let session = match bench.activate() {
98 Ok(session) => session,
99 Err(error) => return error_outcome(format!("activate testbench: {error}")),
100 };
101 let outcome = execute_run(
102 &args.file,
103 false,
104 HashSet::new(),
105 args.argv.clone(),
106 Vec::new(),
107 llm_mode,
108 None,
109 RunProfileOptions::default(),
110 )
111 .await;
112 finalize_session(outcome, session, &args)
113}
114
115async fn run_with_des_runtime(
130 args: TestBenchRunArgs,
131 bench: Testbench,
132 llm_mode: CliLlmMockMode,
133) -> RunOutcome {
134 let (tx, rx) = std::sync::mpsc::channel();
135 thread::Builder::new()
136 .name("harn-des".to_string())
137 .stack_size(CLI_RUNTIME_STACK_SIZE)
138 .spawn(move || {
139 let rt = tokio::runtime::Builder::new_current_thread()
140 .enable_all()
141 .build()
142 .unwrap_or_else(|e| panic!("failed to build DES runtime: {e}"));
143 let outcome = rt.block_on(async move {
144 harn_vm::reset_thread_local_state();
145 let session = match bench.activate() {
146 Ok(s) => s,
147 Err(e) => return error_outcome(format!("activate testbench: {e}")),
148 };
149 let outcome = execute_run(
150 &args.file,
151 false,
152 HashSet::new(),
153 args.argv.clone(),
154 Vec::new(),
155 llm_mode,
156 None,
157 RunProfileOptions::default(),
158 )
159 .await;
160 finalize_session(outcome, session, &args)
161 });
162 let _ = tx.send(outcome);
163 })
164 .expect("spawn DES thread");
165 tokio::task::spawn_blocking(move || {
166 rx.recv()
167 .unwrap_or_else(|_| error_outcome("DES runtime thread panicked".to_string()))
168 })
169 .await
170 .unwrap_or_else(|e| error_outcome(format!("DES runtime blocking task failed: {e:?}")))
171}
172
173fn build_llm_mode(args: &TestBenchRunArgs) -> Result<CliLlmMockMode, String> {
174 match (&args.llm_fixture, &args.llm_record) {
175 (Some(_), Some(_)) => Err("--llm-fixture and --llm-record are mutually exclusive".into()),
176 (Some(path), None) => Ok(CliLlmMockMode::Replay {
177 fixture_path: PathBuf::from(path),
178 }),
179 (None, Some(path)) => Ok(CliLlmMockMode::Record {
180 fixture_path: PathBuf::from(path),
181 }),
182 (None, None) => Ok(CliLlmMockMode::Off),
183 }
184}
185
186fn finalize_session(
187 outcome: RunOutcome,
188 session: harn_vm::testbench::TestbenchSession,
189 args: &TestBenchRunArgs,
190) -> RunOutcome {
191 let finalize = match session.finalize() {
192 Ok(f) => f,
193 Err(error) => return append_error(outcome, format!("finalize testbench: {error}")),
194 };
195 let mut outcome = outcome;
196 if matches!(args.network.as_str(), "deny") {
197 outcome
198 .stderr
199 .push_str("[testbench] network=deny applied for the duration of the run.\n");
200 }
201 if let Some(diff_path) = args.emit_diff.as_ref() {
202 if let Err(error) = persist_overlay_diff(&finalize.fs_diff, &PathBuf::from(diff_path)) {
203 outcome.stderr.push_str(&format!(
204 "warning: failed to write fs diff to {diff_path}: {error}\n"
205 ));
206 }
207 } else if !finalize.fs_diff.is_empty() {
208 outcome
209 .stderr
210 .push_str(&render_diff_summary(&finalize.fs_diff));
211 }
212 if let Some(record_path) = args.process_record.as_ref() {
213 outcome.stderr.push_str(&format!(
214 "[testbench] recorded {} subprocess invocation(s) to {record_path}.\n",
215 finalize.recorded_subprocesses.len()
216 ));
217 }
218 if let Some(toolchain_dir) = args.process_wasi.as_ref() {
219 outcome.stderr.push_str(&format!(
220 "[testbench] subprocess invocations resolved against WASI toolchain at {toolchain_dir}.\n"
221 ));
222 }
223 if let Some(tape) = finalize.tape.as_ref() {
224 outcome.stderr.push_str(&format!(
225 "[testbench] emitted unified tape with {} record(s) to {}.\n",
226 tape.records,
227 tape.path.display(),
228 ));
229 }
230 for leak in &finalize.clock_leaks {
231 outcome.stderr.push_str(&format!(
232 "[testbench] clock leak: {} (count={})\n",
233 leak.capability_id, leak.count,
234 ));
235 }
236 outcome
237}
238
239async fn replay_args(args: TestBenchReplayArgs) -> RunOutcome {
240 let annotations_loaded = match args.annotations.as_deref() {
243 None => None,
244 Some(path) => match AnnotationTape::load(Path::new(path)) {
245 Ok(tape) => Some((path.to_string(), tape)),
246 Err(error) => {
247 return error_outcome(format!("load annotations {path}: {error}"));
248 }
249 },
250 };
251
252 let tape_temp = if annotations_loaded.is_some() && args.emit_tape.is_none() {
257 match tempfile::tempdir() {
258 Ok(dir) => Some(dir),
259 Err(error) => return error_outcome(format!("tempdir for replay tape: {error}")),
260 }
261 } else {
262 None
263 };
264 let emit_tape_path = match (&args.emit_tape, tape_temp.as_ref()) {
265 (Some(path), _) => Some(path.clone()),
266 (None, Some(dir)) => Some(dir.path().join("run.tape").to_string_lossy().into_owned()),
267 (None, None) => None,
268 };
269
270 let derived = TestBenchRunArgs {
271 file: args.file.clone(),
272 start_at_ms: args.start_at_ms,
273 clock: "paused".to_string(),
274 llm_fixture: args.llm_fixture.clone(),
275 llm_record: None,
276 hypothesis_scenario: None,
277 fs_overlay: args.fs_overlay.clone(),
278 process_replay: Some(args.process_tape.clone()),
279 process_record: None,
280 process_wasi: None,
281 network: "deny".to_string(),
282 allow_host: Vec::new(),
283 emit_diff: None,
284 emit_tape: emit_tape_path.clone(),
285 runtime: "paused-tokio".to_string(),
286 argv: args.argv.clone(),
287 };
288 let mut outcome = run_args(derived).await;
289
290 if let (Some((annotations_path, annotations)), Some(tape_path)) =
291 (annotations_loaded, emit_tape_path)
292 {
293 match EventTape::load(Path::new(&tape_path)) {
294 Ok(tape) => {
295 let report = validate_against_tape(&annotations, &tape);
296 outcome.stderr.push_str(&render_annotations_block(
297 &annotations_path,
298 &annotations,
299 &tape,
300 ));
301 if !report.is_ok() {
302 outcome.stderr.push_str(&format!(
303 "[testbench] annotations validation failed with {} problem(s); see `harn test-bench validate-annotations` for the structured report.\n",
304 report.problems.len()
305 ));
306 outcome.exit_code = outcome.exit_code.max(2);
307 }
308 }
309 Err(error) => {
310 outcome.stderr.push_str(&format!(
311 "warning: failed to load tape for annotation surfacing: {error}\n"
312 ));
313 }
314 }
315 }
316 outcome
317}
318
319fn render_annotations_block(
323 annotations_path: &str,
324 annotations: &AnnotationTape,
325 tape: &EventTape,
326) -> String {
327 let mut out = String::new();
328 out.push_str(&format!(
329 "[annotations] loaded {} annotation(s) from {annotations_path}\n",
330 annotations.annotations.len()
331 ));
332 let mut sorted_records: Vec<_> = tape.records.iter().collect();
333 sorted_records.sort_by_key(|record| record.seq);
334 for record in sorted_records {
335 let matches = annotations_for_record(annotations, record);
336 if matches.is_empty() {
337 continue;
338 }
339 out.push_str(&format!(
340 " event seq={} virtual_time_ms={} kind={}\n",
341 record.seq,
342 record.virtual_time_ms,
343 record.kind.label(),
344 ));
345 for annotation in matches {
346 let label = annotation.kind.as_str();
347 let evidence = annotation
348 .evidence
349 .as_deref()
350 .unwrap_or("(no evidence)")
351 .lines()
352 .next()
353 .unwrap_or("(no evidence)");
354 let id = if annotation.id.is_empty() {
355 "(no id)".to_string()
356 } else {
357 annotation.id.clone()
358 };
359 out.push_str(&format!(" [{label}] {id}: {evidence}\n"));
360 }
361 }
362 out
363}
364
365fn validate_annotations_args(args: TestBenchValidateAnnotationsArgs) -> RunOutcome {
366 let tape = match EventTape::load(Path::new(&args.tape)) {
367 Ok(tape) => tape,
368 Err(error) => return error_outcome(format!("load tape {}: {error}", args.tape)),
369 };
370 let annotations = match AnnotationTape::load(Path::new(&args.annotations)) {
371 Ok(tape) => tape,
372 Err(error) => {
373 return error_outcome(format!("load annotations {}: {error}", args.annotations));
374 }
375 };
376 let report = validate_against_tape(&annotations, &tape);
377 let json = match serde_json::to_string_pretty(&report) {
378 Ok(json) => json,
379 Err(error) => return error_outcome(format!("serialize validation report: {error}")),
380 };
381 let mut outcome = RunOutcome::default();
382 if let Some(path) = args.report.as_deref() {
383 if let Err(error) = persist_text(&json, Path::new(path)) {
384 return error_outcome(format!("write validation report: {error}"));
385 }
386 outcome.stderr.push_str(&format!(
387 "[testbench] annotations validation: checked={} problems={} ({})\n",
388 report.annotations_checked,
389 report.problems.len(),
390 path,
391 ));
392 } else {
393 outcome.stdout.push_str(&json);
394 outcome.stdout.push('\n');
395 }
396 if !report.is_ok() {
397 outcome.exit_code = 2;
398 }
399 outcome
400}
401
402fn export_annotations_args(args: TestBenchExportAnnotationsArgs) -> RunOutcome {
403 let annotations = match AnnotationTape::load(Path::new(&args.annotations)) {
404 Ok(tape) => tape,
405 Err(error) => {
406 return error_outcome(format!("load annotations {}: {error}", args.annotations));
407 }
408 };
409
410 let kinds: Vec<AnnotationKind> = if args.kind.is_empty() {
411 Vec::new()
412 } else {
413 let mut parsed = Vec::with_capacity(args.kind.len());
414 for raw in &args.kind {
415 match AnnotationKind::parse_cli(raw) {
416 Ok(kind) => parsed.push(kind),
417 Err(error) => return error_outcome(error),
418 }
419 }
420 parsed
421 };
422
423 let selected: Vec<_> = annotations
424 .annotations
425 .iter()
426 .filter(|annotation| kinds.is_empty() || kinds.contains(&annotation.kind))
427 .collect();
428
429 let body = match args.format.as_str() {
430 "jsonl" | "" => {
431 let mut out = String::new();
432 for annotation in &selected {
433 match serde_json::to_string(annotation) {
434 Ok(line) => {
435 out.push_str(&line);
436 out.push('\n');
437 }
438 Err(error) => {
439 return error_outcome(format!("serialize annotation: {error}"));
440 }
441 }
442 }
443 out
444 }
445 "friction" => {
446 let mut out = String::new();
447 for annotation in &selected {
448 if let Some(event) = harn_vm::testbench::annotations::annotation_to_friction_event(
449 annotation,
450 &annotations.header,
451 ) {
452 match serde_json::to_string(&event) {
453 Ok(line) => {
454 out.push_str(&line);
455 out.push('\n');
456 }
457 Err(error) => {
458 return error_outcome(format!("serialize friction event: {error}"));
459 }
460 }
461 }
462 }
463 out
464 }
465 other => {
466 return error_outcome(format!(
467 "--format must be `jsonl` or `friction`, got `{other}`"
468 ));
469 }
470 };
471
472 let mut outcome = RunOutcome::default();
473 if let Some(path) = args.output.as_deref() {
474 if let Err(error) = persist_text(&body, Path::new(path)) {
475 return error_outcome(format!("write export: {error}"));
476 }
477 outcome.stderr.push_str(&format!(
478 "[testbench] exported {} annotation(s) to {} (format={})\n",
479 selected.len(),
480 path,
481 args.format,
482 ));
483 } else {
484 outcome.stdout.push_str(&body);
485 }
486 outcome
487}
488
489async fn fidelity_args(args: TestBenchFidelityArgs) -> RunOutcome {
490 let mode = match FidelityMode::parse(&args.mode) {
491 Ok(mode) => mode,
492 Err(error) => return error_outcome(error),
493 };
494
495 let (recorded_path, replay_source) = match (&args.against, &args.replay) {
496 (Some(recorded), _) => (recorded.clone(), ReplaySource::ReRun),
497 (None, Some(replay)) => (args.primary.clone(), ReplaySource::Tape(replay.clone())),
498 (None, None) => {
499 return error_outcome(
500 "expected either two tape paths or `--against <tape> <script>`".to_string(),
501 )
502 }
503 };
504
505 let recorded = match EventTape::load(Path::new(&recorded_path)) {
506 Ok(tape) => tape,
507 Err(error) => return error_outcome(format!("load recorded tape: {error}")),
508 };
509
510 let (replay, mut prelude) = match replay_source {
511 ReplaySource::ReRun => {
512 let temp = match tempfile::tempdir() {
513 Ok(dir) => dir,
514 Err(error) => return error_outcome(format!("create temp tape dir: {error}")),
515 };
516 let replay_tape_path = temp.path().join("replay.tape");
517 let start_at = args
518 .start_at_ms
519 .or(recorded.header.started_at_unix_ms)
520 .unwrap_or(DEFAULT_TESTBENCH_START_MS);
521 let derived = TestBenchRunArgs {
522 file: args.primary.clone(),
523 start_at_ms: Some(start_at),
524 clock: "paused".to_string(),
525 llm_fixture: None,
526 llm_record: None,
527 hypothesis_scenario: None,
528 fs_overlay: args.fs_overlay.clone(),
529 process_replay: None,
530 process_record: None,
531 process_wasi: None,
532 network: "deny".to_string(),
533 allow_host: Vec::new(),
534 emit_diff: None,
535 emit_tape: Some(replay_tape_path.to_string_lossy().into_owned()),
536 runtime: "paused-tokio".to_string(),
537 argv: args.argv.clone(),
538 };
539 let inner = run_args(derived).await;
540 match EventTape::load(&replay_tape_path) {
541 Ok(tape) => (tape, inner),
542 Err(error) => return append_error(inner, format!("load replay tape: {error}")),
543 }
544 }
545 ReplaySource::Tape(path) => match EventTape::load(Path::new(&path)) {
546 Ok(tape) => (tape, RunOutcome::default()),
547 Err(error) => return error_outcome(format!("load replay tape: {error}")),
548 },
549 };
550
551 let report = compare(&recorded, &replay, mode);
552 let json = match serde_json::to_string_pretty(&report) {
553 Ok(json) => json,
554 Err(error) => return append_error(prelude, format!("serialize fidelity report: {error}")),
555 };
556 if let Some(path) = args.report.as_ref() {
557 if let Err(error) = persist_fidelity_report(&json, Path::new(path)) {
558 return append_error(prelude, format!("write fidelity report: {error}"));
559 }
560 prelude.stderr.push_str(&format!(
561 "[testbench] fidelity report written to {path} (mode={:?}, score={:.4}, divergences={})\n",
562 report.mode,
563 report.score,
564 report.divergences.len(),
565 ));
566 } else {
567 prelude.stdout.push_str(&json);
568 prelude.stdout.push('\n');
569 }
570 if !report.divergences.is_empty() {
571 prelude.exit_code = prelude.exit_code.max(report_exit_code(&report));
572 }
573 prelude
574}
575
576fn report_exit_code(report: &FidelityReport) -> i32 {
577 if report.divergences.is_empty() {
580 0
581 } else {
582 2
583 }
584}
585
586fn persist_fidelity_report(json: &str, path: &Path) -> Result<(), String> {
587 persist_text(json, path)
588}
589
590fn persist_text(body: &str, path: &Path) -> Result<(), String> {
591 if let Some(parent) = path.parent() {
592 if !parent.as_os_str().is_empty() {
593 fs::create_dir_all(parent)
594 .map_err(|error| format!("mkdir {}: {error}", parent.display()))?;
595 }
596 }
597 fs::write(path, body).map_err(|error| format!("write {}: {error}", path.display()))
598}
599
600fn build_testbench(args: &TestBenchRunArgs) -> Result<Testbench, String> {
601 let clock = match args.clock.as_str() {
602 "paused" => ClockConfig::Paused {
603 starting_at_ms: args.start_at_ms.unwrap_or(DEFAULT_TESTBENCH_START_MS),
604 },
605 "real" => ClockConfig::Real,
606 other => return Err(format!("--clock must be `paused` or `real`, got `{other}`")),
607 };
608
609 let llm = if let Some(fixture) = &args.llm_fixture {
610 LlmConfig::Replay {
611 fixture: PathBuf::from(fixture),
612 }
613 } else if let Some(record) = &args.llm_record {
614 LlmConfig::Record {
615 fixture: PathBuf::from(record),
616 }
617 } else {
618 LlmConfig::Real
619 };
620
621 let filesystem = match &args.fs_overlay {
622 None => FilesystemConfig::Real,
623 Some(root) => FilesystemConfig::Overlay {
624 worktree: PathBuf::from(root),
625 },
626 };
627
628 let subprocess = if let Some(record) = &args.process_record {
629 SubprocessConfig::Record {
630 tape: PathBuf::from(record),
631 }
632 } else if let Some(replay) = &args.process_replay {
633 SubprocessConfig::Replay {
634 tape: PathBuf::from(replay),
635 }
636 } else if let Some(toolchain) = &args.process_wasi {
637 SubprocessConfig::WasiToolchain {
638 dir: PathBuf::from(toolchain),
639 }
640 } else {
641 SubprocessConfig::Real
642 };
643
644 let network = match args.network.as_str() {
645 "deny" => NetworkConfig::DenyByDefault {
646 allow: args.allow_host.clone(),
647 },
648 "real" => NetworkConfig::Real,
649 other => return Err(format!("--network must be `deny` or `real`, got `{other}`")),
650 };
651
652 let tape = match &args.emit_tape {
653 None => TapeConfig::Off,
654 Some(path) => TapeConfig::Emit {
655 path: PathBuf::from(path),
656 argv: args.argv.clone(),
657 script_path: Some(args.file.clone()),
658 },
659 };
660
661 let hypothesis = args
662 .hypothesis_scenario
663 .as_deref()
664 .map(harn_vm::testbench::hypothesis::HypothesisScenario::parse)
665 .transpose()?;
666
667 Ok(Testbench {
668 clock,
669 llm,
670 filesystem,
671 subprocess,
672 network,
673 tape,
674 hypothesis,
675 })
676}
677
678fn persist_overlay_diff(diff: &[DiffEntry], path: &PathBuf) -> Result<(), String> {
679 if let Some(parent) = path.parent() {
680 if !parent.as_os_str().is_empty() {
681 fs::create_dir_all(parent)
682 .map_err(|err| format!("mkdir {}: {err}", parent.display()))?;
683 }
684 }
685 let body = render_unified_diff(diff);
686 fs::write(path, body).map_err(|err| format!("write {}: {err}", path.display()))
687}
688
689fn render_diff_summary(diff: &[DiffEntry]) -> String {
690 let mut out = String::new();
691 out.push_str(&format!(
692 "[testbench] overlay fs diff: {} change(s)\n",
693 diff.len()
694 ));
695 for entry in diff {
696 let label = match &entry.kind {
697 DiffKind::Added { .. } => "added",
698 DiffKind::Modified { .. } => "modified",
699 DiffKind::Deleted => "deleted",
700 };
701 out.push_str(&format!(" {label} {}\n", entry.path.display()));
702 }
703 out
704}
705
706fn error_outcome(message: String) -> RunOutcome {
707 RunOutcome {
708 stdout: String::new(),
709 stderr: format!("error: {message}\n"),
710 exit_code: 1,
711 }
712}
713
714fn append_error(mut outcome: RunOutcome, message: String) -> RunOutcome {
715 outcome.stderr.push_str(&format!("error: {message}\n"));
716 outcome.exit_code = outcome.exit_code.max(1);
717 outcome
718}
719
720fn flush_outcome(outcome: RunOutcome) {
721 use std::io::Write;
722 let _ = std::io::stderr().write_all(outcome.stderr.as_bytes());
723 let _ = std::io::stdout().write_all(outcome.stdout.as_bytes());
724 if outcome.exit_code != 0 {
725 process::exit(outcome.exit_code);
726 }
727}