1use std::collections::HashSet;
2use std::fs;
3use std::io::{self, Write};
4use std::path::{Path, PathBuf};
5use std::process;
6use std::sync::atomic::{AtomicBool, Ordering};
7use std::sync::Arc;
8
9use harn_parser::DiagnosticSeverity;
10use harn_vm::event_log::EventLog;
11
12use crate::commands::mcp::{self, AuthResolution};
13use crate::package;
14use crate::parse_source_file;
15use crate::skill_loader::{
16 canonicalize_cli_dirs, emit_loader_warnings, install_skills_global, load_skills,
17 SkillLoaderInputs,
18};
19
20mod explain_cost;
21
22pub(crate) enum RunFileMcpServeMode {
23 Stdio,
24 Http {
25 options: harn_serve::McpHttpServeOptions,
26 auth_policy: harn_serve::AuthPolicy,
27 },
28}
29
30const CORE_BUILTINS: &[&str] = &[
32 "println",
33 "print",
34 "log",
35 "type_of",
36 "to_string",
37 "to_int",
38 "to_float",
39 "len",
40 "assert",
41 "assert_eq",
42 "assert_ne",
43 "json_parse",
44 "json_stringify",
45 "runtime_context",
46 "task_current",
47 "runtime_context_values",
48 "runtime_context_get",
49 "runtime_context_set",
50 "runtime_context_clear",
51];
52
53pub(crate) fn build_denied_builtins(
58 deny_csv: Option<&str>,
59 allow_csv: Option<&str>,
60) -> HashSet<String> {
61 if let Some(csv) = deny_csv {
62 csv.split(',')
63 .map(|s| s.trim().to_string())
64 .filter(|s| !s.is_empty())
65 .collect()
66 } else if let Some(csv) = allow_csv {
67 let allowed: HashSet<String> = csv
70 .split(',')
71 .map(|s| s.trim().to_string())
72 .filter(|s| !s.is_empty())
73 .collect();
74 let core: HashSet<&str> = CORE_BUILTINS.iter().copied().collect();
75
76 let mut tmp = harn_vm::Vm::new();
78 harn_vm::register_vm_stdlib(&mut tmp);
79 harn_vm::register_store_builtins(&mut tmp, std::path::Path::new("."));
80 harn_vm::register_metadata_builtins(&mut tmp, std::path::Path::new("."));
81
82 tmp.builtin_names()
83 .into_iter()
84 .filter(|name| !allowed.contains(name) && !core.contains(name.as_str()))
85 .collect()
86 } else {
87 HashSet::new()
88 }
89}
90
91fn typecheck_with_imports(
96 program: &[harn_parser::SNode],
97 path: &Path,
98 source: &str,
99) -> Vec<harn_parser::TypeDiagnostic> {
100 if let Err(error) = package::ensure_dependencies_materialized(path) {
101 eprintln!("error: {error}");
102 process::exit(1);
103 }
104 let graph = harn_modules::build(&[path.to_path_buf()]);
105 let mut checker = harn_parser::TypeChecker::new();
106 if let Some(imported) = graph.imported_names_for_file(path) {
107 checker = checker.with_imported_names(imported);
108 }
109 if let Some(imported) = graph.imported_type_declarations_for_file(path) {
110 checker = checker.with_imported_type_decls(imported);
111 }
112 checker.check_with_source(program, source)
113}
114
115pub(crate) fn prepare_eval_temp_file(
126 code: &str,
127) -> Result<(String, tempfile::NamedTempFile), String> {
128 let (header, body) = split_eval_header(code);
129 let wrapped = if header.is_empty() {
130 format!("pipeline main(task) {{\n{body}\n}}")
131 } else {
132 format!("{header}\npipeline main(task) {{\n{body}\n}}")
133 };
134
135 let tmp = create_eval_temp_file()?;
136 Ok((wrapped, tmp))
137}
138
139fn create_eval_temp_file() -> Result<tempfile::NamedTempFile, String> {
144 if let Some(dir) = std::env::current_dir().ok().as_deref() {
145 match tempfile::Builder::new()
148 .prefix(".harn-eval-")
149 .suffix(".harn")
150 .tempfile_in(dir)
151 {
152 Ok(tmp) => return Ok(tmp),
153 Err(error) => eprintln!(
154 "warning: harn run -e: could not create temp file in {}: {error}; \
155 relative imports will not resolve",
156 dir.display()
157 ),
158 }
159 }
160 tempfile::Builder::new()
161 .prefix("harn-eval-")
162 .suffix(".harn")
163 .tempfile()
164 .map_err(|e| format!("failed to create temp file for -e: {e}"))
165}
166
167fn split_eval_header(code: &str) -> (String, String) {
175 let mut header_end = 0usize;
176 let mut last_kept = 0usize;
177 for (idx, line) in code.lines().enumerate() {
178 let trimmed = line.trim_start();
179 if trimmed.is_empty() || trimmed.starts_with("//") {
180 header_end = idx + 1;
181 continue;
182 }
183 let is_import = trimmed.starts_with("import ")
184 || trimmed.starts_with("import\t")
185 || trimmed.starts_with("import\"")
186 || trimmed.starts_with("pub import ")
187 || trimmed.starts_with("pub import\t");
188 if is_import {
189 header_end = idx + 1;
190 last_kept = idx + 1;
191 } else {
192 break;
193 }
194 }
195 if last_kept == 0 {
196 return (String::new(), code.to_string());
197 }
198 let mut header_lines: Vec<&str> = Vec::new();
199 let mut body_lines: Vec<&str> = Vec::new();
200 for (idx, line) in code.lines().enumerate() {
201 if idx < header_end {
202 header_lines.push(line);
203 } else {
204 body_lines.push(line);
205 }
206 }
207 (header_lines.join("\n"), body_lines.join("\n"))
208}
209
210#[derive(Clone, Debug, Default, PartialEq, Eq)]
211pub enum CliLlmMockMode {
212 #[default]
213 Off,
214 Replay {
215 fixture_path: PathBuf,
216 },
217 Record {
218 fixture_path: PathBuf,
219 },
220}
221
222#[derive(Clone, Debug, Default, PartialEq, Eq)]
223pub struct RunAttestationOptions {
224 pub receipt_out: Option<PathBuf>,
225 pub agent_id: Option<String>,
226}
227
228#[derive(Clone, Debug, Default, PartialEq, Eq)]
233pub struct RunProfileOptions {
234 pub text: bool,
235 pub json_path: Option<PathBuf>,
236}
237
238impl RunProfileOptions {
239 pub fn is_enabled(&self) -> bool {
240 self.text || self.json_path.is_some()
241 }
242}
243
244#[derive(Clone, Debug, Default)]
248pub struct RunOutcome {
249 pub stdout: String,
250 pub stderr: String,
251 pub exit_code: i32,
252}
253
254pub fn install_cli_llm_mock_mode(mode: &CliLlmMockMode) -> Result<(), String> {
255 harn_vm::llm::clear_cli_llm_mock_mode();
256 match mode {
257 CliLlmMockMode::Off => Ok(()),
258 CliLlmMockMode::Replay { fixture_path } => {
259 let mocks = harn_vm::llm::load_llm_mocks_jsonl(fixture_path)?;
260 harn_vm::llm::install_cli_llm_mocks(mocks);
261 Ok(())
262 }
263 CliLlmMockMode::Record { .. } => {
264 harn_vm::llm::enable_cli_llm_mock_recording();
265 Ok(())
266 }
267 }
268}
269
270pub fn persist_cli_llm_mock_recording(mode: &CliLlmMockMode) -> Result<(), String> {
271 let CliLlmMockMode::Record { fixture_path } = mode else {
272 return Ok(());
273 };
274 if let Some(parent) = fixture_path.parent() {
275 if !parent.as_os_str().is_empty() {
276 fs::create_dir_all(parent).map_err(|error| {
277 format!(
278 "failed to create fixture directory {}: {error}",
279 parent.display()
280 )
281 })?;
282 }
283 }
284
285 let lines = harn_vm::llm::take_cli_llm_recordings()
286 .into_iter()
287 .map(harn_vm::llm::serialize_llm_mock)
288 .collect::<Result<Vec<_>, _>>()?;
289 let body = if lines.is_empty() {
290 String::new()
291 } else {
292 format!("{}\n", lines.join("\n"))
293 };
294 fs::write(fixture_path, body)
295 .map_err(|error| format!("failed to write {}: {error}", fixture_path.display()))
296}
297
298pub(crate) async fn run_file(
299 path: &str,
300 trace: bool,
301 denied_builtins: HashSet<String>,
302 script_argv: Vec<String>,
303 llm_mock_mode: CliLlmMockMode,
304 attestation: Option<RunAttestationOptions>,
305 profile: RunProfileOptions,
306) {
307 run_file_with_skill_dirs(
308 path,
309 trace,
310 denied_builtins,
311 script_argv,
312 Vec::new(),
313 llm_mock_mode,
314 attestation,
315 profile,
316 )
317 .await;
318}
319
320pub(crate) fn run_explain_cost_file_with_skill_dirs(path: &str) {
321 let outcome = execute_explain_cost(path);
322 if !outcome.stderr.is_empty() {
323 io::stderr().write_all(outcome.stderr.as_bytes()).ok();
324 }
325 if !outcome.stdout.is_empty() {
326 io::stdout().write_all(outcome.stdout.as_bytes()).ok();
327 }
328 if outcome.exit_code != 0 {
329 process::exit(outcome.exit_code);
330 }
331}
332
333pub(crate) async fn run_file_with_skill_dirs(
334 path: &str,
335 trace: bool,
336 denied_builtins: HashSet<String>,
337 script_argv: Vec<String>,
338 skill_dirs_raw: Vec<String>,
339 llm_mock_mode: CliLlmMockMode,
340 attestation: Option<RunAttestationOptions>,
341 profile: RunProfileOptions,
342) {
343 let cancelled = install_signal_shutdown_handler();
345
346 let _stdout_passthrough = StdoutPassthroughGuard::enable();
347 let outcome = execute_run(
348 path,
349 trace,
350 denied_builtins,
351 script_argv,
352 skill_dirs_raw,
353 llm_mock_mode,
354 attestation,
355 profile,
356 )
357 .await;
358
359 if !outcome.stderr.is_empty() {
362 io::stderr().write_all(outcome.stderr.as_bytes()).ok();
363 }
364 if !outcome.stdout.is_empty() {
365 io::stdout().write_all(outcome.stdout.as_bytes()).ok();
366 }
367
368 let mut exit_code = outcome.exit_code;
369 if exit_code != 0 && cancelled.load(Ordering::SeqCst) {
370 exit_code = 124;
371 }
372 if exit_code != 0 {
373 process::exit(exit_code);
374 }
375}
376
377pub fn execute_explain_cost(path: &str) -> RunOutcome {
378 let stdout = String::new();
379 let mut stderr = String::new();
380
381 let (source, program) = parse_source_file(path);
382
383 let mut had_type_error = false;
384 let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
385 for diag in &type_diagnostics {
386 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
387 if matches!(diag.severity, DiagnosticSeverity::Error) {
388 had_type_error = true;
389 }
390 stderr.push_str(&rendered);
391 }
392 if had_type_error {
393 return RunOutcome {
394 stdout,
395 stderr,
396 exit_code: 1,
397 };
398 }
399
400 let extensions = package::load_runtime_extensions(Path::new(path));
401 package::install_runtime_extensions(&extensions);
402 RunOutcome {
403 stdout: explain_cost::render_explain_cost(path, &program),
404 stderr,
405 exit_code: 0,
406 }
407}
408
409struct StdoutPassthroughGuard {
410 previous: bool,
411}
412
413impl StdoutPassthroughGuard {
414 fn enable() -> Self {
415 Self {
416 previous: harn_vm::set_stdout_passthrough(true),
417 }
418 }
419}
420
421impl Drop for StdoutPassthroughGuard {
422 fn drop(&mut self) {
423 harn_vm::set_stdout_passthrough(self.previous);
424 }
425}
426
427fn install_signal_shutdown_handler() -> Arc<AtomicBool> {
428 let cancelled = Arc::new(AtomicBool::new(false));
429 let cancelled_clone = cancelled.clone();
430 tokio::spawn(async move {
431 #[cfg(unix)]
432 {
433 use tokio::signal::unix::{signal, SignalKind};
434 let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM handler");
435 let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT handler");
436 tokio::select! {
437 _ = sigterm.recv() => {},
438 _ = sigint.recv() => {},
439 }
440 cancelled_clone.store(true, Ordering::SeqCst);
441 eprintln!("[harn] signal received, flushing state...");
442 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
443 process::exit(124);
444 }
445 #[cfg(not(unix))]
446 {
447 let _ = tokio::signal::ctrl_c().await;
448 cancelled_clone.store(true, Ordering::SeqCst);
449 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
450 process::exit(124);
451 }
452 });
453 cancelled
454}
455
456pub async fn execute_run(
462 path: &str,
463 trace: bool,
464 denied_builtins: HashSet<String>,
465 script_argv: Vec<String>,
466 skill_dirs_raw: Vec<String>,
467 llm_mock_mode: CliLlmMockMode,
468 attestation: Option<RunAttestationOptions>,
469 profile: RunProfileOptions,
470) -> RunOutcome {
471 let mut stderr = String::new();
472 let mut stdout = String::new();
473
474 let (source, program) = parse_source_file(path);
475
476 let mut had_type_error = false;
477 let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
478 for diag in &type_diagnostics {
479 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
480 if matches!(diag.severity, DiagnosticSeverity::Error) {
481 had_type_error = true;
482 }
483 stderr.push_str(&rendered);
484 }
485 if had_type_error {
486 return RunOutcome {
487 stdout,
488 stderr,
489 exit_code: 1,
490 };
491 }
492
493 let chunk = match harn_vm::Compiler::new().compile(&program) {
494 Ok(c) => c,
495 Err(e) => {
496 stderr.push_str(&format!("error: compile error: {e}\n"));
497 return RunOutcome {
498 stdout,
499 stderr,
500 exit_code: 1,
501 };
502 }
503 };
504
505 if trace {
506 harn_vm::llm::enable_tracing();
507 }
508 if profile.is_enabled() {
509 harn_vm::tracing::set_tracing_enabled(true);
510 }
511 if let Err(error) = install_cli_llm_mock_mode(&llm_mock_mode) {
512 stderr.push_str(&format!("error: {error}\n"));
513 return RunOutcome {
514 stdout,
515 stderr,
516 exit_code: 1,
517 };
518 }
519
520 let mut vm = harn_vm::Vm::new();
521 harn_vm::register_vm_stdlib(&mut vm);
522 crate::install_default_hostlib(&mut vm);
523 let source_parent = std::path::Path::new(path)
524 .parent()
525 .unwrap_or(std::path::Path::new("."));
526 let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
528 let store_base = project_root.as_deref().unwrap_or(source_parent);
529 let attestation_started_at_ms = now_ms();
530 let attestation_log = if attestation.is_some() {
531 Some(harn_vm::event_log::install_memory_for_current_thread(256))
532 } else {
533 None
534 };
535 if let Some(log) = attestation_log.as_ref() {
536 append_run_provenance_event(
537 log,
538 "started",
539 serde_json::json!({
540 "pipeline": path,
541 "argv": &script_argv,
542 "project_root": store_base.display().to_string(),
543 }),
544 )
545 .await;
546 }
547 harn_vm::register_store_builtins(&mut vm, store_base);
548 harn_vm::register_metadata_builtins(&mut vm, store_base);
549 let pipeline_name = std::path::Path::new(path)
550 .file_stem()
551 .and_then(|s| s.to_str())
552 .unwrap_or("default");
553 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
554 vm.set_source_info(path, &source);
555 if !denied_builtins.is_empty() {
556 vm.set_denied_builtins(denied_builtins);
557 }
558 if let Some(ref root) = project_root {
559 vm.set_project_root(root);
560 }
561
562 if let Some(p) = std::path::Path::new(path).parent() {
563 if !p.as_os_str().is_empty() {
564 vm.set_source_dir(p);
565 }
566 }
567
568 let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
571 let loaded = load_skills(&SkillLoaderInputs {
572 cli_dirs,
573 source_path: Some(std::path::PathBuf::from(path)),
574 });
575 emit_loader_warnings(&loaded.loader_warnings);
576 install_skills_global(&mut vm, &loaded);
577
578 let argv_values: Vec<harn_vm::VmValue> = script_argv
581 .iter()
582 .map(|s| harn_vm::VmValue::String(std::rc::Rc::from(s.as_str())))
583 .collect();
584 vm.set_global(
585 "argv",
586 harn_vm::VmValue::List(std::rc::Rc::new(argv_values)),
587 );
588
589 let extensions = package::load_runtime_extensions(Path::new(path));
590 package::install_runtime_extensions(&extensions);
591 if let Some(manifest) = extensions.root_manifest.as_ref() {
592 if !manifest.mcp.is_empty() {
593 connect_mcp_servers(&manifest.mcp, &mut vm).await;
594 }
595 }
596 if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
597 stderr.push_str(&format!(
598 "error: failed to install manifest triggers: {error}\n"
599 ));
600 return RunOutcome {
601 stdout,
602 stderr,
603 exit_code: 1,
604 };
605 }
606 if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
607 stderr.push_str(&format!(
608 "error: failed to install manifest hooks: {error}\n"
609 ));
610 return RunOutcome {
611 stdout,
612 stderr,
613 exit_code: 1,
614 };
615 }
616
617 let local = tokio::task::LocalSet::new();
619 let execution = local
620 .run_until(async {
621 match vm.execute(&chunk).await {
622 Ok(value) => Ok((vm.output(), value)),
623 Err(e) => Err(vm.format_runtime_error(&e)),
624 }
625 })
626 .await;
627 if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
628 stderr.push_str(&format!("error: {error}\n"));
629 return RunOutcome {
630 stdout,
631 stderr,
632 exit_code: 1,
633 };
634 }
635
636 let buffered_stderr = harn_vm::take_stderr_buffer();
638 stderr.push_str(&buffered_stderr);
639
640 let exit_code = match &execution {
641 Ok((_, return_value)) => exit_code_from_return_value(return_value),
642 Err(_) => 1,
643 };
644
645 if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
646 if let Err(error) = emit_run_attestation(
647 log,
648 path,
649 store_base,
650 attestation_started_at_ms,
651 exit_code,
652 options,
653 &mut stderr,
654 )
655 .await
656 {
657 stderr.push_str(&format!(
658 "error: failed to emit provenance receipt: {error}\n"
659 ));
660 return RunOutcome {
661 stdout,
662 stderr,
663 exit_code: 1,
664 };
665 }
666 harn_vm::event_log::reset_active_event_log();
667 }
668
669 match execution {
670 Ok((output, return_value)) => {
671 stdout.push_str(output);
672 if trace {
673 stderr.push_str(&render_trace_summary());
674 }
675 if profile.is_enabled() {
676 if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
677 stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
678 }
679 }
680 if exit_code != 0 {
681 stderr.push_str(&render_return_value_error(&return_value));
682 }
683 RunOutcome {
684 stdout,
685 stderr,
686 exit_code,
687 }
688 }
689 Err(rendered_error) => {
690 stderr.push_str(&rendered_error);
691 if profile.is_enabled() {
692 if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
693 stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
694 }
695 }
696 RunOutcome {
697 stdout,
698 stderr,
699 exit_code: 1,
700 }
701 }
702 }
703}
704
705fn render_and_persist_profile(
706 options: &RunProfileOptions,
707 stderr: &mut String,
708) -> Result<(), String> {
709 let spans = harn_vm::tracing::peek_spans();
710 let profile = harn_vm::profile::build(&spans);
711 if options.text {
712 stderr.push_str(&harn_vm::profile::render(&profile));
713 }
714 if let Some(path) = options.json_path.as_ref() {
715 if let Some(parent) = path.parent() {
716 if !parent.as_os_str().is_empty() {
717 fs::create_dir_all(parent)
718 .map_err(|error| format!("create {}: {error}", parent.display()))?;
719 }
720 }
721 let json = serde_json::to_string_pretty(&profile)
722 .map_err(|error| format!("serialize profile: {error}"))?;
723 fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
724 }
725 Ok(())
726}
727
728async fn append_run_provenance_event(
729 log: &Arc<harn_vm::event_log::AnyEventLog>,
730 kind: &str,
731 payload: serde_json::Value,
732) {
733 let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
734 return;
735 };
736 let _ = log
737 .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
738 .await;
739}
740
741async fn emit_run_attestation(
742 log: &Arc<harn_vm::event_log::AnyEventLog>,
743 path: &str,
744 store_base: &Path,
745 started_at_ms: i64,
746 exit_code: i32,
747 options: &RunAttestationOptions,
748 stderr: &mut String,
749) -> Result<(), String> {
750 let finished_at_ms = now_ms();
751 let status = if exit_code == 0 { "success" } else { "failure" };
752 append_run_provenance_event(
753 log,
754 "finished",
755 serde_json::json!({
756 "pipeline": path,
757 "status": status,
758 "exit_code": exit_code,
759 }),
760 )
761 .await;
762 log.flush()
763 .await
764 .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
765 let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
766 .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
767 let (signing_key, key_id) =
768 harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
769 .await
770 .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
771 let receipt = harn_vm::build_signed_receipt(
772 log,
773 harn_vm::ReceiptBuildOptions {
774 pipeline: path.to_string(),
775 status: status.to_string(),
776 started_at_ms,
777 finished_at_ms,
778 exit_code,
779 producer_name: "harn-cli".to_string(),
780 producer_version: env!("CARGO_PKG_VERSION").to_string(),
781 },
782 &signing_key,
783 key_id,
784 )
785 .await
786 .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
787 let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
788 if let Some(parent) = receipt_path.parent() {
789 fs::create_dir_all(parent)
790 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
791 }
792 let encoded = serde_json::to_vec_pretty(&receipt)
793 .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
794 fs::write(&receipt_path, encoded)
795 .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
796 stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
797 Ok(())
798}
799
800fn receipt_output_path(
801 store_base: &Path,
802 options: &RunAttestationOptions,
803 receipt_id: &str,
804) -> PathBuf {
805 if let Some(path) = options.receipt_out.as_ref() {
806 return path.clone();
807 }
808 harn_vm::runtime_paths::state_root(store_base)
809 .join("receipts")
810 .join(format!("{receipt_id}.json"))
811}
812
813fn now_ms() -> i64 {
814 std::time::SystemTime::now()
815 .duration_since(std::time::UNIX_EPOCH)
816 .map(|duration| duration.as_millis() as i64)
817 .unwrap_or(0)
818}
819
820fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
827 use harn_vm::VmValue;
828 match value {
829 VmValue::Int(n) => (*n).clamp(0, 255) as i32,
830 VmValue::EnumVariant {
831 enum_name,
832 variant,
833 fields,
834 } if enum_name.as_ref() == "Result" && variant.as_ref() == "Err" => 1,
835 _ => 0,
836 }
837}
838
839fn render_return_value_error(value: &harn_vm::VmValue) -> String {
840 let harn_vm::VmValue::EnumVariant {
841 enum_name,
842 variant,
843 fields,
844 } = value
845 else {
846 return String::new();
847 };
848 if enum_name.as_ref() != "Result" || variant.as_ref() != "Err" {
849 return String::new();
850 }
851 let rendered = fields.first().map(|p| p.display()).unwrap_or_default();
852 if rendered.is_empty() {
853 "error\n".to_string()
854 } else if rendered.ends_with('\n') {
855 rendered
856 } else {
857 format!("{rendered}\n")
858 }
859}
860
861pub(crate) async fn connect_mcp_servers(
870 servers: &[package::McpServerConfig],
871 vm: &mut harn_vm::Vm,
872) {
873 use std::collections::BTreeMap;
874 use std::rc::Rc;
875 use std::time::Duration;
876
877 let mut mcp_dict: BTreeMap<String, harn_vm::VmValue> = BTreeMap::new();
878 let mut registrations: Vec<harn_vm::RegisteredMcpServer> = Vec::new();
879
880 for server in servers {
881 let resolved_auth = match mcp::resolve_auth_for_server(server).await {
882 Ok(resolution) => resolution,
883 Err(error) => {
884 eprintln!(
885 "warning: mcp: failed to load auth for '{}': {}",
886 server.name, error
887 );
888 AuthResolution::None
889 }
890 };
891 let spec = serde_json::json!({
892 "name": server.name,
893 "transport": server.transport.clone().unwrap_or_else(|| "stdio".to_string()),
894 "command": server.command,
895 "args": server.args,
896 "env": server.env,
897 "url": server.url,
898 "auth_token": match resolved_auth {
899 AuthResolution::Bearer(token) => Some(token),
900 AuthResolution::None => server.auth_token.clone(),
901 },
902 "protocol_version": server.protocol_version,
903 "proxy_server_name": server.proxy_server_name,
904 });
905
906 registrations.push(harn_vm::RegisteredMcpServer {
909 name: server.name.clone(),
910 spec: spec.clone(),
911 lazy: server.lazy,
912 card: server.card.clone(),
913 keep_alive: server.keep_alive_ms.map(Duration::from_millis),
914 });
915
916 if server.lazy {
917 eprintln!(
918 "[harn] mcp: deferred '{}' (lazy, boots on first use)",
919 server.name
920 );
921 continue;
922 }
923
924 match harn_vm::connect_mcp_server_from_json(&spec).await {
925 Ok(handle) => {
926 eprintln!("[harn] mcp: connected to '{}'", server.name);
927 harn_vm::mcp_install_active(&server.name, handle.clone());
928 mcp_dict.insert(server.name.clone(), harn_vm::VmValue::McpClient(handle));
929 }
930 Err(e) => {
931 eprintln!(
932 "warning: mcp: failed to connect to '{}': {}",
933 server.name, e
934 );
935 }
936 }
937 }
938
939 harn_vm::mcp_register_servers(registrations);
942
943 if !mcp_dict.is_empty() {
944 vm.set_global("mcp", harn_vm::VmValue::Dict(Rc::new(mcp_dict)));
945 }
946}
947
948fn render_trace_summary() -> String {
949 use std::fmt::Write;
950 let entries = harn_vm::llm::take_trace();
951 if entries.is_empty() {
952 return String::new();
953 }
954 let mut out = String::new();
955 let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
956 let mut total_input = 0i64;
957 let mut total_output = 0i64;
958 let mut total_ms = 0u64;
959 for (i, entry) in entries.iter().enumerate() {
960 let _ = writeln!(
961 out,
962 " #{}: {} | {} in + {} out tokens | {} ms",
963 i + 1,
964 entry.model,
965 entry.input_tokens,
966 entry.output_tokens,
967 entry.duration_ms,
968 );
969 total_input += entry.input_tokens;
970 total_output += entry.output_tokens;
971 total_ms += entry.duration_ms;
972 }
973 let total_tokens = total_input + total_output;
974 let cost = (total_input as f64 * 3.0 + total_output as f64 * 15.0) / 1_000_000.0;
976 let _ = writeln!(
977 out,
978 " \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, ~${:.4}\x1b[0m",
979 entries.len(),
980 if entries.len() == 1 { "" } else { "s" },
981 total_tokens,
982 total_input,
983 total_output,
984 total_ms,
985 cost,
986 );
987 out
988}
989
990pub(crate) async fn run_file_mcp_serve(
1004 path: &str,
1005 card_source: Option<&str>,
1006 mode: RunFileMcpServeMode,
1007) {
1008 let (source, program) = crate::parse_source_file(path);
1009
1010 let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
1011 for diag in &type_diagnostics {
1012 match diag.severity {
1013 DiagnosticSeverity::Error => {
1014 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
1015 eprint!("{rendered}");
1016 process::exit(1);
1017 }
1018 DiagnosticSeverity::Warning => {
1019 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
1020 eprint!("{rendered}");
1021 }
1022 }
1023 }
1024
1025 let chunk = match harn_vm::Compiler::new().compile(&program) {
1026 Ok(c) => c,
1027 Err(e) => {
1028 eprintln!("error: compile error: {e}");
1029 process::exit(1);
1030 }
1031 };
1032
1033 let mut vm = harn_vm::Vm::new();
1034 harn_vm::register_vm_stdlib(&mut vm);
1035 crate::install_default_hostlib(&mut vm);
1036 let source_parent = std::path::Path::new(path)
1037 .parent()
1038 .unwrap_or(std::path::Path::new("."));
1039 let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1040 let store_base = project_root.as_deref().unwrap_or(source_parent);
1041 harn_vm::register_store_builtins(&mut vm, store_base);
1042 harn_vm::register_metadata_builtins(&mut vm, store_base);
1043 let pipeline_name = std::path::Path::new(path)
1044 .file_stem()
1045 .and_then(|s| s.to_str())
1046 .unwrap_or("default");
1047 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1048 vm.set_source_info(path, &source);
1049 if let Some(ref root) = project_root {
1050 vm.set_project_root(root);
1051 }
1052 if let Some(p) = std::path::Path::new(path).parent() {
1053 if !p.as_os_str().is_empty() {
1054 vm.set_source_dir(p);
1055 }
1056 }
1057
1058 let loaded = load_skills(&SkillLoaderInputs {
1060 cli_dirs: Vec::new(),
1061 source_path: Some(std::path::PathBuf::from(path)),
1062 });
1063 emit_loader_warnings(&loaded.loader_warnings);
1064 install_skills_global(&mut vm, &loaded);
1065
1066 let extensions = package::load_runtime_extensions(Path::new(path));
1067 package::install_runtime_extensions(&extensions);
1068 if let Some(manifest) = extensions.root_manifest.as_ref() {
1069 if !manifest.mcp.is_empty() {
1070 connect_mcp_servers(&manifest.mcp, &mut vm).await;
1071 }
1072 }
1073 if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1074 eprintln!("error: failed to install manifest triggers: {error}");
1075 process::exit(1);
1076 }
1077 if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1078 eprintln!("error: failed to install manifest hooks: {error}");
1079 process::exit(1);
1080 }
1081
1082 let local = tokio::task::LocalSet::new();
1083 local
1084 .run_until(async {
1085 match vm.execute(&chunk).await {
1086 Ok(_) => {}
1087 Err(e) => {
1088 eprint!("{}", vm.format_runtime_error(&e));
1089 process::exit(1);
1090 }
1091 }
1092
1093 let output = vm.output();
1095 if !output.is_empty() {
1096 eprint!("{output}");
1097 }
1098
1099 let registry = match harn_vm::take_mcp_serve_registry() {
1100 Some(r) => r,
1101 None => {
1102 eprintln!("error: pipeline did not call mcp_serve(registry)");
1103 eprintln!("hint: call mcp_serve(tools) at the end of your pipeline");
1104 process::exit(1);
1105 }
1106 };
1107
1108 let tools = match harn_vm::tool_registry_to_mcp_tools(®istry) {
1109 Ok(t) => t,
1110 Err(e) => {
1111 eprintln!("error: {e}");
1112 process::exit(1);
1113 }
1114 };
1115
1116 let resources = harn_vm::take_mcp_serve_resources();
1117 let resource_templates = harn_vm::take_mcp_serve_resource_templates();
1118 let prompts = harn_vm::take_mcp_serve_prompts();
1119
1120 let server_name = std::path::Path::new(path)
1121 .file_stem()
1122 .and_then(|s| s.to_str())
1123 .unwrap_or("harn")
1124 .to_string();
1125
1126 let mut caps = Vec::new();
1127 if !tools.is_empty() {
1128 caps.push(format!(
1129 "{} tool{}",
1130 tools.len(),
1131 if tools.len() == 1 { "" } else { "s" }
1132 ));
1133 }
1134 let total_resources = resources.len() + resource_templates.len();
1135 if total_resources > 0 {
1136 caps.push(format!(
1137 "{total_resources} resource{}",
1138 if total_resources == 1 { "" } else { "s" }
1139 ));
1140 }
1141 if !prompts.is_empty() {
1142 caps.push(format!(
1143 "{} prompt{}",
1144 prompts.len(),
1145 if prompts.len() == 1 { "" } else { "s" }
1146 ));
1147 }
1148 eprintln!(
1149 "[harn] serve mcp: serving {} as '{server_name}'",
1150 caps.join(", ")
1151 );
1152
1153 let mut server =
1154 harn_vm::McpServer::new(server_name, tools, resources, resource_templates, prompts);
1155 if let Some(source) = card_source {
1156 match resolve_card_source(source) {
1157 Ok(card) => server = server.with_server_card(card),
1158 Err(e) => {
1159 eprintln!("error: --card: {e}");
1160 process::exit(1);
1161 }
1162 }
1163 }
1164 match mode {
1165 RunFileMcpServeMode::Stdio => {
1166 if let Err(e) = server.run(&mut vm).await {
1167 eprintln!("error: MCP server error: {e}");
1168 process::exit(1);
1169 }
1170 }
1171 RunFileMcpServeMode::Http {
1172 options,
1173 auth_policy,
1174 } => {
1175 if let Err(e) = crate::commands::serve::run_script_mcp_http_server(
1176 server,
1177 vm,
1178 options,
1179 auth_policy,
1180 )
1181 .await
1182 {
1183 eprintln!("error: MCP server error: {e}");
1184 process::exit(1);
1185 }
1186 }
1187 }
1188 })
1189 .await;
1190}
1191
1192pub(crate) fn resolve_card_source(source: &str) -> Result<serde_json::Value, String> {
1197 let trimmed = source.trim_start();
1198 if trimmed.starts_with('{') || trimmed.starts_with('[') {
1199 return serde_json::from_str(source).map_err(|e| format!("inline JSON parse error: {e}"));
1200 }
1201 let path = std::path::Path::new(source);
1202 harn_vm::load_server_card_from_path(path).map_err(|e| format!("{e}"))
1203}
1204
1205pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
1206 use notify::{Event, EventKind, RecursiveMode, Watcher};
1207
1208 let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
1209 eprintln!("Error: {e}");
1210 process::exit(1);
1211 });
1212 let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
1213
1214 eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
1215 run_file(
1216 path,
1217 false,
1218 denied_builtins.clone(),
1219 Vec::new(),
1220 CliLlmMockMode::Off,
1221 None,
1222 RunProfileOptions::default(),
1223 )
1224 .await;
1225
1226 let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
1227 let _watcher = {
1228 let tx = tx.clone();
1229 let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
1230 if let Ok(event) = res {
1231 if matches!(
1232 event.kind,
1233 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
1234 ) {
1235 let has_harn = event
1236 .paths
1237 .iter()
1238 .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
1239 if has_harn {
1240 let _ = tx.blocking_send(());
1241 }
1242 }
1243 }
1244 })
1245 .unwrap_or_else(|e| {
1246 eprintln!("Error setting up file watcher: {e}");
1247 process::exit(1);
1248 });
1249 watcher
1250 .watch(watch_dir, RecursiveMode::Recursive)
1251 .unwrap_or_else(|e| {
1252 eprintln!("Error watching directory: {e}");
1253 process::exit(1);
1254 });
1255 watcher };
1257
1258 eprintln!(
1259 "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
1260 watch_dir.display()
1261 );
1262
1263 loop {
1264 rx.recv().await;
1265 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1267 while rx.try_recv().is_ok() {}
1268
1269 eprintln!();
1270 eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
1271 run_file(
1272 path,
1273 false,
1274 denied_builtins.clone(),
1275 Vec::new(),
1276 CliLlmMockMode::Off,
1277 None,
1278 RunProfileOptions::default(),
1279 )
1280 .await;
1281 }
1282}
1283
1284#[cfg(test)]
1285mod tests {
1286 use super::{
1287 execute_explain_cost, execute_run, split_eval_header, CliLlmMockMode, RunProfileOptions,
1288 StdoutPassthroughGuard,
1289 };
1290 use std::collections::HashSet;
1291
1292 #[test]
1293 fn split_eval_header_no_imports_returns_full_body() {
1294 let (header, body) = split_eval_header("println(1 + 2)");
1295 assert_eq!(header, "");
1296 assert_eq!(body, "println(1 + 2)");
1297 }
1298
1299 #[test]
1300 fn split_eval_header_lifts_leading_imports() {
1301 let code = "import \"./lib\"\nimport { x } from \"std/math\"\nprintln(x)";
1302 let (header, body) = split_eval_header(code);
1303 assert_eq!(header, "import \"./lib\"\nimport { x } from \"std/math\"");
1304 assert_eq!(body, "println(x)");
1305 }
1306
1307 #[test]
1308 fn split_eval_header_keeps_pub_import_and_comments_in_header() {
1309 let code = "// header comment\npub import { y } from \"./lib\"\n\nfoo()";
1310 let (header, body) = split_eval_header(code);
1311 assert_eq!(
1312 header,
1313 "// header comment\npub import { y } from \"./lib\"\n"
1314 );
1315 assert_eq!(body, "foo()");
1316 }
1317
1318 #[test]
1319 fn split_eval_header_does_not_lift_imports_after_other_statements() {
1320 let code = "let a = 1\nimport \"./lib\"";
1321 let (header, body) = split_eval_header(code);
1322 assert_eq!(header, "");
1323 assert_eq!(body, "let a = 1\nimport \"./lib\"");
1324 }
1325
1326 #[test]
1327 fn cli_llm_mock_roundtrips_logprobs() {
1328 let mock = harn_vm::llm::parse_llm_mock_value(&serde_json::json!({
1329 "text": "visible",
1330 "logprobs": [{"token": "visible", "logprob": 0.0}]
1331 }))
1332 .expect("parse mock");
1333 assert_eq!(mock.logprobs.len(), 1);
1334
1335 let line = harn_vm::llm::serialize_llm_mock(mock).expect("serialize mock");
1336 let value: serde_json::Value = serde_json::from_str(&line).expect("json line");
1337 assert_eq!(value["logprobs"][0]["token"].as_str(), Some("visible"));
1338
1339 let reparsed = harn_vm::llm::parse_llm_mock_value(&value).expect("reparse mock");
1340 assert_eq!(reparsed.logprobs.len(), 1);
1341 assert_eq!(reparsed.logprobs[0]["logprob"].as_f64(), Some(0.0));
1342 }
1343
1344 #[test]
1345 fn stdout_passthrough_guard_restores_previous_state() {
1346 let original = harn_vm::set_stdout_passthrough(false);
1347 {
1348 let _guard = StdoutPassthroughGuard::enable();
1349 assert!(harn_vm::set_stdout_passthrough(true));
1350 }
1351 assert!(!harn_vm::set_stdout_passthrough(original));
1352 }
1353
1354 #[test]
1355 fn execute_explain_cost_does_not_execute_script() {
1356 let temp = tempfile::TempDir::new().expect("temp dir");
1357 let script = temp.path().join("main.harn");
1358 std::fs::write(
1359 &script,
1360 r#"
1361pipeline main() {
1362 write_file("executed.txt", "bad")
1363 llm_call("hello", nil, {provider: "mock", model: "mock"})
1364}
1365"#,
1366 )
1367 .expect("write script");
1368
1369 let outcome = execute_explain_cost(&script.to_string_lossy());
1370
1371 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1372 assert!(outcome.stdout.contains("LLM cost estimate"));
1373 assert!(
1374 !temp.path().join("executed.txt").exists(),
1375 "--explain-cost must not execute pipeline side effects"
1376 );
1377 }
1378
1379 #[cfg(feature = "hostlib")]
1380 #[tokio::test]
1381 async fn execute_run_installs_hostlib_gate() {
1382 let temp = tempfile::NamedTempFile::new().expect("temp file");
1383 std::fs::write(
1384 temp.path(),
1385 r#"
1386pipeline main() {
1387 let _ = hostlib_enable("tools:deterministic")
1388 println("enabled")
1389}
1390"#,
1391 )
1392 .expect("write script");
1393
1394 let outcome = execute_run(
1395 &temp.path().to_string_lossy(),
1396 false,
1397 HashSet::new(),
1398 Vec::new(),
1399 Vec::new(),
1400 CliLlmMockMode::Off,
1401 None,
1402 RunProfileOptions::default(),
1403 )
1404 .await;
1405
1406 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1407 assert_eq!(outcome.stdout.trim(), "enabled");
1408 }
1409
1410 #[cfg(all(feature = "hostlib", unix))]
1411 #[tokio::test]
1412 async fn execute_run_can_read_hostlib_command_artifacts() {
1413 let temp = tempfile::NamedTempFile::new().expect("temp file");
1414 std::fs::write(
1415 temp.path(),
1416 r#"
1417pipeline main() {
1418 let _ = hostlib_enable("tools:deterministic")
1419 let result = hostlib_tools_run_command({
1420 argv: ["sh", "-c", "i=0; while [ $i -lt 2000 ]; do printf x; i=$((i+1)); done"],
1421 capture: {max_inline_bytes: 8},
1422 timeout_ms: 5000,
1423 })
1424 println(starts_with(result.command_id, "cmd_"))
1425 println(len(result.stdout))
1426 println(result.byte_count)
1427 let window = hostlib_tools_read_command_output({
1428 command_id: result.command_id,
1429 offset: 1990,
1430 length: 20,
1431 })
1432 println(len(window.content))
1433 println(window.eof)
1434}
1435"#,
1436 )
1437 .expect("write script");
1438
1439 let outcome = execute_run(
1440 &temp.path().to_string_lossy(),
1441 false,
1442 HashSet::new(),
1443 Vec::new(),
1444 Vec::new(),
1445 CliLlmMockMode::Off,
1446 None,
1447 RunProfileOptions::default(),
1448 )
1449 .await;
1450
1451 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1452 assert_eq!(outcome.stdout.trim(), "true\n8\n2000\n10\ntrue");
1453 }
1454}