1use harn_vm::value::DictMap;
24use harn_vm::{VmDictExt, VmValue};
25
26use crate::error::HostlibError;
27use crate::registry::{BuiltinRegistry, HostlibCapability};
28use crate::tools::inspect_test_results::{get_run, AuthorizedTestPlanIdentity};
29use crate::tools::payload::{optional_string, require_dict_arg, require_string};
30
31pub const ISSUE_BUILTIN: &str = "__harness_verdict_issue";
34
35pub struct VerdictCapability;
37
38impl HostlibCapability for VerdictCapability {
39 fn module_name(&self) -> &'static str {
40 "verdict"
41 }
42
43 fn register_builtins(&self, registry: &mut BuiltinRegistry) {
44 registry.register_gated_fn("verdict", ISSUE_BUILTIN, "issue", verdict_issue_builtin);
48 }
49}
50
51fn verdict_issue_builtin(args: &[VmValue]) -> Result<VmValue, HostlibError> {
52 let map = require_dict_arg(ISSUE_BUILTIN, args)?;
53 let result_handle = require_string(ISSUE_BUILTIN, &map, "result_handle")?;
54 let subject = optional_string(ISSUE_BUILTIN, &map, "subject")?;
55
56 let Some(run) = get_run(&result_handle) else {
60 return Ok(outcome_dict(
61 "unavailable",
62 0,
63 0,
64 &result_handle,
65 "",
66 None,
67 None,
68 subject.as_deref(),
69 "no host execution recorded under this result handle; a positive verdict requires a real run_test execution",
70 ));
71 };
72 let owner = run.execution_scope.as_deref();
73
74 let active = harn_vm::current_execution_scope();
80 let scope_ok = matches!((&active, &run.execution_scope), (Some(a), Some(o)) if a == o);
81 if !scope_ok {
82 return Ok(outcome_dict(
83 "unavailable",
84 0,
85 0,
86 &result_handle,
87 &run.content_hash,
88 owner,
89 run.artifacts.authorized_test_plan.as_ref(),
90 subject.as_deref(),
91 "result handle belongs to a different or ended execution; a positive verdict must be issued within the run that produced it",
92 ));
93 }
94
95 if run.artifacts.exit_code != 0 {
99 return Ok(outcome_dict(
100 "fail",
101 0,
102 0,
103 &result_handle,
104 &run.content_hash,
105 owner,
106 run.artifacts.authorized_test_plan.as_ref(),
107 subject.as_deref(),
108 "host execution exited nonzero",
109 ));
110 }
111
112 let Some(summary) = run.summary else {
116 return Ok(outcome_dict(
117 "unavailable",
118 0,
119 0,
120 &result_handle,
121 &run.content_hash,
122 owner,
123 run.artifacts.authorized_test_plan.as_ref(),
124 subject.as_deref(),
125 "host execution produced no recognized test results",
126 ));
127 };
128 let total = summary.passed + summary.failed + summary.skipped;
129 if summary.failed > 0 {
130 return Ok(outcome_dict(
131 "fail",
132 summary.passed,
133 total,
134 &result_handle,
135 &run.content_hash,
136 owner,
137 run.artifacts.authorized_test_plan.as_ref(),
138 subject.as_deref(),
139 "host execution reported failing tests",
140 ));
141 }
142 if summary.passed == 0 {
143 return Ok(outcome_dict(
144 "unavailable",
145 0,
146 total,
147 &result_handle,
148 &run.content_hash,
149 owner,
150 run.artifacts.authorized_test_plan.as_ref(),
151 subject.as_deref(),
152 "host execution reported zero passing tests",
153 ));
154 }
155
156 let Some(plan) = run.artifacts.authorized_test_plan.as_ref() else {
161 return Ok(outcome_dict(
162 "unavailable",
163 0,
164 total,
165 &result_handle,
166 &run.content_hash,
167 owner,
168 None,
169 subject.as_deref(),
170 "execution was not produced by a host-discovered test plan bound to the active workspace",
171 ));
172 };
173 Ok(outcome_dict(
174 "pass",
175 summary.passed,
176 total,
177 &result_handle,
178 &run.content_hash,
179 owner,
180 Some(plan),
181 subject.as_deref(),
182 "",
183 ))
184}
185
186#[allow(clippy::too_many_arguments)]
187fn outcome_dict(
188 outcome: &str,
189 passed: u32,
190 total: u32,
191 artifact_id: &str,
192 artifact_hash: &str,
193 execution_scope: Option<&str>,
194 plan: Option<&AuthorizedTestPlanIdentity>,
195 subject: Option<&str>,
196 detail: &str,
197) -> VmValue {
198 let mut map = DictMap::new();
199 map.put_str("outcome", outcome);
200 map.put_int("passed", i64::from(passed));
201 map.put_int("total", i64::from(total));
202 map.put_str("artifact_id", artifact_id);
203 map.put_str("artifact_hash", artifact_hash);
204 map.put_opt_str("execution_scope", execution_scope);
206 map.put_opt_str("plan_id", plan.map(|identity| identity.plan_id.as_str()));
207 map.put_opt_str(
208 "workspace_hash",
209 plan.map(|identity| identity.workspace_hash.as_str()),
210 );
211 map.put_opt_str(
212 "command_hash",
213 plan.map(|identity| identity.command_hash.as_str()),
214 );
215 map.put_opt_str("subject", subject);
216 map.put_str("detail", detail);
217 VmValue::dict_map(map)
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223 use crate::tools::inspect_test_results::{store_run, RawArtifacts, TestSummaryData};
224 use harn_lexer::Lexer;
225 use harn_parser::Parser;
226 use harn_vm::orchestration::RunExecutionRecord;
227 use harn_vm::{enter_execution_scope, mint_execution_scope, register_vm_stdlib, Compiler, Vm};
228 use std::path::Path;
229 use std::sync::Arc;
230 use tempfile::TempDir;
231
232 fn issue_arg(result_handle: &str) -> Vec<VmValue> {
235 let mut map = DictMap::new();
236 map.put_str("result_handle", result_handle);
237 vec![VmValue::dict_map(map)]
238 }
239
240 fn outcome_of(v: &VmValue) -> String {
241 v.as_dict()
242 .and_then(|d| d.get("outcome"))
243 .map(|o| o.as_str_cow().into_owned())
244 .unwrap_or_default()
245 }
246
247 fn artifacts(stdout: &str, exit_code: i32) -> RawArtifacts {
248 RawArtifacts {
249 stdout: stdout.to_string(),
250 stderr: String::new(),
251 exit_code,
252 junit_path: None,
253 ecosystem: None,
254 argv: Vec::new(),
255 authorized_test_plan: None,
256 }
257 }
258
259 fn cargo_fixture(parent: &Path, name: &str) -> TempDir {
260 let dir = tempfile::Builder::new()
261 .prefix(name)
262 .tempdir_in(parent)
263 .expect("fixture dir");
264 std::fs::write(
265 dir.path().join("Cargo.toml"),
266 format!(
267 "[package]\nname = \"{name}\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[lib]\npath = \"lib.rs\"\n"
268 ),
269 )
270 .expect("fixture manifest");
271 std::fs::write(
272 dir.path().join("lib.rs"),
273 "#[cfg(test)]\nmod tests {\n #[test]\n fn green() {}\n}\n",
274 )
275 .expect("fixture source");
276 dir
277 }
278
279 fn run_test_in(cwd: &Path, argv: Option<Vec<&str>>, filter: Option<&str>) -> VmValue {
280 let mut req = DictMap::new();
281 req.put_str("cwd", cwd.to_string_lossy());
282 if let Some(argv) = argv {
283 req.put(
284 "argv",
285 VmValue::List(Arc::new(argv.into_iter().map(VmValue::string).collect())),
286 );
287 }
288 req.put_opt_str("filter", filter);
289 crate::tools::run_test::handle(&[VmValue::dict_map(req)]).expect("run_test ok")
290 }
291
292 fn result_handle(result: &VmValue) -> String {
293 result
294 .as_dict()
295 .and_then(|dict| dict.get("result_handle"))
296 .map(|handle| handle.as_str_cow().into_owned())
297 .expect("run_test returns a result_handle")
298 }
299
300 fn compile(source: &str) -> harn_vm::Chunk {
301 let mut lexer = Lexer::new(source);
302 let tokens = lexer.tokenize().expect("tokenize");
303 let mut parser = Parser::new(tokens);
304 let program = parser.parse().expect("parse");
305 Compiler::new().compile(&program).expect("compile")
306 }
307
308 fn harn_string(value: &Path) -> String {
309 value
310 .to_string_lossy()
311 .replace('\\', "\\\\")
312 .replace('"', "\\\"")
313 }
314
315 fn overlap_vm(barrier: Arc<tokio::sync::Barrier>) -> Vm {
316 let mut vm = Vm::new();
317 register_vm_stdlib(&mut vm);
318 let _ = crate::install_default(&mut vm);
319 vm.register_async_builtin("__test_overlap", move |_ctx, _args| {
320 let barrier = barrier.clone();
321 async move {
322 barrier.wait().await;
323 Ok(VmValue::Nil)
324 }
325 });
326 vm
327 }
328
329 fn record_in_scope(
334 scope: &Arc<str>,
335 arts: RawArtifacts,
336 summary: Option<TestSummaryData>,
337 ) -> String {
338 let _g = enter_execution_scope(scope.clone());
339 store_run(arts, summary)
340 }
341
342 #[cfg(unix)]
345 #[test]
346 fn arbitrary_passing_command_cannot_issue_pass() {
347 let _scope = enter_execution_scope(mint_execution_scope());
348 let cwd = std::env::current_dir().expect("cwd");
349 let res = run_test_in(
350 &cwd,
351 Some(vec![
352 "sh",
353 "-c",
354 "printf 'running 1 test\\ntest a ... ok\\n\\ntest result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out\\n'",
355 ]),
356 None,
357 );
358 let handle = result_handle(&res);
359 let out = verdict_issue_builtin(&issue_arg(&handle)).expect("issue ok");
360 assert_eq!(outcome_of(&out), "unavailable");
361 }
362
363 #[test]
367 fn host_discovered_test_plan_issues_pass() {
368 let workspace = TempDir::new().expect("workspace");
369 let fixture = cargo_fixture(workspace.path(), "verdict_positive");
370 harn_vm::stdlib::process::set_thread_execution_context(Some(RunExecutionRecord {
371 cwd: Some(fixture.path().to_string_lossy().into_owned()),
372 project_root: Some(fixture.path().to_string_lossy().into_owned()),
373 ..RunExecutionRecord::default()
374 }));
375 let _scope = enter_execution_scope(mint_execution_scope());
376 let res = run_test_in(fixture.path(), None, None);
377 let out = verdict_issue_builtin(&issue_arg(&result_handle(&res))).expect("issue ok");
378 harn_vm::stdlib::process::set_thread_execution_context(None);
379 assert_eq!(outcome_of(&out), "pass");
380 }
381
382 #[test]
385 fn filtered_discovered_test_plan_cannot_issue_pass() {
386 let workspace = TempDir::new().expect("workspace");
387 let fixture = cargo_fixture(workspace.path(), "verdict_filtered");
388 harn_vm::stdlib::process::set_thread_execution_context(Some(RunExecutionRecord {
389 cwd: Some(fixture.path().to_string_lossy().into_owned()),
390 project_root: Some(fixture.path().to_string_lossy().into_owned()),
391 ..RunExecutionRecord::default()
392 }));
393 let _scope = enter_execution_scope(mint_execution_scope());
394 let res = run_test_in(fixture.path(), None, Some("green"));
395 let out = verdict_issue_builtin(&issue_arg(&result_handle(&res))).expect("issue ok");
396 harn_vm::stdlib::process::set_thread_execution_context(None);
397 assert_eq!(outcome_of(&out), "unavailable");
398 }
399
400 #[tokio::test(flavor = "current_thread")]
405 async fn overlapping_top_level_vms_issue_only_their_own_receipts() {
406 tokio::task::LocalSet::new()
407 .run_until(async {
408 let workspace = TempDir::new().expect("workspace");
409 let fixture = cargo_fixture(workspace.path(), "verdict_overlap");
410 harn_vm::stdlib::process::set_thread_execution_context(Some(RunExecutionRecord {
411 cwd: Some(fixture.path().to_string_lossy().into_owned()),
412 project_root: Some(fixture.path().to_string_lossy().into_owned()),
413 ..RunExecutionRecord::default()
414 }));
415
416 let source = |cwd: &Path| {
417 format!(
418 r#"
419import {{ verdict_disposition, verdict_from_run }} from "std/agent/verdict"
420
421pipeline default(_task) {{
422 hostlib_enable("tools:deterministic")
423 const run = hostlib_tools_run_test({{cwd: "{}", timeout_ms: 120000}})
424 __test_overlap()
425 return verdict_disposition(verdict_from_run(harness, run))
426}}
427"#,
428 harn_string(cwd)
429 )
430 };
431 let chunk_a = compile(&source(fixture.path()));
432 let chunk_b = compile(&source(fixture.path()));
433 let barrier = Arc::new(tokio::sync::Barrier::new(2));
434 let mut vm_a = overlap_vm(barrier.clone());
435 let mut vm_b = overlap_vm(barrier);
436
437 let (result_a, result_b) =
438 tokio::join!(vm_a.execute(&chunk_a), vm_b.execute(&chunk_b));
439 harn_vm::stdlib::process::set_thread_execution_context(None);
440 assert_eq!(result_a.expect("vm A").display(), "pass");
441 assert_eq!(result_b.expect("vm B").display(), "pass");
442 })
443 .await;
444 }
445
446 #[test]
449 fn unrecorded_handle_is_never_a_pass() {
450 let _scope = enter_execution_scope(mint_execution_scope());
451 let out = verdict_issue_builtin(&issue_arg("htr-deadbeef-999999")).expect("issue ok");
452 assert_eq!(outcome_of(&out), "unavailable");
453 }
454
455 #[test]
458 fn cross_scope_handle_is_rejected() {
459 let scope_a: Arc<str> = mint_execution_scope();
460 let handle = record_in_scope(
461 &scope_a,
462 artifacts("test result: ok. 2 passed; 0 failed", 0),
463 Some(TestSummaryData {
464 passed: 2,
465 failed: 0,
466 skipped: 0,
467 }),
468 );
469 let _scope_b = enter_execution_scope(mint_execution_scope());
470 let out = verdict_issue_builtin(&issue_arg(&handle)).expect("issue ok");
471 assert_eq!(outcome_of(&out), "unavailable");
472 }
473
474 #[test]
477 fn no_active_scope_is_rejected() {
478 let scope_a: Arc<str> = mint_execution_scope();
479 let handle = record_in_scope(
480 &scope_a,
481 artifacts("test result: ok. 1 passed; 0 failed", 0),
482 Some(TestSummaryData {
483 passed: 1,
484 failed: 0,
485 skipped: 0,
486 }),
487 );
488 let out = verdict_issue_builtin(&issue_arg(&handle)).expect("issue ok");
490 assert_eq!(outcome_of(&out), "unavailable");
491 }
492
493 #[test]
496 fn nonzero_exit_with_passing_text_is_not_a_pass() {
497 let _g = enter_execution_scope(mint_execution_scope());
498 let handle = store_run(
499 artifacts("test result: ok. 1 passed; 0 failed", 1),
500 Some(TestSummaryData {
501 passed: 1,
502 failed: 0,
503 skipped: 0,
504 }),
505 );
506 let out = verdict_issue_builtin(&issue_arg(&handle)).expect("issue ok");
507 assert_eq!(outcome_of(&out), "fail");
508 }
509
510 #[test]
513 fn red_host_execution_issues_fail() {
514 let _g = enter_execution_scope(mint_execution_scope());
515 let handle = store_run(
516 artifacts("test result: FAILED. 1 passed; 1 failed", 1),
517 Some(TestSummaryData {
518 passed: 1,
519 failed: 1,
520 skipped: 0,
521 }),
522 );
523 let out = verdict_issue_builtin(&issue_arg(&handle)).expect("issue ok");
524 assert_eq!(outcome_of(&out), "fail");
525 }
526
527 #[test]
530 fn recorded_but_unparsed_execution_is_unavailable() {
531 let _g = enter_execution_scope(mint_execution_scope());
532 let handle = store_run(artifacts("not test output", 0), None);
533 let out = verdict_issue_builtin(&issue_arg(&handle)).expect("issue ok");
534 assert_eq!(outcome_of(&out), "unavailable");
535 }
536}