1#![recursion_limit = "256"]
2
3pub mod acp;
4pub mod cli;
5pub mod commands;
6pub mod config;
7pub mod env_guard;
8pub mod format;
9pub mod package;
10pub mod skill_loader;
11pub mod skill_provenance;
12pub mod test_runner;
13#[doc(hidden)]
14pub mod tests;
15
16use clap::{error::ErrorKind, CommandFactory, Parser as ClapParser};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use std::{env, fs, process, thread};
20
21use cli::{
22 Cli, Command, MergeCaptainCommand, MergeCaptainMockCommand, ModelInfoArgs, PackageCacheCommand,
23 PackageCommand, PersonaCommand, RunsCommand, ServeCommand, SkillCommand, SkillKeyCommand,
24 SkillTrustCommand, SkillsCommand,
25};
26use harn_lexer::Lexer;
27use harn_parser::{DiagnosticSeverity, Parser, TypeChecker};
28
29pub const CLI_RUNTIME_STACK_SIZE: usize = 16 * 1024 * 1024;
30
31#[cfg(feature = "hostlib")]
32pub(crate) fn install_default_hostlib(vm: &mut harn_vm::Vm) {
33 let _ = harn_hostlib::install_default(vm);
34}
35
36#[cfg(not(feature = "hostlib"))]
37pub(crate) fn install_default_hostlib(_vm: &mut harn_vm::Vm) {}
38
39pub fn run() {
42 let handle = thread::Builder::new()
43 .name("harn-cli".to_string())
44 .stack_size(CLI_RUNTIME_STACK_SIZE)
45 .spawn(|| {
46 let runtime = tokio::runtime::Builder::new_multi_thread()
47 .enable_all()
48 .build()
49 .unwrap_or_else(|error| {
50 eprintln!("failed to start async runtime: {error}");
51 process::exit(1);
52 });
53 runtime.block_on(async_main());
54 })
55 .unwrap_or_else(|error| {
56 eprintln!("failed to start CLI runtime thread: {error}");
57 process::exit(1);
58 });
59
60 if let Err(payload) = handle.join() {
61 std::panic::resume_unwind(payload);
62 }
63}
64
65async fn async_main() {
66 let raw_args = normalize_serve_args(env::args().collect());
67 if raw_args.len() == 2 && raw_args[1].ends_with(".harn") {
68 commands::run::run_file(
69 &raw_args[1],
70 false,
71 std::collections::HashSet::new(),
72 Vec::new(),
73 commands::run::CliLlmMockMode::Off,
74 None,
75 )
76 .await;
77 return;
78 }
79
80 let cli = match Cli::try_parse_from(&raw_args) {
81 Ok(cli) => cli,
82 Err(error) => {
83 if matches!(
84 error.kind(),
85 ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
86 ) {
87 error.exit();
88 }
89 error.exit();
90 }
91 };
92
93 match cli.command.expect("clap requires a command") {
94 Command::Version => print_version(),
95 Command::Skill(args) => match args.command {
96 SkillCommand::Key(key_args) => match key_args.command {
97 SkillKeyCommand::Generate(generate) => commands::skill::run_key_generate(&generate),
98 },
99 SkillCommand::Sign(sign) => commands::skill::run_sign(&sign),
100 SkillCommand::Endorse(endorse) => commands::skill::run_endorse(&endorse),
101 SkillCommand::Verify(verify) => commands::skill::run_verify(&verify),
102 SkillCommand::WhoSigned(who_signed) => {
103 commands::skill::run_who_signed(&who_signed).await
104 }
105 SkillCommand::Trust(trust_args) => match trust_args.command {
106 SkillTrustCommand::Add(add) => commands::skill::run_trust_add(&add),
107 SkillTrustCommand::List(list) => commands::skill::run_trust_list(&list),
108 },
109 },
110 Command::Run(args) => {
111 let denied =
112 commands::run::build_denied_builtins(args.deny.as_deref(), args.allow.as_deref());
113 let llm_mock_mode = if let Some(path) = args.llm_mock.as_ref() {
114 commands::run::CliLlmMockMode::Replay {
115 fixture_path: PathBuf::from(path),
116 }
117 } else if let Some(path) = args.llm_mock_record.as_ref() {
118 commands::run::CliLlmMockMode::Record {
119 fixture_path: PathBuf::from(path),
120 }
121 } else {
122 commands::run::CliLlmMockMode::Off
123 };
124 let attestation = args.attest.then(|| commands::run::RunAttestationOptions {
125 receipt_out: args.receipt_out.as_ref().map(PathBuf::from),
126 agent_id: args.attest_agent.clone(),
127 });
128
129 match (args.eval.as_deref(), args.file.as_deref()) {
130 (Some(code), None) => {
131 let wrapped = format!("pipeline main(task) {{\n{code}\n}}");
132 let tmp = tempfile::Builder::new()
137 .prefix("harn-eval-")
138 .suffix(".harn")
139 .tempfile()
140 .unwrap_or_else(|e| {
141 command_error(&format!("failed to create temp file for -e: {e}"))
142 });
143 let tmp_path: PathBuf = tmp.path().to_path_buf();
144 fs::write(&tmp_path, &wrapped).unwrap_or_else(|e| {
145 command_error(&format!("failed to write temp file for -e: {e}"))
146 });
147 let tmp_str = tmp_path.to_string_lossy().into_owned();
148 commands::run::run_file_with_skill_dirs(
149 &tmp_str,
150 args.trace,
151 denied,
152 args.argv.clone(),
153 args.skill_dir.clone(),
154 llm_mock_mode.clone(),
155 attestation.clone(),
156 )
157 .await;
158 drop(tmp);
159 }
160 (None, Some(file)) => {
161 commands::run::run_file_with_skill_dirs(
162 file,
163 args.trace,
164 denied,
165 args.argv.clone(),
166 args.skill_dir.clone(),
167 llm_mock_mode,
168 attestation,
169 )
170 .await
171 }
172 (Some(_), Some(_)) => command_error(
173 "`harn run` accepts either `-e <code>` or `<file.harn>`, not both",
174 ),
175 (None, None) => {
176 command_error("`harn run` requires either `-e <code>` or `<file.harn>`")
177 }
178 }
179 }
180 Command::Check(args) => {
181 if args.provider_matrix {
182 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
183 let extensions = package::load_runtime_extensions(&cwd);
184 package::install_runtime_extensions(&extensions);
185 commands::check::provider_matrix::run(args.format, args.filter.as_deref());
186 return;
187 }
188 if args.connector_matrix {
189 commands::check::connector_matrix::run(
190 args.format,
191 args.filter.as_deref(),
192 &args.targets,
193 );
194 return;
195 }
196 let mut target_strings: Vec<String> = args.targets.clone();
197 if args.workspace {
198 let anchor = target_strings.first().map(Path::new);
199 match package::load_workspace_config(anchor) {
200 Some((workspace, manifest_dir)) if !workspace.pipelines.is_empty() => {
201 for pipeline in &workspace.pipelines {
202 let candidate = Path::new(pipeline);
203 let resolved = if candidate.is_absolute() {
204 candidate.to_path_buf()
205 } else {
206 manifest_dir.join(candidate)
207 };
208 target_strings.push(resolved.to_string_lossy().into_owned());
209 }
210 }
211 Some(_) => command_error(
212 "--workspace requires `[workspace].pipelines` in the nearest harn.toml",
213 ),
214 None => command_error(
215 "--workspace could not find a harn.toml walking up from the target(s)",
216 ),
217 }
218 }
219 if target_strings.is_empty() {
220 command_error(
221 "`harn check` requires at least one target path, or `--workspace` with `[workspace].pipelines`",
222 );
223 }
224 for target in &target_strings {
225 if let Err(error) = package::validate_runtime_manifest_extensions(Path::new(target))
226 {
227 command_error(&format!("manifest extension validation failed: {error}"));
228 }
229 }
230 let targets: Vec<&str> = target_strings.iter().map(String::as_str).collect();
231 let files = commands::check::collect_harn_targets(&targets);
232 if files.is_empty() {
233 command_error("no .harn files found under the given target(s)");
234 }
235 let module_graph = commands::check::build_module_graph(&files);
236 let cross_file_imports = commands::check::collect_cross_file_imports(&module_graph);
237 let mut should_fail = false;
238 for file in &files {
239 let mut config = package::load_check_config(Some(file));
240 if let Some(path) = args.host_capabilities.as_ref() {
241 config.host_capabilities_path = Some(path.clone());
242 }
243 if let Some(path) = args.bundle_root.as_ref() {
244 config.bundle_root = Some(path.clone());
245 }
246 if args.strict_types {
247 config.strict_types = true;
248 }
249 if let Some(sev) = args.preflight.as_deref() {
250 config.preflight_severity = Some(sev.to_string());
251 }
252 let outcome = commands::check::check_file_inner(
253 file,
254 &config,
255 &cross_file_imports,
256 &module_graph,
257 args.invariants,
258 );
259 should_fail |= outcome.should_fail(config.strict);
260 }
261 if should_fail {
262 process::exit(1);
263 }
264 }
265 Command::Explain(args) => {
266 let code = commands::explain::run_explain(&args);
267 if code != 0 {
268 process::exit(code);
269 }
270 }
271 Command::Contracts(args) => {
272 commands::contracts::handle_contracts_command(args).await;
273 }
274 Command::Connect(args) => {
275 commands::connect::run_connect(*args).await;
276 }
277 Command::Lint(args) => {
278 let targets: Vec<&str> = args.targets.iter().map(String::as_str).collect();
279 let files = commands::check::collect_harn_targets(&targets);
280 if files.is_empty() {
281 command_error("no .harn files found under the given target(s)");
282 }
283 let module_graph = commands::check::build_module_graph(&files);
284 let cross_file_imports = commands::check::collect_cross_file_imports(&module_graph);
285 if args.fix {
286 for file in &files {
287 let mut config = package::load_check_config(Some(file));
288 commands::check::apply_harn_lint_config(file, &mut config);
289 let require_header = args.require_file_header
290 || commands::check::harn_lint_require_file_header(file);
291 let complexity_threshold =
292 commands::check::harn_lint_complexity_threshold(file);
293 let persona_step_allowlist =
294 commands::check::harn_lint_persona_step_allowlist(file);
295 commands::check::lint_fix_file(
296 file,
297 &config,
298 &cross_file_imports,
299 &module_graph,
300 require_header,
301 complexity_threshold,
302 &persona_step_allowlist,
303 );
304 }
305 } else {
306 let mut should_fail = false;
307 for file in &files {
308 let mut config = package::load_check_config(Some(file));
309 commands::check::apply_harn_lint_config(file, &mut config);
310 let require_header = args.require_file_header
311 || commands::check::harn_lint_require_file_header(file);
312 let complexity_threshold =
313 commands::check::harn_lint_complexity_threshold(file);
314 let persona_step_allowlist =
315 commands::check::harn_lint_persona_step_allowlist(file);
316 let outcome = commands::check::lint_file_inner(
317 file,
318 &config,
319 &cross_file_imports,
320 &module_graph,
321 require_header,
322 complexity_threshold,
323 &persona_step_allowlist,
324 );
325 should_fail |= outcome.should_fail(config.strict);
326 }
327 if should_fail {
328 process::exit(1);
329 }
330 }
331 }
332 Command::Fmt(args) => {
333 let targets: Vec<&str> = args.targets.iter().map(String::as_str).collect();
334 let anchor = targets.first().map(Path::new).unwrap_or(Path::new("."));
337 let loaded = match config::load_for_path(anchor) {
338 Ok(c) => c,
339 Err(e) => {
340 eprintln!("warning: {e}");
341 config::HarnConfig::default()
342 }
343 };
344 let mut opts = harn_fmt::FmtOptions::default();
345 if let Some(w) = loaded.fmt.line_width {
346 opts.line_width = w;
347 }
348 if let Some(w) = loaded.fmt.separator_width {
349 opts.separator_width = w;
350 }
351 if let Some(w) = args.line_width {
352 opts.line_width = w;
353 }
354 if let Some(w) = args.separator_width {
355 opts.separator_width = w;
356 }
357 commands::check::fmt_targets(
358 &targets,
359 commands::check::FmtMode::from_check_flag(args.check),
360 &opts,
361 );
362 }
363 Command::Test(args) => {
364 if args.target.as_deref() == Some("agents-conformance") {
365 if args.selection.is_some() {
366 command_error(
367 "`harn test agents-conformance` does not accept a second positional target; use --category instead",
368 );
369 }
370 if args.evals || args.determinism || args.record || args.replay || args.watch {
371 command_error(
372 "`harn test agents-conformance` cannot be combined with --evals, --determinism, --record, --replay, or --watch",
373 );
374 }
375 let Some(target_url) = args.agents_target.clone() else {
376 command_error("`harn test agents-conformance` requires --target <url>");
377 };
378 commands::agents_conformance::run_agents_conformance(
379 commands::agents_conformance::AgentsConformanceConfig {
380 target_url,
381 api_key: args.agents_api_key.clone(),
382 categories: args.agents_category.clone(),
383 timeout_ms: args.timeout,
384 verbose: args.verbose,
385 json: args.json,
386 json_out: args.json_out.clone(),
387 workspace_id: args.agents_workspace_id.clone(),
388 session_id: args.agents_session_id.clone(),
389 },
390 )
391 .await;
392 return;
393 }
394 if args.target.as_deref() == Some("protocols") {
395 if args.evals || args.determinism || args.record || args.replay || args.watch {
396 command_error(
397 "`harn test protocols` cannot be combined with --evals, --determinism, --record, --replay, or --watch",
398 );
399 }
400 if args.junit.is_some()
401 || args.agents_target.is_some()
402 || args.agents_api_key.is_some()
403 || !args.agents_category.is_empty()
404 || args.json
405 || args.json_out.is_some()
406 || args.agents_workspace_id.is_some()
407 || args.agents_session_id.is_some()
408 || args.parallel
409 || !args.skill_dir.is_empty()
410 {
411 command_error(
412 "`harn test protocols` accepts only --filter, --verbose, --timing, and an optional fixture selection",
413 );
414 }
415 commands::protocol_conformance::run_protocol_conformance(
416 args.selection.as_deref(),
417 args.filter.as_deref(),
418 args.verbose || args.timing,
419 );
420 return;
421 }
422 if args.evals {
423 if args.determinism || args.record || args.replay || args.watch {
424 command_error("--evals cannot be combined with --determinism, --record, --replay, or --watch");
425 }
426 if args.target.as_deref() != Some("package") || args.selection.is_some() {
427 command_error("package evals are run with `harn test package --evals`");
428 }
429 run_package_evals();
430 } else if args.determinism {
431 if args.watch {
432 command_error("--determinism cannot be combined with --watch");
433 }
434 if args.record || args.replay {
435 command_error("--determinism manages its own record/replay cycle");
436 }
437 if let Some(t) = args.target.as_deref() {
438 if t == "conformance" {
439 commands::test::run_conformance_determinism_tests(
440 t,
441 args.selection.as_deref(),
442 args.filter.as_deref(),
443 args.timeout,
444 )
445 .await;
446 } else if args.selection.is_some() {
447 command_error(
448 "only `harn test conformance` accepts a second positional target",
449 );
450 } else {
451 commands::test::run_determinism_tests(
452 t,
453 args.filter.as_deref(),
454 args.timeout,
455 )
456 .await;
457 }
458 } else {
459 let test_dir = if PathBuf::from("tests").is_dir() {
460 "tests".to_string()
461 } else {
462 command_error("no path specified and no tests/ directory found");
463 };
464 if args.selection.is_some() {
465 command_error(
466 "only `harn test conformance` accepts a second positional target",
467 );
468 }
469 commands::test::run_determinism_tests(
470 &test_dir,
471 args.filter.as_deref(),
472 args.timeout,
473 )
474 .await;
475 }
476 } else {
477 if args.record {
478 harn_vm::llm::set_replay_mode(
479 harn_vm::llm::LlmReplayMode::Record,
480 ".harn-fixtures",
481 );
482 } else if args.replay {
483 harn_vm::llm::set_replay_mode(
484 harn_vm::llm::LlmReplayMode::Replay,
485 ".harn-fixtures",
486 );
487 }
488
489 if let Some(t) = args.target.as_deref() {
490 if t == "conformance" {
491 commands::test::run_conformance_tests(
492 t,
493 args.selection.as_deref(),
494 args.filter.as_deref(),
495 args.junit.as_deref(),
496 args.timeout,
497 args.verbose,
498 args.timing,
499 )
500 .await;
501 } else if args.selection.is_some() {
502 command_error(
503 "only `harn test conformance` accepts a second positional target",
504 );
505 } else if args.watch {
506 commands::test::run_watch_tests(
507 t,
508 args.filter.as_deref(),
509 args.timeout,
510 args.parallel,
511 )
512 .await;
513 } else {
514 commands::test::run_user_tests(
515 t,
516 args.filter.as_deref(),
517 args.timeout,
518 args.parallel,
519 )
520 .await;
521 }
522 } else {
523 let test_dir = if PathBuf::from("tests").is_dir() {
524 "tests".to_string()
525 } else {
526 command_error("no path specified and no tests/ directory found");
527 };
528 if args.selection.is_some() {
529 command_error(
530 "only `harn test conformance` accepts a second positional target",
531 );
532 }
533 if args.watch {
534 commands::test::run_watch_tests(
535 &test_dir,
536 args.filter.as_deref(),
537 args.timeout,
538 args.parallel,
539 )
540 .await;
541 } else {
542 commands::test::run_user_tests(
543 &test_dir,
544 args.filter.as_deref(),
545 args.timeout,
546 args.parallel,
547 )
548 .await;
549 }
550 }
551 }
552 }
553 Command::Init(args) => commands::init::init_project(args.name.as_deref(), args.template),
554 Command::New(args) => match commands::init::resolve_new_args(&args) {
555 Ok((name, template)) => commands::init::init_project(name.as_deref(), template),
556 Err(error) => {
557 eprintln!("error: {error}");
558 process::exit(1);
559 }
560 },
561 Command::Doctor(args) => commands::doctor::run_doctor(!args.no_network).await,
562 Command::Serve(args) => match args.command {
563 ServeCommand::Acp(args) => {
564 if let Err(error) = commands::serve::run_acp_server(&args).await {
565 command_error(&error);
566 }
567 }
568 ServeCommand::A2a(args) => {
569 if let Err(error) = commands::serve::run_a2a_server(&args).await {
570 command_error(&error);
571 }
572 }
573 ServeCommand::Mcp(args) => {
574 if let Err(error) = commands::serve::run_mcp_server(&args).await {
575 command_error(&error);
576 }
577 }
578 },
579 Command::Connector(args) => {
580 if let Err(error) = commands::connector::handle_connector_command(args).await {
581 eprintln!("error: {error}");
582 process::exit(1);
583 }
584 }
585 Command::Mcp(args) => commands::mcp::handle_mcp_command(&args.command).await,
586 Command::Watch(args) => {
587 let denied =
588 commands::run::build_denied_builtins(args.deny.as_deref(), args.allow.as_deref());
589 commands::run::run_watch(&args.file, denied).await;
590 }
591 Command::Portal(args) => {
592 commands::portal::run_portal(&args.dir, &args.host, args.port, args.open).await
593 }
594 Command::Trigger(args) => {
595 if let Err(error) = commands::trigger::handle(args).await {
596 eprintln!("error: {error}");
597 process::exit(1);
598 }
599 }
600 Command::Flow(args) => match commands::flow::run_flow(&args) {
601 Ok(code) => {
602 if code != 0 {
603 process::exit(code);
604 }
605 }
606 Err(error) => command_error(&error),
607 },
608 Command::Trace(args) => {
609 if let Err(error) = commands::trace::handle(args).await {
610 eprintln!("error: {error}");
611 process::exit(1);
612 }
613 }
614 Command::Crystallize(args) => {
615 if let Err(error) = commands::crystallize::run(args) {
616 eprintln!("error: {error}");
617 process::exit(1);
618 }
619 }
620 Command::Trust(args) | Command::TrustGraph(args) => {
621 if let Err(error) = commands::trust::handle(args).await {
622 eprintln!("error: {error}");
623 process::exit(1);
624 }
625 }
626 Command::Verify(args) => {
627 if let Err(error) = verify_provenance_receipt(&args.receipt, args.json) {
628 eprintln!("error: {error}");
629 process::exit(1);
630 }
631 }
632 Command::Orchestrator(args) => {
633 if let Err(error) = commands::orchestrator::handle(args).await {
634 eprintln!("error: {error}");
635 process::exit(1);
636 }
637 }
638 Command::Playground(args) => {
639 let llm_mock_mode = if let Some(path) = args.llm_mock.as_ref() {
640 commands::run::CliLlmMockMode::Replay {
641 fixture_path: PathBuf::from(path),
642 }
643 } else if let Some(path) = args.llm_mock_record.as_ref() {
644 commands::run::CliLlmMockMode::Record {
645 fixture_path: PathBuf::from(path),
646 }
647 } else {
648 commands::run::CliLlmMockMode::Off
649 };
650 if let Err(error) = commands::playground::run_command(args, llm_mock_mode).await {
651 eprint!("{error}");
652 process::exit(1);
653 }
654 }
655 Command::Runs(args) => match args.command {
656 RunsCommand::Inspect(inspect) => {
657 inspect_run_record(&inspect.path, inspect.compare.as_deref())
658 }
659 },
660 Command::Replay(args) => replay_run_record(&args.path),
661 Command::Eval(args) => {
662 let llm_mock_mode = if let Some(path) = args.llm_mock.as_ref() {
663 commands::run::CliLlmMockMode::Replay {
664 fixture_path: PathBuf::from(path),
665 }
666 } else if let Some(path) = args.llm_mock_record.as_ref() {
667 commands::run::CliLlmMockMode::Record {
668 fixture_path: PathBuf::from(path),
669 }
670 } else {
671 commands::run::CliLlmMockMode::Off
672 };
673 eval_run_record(
674 &args.path,
675 args.compare.as_deref(),
676 args.structural_experiment.as_deref(),
677 &args.argv,
678 &llm_mock_mode,
679 )
680 }
681 Command::Repl => commands::repl::run_repl().await,
682 Command::Bench(args) => commands::bench::run_bench(&args.file, args.iterations).await,
683 Command::Viz(args) => commands::viz::run_viz(&args.file, args.output.as_deref()),
684 Command::Install(args) => package::install_packages(
685 args.frozen || args.locked || args.offline,
686 args.refetch.as_deref(),
687 args.offline,
688 ),
689 Command::Add(args) => package::add_package_with_registry(
690 &args.name_or_spec,
691 args.alias.as_deref(),
692 args.git.as_deref(),
693 args.tag.as_deref(),
694 args.rev.as_deref(),
695 args.branch.as_deref(),
696 args.path.as_deref(),
697 args.registry.as_deref(),
698 ),
699 Command::Update(args) => package::update_packages(args.alias.as_deref(), args.all),
700 Command::Remove(args) => package::remove_package(&args.alias),
701 Command::Lock => package::lock_packages(),
702 Command::Package(args) => match args.command {
703 PackageCommand::Search(search) => package::search_package_registry(
704 search.query.as_deref(),
705 search.registry.as_deref(),
706 search.json,
707 ),
708 PackageCommand::Info(info) => {
709 package::show_package_registry_info(&info.name, info.registry.as_deref(), info.json)
710 }
711 PackageCommand::Check(check) => {
712 package::check_package(check.package.as_deref(), check.json)
713 }
714 PackageCommand::Pack(pack) => package::pack_package(
715 pack.package.as_deref(),
716 pack.output.as_deref(),
717 pack.dry_run,
718 pack.json,
719 ),
720 PackageCommand::Docs(docs) => package::generate_package_docs(
721 docs.package.as_deref(),
722 docs.output.as_deref(),
723 docs.check,
724 ),
725 PackageCommand::Cache(cache) => match cache.command {
726 PackageCacheCommand::List => package::list_package_cache(),
727 PackageCacheCommand::Clean(clean) => package::clean_package_cache(clean.all),
728 PackageCacheCommand::Verify(verify) => {
729 package::verify_package_cache(verify.materialized)
730 }
731 },
732 },
733 Command::Publish(args) => package::publish_package(
734 args.package.as_deref(),
735 args.dry_run,
736 args.registry.as_deref(),
737 args.json,
738 ),
739 Command::MergeCaptain(args) => match args.command {
740 MergeCaptainCommand::Run(run) => {
741 let code = commands::merge_captain::run_driver(&run);
742 if code != 0 {
743 process::exit(code);
744 }
745 }
746 MergeCaptainCommand::Ladder(ladder) => {
747 let code = commands::merge_captain::run_ladder(&ladder);
748 if code != 0 {
749 process::exit(code);
750 }
751 }
752 MergeCaptainCommand::Iterate(iterate) => {
753 let code = commands::merge_captain::run_iterate(&iterate);
754 if code != 0 {
755 process::exit(code);
756 }
757 }
758 MergeCaptainCommand::Audit(audit) => {
759 let code = commands::merge_captain::run_audit(&audit);
760 if code != 0 {
761 process::exit(code);
762 }
763 }
764 MergeCaptainCommand::Mock(mock) => {
765 let code = match mock {
766 MergeCaptainMockCommand::Init(args) => {
767 commands::merge_captain_mock::run_init(&args)
768 }
769 MergeCaptainMockCommand::Step(args) => {
770 commands::merge_captain_mock::run_step(&args)
771 }
772 MergeCaptainMockCommand::Status(args) => {
773 commands::merge_captain_mock::run_status(&args)
774 }
775 MergeCaptainMockCommand::Serve(args) => {
776 commands::merge_captain_mock::run_serve(&args).await
777 }
778 MergeCaptainMockCommand::Cleanup(args) => {
779 commands::merge_captain_mock::run_cleanup(&args)
780 }
781 MergeCaptainMockCommand::Scenarios => {
782 commands::merge_captain_mock::run_scenarios()
783 }
784 };
785 if code != 0 {
786 process::exit(code);
787 }
788 }
789 },
790 Command::Persona(args) => match args.command {
791 PersonaCommand::New(new) => {
792 if let Err(error) = commands::persona_scaffold::run_new(&new) {
793 eprintln!("error: {error}");
794 process::exit(1);
795 }
796 }
797 PersonaCommand::Doctor(doctor) => {
798 if let Err(error) =
799 commands::persona_doctor::run_doctor(args.manifest.as_deref(), &doctor).await
800 {
801 eprintln!("error: {error}");
802 process::exit(1);
803 }
804 }
805 PersonaCommand::Check(check) => {
806 commands::persona::run_check(args.manifest.as_deref(), &check)
807 }
808 PersonaCommand::List(list) => {
809 commands::persona::run_list(args.manifest.as_deref(), &list)
810 }
811 PersonaCommand::Inspect(inspect) => {
812 commands::persona::run_inspect(args.manifest.as_deref(), &inspect)
813 }
814 PersonaCommand::Status(status) => {
815 if let Err(error) = commands::persona::run_status(
816 args.manifest.as_deref(),
817 &args.state_dir,
818 &status,
819 )
820 .await
821 {
822 eprintln!("error: {error}");
823 process::exit(1);
824 }
825 }
826 PersonaCommand::Pause(control) => {
827 if let Err(error) = commands::persona::run_pause(
828 args.manifest.as_deref(),
829 &args.state_dir,
830 &control,
831 )
832 .await
833 {
834 eprintln!("error: {error}");
835 process::exit(1);
836 }
837 }
838 PersonaCommand::Resume(control) => {
839 if let Err(error) = commands::persona::run_resume(
840 args.manifest.as_deref(),
841 &args.state_dir,
842 &control,
843 )
844 .await
845 {
846 eprintln!("error: {error}");
847 process::exit(1);
848 }
849 }
850 PersonaCommand::Disable(control) => {
851 if let Err(error) = commands::persona::run_disable(
852 args.manifest.as_deref(),
853 &args.state_dir,
854 &control,
855 )
856 .await
857 {
858 eprintln!("error: {error}");
859 process::exit(1);
860 }
861 }
862 PersonaCommand::Tick(tick) => {
863 if let Err(error) =
864 commands::persona::run_tick(args.manifest.as_deref(), &args.state_dir, &tick)
865 .await
866 {
867 eprintln!("error: {error}");
868 process::exit(1);
869 }
870 }
871 PersonaCommand::Trigger(trigger) => {
872 if let Err(error) = commands::persona::run_trigger(
873 args.manifest.as_deref(),
874 &args.state_dir,
875 &trigger,
876 )
877 .await
878 {
879 eprintln!("error: {error}");
880 process::exit(1);
881 }
882 }
883 PersonaCommand::Spend(spend) => {
884 if let Err(error) =
885 commands::persona::run_spend(args.manifest.as_deref(), &args.state_dir, &spend)
886 .await
887 {
888 eprintln!("error: {error}");
889 process::exit(1);
890 }
891 }
892 },
893 Command::ModelInfo(args) => {
894 if !print_model_info(&args).await {
895 process::exit(1);
896 }
897 }
898 Command::ProviderCatalog(args) => print_provider_catalog(args.available_only),
899 Command::ProviderReady(args) => {
900 run_provider_ready(
901 &args.provider,
902 args.model.as_deref(),
903 args.base_url.as_deref(),
904 args.json,
905 )
906 .await
907 }
908 Command::Skills(args) => match args.command {
909 SkillsCommand::List(list) => commands::skills::run_list(&list),
910 SkillsCommand::Inspect(inspect) => commands::skills::run_inspect(&inspect),
911 SkillsCommand::Match(matcher) => commands::skills::run_match(&matcher),
912 SkillsCommand::Install(install) => commands::skills::run_install(&install),
913 SkillsCommand::New(new_args) => commands::skills::run_new(&new_args),
914 },
915 Command::DumpHighlightKeywords(args) => {
916 commands::dump_highlight_keywords::run(&args.output, args.check);
917 }
918 Command::DumpTriggerQuickref(args) => {
919 commands::dump_trigger_quickref::run(&args.output, args.check);
920 }
921 Command::DumpConnectorMatrix(args) => {
922 commands::check::connector_matrix::run_docs(&args.output, &args.sources, args.check);
923 }
924 }
925}
926
927fn normalize_serve_args(mut raw_args: Vec<String>) -> Vec<String> {
928 if raw_args.len() > 2
929 && raw_args.get(1).is_some_and(|arg| arg == "serve")
930 && !matches!(
931 raw_args.get(2).map(String::as_str),
932 Some("acp" | "a2a" | "mcp" | "-h" | "--help")
933 )
934 {
935 raw_args.insert(2, "a2a".to_string());
936 }
937 raw_args
938}
939
940fn print_version() {
941 println!(
942 r#"
943 ╱▔▔╲
944 ╱ ╲ harn v{}
945 │ ◆ │ the agent harness language
946 │ │
947 ╰──╯╱
948 ╱╱
949"#,
950 env!("CARGO_PKG_VERSION")
951 );
952}
953
954async fn print_model_info(args: &ModelInfoArgs) -> bool {
955 let resolved = harn_vm::llm_config::resolve_model_info(&args.model);
956 let api_key_result = harn_vm::llm::resolve_api_key(&resolved.provider);
957 let api_key_set = api_key_result.is_ok();
958 let api_key = api_key_result.unwrap_or_default();
959 let context_window =
960 harn_vm::llm::fetch_provider_max_context(&resolved.provider, &resolved.id, &api_key).await;
961 let readiness = local_openai_readiness(&resolved.provider, &resolved.id, &api_key).await;
962 let catalog = harn_vm::llm_config::model_catalog_entry(&resolved.id);
963 let capabilities = harn_vm::llm::capabilities::lookup(&resolved.provider, &resolved.id);
964 let mut payload = serde_json::json!({
965 "alias": args.model,
966 "id": resolved.id,
967 "provider": resolved.provider,
968 "resolved_alias": resolved.alias,
969 "tool_format": resolved.tool_format,
970 "tier": resolved.tier,
971 "api_key_set": api_key_set,
972 "context_window": context_window,
973 "readiness": readiness,
974 "catalog": catalog,
975 "capabilities": {
976 "native_tools": capabilities.native_tools,
977 "defer_loading": capabilities.defer_loading,
978 "tool_search": capabilities.tool_search,
979 "max_tools": capabilities.max_tools,
980 "prompt_caching": capabilities.prompt_caching,
981 "vision": capabilities.vision,
982 "vision_supported": capabilities.vision_supported,
983 "audio": capabilities.audio,
984 "pdf": capabilities.pdf,
985 "files_api_supported": capabilities.files_api_supported,
986 "json_schema": capabilities.json_schema,
987 "thinking": !capabilities.thinking_modes.is_empty(),
988 "thinking_modes": capabilities.thinking_modes,
989 "interleaved_thinking_supported": capabilities.interleaved_thinking_supported,
990 "anthropic_beta_features": capabilities.anthropic_beta_features,
991 "preserve_thinking": capabilities.preserve_thinking,
992 "server_parser": capabilities.server_parser,
993 "honors_chat_template_kwargs": capabilities.honors_chat_template_kwargs,
994 "recommended_endpoint": capabilities.recommended_endpoint,
995 "text_tool_wire_format_supported": capabilities.text_tool_wire_format_supported,
996 },
997 "qc_default_model": harn_vm::llm_config::qc_default_model(&resolved.provider),
998 });
999
1000 let should_verify = args.verify || args.warm;
1001 let mut ok = true;
1002 if should_verify {
1003 if resolved.provider == "ollama" {
1004 let mut readiness = harn_vm::llm::OllamaReadinessOptions::new(resolved.id.clone());
1005 readiness.warm = args.warm;
1006 readiness.keep_alive = args
1007 .keep_alive
1008 .as_deref()
1009 .and_then(harn_vm::llm::normalize_ollama_keep_alive);
1010 let result = harn_vm::llm::ollama_readiness(readiness).await;
1011 ok = result.valid;
1012 payload["readiness"] = serde_json::to_value(&result).unwrap_or_else(|error| {
1013 serde_json::json!({
1014 "valid": false,
1015 "status": "serialization_error",
1016 "message": format!("failed to serialize readiness result: {error}"),
1017 })
1018 });
1019 } else {
1020 ok = false;
1021 payload["readiness"] = serde_json::json!({
1022 "valid": false,
1023 "status": "unsupported_provider",
1024 "message": format!(
1025 "model-info --verify is only supported for Ollama models; resolved provider is '{}'",
1026 resolved.provider
1027 ),
1028 "provider": resolved.provider,
1029 });
1030 }
1031 }
1032
1033 println!(
1034 "{}",
1035 serde_json::to_string(&payload).unwrap_or_else(|error| {
1036 command_error(&format!("failed to serialize model info: {error}"))
1037 })
1038 );
1039 ok
1040}
1041
1042async fn local_openai_readiness(
1043 provider: &str,
1044 model: &str,
1045 api_key: &str,
1046) -> Option<serde_json::Value> {
1047 let def = harn_vm::llm_config::provider_config(provider)?;
1048 if def.auth_style != "none" || !harn_vm::llm::supports_model_readiness_probe(&def) {
1049 return None;
1050 }
1051 let readiness = harn_vm::llm::probe_openai_compatible_model(provider, model, api_key).await;
1052 Some(serde_json::json!({
1053 "valid": readiness.valid,
1054 "category": readiness.category,
1055 "message": readiness.message,
1056 "provider": readiness.provider,
1057 "model": readiness.model,
1058 "url": readiness.url,
1059 "status": readiness.status,
1060 "available_models": readiness.available_models,
1061 }))
1062}
1063
1064fn print_provider_catalog(available_only: bool) {
1065 let provider_names = if available_only {
1066 harn_vm::llm_config::available_provider_names()
1067 } else {
1068 harn_vm::llm_config::provider_names()
1069 };
1070 let providers: Vec<_> = provider_names
1071 .into_iter()
1072 .filter_map(|name| {
1073 harn_vm::llm_config::provider_config(&name).map(|def| {
1074 serde_json::json!({
1075 "name": name,
1076 "display_name": def.display_name,
1077 "icon": def.icon,
1078 "base_url": harn_vm::llm_config::resolve_base_url(&def),
1079 "base_url_env": def.base_url_env,
1080 "auth_style": def.auth_style,
1081 "auth_envs": harn_vm::llm_config::auth_env_names(&def.auth_env),
1082 "auth_available": harn_vm::llm_config::provider_key_available(&name),
1083 "features": def.features,
1084 "cost_per_1k_in": def.cost_per_1k_in,
1085 "cost_per_1k_out": def.cost_per_1k_out,
1086 "latency_p50_ms": def.latency_p50_ms,
1087 })
1088 })
1089 })
1090 .collect();
1091 let models: Vec<_> = harn_vm::llm_config::model_catalog_entries()
1092 .into_iter()
1093 .map(|(id, model)| {
1094 serde_json::json!({
1095 "id": id,
1096 "name": model.name,
1097 "provider": model.provider,
1098 "context_window": model.context_window,
1099 "stream_timeout": model.stream_timeout,
1100 "capabilities": model.capabilities,
1101 "pricing": model.pricing,
1102 })
1103 })
1104 .collect();
1105 let aliases: Vec<_> = harn_vm::llm_config::alias_entries()
1106 .into_iter()
1107 .map(|(name, alias)| {
1108 serde_json::json!({
1109 "name": name,
1110 "id": alias.id,
1111 "provider": alias.provider,
1112 "tool_format": alias.tool_format,
1113 })
1114 })
1115 .collect();
1116 let payload = serde_json::json!({
1117 "providers": providers,
1118 "known_model_names": harn_vm::llm_config::known_model_names(),
1119 "available_providers": harn_vm::llm_config::available_provider_names(),
1120 "aliases": aliases,
1121 "models": models,
1122 "qc_defaults": harn_vm::llm_config::qc_defaults(),
1123 });
1124 println!(
1125 "{}",
1126 serde_json::to_string(&payload).unwrap_or_else(|error| {
1127 command_error(&format!("failed to serialize provider catalog: {error}"))
1128 })
1129 );
1130}
1131
1132async fn run_provider_ready(
1133 provider: &str,
1134 model: Option<&str>,
1135 base_url: Option<&str>,
1136 json: bool,
1137) {
1138 let readiness =
1139 harn_vm::llm::readiness::probe_provider_readiness(provider, model, base_url).await;
1140 if json {
1141 match serde_json::to_string_pretty(&readiness) {
1142 Ok(payload) => println!("{payload}"),
1143 Err(error) => command_error(&format!("failed to serialize readiness result: {error}")),
1144 }
1145 } else if readiness.ok {
1146 println!("{}", readiness.message);
1147 } else {
1148 eprintln!("{}", readiness.message);
1149 }
1150 if !readiness.ok {
1151 process::exit(1);
1152 }
1153}
1154
1155fn command_error(message: &str) -> ! {
1156 Cli::command()
1157 .error(ErrorKind::ValueValidation, message)
1158 .exit()
1159}
1160
1161fn verify_provenance_receipt(path: &str, json: bool) -> Result<(), String> {
1162 let raw =
1163 fs::read_to_string(path).map_err(|error| format!("failed to read {path}: {error}"))?;
1164 let receipt: harn_vm::ProvenanceReceipt = serde_json::from_str(&raw)
1165 .map_err(|error| format!("failed to parse provenance receipt {path}: {error}"))?;
1166 let report = harn_vm::verify_receipt(&receipt);
1167 if json {
1168 println!(
1169 "{}",
1170 serde_json::to_string_pretty(&report).map_err(|error| error.to_string())?
1171 );
1172 } else if report.verified {
1173 println!(
1174 "verified receipt={} events={} receipt_hash={} event_root_hash={}",
1175 report.receipt_id.unwrap_or_else(|| "-".to_string()),
1176 report.event_count,
1177 report.receipt_hash.unwrap_or_else(|| "-".to_string()),
1178 report.event_root_hash.unwrap_or_else(|| "-".to_string())
1179 );
1180 } else {
1181 println!(
1182 "failed receipt={} events={}",
1183 report.receipt_id.unwrap_or_else(|| "-".to_string()),
1184 report.event_count
1185 );
1186 for error in &report.errors {
1187 println!(" {error}");
1188 }
1189 return Err("provenance receipt verification failed".to_string());
1190 }
1191 Ok(())
1192}
1193
1194fn load_run_record_or_exit(path: &Path) -> harn_vm::orchestration::RunRecord {
1195 match harn_vm::orchestration::load_run_record(path) {
1196 Ok(run) => run,
1197 Err(error) => {
1198 eprintln!("Failed to load run record: {error}");
1199 process::exit(1);
1200 }
1201 }
1202}
1203
1204fn load_eval_suite_manifest_or_exit(path: &Path) -> harn_vm::orchestration::EvalSuiteManifest {
1205 harn_vm::orchestration::load_eval_suite_manifest(path).unwrap_or_else(|error| {
1206 eprintln!("Failed to load eval manifest {}: {error}", path.display());
1207 process::exit(1);
1208 })
1209}
1210
1211fn load_eval_pack_manifest_or_exit(path: &Path) -> harn_vm::orchestration::EvalPackManifest {
1212 harn_vm::orchestration::load_eval_pack_manifest(path).unwrap_or_else(|error| {
1213 eprintln!("Failed to load eval pack {}: {error}", path.display());
1214 process::exit(1);
1215 })
1216}
1217
1218fn load_persona_eval_ladder_manifest_or_exit(
1219 path: &Path,
1220) -> harn_vm::orchestration::PersonaEvalLadderManifest {
1221 harn_vm::orchestration::load_persona_eval_ladder_manifest(path).unwrap_or_else(|error| {
1222 eprintln!(
1223 "Failed to load persona eval ladder {}: {error}",
1224 path.display()
1225 );
1226 process::exit(1);
1227 })
1228}
1229
1230fn file_looks_like_eval_manifest(path: &Path) -> bool {
1231 if path.file_name().and_then(|name| name.to_str()) == Some("harn.eval.toml") {
1232 return true;
1233 }
1234 if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
1235 let Ok(content) = fs::read_to_string(path) else {
1236 return false;
1237 };
1238 return toml::from_str::<harn_vm::orchestration::EvalPackManifest>(&content)
1239 .is_ok_and(|manifest| !manifest.cases.is_empty() || !manifest.ladders.is_empty());
1240 }
1241 let Ok(content) = fs::read_to_string(path) else {
1242 return false;
1243 };
1244 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1245 return false;
1246 };
1247 json.get("_type").and_then(|value| value.as_str()) == Some("eval_suite_manifest")
1248 || json.get("cases").is_some()
1249}
1250
1251fn file_looks_like_eval_pack_manifest(path: &Path) -> bool {
1252 if path.file_name().and_then(|name| name.to_str()) == Some("harn.eval.toml") {
1253 return true;
1254 }
1255 if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
1256 return file_looks_like_eval_manifest(path);
1257 }
1258 let Ok(content) = fs::read_to_string(path) else {
1259 return false;
1260 };
1261 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1262 return false;
1263 };
1264 json.get("version").is_some()
1265 && (json.get("cases").is_some() || json.get("ladders").is_some())
1266 && json.get("_type").and_then(|value| value.as_str()) != Some("eval_suite_manifest")
1267}
1268
1269fn file_looks_like_persona_eval_ladder_manifest(path: &Path) -> bool {
1270 let Ok(content) = fs::read_to_string(path) else {
1271 return false;
1272 };
1273 if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
1274 let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
1275 return false;
1276 };
1277 return json.get("_type").and_then(|value| value.as_str())
1278 == Some("persona_eval_ladder_manifest")
1279 || json.get("timeout_tiers").is_some()
1280 || json.get("timeout-tiers").is_some();
1281 }
1282 toml::from_str::<harn_vm::orchestration::PersonaEvalLadderManifest>(&content).is_ok_and(
1283 |manifest| {
1284 manifest
1285 .type_name
1286 .eq_ignore_ascii_case("persona_eval_ladder_manifest")
1287 || (!manifest.timeout_tiers.is_empty() && manifest.backend.path.is_some())
1288 },
1289 )
1290}
1291
1292fn collect_run_record_paths(path: &str) -> Vec<PathBuf> {
1293 let path = Path::new(path);
1294 if path.is_file() {
1295 return vec![path.to_path_buf()];
1296 }
1297 if path.is_dir() {
1298 let mut entries: Vec<PathBuf> = fs::read_dir(path)
1299 .unwrap_or_else(|error| {
1300 eprintln!("Failed to read run directory {}: {error}", path.display());
1301 process::exit(1);
1302 })
1303 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
1304 .filter(|entry| entry.extension().and_then(|ext| ext.to_str()) == Some("json"))
1305 .collect();
1306 entries.sort();
1307 return entries;
1308 }
1309 eprintln!("Run path does not exist: {}", path.display());
1310 process::exit(1);
1311}
1312
1313fn print_run_diff(diff: &harn_vm::orchestration::RunDiffReport) {
1314 println!(
1315 "Diff: {} -> {} [{} -> {}]",
1316 diff.left_run_id, diff.right_run_id, diff.left_status, diff.right_status
1317 );
1318 println!("Identical: {}", diff.identical);
1319 println!("Stage diffs: {}", diff.stage_diffs.len());
1320 println!("Tool diffs: {}", diff.tool_diffs.len());
1321 println!("Observability diffs: {}", diff.observability_diffs.len());
1322 println!("Transition delta: {}", diff.transition_count_delta);
1323 println!("Artifact delta: {}", diff.artifact_count_delta);
1324 println!("Checkpoint delta: {}", diff.checkpoint_count_delta);
1325 for stage in &diff.stage_diffs {
1326 println!("- {} [{}]", stage.node_id, stage.change);
1327 for detail in &stage.details {
1328 println!(" {}", detail);
1329 }
1330 }
1331 for tool in &diff.tool_diffs {
1332 println!("- tool {} [{}]", tool.tool_name, tool.args_hash);
1333 println!(" left: {:?}", tool.left_result);
1334 println!(" right: {:?}", tool.right_result);
1335 }
1336 for item in &diff.observability_diffs {
1337 println!("- {} [{}]", item.label, item.section);
1338 for detail in &item.details {
1339 println!(" {}", detail);
1340 }
1341 }
1342}
1343
1344fn inspect_run_record(path: &str, compare: Option<&str>) {
1345 let run = load_run_record_or_exit(Path::new(path));
1346 println!("Run: {}", run.id);
1347 println!(
1348 "Workflow: {}",
1349 run.workflow_name
1350 .clone()
1351 .unwrap_or_else(|| run.workflow_id.clone())
1352 );
1353 println!("Status: {}", run.status);
1354 println!("Task: {}", run.task);
1355 println!("Stages: {}", run.stages.len());
1356 println!("Artifacts: {}", run.artifacts.len());
1357 println!("Transitions: {}", run.transitions.len());
1358 println!("Checkpoints: {}", run.checkpoints.len());
1359 println!("HITL questions: {}", run.hitl_questions.len());
1360 if let Some(observability) = &run.observability {
1361 println!("Planner rounds: {}", observability.planner_rounds.len());
1362 println!("Research facts: {}", observability.research_fact_count);
1363 println!("Workers: {}", observability.worker_lineage.len());
1364 println!(
1365 "Action graph: {} nodes / {} edges",
1366 observability.action_graph_nodes.len(),
1367 observability.action_graph_edges.len()
1368 );
1369 println!(
1370 "Transcript pointers: {}",
1371 observability.transcript_pointers.len()
1372 );
1373 println!("Daemon events: {}", observability.daemon_events.len());
1374 }
1375 if let Some(parent_worker_id) = run
1376 .metadata
1377 .get("parent_worker_id")
1378 .and_then(|value| value.as_str())
1379 {
1380 println!("Parent worker: {}", parent_worker_id);
1381 }
1382 if let Some(parent_stage_id) = run
1383 .metadata
1384 .get("parent_stage_id")
1385 .and_then(|value| value.as_str())
1386 {
1387 println!("Parent stage: {}", parent_stage_id);
1388 }
1389 if run
1390 .metadata
1391 .get("delegated")
1392 .and_then(|value| value.as_bool())
1393 .unwrap_or(false)
1394 {
1395 println!("Delegated: true");
1396 }
1397 println!(
1398 "Pending nodes: {}",
1399 if run.pending_nodes.is_empty() {
1400 "-".to_string()
1401 } else {
1402 run.pending_nodes.join(", ")
1403 }
1404 );
1405 println!(
1406 "Replay fixture: {}",
1407 if run.replay_fixture.is_some() {
1408 "embedded"
1409 } else {
1410 "derived"
1411 }
1412 );
1413 for stage in &run.stages {
1414 let worker = stage.metadata.get("worker");
1415 let worker_suffix = worker
1416 .and_then(|value| value.get("name"))
1417 .and_then(|value| value.as_str())
1418 .map(|name| format!(" worker={name}"))
1419 .unwrap_or_default();
1420 println!(
1421 "- {} [{}] status={} outcome={} branch={}{}",
1422 stage.node_id,
1423 stage.kind,
1424 stage.status,
1425 stage.outcome,
1426 stage.branch.clone().unwrap_or_else(|| "-".to_string()),
1427 worker_suffix,
1428 );
1429 if let Some(worker) = worker {
1430 if let Some(worker_id) = worker.get("id").and_then(|value| value.as_str()) {
1431 println!(" worker_id: {}", worker_id);
1432 }
1433 if let Some(child_run_id) = worker.get("child_run_id").and_then(|value| value.as_str())
1434 {
1435 println!(" child_run_id: {}", child_run_id);
1436 }
1437 if let Some(child_run_path) = worker
1438 .get("child_run_path")
1439 .and_then(|value| value.as_str())
1440 {
1441 println!(" child_run_path: {}", child_run_path);
1442 }
1443 }
1444 }
1445 if let Some(observability) = &run.observability {
1446 for round in &observability.planner_rounds {
1447 println!(
1448 "- planner {} iterations={} llm_calls={} tools={} research_facts={}",
1449 round.node_id,
1450 round.iteration_count,
1451 round.llm_call_count,
1452 round.tool_execution_count,
1453 round.research_facts.len()
1454 );
1455 }
1456 for pointer in &observability.transcript_pointers {
1457 println!(
1458 "- transcript {} [{}] available={} {}",
1459 pointer.label,
1460 pointer.kind,
1461 pointer.available,
1462 pointer
1463 .path
1464 .clone()
1465 .unwrap_or_else(|| pointer.location.clone())
1466 );
1467 }
1468 for event in &observability.daemon_events {
1469 println!(
1470 "- daemon {} [{:?}] at {}",
1471 event.name, event.kind, event.timestamp
1472 );
1473 println!(" id: {}", event.daemon_id);
1474 println!(" persist_path: {}", event.persist_path);
1475 if let Some(summary) = &event.payload_summary {
1476 println!(" payload: {}", summary);
1477 }
1478 }
1479 }
1480 if let Some(compare_path) = compare {
1481 let baseline = load_run_record_or_exit(Path::new(compare_path));
1482 print_run_diff(&harn_vm::orchestration::diff_run_records(&baseline, &run));
1483 }
1484}
1485
1486fn replay_run_record(path: &str) {
1487 let run = load_run_record_or_exit(Path::new(path));
1488 println!("Replay: {}", run.id);
1489 for stage in &run.stages {
1490 println!(
1491 "[{}] status={} outcome={} branch={}",
1492 stage.node_id,
1493 stage.status,
1494 stage.outcome,
1495 stage.branch.clone().unwrap_or_else(|| "-".to_string())
1496 );
1497 if let Some(text) = &stage.visible_text {
1498 println!(" visible: {}", text);
1499 }
1500 if let Some(verification) = &stage.verification {
1501 println!(" verification: {}", verification);
1502 }
1503 }
1504 if let Some(transcript) = &run.transcript {
1505 println!(
1506 "Transcript events persisted: {}",
1507 transcript["events"]
1508 .as_array()
1509 .map(|v| v.len())
1510 .unwrap_or(0)
1511 );
1512 }
1513 let fixture = run
1514 .replay_fixture
1515 .clone()
1516 .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(&run));
1517 let report = harn_vm::orchestration::evaluate_run_against_fixture(&run, &fixture);
1518 println!(
1519 "Embedded replay fixture: {}",
1520 if report.pass { "PASS" } else { "FAIL" }
1521 );
1522 for transition in &run.transitions {
1523 println!(
1524 "transition {} -> {} ({})",
1525 transition
1526 .from_node_id
1527 .clone()
1528 .unwrap_or_else(|| "start".to_string()),
1529 transition.to_node_id,
1530 transition
1531 .branch
1532 .clone()
1533 .unwrap_or_else(|| "default".to_string())
1534 );
1535 }
1536}
1537
1538fn eval_run_record(
1539 path: &str,
1540 compare: Option<&str>,
1541 structural_experiment: Option<&str>,
1542 argv: &[String],
1543 llm_mock_mode: &commands::run::CliLlmMockMode,
1544) {
1545 if let Some(experiment) = structural_experiment {
1546 let path_buf = PathBuf::from(path);
1547 if !path_buf.is_file() || path_buf.extension().and_then(|ext| ext.to_str()) != Some("harn")
1548 {
1549 eprintln!(
1550 "--structural-experiment currently requires a .harn pipeline path, got {}",
1551 path
1552 );
1553 process::exit(1);
1554 }
1555 if compare.is_some() {
1556 eprintln!("--compare cannot be combined with --structural-experiment");
1557 process::exit(1);
1558 }
1559 if matches!(llm_mock_mode, commands::run::CliLlmMockMode::Record { .. }) {
1560 eprintln!("--llm-mock-record cannot be combined with --structural-experiment");
1561 process::exit(1);
1562 }
1563 let path_buf = fs::canonicalize(&path_buf).unwrap_or_else(|error| {
1564 command_error(&format!(
1565 "failed to canonicalize structural eval pipeline {}: {error}",
1566 path_buf.display()
1567 ))
1568 });
1569 run_structural_experiment_eval(&path_buf, experiment, argv, llm_mock_mode);
1570 return;
1571 }
1572
1573 let path_buf = PathBuf::from(path);
1574 if path_buf.is_file() && file_looks_like_persona_eval_ladder_manifest(&path_buf) {
1575 if compare.is_some() {
1576 eprintln!("--compare is not supported with persona eval ladder manifests");
1577 process::exit(1);
1578 }
1579 let manifest = load_persona_eval_ladder_manifest_or_exit(&path_buf);
1580 let report =
1581 harn_vm::orchestration::run_persona_eval_ladder(&manifest).unwrap_or_else(|error| {
1582 eprintln!(
1583 "Failed to evaluate persona eval ladder {}: {error}",
1584 path_buf.display()
1585 );
1586 process::exit(1);
1587 });
1588 print_persona_ladder_report(&report);
1589 if !report.pass {
1590 process::exit(1);
1591 }
1592 return;
1593 }
1594
1595 if path_buf.is_file() && file_looks_like_eval_pack_manifest(&path_buf) {
1596 if compare.is_some() {
1597 eprintln!("--compare is not supported with eval pack manifests");
1598 process::exit(1);
1599 }
1600 let manifest = load_eval_pack_manifest_or_exit(&path_buf);
1601 let report = harn_vm::orchestration::evaluate_eval_pack_manifest(&manifest).unwrap_or_else(
1602 |error| {
1603 eprintln!(
1604 "Failed to evaluate eval pack {}: {error}",
1605 path_buf.display()
1606 );
1607 process::exit(1);
1608 },
1609 );
1610 print_eval_pack_report(&report);
1611 if !report.pass {
1612 process::exit(1);
1613 }
1614 return;
1615 }
1616
1617 if path_buf.is_file() && file_looks_like_eval_manifest(&path_buf) {
1618 if compare.is_some() {
1619 eprintln!("--compare is not supported with eval suite manifests");
1620 process::exit(1);
1621 }
1622 let manifest = load_eval_suite_manifest_or_exit(&path_buf);
1623 let suite = harn_vm::orchestration::evaluate_run_suite_manifest(&manifest).unwrap_or_else(
1624 |error| {
1625 eprintln!(
1626 "Failed to evaluate manifest {}: {error}",
1627 path_buf.display()
1628 );
1629 process::exit(1);
1630 },
1631 );
1632 println!(
1633 "{} {} passed, {} failed, {} total",
1634 if suite.pass { "PASS" } else { "FAIL" },
1635 suite.passed,
1636 suite.failed,
1637 suite.total
1638 );
1639 for case in &suite.cases {
1640 println!(
1641 "- {} [{}] {}",
1642 case.label.clone().unwrap_or_else(|| case.run_id.clone()),
1643 case.workflow_id,
1644 if case.pass { "PASS" } else { "FAIL" }
1645 );
1646 if let Some(path) = &case.source_path {
1647 println!(" path: {}", path);
1648 }
1649 if let Some(comparison) = &case.comparison {
1650 println!(" baseline identical: {}", comparison.identical);
1651 if !comparison.identical {
1652 println!(
1653 " baseline status: {} -> {}",
1654 comparison.left_status, comparison.right_status
1655 );
1656 }
1657 }
1658 for failure in &case.failures {
1659 println!(" {}", failure);
1660 }
1661 }
1662 if !suite.pass {
1663 process::exit(1);
1664 }
1665 return;
1666 }
1667
1668 let paths = collect_run_record_paths(path);
1669 if paths.len() > 1 {
1670 let mut cases = Vec::new();
1671 for path in &paths {
1672 let run = load_run_record_or_exit(path);
1673 let fixture = run
1674 .replay_fixture
1675 .clone()
1676 .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(&run));
1677 cases.push((run, fixture, Some(path.display().to_string())));
1678 }
1679 let suite = harn_vm::orchestration::evaluate_run_suite(cases);
1680 println!(
1681 "{} {} passed, {} failed, {} total",
1682 if suite.pass { "PASS" } else { "FAIL" },
1683 suite.passed,
1684 suite.failed,
1685 suite.total
1686 );
1687 for case in &suite.cases {
1688 println!(
1689 "- {} [{}] {}",
1690 case.run_id,
1691 case.workflow_id,
1692 if case.pass { "PASS" } else { "FAIL" }
1693 );
1694 if let Some(path) = &case.source_path {
1695 println!(" path: {}", path);
1696 }
1697 if let Some(comparison) = &case.comparison {
1698 println!(" baseline identical: {}", comparison.identical);
1699 }
1700 for failure in &case.failures {
1701 println!(" {}", failure);
1702 }
1703 }
1704 if !suite.pass {
1705 process::exit(1);
1706 }
1707 return;
1708 }
1709
1710 let run = load_run_record_or_exit(&paths[0]);
1711 let fixture = run
1712 .replay_fixture
1713 .clone()
1714 .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(&run));
1715 let report = harn_vm::orchestration::evaluate_run_against_fixture(&run, &fixture);
1716 println!("{}", if report.pass { "PASS" } else { "FAIL" });
1717 println!("Stages: {}", report.stage_count);
1718 if let Some(compare_path) = compare {
1719 let baseline = load_run_record_or_exit(Path::new(compare_path));
1720 print_run_diff(&harn_vm::orchestration::diff_run_records(&baseline, &run));
1721 }
1722 if !report.failures.is_empty() {
1723 for failure in &report.failures {
1724 println!("- {}", failure);
1725 }
1726 }
1727 if !report.pass {
1728 process::exit(1);
1729 }
1730}
1731
1732fn print_eval_pack_report(report: &harn_vm::orchestration::EvalPackReport) {
1733 println!(
1734 "{} {} passed, {} blocking failed, {} warning, {} informational, {} total",
1735 if report.pass { "PASS" } else { "FAIL" },
1736 report.passed,
1737 report.blocking_failed,
1738 report.warning_failed,
1739 report.informational_failed,
1740 report.total
1741 );
1742 for case in &report.cases {
1743 println!(
1744 "- {} [{}] {} ({})",
1745 case.label,
1746 case.workflow_id,
1747 if case.pass { "PASS" } else { "FAIL" },
1748 case.severity
1749 );
1750 if let Some(path) = &case.source_path {
1751 println!(" path: {}", path);
1752 }
1753 if let Some(comparison) = &case.comparison {
1754 println!(" baseline identical: {}", comparison.identical);
1755 if !comparison.identical {
1756 println!(
1757 " baseline status: {} -> {}",
1758 comparison.left_status, comparison.right_status
1759 );
1760 }
1761 }
1762 for failure in &case.failures {
1763 println!(" {}", failure);
1764 }
1765 for warning in &case.warnings {
1766 println!(" warning: {}", warning);
1767 }
1768 for item in &case.informational {
1769 println!(" info: {}", item);
1770 }
1771 }
1772 for ladder in &report.ladders {
1773 println!(
1774 "- ladder {} [{}] {} ({}) first_correct={}/{}",
1775 ladder.id,
1776 ladder.persona,
1777 if ladder.pass { "PASS" } else { "FAIL" },
1778 ladder.severity,
1779 ladder.first_correct_route.as_deref().unwrap_or("<none>"),
1780 ladder.first_correct_tier.as_deref().unwrap_or("<none>")
1781 );
1782 println!(" artifacts: {}", ladder.artifact_root);
1783 for tier in &ladder.tiers {
1784 println!(
1785 " - {} [{}] {} tools={} models={} latency={}ms cost=${:.6}",
1786 tier.timeout_tier,
1787 tier.route_id,
1788 tier.outcome,
1789 tier.tool_calls,
1790 tier.model_calls,
1791 tier.latency_ms,
1792 tier.cost_usd
1793 );
1794 for reason in &tier.degradation_reasons {
1795 println!(" {}", reason);
1796 }
1797 }
1798 }
1799}
1800
1801fn print_persona_ladder_report(report: &harn_vm::orchestration::PersonaEvalLadderReport) {
1802 println!(
1803 "{} ladder {} passed, {} degraded/looped, {} total",
1804 if report.pass { "PASS" } else { "FAIL" },
1805 report.passed,
1806 report.failed,
1807 report.total
1808 );
1809 println!(
1810 "first_correct: {}/{}",
1811 report.first_correct_route.as_deref().unwrap_or("<none>"),
1812 report.first_correct_tier.as_deref().unwrap_or("<none>")
1813 );
1814 println!("artifacts: {}", report.artifact_root);
1815 for tier in &report.tiers {
1816 println!(
1817 "- {} [{}] {} tools={} models={} latency={}ms cost=${:.6}",
1818 tier.timeout_tier,
1819 tier.route_id,
1820 tier.outcome,
1821 tier.tool_calls,
1822 tier.model_calls,
1823 tier.latency_ms,
1824 tier.cost_usd
1825 );
1826 for reason in &tier.degradation_reasons {
1827 println!(" {}", reason);
1828 }
1829 }
1830}
1831
1832fn run_package_evals() {
1833 let paths = package::load_package_eval_pack_paths(None).unwrap_or_else(|error| {
1834 eprintln!("{error}");
1835 process::exit(1);
1836 });
1837 let mut all_pass = true;
1838 for path in &paths {
1839 println!("Eval pack: {}", path.display());
1840 let manifest = load_eval_pack_manifest_or_exit(path);
1841 let report = harn_vm::orchestration::evaluate_eval_pack_manifest(&manifest).unwrap_or_else(
1842 |error| {
1843 eprintln!("Failed to evaluate eval pack {}: {error}", path.display());
1844 process::exit(1);
1845 },
1846 );
1847 print_eval_pack_report(&report);
1848 all_pass &= report.pass;
1849 }
1850 if !all_pass {
1851 process::exit(1);
1852 }
1853}
1854
1855fn run_structural_experiment_eval(
1856 path: &Path,
1857 experiment: &str,
1858 argv: &[String],
1859 llm_mock_mode: &commands::run::CliLlmMockMode,
1860) {
1861 let baseline_dir = tempfile::Builder::new()
1862 .prefix("harn-eval-baseline-")
1863 .tempdir()
1864 .unwrap_or_else(|error| {
1865 command_error(&format!("failed to create baseline tempdir: {error}"))
1866 });
1867 let variant_dir = tempfile::Builder::new()
1868 .prefix("harn-eval-variant-")
1869 .tempdir()
1870 .unwrap_or_else(|error| {
1871 command_error(&format!("failed to create variant tempdir: {error}"))
1872 });
1873
1874 let baseline = spawn_eval_pipeline_run(path, baseline_dir.path(), None, argv, llm_mock_mode);
1875 if !baseline.status.success() {
1876 relay_subprocess_failure("baseline", &baseline);
1877 }
1878
1879 let variant = spawn_eval_pipeline_run(
1880 path,
1881 variant_dir.path(),
1882 Some(experiment),
1883 argv,
1884 llm_mock_mode,
1885 );
1886 if !variant.status.success() {
1887 relay_subprocess_failure("variant", &variant);
1888 }
1889
1890 let baseline_runs = collect_structural_eval_runs(baseline_dir.path());
1891 let variant_runs = collect_structural_eval_runs(variant_dir.path());
1892 if baseline_runs.is_empty() || variant_runs.is_empty() {
1893 eprintln!(
1894 "structural eval expected workflow run records under {} and {}, but one side was empty",
1895 baseline_dir.path().display(),
1896 variant_dir.path().display()
1897 );
1898 process::exit(1);
1899 }
1900 if baseline_runs.len() != variant_runs.len() {
1901 eprintln!(
1902 "structural eval produced different run counts: baseline={} variant={}",
1903 baseline_runs.len(),
1904 variant_runs.len()
1905 );
1906 process::exit(1);
1907 }
1908
1909 let mut baseline_ok = 0usize;
1910 let mut variant_ok = 0usize;
1911 let mut any_failures = false;
1912
1913 println!("Structural experiment: {}", experiment);
1914 println!("Cases: {}", baseline_runs.len());
1915 for (baseline_run, variant_run) in baseline_runs.iter().zip(variant_runs.iter()) {
1916 let baseline_fixture = baseline_run
1917 .replay_fixture
1918 .clone()
1919 .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(baseline_run));
1920 let variant_fixture = variant_run
1921 .replay_fixture
1922 .clone()
1923 .unwrap_or_else(|| harn_vm::orchestration::replay_fixture_from_run(variant_run));
1924 let baseline_report =
1925 harn_vm::orchestration::evaluate_run_against_fixture(baseline_run, &baseline_fixture);
1926 let variant_report =
1927 harn_vm::orchestration::evaluate_run_against_fixture(variant_run, &variant_fixture);
1928 let diff = harn_vm::orchestration::diff_run_records(baseline_run, variant_run);
1929 if baseline_report.pass {
1930 baseline_ok += 1;
1931 }
1932 if variant_report.pass {
1933 variant_ok += 1;
1934 }
1935 any_failures |= !baseline_report.pass || !variant_report.pass;
1936 println!(
1937 "- {} [{}]",
1938 variant_run
1939 .workflow_name
1940 .clone()
1941 .unwrap_or_else(|| variant_run.workflow_id.clone()),
1942 variant_run.task
1943 );
1944 println!(
1945 " baseline: {}",
1946 if baseline_report.pass { "PASS" } else { "FAIL" }
1947 );
1948 for failure in &baseline_report.failures {
1949 println!(" {}", failure);
1950 }
1951 println!(
1952 " variant: {}",
1953 if variant_report.pass { "PASS" } else { "FAIL" }
1954 );
1955 for failure in &variant_report.failures {
1956 println!(" {}", failure);
1957 }
1958 println!(" diff identical: {}", diff.identical);
1959 println!(" stage diffs: {}", diff.stage_diffs.len());
1960 println!(" tool diffs: {}", diff.tool_diffs.len());
1961 println!(" observability diffs: {}", diff.observability_diffs.len());
1962 }
1963
1964 println!("Baseline {} / {} passed", baseline_ok, baseline_runs.len());
1965 println!("Variant {} / {} passed", variant_ok, variant_runs.len());
1966
1967 if any_failures {
1968 process::exit(1);
1969 }
1970}
1971
1972fn spawn_eval_pipeline_run(
1973 path: &Path,
1974 run_dir: &Path,
1975 structural_experiment: Option<&str>,
1976 argv: &[String],
1977 llm_mock_mode: &commands::run::CliLlmMockMode,
1978) -> std::process::Output {
1979 let exe = env::current_exe().unwrap_or_else(|error| {
1980 command_error(&format!("failed to resolve current executable: {error}"))
1981 });
1982 let mut command = std::process::Command::new(exe);
1983 command.current_dir(path.parent().unwrap_or_else(|| Path::new(".")));
1984 command.arg("run");
1985 match llm_mock_mode {
1986 commands::run::CliLlmMockMode::Off => {}
1987 commands::run::CliLlmMockMode::Replay { fixture_path } => {
1988 command
1989 .arg("--llm-mock")
1990 .arg(absolute_cli_path(fixture_path));
1991 }
1992 commands::run::CliLlmMockMode::Record { fixture_path } => {
1993 command
1994 .arg("--llm-mock-record")
1995 .arg(absolute_cli_path(fixture_path));
1996 }
1997 }
1998 command.arg(path);
1999 if !argv.is_empty() {
2000 command.arg("--");
2001 command.args(argv);
2002 }
2003 command.env(harn_vm::runtime_paths::HARN_RUN_DIR_ENV, run_dir);
2004 if let Some(experiment) = structural_experiment {
2005 command.env("HARN_STRUCTURAL_EXPERIMENT", experiment);
2006 }
2007 command.output().unwrap_or_else(|error| {
2008 command_error(&format!(
2009 "failed to spawn `harn run {}` for structural eval: {error}",
2010 path.display()
2011 ))
2012 })
2013}
2014
2015fn absolute_cli_path(path: &Path) -> PathBuf {
2016 if path.is_absolute() {
2017 return path.to_path_buf();
2018 }
2019 env::current_dir()
2020 .unwrap_or_else(|_| PathBuf::from("."))
2021 .join(path)
2022}
2023
2024fn relay_subprocess_failure(label: &str, output: &std::process::Output) -> ! {
2025 let stdout = String::from_utf8_lossy(&output.stdout);
2026 let stderr = String::from_utf8_lossy(&output.stderr);
2027 if !stdout.trim().is_empty() {
2028 eprintln!("[{label}] stdout:\n{stdout}");
2029 }
2030 if !stderr.trim().is_empty() {
2031 eprintln!("[{label}] stderr:\n{stderr}");
2032 }
2033 process::exit(output.status.code().unwrap_or(1));
2034}
2035
2036fn collect_structural_eval_runs(dir: &Path) -> Vec<harn_vm::orchestration::RunRecord> {
2037 let mut paths: Vec<PathBuf> = fs::read_dir(dir)
2038 .unwrap_or_else(|error| {
2039 command_error(&format!(
2040 "failed to read structural eval run dir {}: {error}",
2041 dir.display()
2042 ))
2043 })
2044 .filter_map(|entry| entry.ok().map(|entry| entry.path()))
2045 .filter(|entry| entry.extension().and_then(|ext| ext.to_str()) == Some("json"))
2046 .collect();
2047 paths.sort();
2048 let mut runs: Vec<_> = paths
2049 .iter()
2050 .map(|path| load_run_record_or_exit(path))
2051 .collect();
2052 runs.sort_by(|left, right| {
2053 (
2054 left.started_at.as_str(),
2055 left.workflow_id.as_str(),
2056 left.task.as_str(),
2057 )
2058 .cmp(&(
2059 right.started_at.as_str(),
2060 right.workflow_id.as_str(),
2061 right.task.as_str(),
2062 ))
2063 });
2064 runs
2065}
2066
2067pub(crate) fn parse_source_file(path: &str) -> (String, Vec<harn_parser::SNode>) {
2069 let source = match fs::read_to_string(path) {
2070 Ok(s) => s,
2071 Err(e) => {
2072 eprintln!("Error reading {path}: {e}");
2073 process::exit(1);
2074 }
2075 };
2076
2077 let mut lexer = Lexer::new(&source);
2078 let tokens = match lexer.tokenize() {
2079 Ok(t) => t,
2080 Err(e) => {
2081 let diagnostic = harn_parser::diagnostic::render_diagnostic(
2082 &source,
2083 path,
2084 &error_span_from_lex(&e),
2085 "error",
2086 &e.to_string(),
2087 Some("here"),
2088 None,
2089 );
2090 eprint!("{diagnostic}");
2091 process::exit(1);
2092 }
2093 };
2094
2095 let mut parser = Parser::new(tokens);
2096 let program = match parser.parse() {
2097 Ok(p) => p,
2098 Err(err) => {
2099 if parser.all_errors().is_empty() {
2100 let span = error_span_from_parse(&err);
2101 let diagnostic = harn_parser::diagnostic::render_diagnostic(
2102 &source,
2103 path,
2104 &span,
2105 "error",
2106 &harn_parser::diagnostic::parser_error_message(&err),
2107 Some(harn_parser::diagnostic::parser_error_label(&err)),
2108 harn_parser::diagnostic::parser_error_help(&err),
2109 );
2110 eprint!("{diagnostic}");
2111 } else {
2112 for e in parser.all_errors() {
2113 let span = error_span_from_parse(e);
2114 let diagnostic = harn_parser::diagnostic::render_diagnostic(
2115 &source,
2116 path,
2117 &span,
2118 "error",
2119 &harn_parser::diagnostic::parser_error_message(e),
2120 Some(harn_parser::diagnostic::parser_error_label(e)),
2121 harn_parser::diagnostic::parser_error_help(e),
2122 );
2123 eprint!("{diagnostic}");
2124 }
2125 }
2126 process::exit(1);
2127 }
2128 };
2129
2130 (source, program)
2131}
2132
2133fn error_span_from_lex(e: &harn_lexer::LexerError) -> harn_lexer::Span {
2134 match e {
2135 harn_lexer::LexerError::UnexpectedCharacter(_, span)
2136 | harn_lexer::LexerError::UnterminatedString(span)
2137 | harn_lexer::LexerError::UnterminatedBlockComment(span) => *span,
2138 }
2139}
2140
2141fn error_span_from_parse(e: &harn_parser::ParserError) -> harn_lexer::Span {
2142 match e {
2143 harn_parser::ParserError::Unexpected { span, .. } => *span,
2144 harn_parser::ParserError::UnexpectedEof { span, .. } => *span,
2145 }
2146}
2147
2148pub(crate) async fn execute(source: &str, source_path: Option<&Path>) -> Result<String, String> {
2150 let mut lexer = Lexer::new(source);
2151 let tokens = lexer.tokenize().map_err(|e| e.to_string())?;
2152 let mut parser = Parser::new(tokens);
2153 let program = parser.parse().map_err(|e| e.to_string())?;
2154
2155 let mut checker = TypeChecker::new();
2160 if let Some(path) = source_path {
2161 let graph = harn_modules::build(&[path.to_path_buf()]);
2162 if let Some(imported) = graph.imported_names_for_file(path) {
2163 checker = checker.with_imported_names(imported);
2164 }
2165 if let Some(imported) = graph.imported_type_declarations_for_file(path) {
2166 checker = checker.with_imported_type_decls(imported);
2167 }
2168 }
2169 let type_diagnostics = checker.check(&program);
2170 let mut warning_lines = Vec::new();
2171 for diag in &type_diagnostics {
2172 match diag.severity {
2173 DiagnosticSeverity::Error => return Err(diag.message.clone()),
2174 DiagnosticSeverity::Warning => {
2175 warning_lines.push(format!("warning: {}", diag.message));
2176 }
2177 }
2178 }
2179
2180 let chunk = harn_vm::Compiler::new()
2181 .compile(&program)
2182 .map_err(|e| e.to_string())?;
2183
2184 let local = tokio::task::LocalSet::new();
2185 local
2186 .run_until(async {
2187 let mut vm = harn_vm::Vm::new();
2188 harn_vm::register_vm_stdlib(&mut vm);
2189 install_default_hostlib(&mut vm);
2190 let source_parent = source_path
2191 .and_then(|p| p.parent())
2192 .unwrap_or(std::path::Path::new("."));
2193 let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
2194 let store_base = project_root.as_deref().unwrap_or(source_parent);
2195 let execution_cwd = std::env::current_dir()
2196 .unwrap_or_else(|_| std::path::PathBuf::from("."))
2197 .to_string_lossy()
2198 .into_owned();
2199 let source_dir = source_parent.to_string_lossy().into_owned();
2200 if source_path.is_some_and(is_conformance_path) {
2201 harn_vm::event_log::install_memory_for_current_thread(64);
2202 }
2203 harn_vm::register_store_builtins(&mut vm, store_base);
2204 harn_vm::register_metadata_builtins(&mut vm, store_base);
2205 let pipeline_name = source_path
2206 .and_then(|p| p.file_stem())
2207 .and_then(|s| s.to_str())
2208 .unwrap_or("default");
2209 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
2210 harn_vm::stdlib::process::set_thread_execution_context(Some(
2211 harn_vm::orchestration::RunExecutionRecord {
2212 cwd: Some(execution_cwd),
2213 source_dir: Some(source_dir),
2214 env: std::collections::BTreeMap::new(),
2215 adapter: None,
2216 repo_path: None,
2217 worktree_path: None,
2218 branch: None,
2219 base_ref: None,
2220 cleanup: None,
2221 },
2222 ));
2223 if let Some(ref root) = project_root {
2224 vm.set_project_root(root);
2225 }
2226 if let Some(path) = source_path {
2227 if let Some(parent) = path.parent() {
2228 if !parent.as_os_str().is_empty() {
2229 vm.set_source_dir(parent);
2230 }
2231 }
2232 }
2233 let loaded = skill_loader::load_skills(&skill_loader::SkillLoaderInputs {
2237 cli_dirs: Vec::new(),
2238 source_path: source_path.map(Path::to_path_buf),
2239 });
2240 skill_loader::emit_loader_warnings(&loaded.loader_warnings);
2241 skill_loader::install_skills_global(&mut vm, &loaded);
2242 if let Some(path) = source_path {
2243 let extensions = package::load_runtime_extensions(path);
2244 package::install_runtime_extensions(&extensions);
2245 package::install_manifest_triggers(&mut vm, &extensions)
2246 .await
2247 .map_err(|error| format!("failed to install manifest triggers: {error}"))?;
2248 package::install_manifest_hooks(&mut vm, &extensions)
2249 .await
2250 .map_err(|error| format!("failed to install manifest hooks: {error}"))?;
2251 }
2252 let _event_log = harn_vm::event_log::active_event_log()
2253 .unwrap_or_else(|| harn_vm::event_log::install_memory_for_current_thread(64));
2254 let connector_clients_installed =
2255 should_install_default_connector_clients(source, source_path);
2256 if connector_clients_installed {
2257 install_default_connector_clients(store_base)
2258 .await
2259 .map_err(|error| format!("failed to initialize connector clients: {error}"))?;
2260 }
2261 let execution_result = vm.execute(&chunk).await.map_err(|e| e.to_string());
2262 harn_vm::egress::reset_egress_policy_for_host();
2263 if connector_clients_installed {
2264 harn_vm::clear_active_connector_clients();
2265 }
2266 harn_vm::stdlib::process::set_thread_execution_context(None);
2267 execution_result?;
2268 let mut output = String::new();
2269 for wl in &warning_lines {
2270 output.push_str(wl);
2271 output.push('\n');
2272 }
2273 output.push_str(vm.output());
2274 Ok(output)
2275 })
2276 .await
2277}
2278
2279fn should_install_default_connector_clients(source: &str, source_path: Option<&Path>) -> bool {
2280 if !source_path.is_some_and(is_conformance_path) {
2281 return true;
2282 }
2283 source.contains("connector_call")
2284 || source.contains("std/connectors")
2285 || source.contains("connectors/")
2286}
2287
2288fn is_conformance_path(path: &Path) -> bool {
2289 path.components()
2290 .any(|component| component.as_os_str() == "conformance")
2291}
2292
2293async fn install_default_connector_clients(base_dir: &Path) -> Result<(), String> {
2294 let event_log = harn_vm::event_log::active_event_log()
2295 .unwrap_or_else(|| harn_vm::event_log::install_memory_for_current_thread(64));
2296 let secret_namespace = connector_secret_namespace(base_dir);
2297 let secrets: Arc<dyn harn_vm::secrets::SecretProvider> = Arc::new(
2298 harn_vm::secrets::configured_default_chain(secret_namespace)
2299 .map_err(|error| format!("failed to configure secret providers: {error}"))?,
2300 );
2301
2302 let registry = harn_vm::ConnectorRegistry::default();
2303 let metrics = Arc::new(harn_vm::MetricsRegistry::default());
2304 let inbox = Arc::new(
2305 harn_vm::InboxIndex::new(event_log.clone(), metrics.clone())
2306 .await
2307 .map_err(|error| error.to_string())?,
2308 );
2309 registry
2310 .init_all(harn_vm::ConnectorCtx {
2311 event_log,
2312 secrets,
2313 inbox,
2314 metrics,
2315 rate_limiter: Arc::new(harn_vm::RateLimiterFactory::default()),
2316 })
2317 .await
2318 .map_err(|error| error.to_string())?;
2319 let clients = registry.client_map().await;
2320 harn_vm::install_active_connector_clients(clients);
2321 Ok(())
2322}
2323
2324fn connector_secret_namespace(base_dir: &Path) -> String {
2325 match std::env::var("HARN_SECRET_NAMESPACE") {
2326 Ok(namespace) if !namespace.trim().is_empty() => namespace,
2327 _ => {
2328 let leaf = base_dir
2329 .file_name()
2330 .and_then(|name| name.to_str())
2331 .filter(|name| !name.is_empty())
2332 .unwrap_or("workspace");
2333 format!("harn/{leaf}")
2334 }
2335 }
2336}
2337
2338#[cfg(test)]
2339mod main_tests {
2340 use super::{normalize_serve_args, should_install_default_connector_clients};
2341 use std::path::Path;
2342
2343 #[test]
2344 fn normalize_serve_args_inserts_a2a_for_legacy_shape() {
2345 let args = normalize_serve_args(vec![
2346 "harn".to_string(),
2347 "serve".to_string(),
2348 "--port".to_string(),
2349 "3000".to_string(),
2350 "agent.harn".to_string(),
2351 ]);
2352 assert_eq!(
2353 args,
2354 vec![
2355 "harn".to_string(),
2356 "serve".to_string(),
2357 "a2a".to_string(),
2358 "--port".to_string(),
2359 "3000".to_string(),
2360 "agent.harn".to_string(),
2361 ]
2362 );
2363 }
2364
2365 #[test]
2366 fn normalize_serve_args_preserves_explicit_subcommands() {
2367 let args = normalize_serve_args(vec![
2368 "harn".to_string(),
2369 "serve".to_string(),
2370 "acp".to_string(),
2371 "server.harn".to_string(),
2372 ]);
2373 assert_eq!(
2374 args,
2375 vec![
2376 "harn".to_string(),
2377 "serve".to_string(),
2378 "acp".to_string(),
2379 "server.harn".to_string(),
2380 ]
2381 );
2382 }
2383
2384 #[test]
2385 fn conformance_skips_connector_clients_unless_fixture_uses_connectors() {
2386 let path = Path::new("conformance/tests/language/basic.harn");
2387 assert!(!should_install_default_connector_clients(
2388 "println(1)",
2389 Some(path)
2390 ));
2391 assert!(!should_install_default_connector_clients(
2392 "trust_graph_verify_chain()",
2393 Some(path)
2394 ));
2395 assert!(should_install_default_connector_clients(
2396 "import { post_message } from \"std/connectors/slack\"",
2397 Some(path)
2398 ));
2399 assert!(should_install_default_connector_clients(
2400 "println(1)",
2401 Some(Path::new("examples/demo.harn"))
2402 ));
2403 }
2404}