Skip to main content

harn_cli/commands/
run.rs

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, Mutex};
8use std::time::Instant;
9
10use harn_parser::DiagnosticSeverity;
11use harn_vm::event_log::EventLog;
12
13use crate::commands::mcp::{self, AuthResolution};
14use crate::commands::time::RunTiming;
15use crate::package;
16use crate::parse_source_file;
17use crate::skill_loader::{
18    canonicalize_cli_dirs, emit_loader_warnings, install_skills_global, load_skills,
19    SkillLoaderInputs,
20};
21
22mod explain_cost;
23pub mod harnpack;
24pub mod json_events;
25
26use self::harnpack::{HarnpackError, HarnpackRunOptions, PreparedHarnpack};
27use self::json_events::NdjsonEmitter;
28
29/// JSON event-stream configuration for `--json` runs.
30#[derive(Clone, Default)]
31pub struct RunJsonOptions {
32    /// Suppress `stdout` / `stderr` events. Transcript, tool, hook,
33    /// persona, and the terminal result/error events still flow.
34    pub quiet: bool,
35}
36
37pub(crate) enum RunFileMcpServeMode {
38    Stdio,
39    Http {
40        options: harn_serve::McpHttpServeOptions,
41        auth_policy: harn_serve::AuthPolicy,
42    },
43}
44
45/// Core builtins that are never denied, even when using `--allow`.
46const CORE_BUILTINS: &[&str] = &[
47    "println",
48    "print",
49    "log",
50    "type_of",
51    "to_string",
52    "to_int",
53    "to_float",
54    "len",
55    "assert",
56    "assert_eq",
57    "assert_ne",
58    "json_parse",
59    "json_stringify",
60    "runtime_context",
61    "task_current",
62    "runtime_context_values",
63    "runtime_context_get",
64    "runtime_context_set",
65    "runtime_context_clear",
66];
67
68/// Build the set of denied builtin names from `--deny` or `--allow` flags.
69///
70/// - `--deny a,b,c` denies exactly those names.
71/// - `--allow a,b,c` denies everything *except* the listed names and the core builtins.
72pub(crate) fn build_denied_builtins(
73    deny_csv: Option<&str>,
74    allow_csv: Option<&str>,
75) -> HashSet<String> {
76    if let Some(csv) = deny_csv {
77        csv.split(',')
78            .map(|s| s.trim().to_string())
79            .filter(|s| !s.is_empty())
80            .collect()
81    } else if let Some(csv) = allow_csv {
82        // With --allow, we mark every registered stdlib builtin as denied
83        // *except* those in the allow list and the core builtins.
84        let allowed: HashSet<String> = csv
85            .split(',')
86            .map(|s| s.trim().to_string())
87            .filter(|s| !s.is_empty())
88            .collect();
89        let core: HashSet<&str> = CORE_BUILTINS.iter().copied().collect();
90
91        // Create a temporary VM with stdlib registered to enumerate all builtin names.
92        let mut tmp = harn_vm::Vm::new();
93        harn_vm::register_vm_stdlib(&mut tmp);
94        harn_vm::register_store_builtins(&mut tmp, std::path::Path::new("."));
95        harn_vm::register_metadata_builtins(&mut tmp, std::path::Path::new("."));
96
97        tmp.builtin_names()
98            .into_iter()
99            .filter(|name| !allowed.contains(name) && !core.contains(name.as_str()))
100            .collect()
101    } else {
102        HashSet::new()
103    }
104}
105
106/// Result of [`compile_or_load_chunk_for_run`]. Failures propagate as
107/// diagnostic text on the run path so callers map them straight to a
108/// non-zero exit code without bespoke error types.
109pub(crate) struct LoadedChunk {
110    pub(crate) source: String,
111    pub(crate) chunk: harn_vm::Chunk,
112}
113
114/// Load the entry pipeline as a runnable [`harn_vm::Chunk`], using the
115/// content-addressed bytecode cache when its key matches. On a cache miss
116/// we read, parse, type-check, and compile, then persist the chunk.
117/// On a hit we skip parse/typecheck/compile entirely — the cache invariant
118/// is that a stored chunk passed those phases on the writer's harn build,
119/// and the key includes every transitively-imported user file so any
120/// change re-runs the full path.
121///
122/// `stderr` receives any diagnostic output. Returns `None` when a fatal
123/// type or compile error blocks execution; the caller maps that to
124/// exit-code 1.
125pub(crate) fn compile_or_load_chunk_for_run(
126    path: &str,
127    stderr: &mut String,
128) -> Option<LoadedChunk> {
129    compile_or_load_chunk_with_timing(path, stderr, None)
130}
131
132/// Like [`compile_or_load_chunk_for_run`] but lets the caller observe
133/// per-phase wall-clock timings (parse, typecheck, bytecode compile +
134/// cache hit/miss). Used by `harn time run` to drive the same code
135/// path as `harn run` while reporting phase-level timing.
136//
137// The `as_deref_mut` calls reborrow the inner `&mut RunTiming` so each
138// phase can mutate it independently. Clippy's `needless_option_as_deref`
139// is correct that the surface types match — that's exactly the
140// reborrow we want.
141#[allow(clippy::needless_option_as_deref)]
142pub(crate) fn compile_or_load_chunk_with_timing(
143    path: &str,
144    stderr: &mut String,
145    mut timing: Option<&mut RunTiming>,
146) -> Option<LoadedChunk> {
147    let source = match fs::read_to_string(path) {
148        Ok(s) => s,
149        Err(e) => {
150            stderr.push_str(&format!("Error reading {path}: {e}\n"));
151            return None;
152        }
153    };
154    if let Some(t) = timing.as_deref_mut() {
155        t.input_bytes = source.len() as u64;
156    }
157
158    let compile_phase_start = Instant::now();
159    let lookup = harn_vm::bytecode_cache::load(Path::new(path), &source);
160    if let Some(chunk) = lookup.chunk {
161        if let Some(t) = timing.as_deref_mut() {
162            t.cache_hit = true;
163            t.bytecode_compile = compile_phase_start.elapsed();
164        }
165        return Some(LoadedChunk { source, chunk });
166    }
167    if let Some(t) = timing.as_deref_mut() {
168        t.cache_hit = false;
169    }
170
171    let parse_start = Instant::now();
172    let (parsed_source, program) = parse_source_file(path);
173    debug_assert_eq!(parsed_source, source, "parse_source_file re-read drifted");
174    if let Some(t) = timing.as_deref_mut() {
175        t.parse = parse_start.elapsed();
176    }
177
178    let typecheck_start = Instant::now();
179    let mut had_type_error = false;
180    let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
181    for diag in &type_diagnostics {
182        let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
183        if matches!(diag.severity, DiagnosticSeverity::Error) {
184            had_type_error = true;
185        }
186        stderr.push_str(&rendered);
187    }
188    if let Some(t) = timing.as_deref_mut() {
189        t.typecheck = typecheck_start.elapsed();
190    }
191    if had_type_error {
192        return None;
193    }
194
195    let compile_step_start = Instant::now();
196    let chunk = match harn_vm::Compiler::new().compile(&program) {
197        Ok(c) => c,
198        Err(e) => {
199            stderr.push_str(&format!("error: compile error: {e}\n"));
200            return None;
201        }
202    };
203
204    // Cache misses are best-effort — read-only homedirs, full disks, and
205    // sandboxes are common in CI environments. Surface the failure as a
206    // single-line warning when explicitly requested via the audit hook;
207    // otherwise stay quiet to avoid bloating happy-path output.
208    if let Err(err) = harn_vm::bytecode_cache::store(&lookup.key, &chunk) {
209        if std::env::var_os("HARN_BYTECODE_CACHE_DEBUG").is_some() {
210            eprintln!("[harn] bytecode cache write skipped: {err}");
211        }
212    }
213    if let Some(t) = timing.as_deref_mut() {
214        t.bytecode_compile = compile_step_start.elapsed();
215    }
216
217    Some(LoadedChunk { source, chunk })
218}
219
220/// Run the static type checker against `program` with cross-module
221/// import-aware call resolution when the file's imports all resolve. Used
222/// by `run_file` and the MCP server entry so `harn run` catches undefined
223/// cross-module calls before the VM starts.
224fn typecheck_with_imports(
225    program: &[harn_parser::SNode],
226    path: &Path,
227    source: &str,
228) -> Vec<harn_parser::TypeDiagnostic> {
229    if let Err(error) = package::ensure_dependencies_materialized(path) {
230        eprintln!("error: {error}");
231        process::exit(1);
232    }
233    let graph = harn_modules::build(&[path.to_path_buf()]);
234    let mut checker = harn_parser::TypeChecker::new();
235    if let Some(imported) = graph.imported_names_for_file(path) {
236        checker = checker.with_imported_names(imported);
237    }
238    if let Some(imported) = graph.imported_type_declarations_for_file(path) {
239        checker = checker.with_imported_type_decls(imported);
240    }
241    if let Some(imported) = graph.imported_callable_declarations_for_file(path) {
242        checker = checker.with_imported_callable_decls(imported);
243    }
244    checker.check_with_source(program, source)
245}
246
247/// Build the wrapped source and temp file backing a `harn run -e` invocation.
248///
249/// `import` is a top-level declaration in Harn, so the leading prefix of
250/// import lines (with surrounding blanks/comments) is hoisted out of the
251/// `pipeline main(task) { ... }` wrapper. The temp file is created in the
252/// current working directory so relative imports (`import "./lib"`) and
253/// `harn.toml` discovery resolve against the user's project, not the
254/// system temp dir. If the CWD is unwritable we fall back to the system
255/// temp dir with a stderr warning — pure-expression `-e` still works,
256/// but relative imports will fail to resolve.
257pub(crate) fn prepare_eval_temp_file(
258    code: &str,
259) -> Result<(String, tempfile::NamedTempFile), String> {
260    let (header, body) = split_eval_header(code);
261    let wrapped = if header.is_empty() {
262        format!("pipeline main(task) {{\n{body}\n}}")
263    } else {
264        format!("{header}\npipeline main(task) {{\n{body}\n}}")
265    };
266
267    let tmp = create_eval_temp_file()?;
268    Ok((wrapped, tmp))
269}
270
271/// Try to place the `-e` temp file in the current working directory so
272/// relative imports and `harn.toml` discovery resolve against the user's
273/// project. Fall back to the system temp dir on failure (with a warning),
274/// so pure-expression `-e` keeps working in read-only contexts.
275fn create_eval_temp_file() -> Result<tempfile::NamedTempFile, String> {
276    if let Some(dir) = std::env::current_dir().ok().as_deref() {
277        // Hidden prefix on Unix so editors / tree-walkers are less likely
278        // to pick the file up during its short lifetime.
279        match tempfile::Builder::new()
280            .prefix(".harn-eval-")
281            .suffix(".harn")
282            .tempfile_in(dir)
283        {
284            Ok(tmp) => return Ok(tmp),
285            Err(error) => eprintln!(
286                "warning: harn run -e: could not create temp file in {}: {error}; \
287                 relative imports will not resolve",
288                dir.display()
289            ),
290        }
291    }
292    tempfile::Builder::new()
293        .prefix("harn-eval-")
294        .suffix(".harn")
295        .tempfile()
296        .map_err(|e| format!("failed to create temp file for -e: {e}"))
297}
298
299/// Split the `-e` input into a header (top-level imports + leading
300/// blanks/comments) and a body (everything else, to be wrapped in
301/// `pipeline main(task)`). The header may be empty.
302///
303/// Lines whose first non-whitespace token is `import` or `pub import`
304/// are treated as imports. Scanning stops at the first non-blank,
305/// non-comment, non-import line.
306fn split_eval_header(code: &str) -> (String, String) {
307    let mut header_end = 0usize;
308    let mut last_kept = 0usize;
309    for (idx, line) in code.lines().enumerate() {
310        let trimmed = line.trim_start();
311        if trimmed.is_empty() || trimmed.starts_with("//") {
312            header_end = idx + 1;
313            continue;
314        }
315        let is_import = trimmed.starts_with("import ")
316            || trimmed.starts_with("import\t")
317            || trimmed.starts_with("import\"")
318            || trimmed.starts_with("pub import ")
319            || trimmed.starts_with("pub import\t");
320        if is_import {
321            header_end = idx + 1;
322            last_kept = idx + 1;
323        } else {
324            break;
325        }
326    }
327    if last_kept == 0 {
328        return (String::new(), code.to_string());
329    }
330    let mut header_lines: Vec<&str> = Vec::new();
331    let mut body_lines: Vec<&str> = Vec::new();
332    for (idx, line) in code.lines().enumerate() {
333        if idx < header_end {
334            header_lines.push(line);
335        } else {
336            body_lines.push(line);
337        }
338    }
339    (header_lines.join("\n"), body_lines.join("\n"))
340}
341
342#[derive(Clone, Debug, Default, PartialEq, Eq)]
343pub enum CliLlmMockMode {
344    #[default]
345    Off,
346    Replay {
347        fixture_path: PathBuf,
348    },
349    Record {
350        fixture_path: PathBuf,
351    },
352}
353
354#[derive(Clone, Debug, Default, PartialEq, Eq)]
355pub struct RunAttestationOptions {
356    pub receipt_out: Option<PathBuf>,
357    pub agent_id: Option<String>,
358}
359
360/// Opt-in profiling. When `text` is true the run prints a categorical
361/// breakdown to stderr after execution; when `json_path` is set the same
362/// rollup is serialized to that path. Either flag enables span tracing
363/// (i.e. `harn_vm::tracing::set_tracing_enabled(true)`).
364#[derive(Clone, Debug, Default, PartialEq, Eq)]
365pub struct RunProfileOptions {
366    pub text: bool,
367    pub json_path: Option<PathBuf>,
368}
369
370impl RunProfileOptions {
371    pub fn is_enabled(&self) -> bool {
372        self.text || self.json_path.is_some()
373    }
374}
375
376#[derive(Clone)]
377pub struct RunInterruptTokens {
378    pub cancel_token: Arc<AtomicBool>,
379    pub signal_token: Arc<Mutex<Option<String>>>,
380}
381
382struct ExecuteRunInputs<'a> {
383    path: &'a str,
384    trace: bool,
385    denied_builtins: HashSet<String>,
386    script_argv: Vec<String>,
387    skill_dirs_raw: Vec<String>,
388    llm_mock_mode: CliLlmMockMode,
389    attestation: Option<RunAttestationOptions>,
390    profile: RunProfileOptions,
391    interrupt_tokens: Option<RunInterruptTokens>,
392    json: Option<(RunJsonOptions, Box<dyn io::Write + Send>)>,
393    timing: Option<&'a mut RunTiming>,
394    harnpack: HarnpackRunOptions,
395}
396
397/// Captured outcome of an in-process `execute_run` invocation. Tests use this
398/// instead of spawning the `harn` binary; the binary entry point translates
399/// it into real stdout/stderr writes + `process::exit`.
400#[derive(Clone, Debug, Default)]
401pub struct RunOutcome {
402    pub stdout: String,
403    pub stderr: String,
404    pub exit_code: i32,
405}
406
407pub fn install_cli_llm_mock_mode(mode: &CliLlmMockMode) -> Result<(), String> {
408    harn_vm::llm::clear_cli_llm_mock_mode();
409    match mode {
410        CliLlmMockMode::Off => Ok(()),
411        CliLlmMockMode::Replay { fixture_path } => {
412            let mocks = harn_vm::llm::load_llm_mocks_jsonl(fixture_path)?;
413            harn_vm::llm::install_cli_llm_mocks(mocks);
414            Ok(())
415        }
416        CliLlmMockMode::Record { .. } => {
417            harn_vm::llm::enable_cli_llm_mock_recording();
418            Ok(())
419        }
420    }
421}
422
423pub fn persist_cli_llm_mock_recording(mode: &CliLlmMockMode) -> Result<(), String> {
424    let CliLlmMockMode::Record { fixture_path } = mode else {
425        return Ok(());
426    };
427    if let Some(parent) = fixture_path.parent() {
428        if !parent.as_os_str().is_empty() {
429            fs::create_dir_all(parent).map_err(|error| {
430                format!(
431                    "failed to create fixture directory {}: {error}",
432                    parent.display()
433                )
434            })?;
435        }
436    }
437
438    let lines = harn_vm::llm::take_cli_llm_recordings()
439        .into_iter()
440        .map(harn_vm::llm::serialize_llm_mock)
441        .collect::<Result<Vec<_>, _>>()?;
442    let body = if lines.is_empty() {
443        String::new()
444    } else {
445        format!("{}\n", lines.join("\n"))
446    };
447    fs::write(fixture_path, body)
448        .map_err(|error| format!("failed to write {}: {error}", fixture_path.display()))
449}
450
451pub(crate) async fn run_file(
452    path: &str,
453    trace: bool,
454    denied_builtins: HashSet<String>,
455    script_argv: Vec<String>,
456    llm_mock_mode: CliLlmMockMode,
457    attestation: Option<RunAttestationOptions>,
458    profile: RunProfileOptions,
459) {
460    run_file_with_skill_dirs(
461        path,
462        trace,
463        denied_builtins,
464        script_argv,
465        Vec::new(),
466        llm_mock_mode,
467        attestation,
468        profile,
469        None,
470        HarnpackRunOptions::default(),
471    )
472    .await;
473}
474
475pub(crate) fn run_explain_cost_file_with_skill_dirs(path: &str) {
476    let outcome = execute_explain_cost(path);
477    if !outcome.stderr.is_empty() {
478        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
479    }
480    if !outcome.stdout.is_empty() {
481        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
482    }
483    if outcome.exit_code != 0 {
484        process::exit(outcome.exit_code);
485    }
486}
487
488#[allow(clippy::too_many_arguments)]
489pub(crate) async fn run_file_with_skill_dirs(
490    path: &str,
491    trace: bool,
492    denied_builtins: HashSet<String>,
493    script_argv: Vec<String>,
494    skill_dirs_raw: Vec<String>,
495    llm_mock_mode: CliLlmMockMode,
496    attestation: Option<RunAttestationOptions>,
497    profile: RunProfileOptions,
498    json: Option<RunJsonOptions>,
499    harnpack: HarnpackRunOptions,
500) {
501    // Graceful shutdown: flush run records before exit on SIGINT/SIGTERM.
502    let interrupt_tokens = install_signal_shutdown_handler();
503
504    let _stdout_passthrough = StdoutPassthroughGuard::enable();
505    let json_with_stdout =
506        json.map(|opts| (opts, Box::new(io::stdout()) as Box<dyn io::Write + Send>));
507    let outcome = execute_run_inner(ExecuteRunInputs {
508        path,
509        trace,
510        denied_builtins,
511        script_argv,
512        skill_dirs_raw,
513        llm_mock_mode,
514        attestation,
515        profile,
516        interrupt_tokens: Some(interrupt_tokens.clone()),
517        json: json_with_stdout,
518        timing: None,
519        harnpack,
520    })
521    .await;
522
523    // `harn run` streams normal program stdout during execution. Any stdout
524    // left here came from older capture paths, so flush it after diagnostics.
525    if !outcome.stderr.is_empty() {
526        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
527    }
528    if !outcome.stdout.is_empty() {
529        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
530    }
531
532    let mut exit_code = outcome.exit_code;
533    if exit_code != 0 && interrupt_tokens.cancel_token.load(Ordering::SeqCst) {
534        exit_code = 124;
535    }
536    if exit_code != 0 {
537        process::exit(exit_code);
538    }
539}
540
541#[allow(clippy::too_many_arguments)]
542pub(crate) async fn run_resume_with_skill_dirs(
543    target: &str,
544    trace: bool,
545    denied_builtins: HashSet<String>,
546    resume_argv: Vec<String>,
547    skill_dirs_raw: Vec<String>,
548    llm_mock_mode: CliLlmMockMode,
549    attestation: Option<RunAttestationOptions>,
550    profile: RunProfileOptions,
551    json: Option<RunJsonOptions>,
552) {
553    let source = r#"import { resume_agent, wait_agent } from "std/agent/workers"
554
555pipeline main(task) {
556  let input = if len(argv) > 1 {
557    argv[1]
558  } else {
559    nil
560  }
561  let handle = resume_agent(argv[0], input, true)
562  return wait_agent(handle)
563}
564"#;
565    let tmp = create_eval_temp_file().unwrap_or_else(|e| {
566        eprintln!("error: {e}");
567        process::exit(1);
568    });
569    let tmp_path = tmp.path().to_path_buf();
570    if let Err(error) = fs::write(&tmp_path, source) {
571        eprintln!("error: failed to write temp file for --resume: {error}");
572        process::exit(1);
573    }
574    let mut argv = Vec::with_capacity(resume_argv.len() + 1);
575    argv.push(target.to_string());
576    argv.extend(resume_argv);
577    let tmp_str = tmp_path.to_string_lossy().into_owned();
578    run_file_with_skill_dirs(
579        &tmp_str,
580        trace,
581        denied_builtins,
582        argv,
583        skill_dirs_raw,
584        llm_mock_mode,
585        attestation,
586        profile,
587        json,
588        HarnpackRunOptions::default(),
589    )
590    .await;
591}
592
593pub fn execute_explain_cost(path: &str) -> RunOutcome {
594    let stdout = String::new();
595    let mut stderr = String::new();
596
597    let (source, program) = parse_source_file(path);
598
599    let mut had_type_error = false;
600    let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
601    for diag in &type_diagnostics {
602        let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
603        if matches!(diag.severity, DiagnosticSeverity::Error) {
604            had_type_error = true;
605        }
606        stderr.push_str(&rendered);
607    }
608    if had_type_error {
609        return RunOutcome {
610            stdout,
611            stderr,
612            exit_code: 1,
613        };
614    }
615
616    let extensions = package::load_runtime_extensions(Path::new(path));
617    package::install_runtime_extensions(&extensions);
618    RunOutcome {
619        stdout: explain_cost::render_explain_cost(path, &program),
620        stderr,
621        exit_code: 0,
622    }
623}
624
625pub(crate) struct StdoutPassthroughGuard {
626    previous: bool,
627}
628
629impl StdoutPassthroughGuard {
630    pub(crate) fn enable() -> Self {
631        Self {
632            previous: harn_vm::set_stdout_passthrough(true),
633        }
634    }
635}
636
637impl Drop for StdoutPassthroughGuard {
638    fn drop(&mut self) {
639        harn_vm::set_stdout_passthrough(self.previous);
640    }
641}
642
643// User-facing copy on Ctrl-C. We want the operator to know that a brief
644// pause after the first signal is expected (the VM rewinds the active
645// instruction, drops in-flight async ops like a hanging Ollama request,
646// and unwinds frames before the runtime exits) so they don't reflexively
647// reach for a second Ctrl-C and force-kill the process. The "Ctrl-C
648// again to force-exit" hint is load-bearing — earlier runs of harn
649// released to the fleet showed operators routinely double-tapping the
650// shortcut and losing the chance to inspect the error trace.
651const FIRST_SIGNAL_MESSAGE: &str =
652    "[harn] signal received, interrupting VM (give it a moment to unwind in-flight async ops; Ctrl-C again to force-exit)...";
653
654fn install_signal_shutdown_handler() -> RunInterruptTokens {
655    let tokens = RunInterruptTokens {
656        cancel_token: Arc::new(AtomicBool::new(false)),
657        signal_token: Arc::new(Mutex::new(None)),
658    };
659    let tokens_clone = tokens.clone();
660    tokio::spawn(async move {
661        #[cfg(unix)]
662        {
663            use tokio::signal::unix::{signal, SignalKind};
664            let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM handler");
665            let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT handler");
666            let mut sighup = signal(SignalKind::hangup()).expect("SIGHUP handler");
667            let mut seen_signal = false;
668            loop {
669                let signal_name = tokio::select! {
670                    _ = sigterm.recv() => "SIGTERM",
671                    _ = sigint.recv() => "SIGINT",
672                    _ = sighup.recv() => "SIGHUP",
673                };
674                if seen_signal {
675                    eprintln!("[harn] second signal received, terminating");
676                    process::exit(124);
677                }
678                seen_signal = true;
679                request_vm_interrupt(&tokens_clone, signal_name);
680                eprintln!("{FIRST_SIGNAL_MESSAGE}");
681            }
682        }
683        #[cfg(not(unix))]
684        {
685            let mut seen_signal = false;
686            loop {
687                let _ = tokio::signal::ctrl_c().await;
688                if seen_signal {
689                    eprintln!("[harn] second signal received, terminating");
690                    process::exit(124);
691                }
692                seen_signal = true;
693                request_vm_interrupt(&tokens_clone, "SIGINT");
694                eprintln!("{FIRST_SIGNAL_MESSAGE}");
695            }
696        }
697    });
698    tokens
699}
700
701fn request_vm_interrupt(tokens: &RunInterruptTokens, signal_name: &str) {
702    if let Ok(mut signal) = tokens.signal_token.lock() {
703        *signal = Some(signal_name.to_string());
704    }
705    tokens.cancel_token.store(true, Ordering::SeqCst);
706}
707
708/// In-process equivalent of `run_file_with_skill_dirs`. Returns the captured
709/// stdout, stderr, and what exit code the binary entry would have used,
710/// instead of writing to real stdout/stderr or calling `process::exit`.
711///
712/// Tests should call this directly. The `harn run` binary path wraps it.
713pub async fn execute_run(
714    path: &str,
715    trace: bool,
716    denied_builtins: HashSet<String>,
717    script_argv: Vec<String>,
718    skill_dirs_raw: Vec<String>,
719    llm_mock_mode: CliLlmMockMode,
720    attestation: Option<RunAttestationOptions>,
721    profile: RunProfileOptions,
722) -> RunOutcome {
723    execute_run_with_harnpack_options(
724        path,
725        trace,
726        denied_builtins,
727        script_argv,
728        skill_dirs_raw,
729        llm_mock_mode,
730        attestation,
731        profile,
732        HarnpackRunOptions::default(),
733    )
734    .await
735}
736
737/// [`execute_run`] for callers that want to opt-in to the `.harnpack`
738/// verify-replay-execute path. Used by `harn run <bundle.harnpack>`
739/// integration tests and by the binary entry once it has parsed the
740/// `--allow-unsigned` / `--dry-run-verify` flags.
741#[allow(clippy::too_many_arguments)]
742pub async fn execute_run_with_harnpack_options(
743    path: &str,
744    trace: bool,
745    denied_builtins: HashSet<String>,
746    script_argv: Vec<String>,
747    skill_dirs_raw: Vec<String>,
748    llm_mock_mode: CliLlmMockMode,
749    attestation: Option<RunAttestationOptions>,
750    profile: RunProfileOptions,
751    harnpack: HarnpackRunOptions,
752) -> RunOutcome {
753    execute_run_inner(ExecuteRunInputs {
754        path,
755        trace,
756        denied_builtins,
757        script_argv,
758        skill_dirs_raw,
759        llm_mock_mode,
760        attestation,
761        profile,
762        interrupt_tokens: None,
763        json: None,
764        timing: None,
765        harnpack,
766    })
767    .await
768}
769
770/// `execute_run` variant for `--json` mode. Returns once the run is
771/// complete; the NDJSON event stream — including the terminal `result`
772/// or `error` event — has already been written to `out` and flushed.
773/// `out` must be `Send` because the run-event sink may be called from
774/// any worker thread the VM spawns.
775#[allow(clippy::too_many_arguments)]
776pub async fn execute_run_json(
777    path: &str,
778    trace: bool,
779    denied_builtins: HashSet<String>,
780    script_argv: Vec<String>,
781    skill_dirs_raw: Vec<String>,
782    llm_mock_mode: CliLlmMockMode,
783    attestation: Option<RunAttestationOptions>,
784    profile: RunProfileOptions,
785    out: Box<dyn io::Write + Send>,
786    options: RunJsonOptions,
787) -> RunOutcome {
788    execute_run_inner(ExecuteRunInputs {
789        path,
790        trace,
791        denied_builtins,
792        script_argv,
793        skill_dirs_raw,
794        llm_mock_mode,
795        attestation,
796        profile,
797        interrupt_tokens: None,
798        json: Some((options, out)),
799        timing: None,
800        harnpack: HarnpackRunOptions::default(),
801    })
802    .await
803}
804
805/// Run a `.harn` file with the default builtin/argv set and record
806/// phase timings into `timing`. Used by `harn time run` so the
807/// instrumented run shares the exact code path as plain `harn run`.
808pub(crate) async fn execute_run_with_timing(
809    path: &str,
810    script_argv: Vec<String>,
811    timing: Option<&mut RunTiming>,
812) -> RunOutcome {
813    execute_run_inner(ExecuteRunInputs {
814        path,
815        trace: false,
816        denied_builtins: HashSet::new(),
817        script_argv,
818        skill_dirs_raw: Vec::new(),
819        llm_mock_mode: CliLlmMockMode::Off,
820        attestation: None,
821        profile: RunProfileOptions::default(),
822        interrupt_tokens: None,
823        json: None,
824        timing,
825        harnpack: HarnpackRunOptions::default(),
826    })
827    .await
828}
829
830// See [`compile_or_load_chunk_with_timing`] for why `as_deref_mut` is
831// the intentional reborrow pattern here.
832#[allow(clippy::needless_option_as_deref)]
833async fn execute_run_inner(inputs: ExecuteRunInputs<'_>) -> RunOutcome {
834    let ExecuteRunInputs {
835        path,
836        trace,
837        denied_builtins,
838        script_argv,
839        skill_dirs_raw,
840        llm_mock_mode,
841        attestation,
842        profile,
843        interrupt_tokens,
844        json,
845        mut timing,
846        harnpack,
847    } = inputs;
848
849    // `--json` installs an in-process sink that diverts every
850    // observable VM event (stdout, stderr, transcript, tool, hook,
851    // persona) into a single NDJSON stream on `out`. The sink stays
852    // active until we drop the guard below — fatal errors emit a
853    // terminal `error` event on the same stream before bailing.
854    let json_session = json.map(|(options, out)| JsonRunSession::install(options, out));
855
856    let mut stderr = String::new();
857    let mut stdout = String::new();
858
859    // `.harnpack` preflight: verify signature + replay archive into the
860    // content-addressed cache before we touch the chunk loader. The
861    // outcome path (entrypoint inside the unpacked tree) replaces the
862    // CLI-supplied `path` for everything below.
863    let owned_run_path: String;
864    let resolved_path: &str = if harnpack::looks_like_harnpack(Path::new(path)) {
865        let outcome = match harnpack::prepare_harnpack(Path::new(path), &harnpack, &mut stderr) {
866            Ok(prepared) => prepared,
867            Err(err) => return finalize_harnpack_error(stderr, json_session, err),
868        };
869        harn_vm::run_events::emit(harn_vm::run_events::RunEvent::PackRun {
870            bundle_hash: outcome.bundle_hash.clone(),
871            signature_verified: outcome.signature_verified,
872            key_id: outcome.key_id.clone(),
873            cache_hit: outcome.cache_hit,
874            dry_run_verify: harnpack.dry_run_verify,
875        });
876        if harnpack.dry_run_verify {
877            return finalize_harnpack_dry_run(stderr, json_session, &outcome);
878        }
879        owned_run_path = outcome.entrypoint_path.to_string_lossy().into_owned();
880        owned_run_path.as_str()
881    } else {
882        path
883    };
884
885    let Some(LoadedChunk { source, chunk }) =
886        compile_or_load_chunk_with_timing(resolved_path, &mut stderr, timing.as_deref_mut())
887    else {
888        if let Some(session) = json_session {
889            return session.finalize_error("compile_error", stderr, 1);
890        }
891        return RunOutcome {
892            stdout,
893            stderr,
894            exit_code: 1,
895        };
896    };
897    let path = resolved_path;
898
899    // Bracket the VM-setup phase explicitly. `run_setup` covers
900    // everything between the bytecode compile and the first VM
901    // instruction; `run_main` covers `vm.execute` proper.
902    let setup_start = Instant::now();
903
904    if trace {
905        harn_vm::llm::enable_tracing();
906    }
907    if profile.is_enabled() {
908        harn_vm::tracing::set_tracing_enabled(true);
909    }
910    if let Err(error) = install_cli_llm_mock_mode(&llm_mock_mode) {
911        stderr.push_str(&format!("error: {error}\n"));
912        if let Some(session) = json_session {
913            return session.finalize_error("llm_mock_install", error, 1);
914        }
915        return RunOutcome {
916            stdout,
917            stderr,
918            exit_code: 1,
919        };
920    }
921
922    let mut vm = harn_vm::Vm::new();
923    if let Some(interrupt_tokens) = interrupt_tokens {
924        vm.install_interrupt_signal_token(interrupt_tokens.signal_token);
925        vm.install_cancel_token(interrupt_tokens.cancel_token);
926    }
927    harn_vm::register_vm_stdlib(&mut vm);
928    crate::install_default_hostlib(&mut vm);
929    let source_parent = std::path::Path::new(path)
930        .parent()
931        .unwrap_or(std::path::Path::new("."));
932    // Metadata/store rooted at harn.toml when present; source dir otherwise.
933    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
934    let store_base = project_root.as_deref().unwrap_or(source_parent);
935    let attestation_started_at_ms = now_ms();
936    let attestation_log = if attestation.is_some() {
937        Some(harn_vm::event_log::install_memory_for_current_thread(256))
938    } else {
939        None
940    };
941    if let Some(log) = attestation_log.as_ref() {
942        append_run_provenance_event(
943            log,
944            "started",
945            serde_json::json!({
946                "pipeline": path,
947                "argv": &script_argv,
948                "project_root": store_base.display().to_string(),
949            }),
950        )
951        .await;
952    }
953    harn_vm::register_store_builtins(&mut vm, store_base);
954    harn_vm::register_metadata_builtins(&mut vm, store_base);
955    let pipeline_name = std::path::Path::new(path)
956        .file_stem()
957        .and_then(|s| s.to_str())
958        .unwrap_or("default");
959    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
960    vm.set_source_info(path, &source);
961    if !denied_builtins.is_empty() {
962        vm.set_denied_builtins(denied_builtins);
963    }
964    if let Some(ref root) = project_root {
965        vm.set_project_root(root);
966    }
967
968    if let Some(p) = std::path::Path::new(path).parent() {
969        if !p.as_os_str().is_empty() {
970            vm.set_source_dir(p);
971        }
972    }
973
974    // Load filesystem + manifest skills before the pipeline runs so
975    // `skills` is populated with a pre-discovered registry (see #73).
976    let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
977    let loaded = load_skills(&SkillLoaderInputs {
978        cli_dirs,
979        source_path: Some(std::path::PathBuf::from(path)),
980    });
981    emit_loader_warnings(&loaded.loader_warnings);
982    install_skills_global(&mut vm, &loaded);
983
984    // `harn run script.harn -- a b c` yields `argv == ["a", "b", "c"]`.
985    // Always set so scripts can rely on `len(argv)`.
986    let argv_values: Vec<harn_vm::VmValue> = script_argv
987        .iter()
988        .map(|s| harn_vm::VmValue::String(std::rc::Rc::from(s.as_str())))
989        .collect();
990    vm.set_global(
991        "argv",
992        harn_vm::VmValue::List(std::rc::Rc::new(argv_values)),
993    );
994
995    // Install the script's `Harness` capability handle so the auto-call
996    // emitted by `Compiler::compile()` for `fn main(harness: Harness)`
997    // entrypoints can read it.
998    vm.set_harness(harn_vm::Harness::real());
999
1000    let extensions = package::load_runtime_extensions(Path::new(path));
1001    package::install_runtime_extensions(&extensions);
1002    if let Some(manifest) = extensions.root_manifest.as_ref() {
1003        if !manifest.mcp.is_empty() {
1004            connect_mcp_servers(&manifest.mcp, &mut vm).await;
1005        }
1006    }
1007    if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1008        stderr.push_str(&format!(
1009            "error: failed to install manifest triggers: {error}\n"
1010        ));
1011        if let Some(session) = json_session {
1012            return session.finalize_error("manifest_triggers", error.to_string(), 1);
1013        }
1014        return RunOutcome {
1015            stdout,
1016            stderr,
1017            exit_code: 1,
1018        };
1019    }
1020    if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1021        stderr.push_str(&format!(
1022            "error: failed to install manifest hooks: {error}\n"
1023        ));
1024        if let Some(session) = json_session {
1025            return session.finalize_error("manifest_hooks", error.to_string(), 1);
1026        }
1027        return RunOutcome {
1028            stdout,
1029            stderr,
1030            exit_code: 1,
1031        };
1032    }
1033
1034    // Run inside a LocalSet so spawn_local works for concurrency builtins.
1035    let local = tokio::task::LocalSet::new();
1036    if let Some(t) = timing.as_deref_mut() {
1037        t.run_setup = setup_start.elapsed();
1038    }
1039    let main_start = Instant::now();
1040    let execution = local
1041        .run_until(async {
1042            match vm.execute(&chunk).await {
1043                Ok(value) => Ok((vm.output(), value)),
1044                Err(e) => Err(vm.format_runtime_error(&e)),
1045            }
1046        })
1047        .await;
1048    if let Some(t) = timing.as_deref_mut() {
1049        t.run_main = main_start.elapsed();
1050    }
1051    if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
1052        stderr.push_str(&format!("error: {error}\n"));
1053        if let Some(session) = json_session {
1054            return session.finalize_error("llm_mock_record", error, 1);
1055        }
1056        return RunOutcome {
1057            stdout,
1058            stderr,
1059            exit_code: 1,
1060        };
1061    }
1062
1063    // Always drain any captured stderr accumulated during execution.
1064    let buffered_stderr = harn_vm::take_stderr_buffer();
1065    stderr.push_str(&buffered_stderr);
1066
1067    let exit_code = match &execution {
1068        Ok((_, return_value)) => exit_code_from_return_value(return_value),
1069        Err(_) => 1,
1070    };
1071
1072    if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
1073        if let Err(error) = emit_run_attestation(
1074            log,
1075            path,
1076            store_base,
1077            attestation_started_at_ms,
1078            exit_code,
1079            options,
1080            &mut stderr,
1081        )
1082        .await
1083        {
1084            stderr.push_str(&format!(
1085                "error: failed to emit provenance receipt: {error}\n"
1086            ));
1087            if let Some(session) = json_session {
1088                return session.finalize_error("attestation", error.to_string(), 1);
1089            }
1090            return RunOutcome {
1091                stdout,
1092                stderr,
1093                exit_code: 1,
1094            };
1095        }
1096        harn_vm::event_log::reset_active_event_log();
1097    }
1098
1099    match execution {
1100        Ok((output, return_value)) => {
1101            stdout.push_str(output);
1102            if trace {
1103                stderr.push_str(&render_trace_summary());
1104            }
1105            if profile.is_enabled() {
1106                if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
1107                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1108                }
1109            }
1110            if exit_code != 0 {
1111                stderr.push_str(&render_return_value_error(&return_value));
1112            }
1113            if let Some(session) = json_session {
1114                let value = harn_vm::llm::vm_value_to_json(&return_value);
1115                return session.finalize_result(value, exit_code);
1116            }
1117            RunOutcome {
1118                stdout,
1119                stderr,
1120                exit_code,
1121            }
1122        }
1123        Err(rendered_error) => {
1124            stderr.push_str(&rendered_error);
1125            if profile.is_enabled() {
1126                if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
1127                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1128                }
1129            }
1130            if let Some(session) = json_session {
1131                return session.finalize_error("runtime", rendered_error, 1);
1132            }
1133            RunOutcome {
1134                stdout,
1135                stderr,
1136                exit_code: 1,
1137            }
1138        }
1139    }
1140}
1141
1142fn render_and_persist_profile(
1143    options: &RunProfileOptions,
1144    stderr: &mut String,
1145) -> Result<(), String> {
1146    let spans = harn_vm::tracing::peek_spans();
1147    let profile = harn_vm::profile::build(&spans);
1148    if options.text {
1149        stderr.push_str(&harn_vm::profile::render(&profile));
1150    }
1151    if let Some(path) = options.json_path.as_ref() {
1152        if let Some(parent) = path.parent() {
1153            if !parent.as_os_str().is_empty() {
1154                fs::create_dir_all(parent)
1155                    .map_err(|error| format!("create {}: {error}", parent.display()))?;
1156            }
1157        }
1158        let json = serde_json::to_string_pretty(&profile)
1159            .map_err(|error| format!("serialize profile: {error}"))?;
1160        fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
1161    }
1162    Ok(())
1163}
1164
1165async fn append_run_provenance_event(
1166    log: &Arc<harn_vm::event_log::AnyEventLog>,
1167    kind: &str,
1168    payload: serde_json::Value,
1169) {
1170    let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
1171        return;
1172    };
1173    let _ = log
1174        .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
1175        .await;
1176}
1177
1178async fn emit_run_attestation(
1179    log: &Arc<harn_vm::event_log::AnyEventLog>,
1180    path: &str,
1181    store_base: &Path,
1182    started_at_ms: i64,
1183    exit_code: i32,
1184    options: &RunAttestationOptions,
1185    stderr: &mut String,
1186) -> Result<(), String> {
1187    let finished_at_ms = now_ms();
1188    let status = if exit_code == 0 { "success" } else { "failure" };
1189    append_run_provenance_event(
1190        log,
1191        "finished",
1192        serde_json::json!({
1193            "pipeline": path,
1194            "status": status,
1195            "exit_code": exit_code,
1196        }),
1197    )
1198    .await;
1199    log.flush()
1200        .await
1201        .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
1202    let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
1203        .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
1204    let (signing_key, key_id) =
1205        harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
1206            .await
1207            .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
1208    let receipt = harn_vm::build_signed_receipt(
1209        log,
1210        harn_vm::ReceiptBuildOptions {
1211            pipeline: path.to_string(),
1212            status: status.to_string(),
1213            started_at_ms,
1214            finished_at_ms,
1215            exit_code,
1216            producer_name: "harn-cli".to_string(),
1217            producer_version: env!("CARGO_PKG_VERSION").to_string(),
1218        },
1219        &signing_key,
1220        key_id,
1221    )
1222    .await
1223    .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
1224    let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
1225    if let Some(parent) = receipt_path.parent() {
1226        fs::create_dir_all(parent)
1227            .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1228    }
1229    let encoded = serde_json::to_vec_pretty(&receipt)
1230        .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
1231    fs::write(&receipt_path, encoded)
1232        .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
1233    stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
1234    Ok(())
1235}
1236
1237fn receipt_output_path(
1238    store_base: &Path,
1239    options: &RunAttestationOptions,
1240    receipt_id: &str,
1241) -> PathBuf {
1242    if let Some(path) = options.receipt_out.as_ref() {
1243        return path.clone();
1244    }
1245    harn_vm::runtime_paths::state_root(store_base)
1246        .join("receipts")
1247        .join(format!("{receipt_id}.json"))
1248}
1249
1250fn now_ms() -> i64 {
1251    std::time::SystemTime::now()
1252        .duration_since(std::time::UNIX_EPOCH)
1253        .map(|duration| duration.as_millis() as i64)
1254        .unwrap_or(0)
1255}
1256
1257/// Map a script's top-level return value to a process exit code.
1258///
1259/// - `int n`             → exit n (clamped to 0..=255)
1260/// - `Result::Ok(_)`     → exit 0
1261/// - `Result::Err(_)`    → exit 1
1262/// - anything else       → exit 0
1263fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
1264    use harn_vm::VmValue;
1265    match value {
1266        VmValue::Int(n) => (*n).clamp(0, 255) as i32,
1267        VmValue::EnumVariant {
1268            enum_name,
1269            variant,
1270            fields,
1271        } if enum_name.as_ref() == "Result" && variant.as_ref() == "Err" => 1,
1272        _ => 0,
1273    }
1274}
1275
1276/// State for a single `harn run --json` invocation. Installs the
1277/// run-event sink in [`Self::install`] and removes it in [`Drop`], so
1278/// every exit path through `execute_run_inner` cleans up correctly
1279/// even if a panic unwinds out of the VM. Save-and-restore of any
1280/// previously installed sink keeps the helper safe to nest (rare, but
1281/// in-process embeddings can call into `harn run` from a host that
1282/// already had a sink wired).
1283///
1284/// `finalize_result` / `finalize_error` emit the terminal event and
1285/// build a [`RunOutcome`] whose stdout/stderr captured-buffer fields
1286/// stay **empty** — the canonical stream is on `out`.
1287/// `outcome.exit_code` still carries the process exit code so the
1288/// binary entry can `process::exit(...)`.
1289struct JsonRunSession {
1290    emitter: self::json_events::NdjsonEmitter,
1291    prior_sink: Option<Arc<dyn harn_vm::run_events::RunEventSink>>,
1292}
1293
1294impl JsonRunSession {
1295    fn install(options: RunJsonOptions, out: Box<dyn io::Write + Send>) -> Self {
1296        let emitter = NdjsonEmitter::new(out, options.quiet);
1297        let prior_sink = harn_vm::run_events::install_sink(emitter.sink());
1298        Self {
1299            emitter,
1300            prior_sink,
1301        }
1302    }
1303
1304    fn finalize_result(self, value: serde_json::Value, exit_code: i32) -> RunOutcome {
1305        self.emitter.emit_result(value, exit_code);
1306        RunOutcome {
1307            stdout: String::new(),
1308            stderr: String::new(),
1309            exit_code,
1310        }
1311    }
1312
1313    fn finalize_error(
1314        self,
1315        code: impl Into<String>,
1316        message: impl Into<String>,
1317        exit_code: i32,
1318    ) -> RunOutcome {
1319        self.emitter.emit_error(code, message);
1320        RunOutcome {
1321            stdout: String::new(),
1322            stderr: String::new(),
1323            exit_code,
1324        }
1325    }
1326}
1327
1328impl Drop for JsonRunSession {
1329    fn drop(&mut self) {
1330        match self.prior_sink.take() {
1331            Some(prior) => {
1332                harn_vm::run_events::install_sink(prior);
1333            }
1334            None => harn_vm::run_events::clear_sink(),
1335        }
1336    }
1337}
1338
1339/// Translate a preflight failure into either the `--json` error event
1340/// stream or a plain stderr message plus exit-code 1. Keeps the
1341/// `.harnpack` verify path's error reporting consistent with the rest
1342/// of `harn run`.
1343fn finalize_harnpack_error(
1344    mut stderr: String,
1345    json_session: Option<JsonRunSession>,
1346    err: HarnpackError,
1347) -> RunOutcome {
1348    stderr.push_str(&format!("error: {}\n", err.message));
1349    if let Some(session) = json_session {
1350        return session.finalize_error(err.code, err.message, 1);
1351    }
1352    RunOutcome {
1353        stdout: String::new(),
1354        stderr,
1355        exit_code: 1,
1356    }
1357}
1358
1359/// Successful `--dry-run-verify` path. Reports the bundle hash and
1360/// signature outcome on stderr (since stdout belongs to the script) and
1361/// emits a terminal `result` event when `--json` is active so consumers
1362/// see the run complete.
1363fn finalize_harnpack_dry_run(
1364    mut stderr: String,
1365    json_session: Option<JsonRunSession>,
1366    prepared: &PreparedHarnpack,
1367) -> RunOutcome {
1368    let summary = format!(
1369        "[harn] harnpack verify ok: bundle_hash={}, signature_verified={}, cache_hit={}\n",
1370        prepared.bundle_hash, prepared.signature_verified, prepared.cache_hit
1371    );
1372    stderr.push_str(&summary);
1373    if let Some(session) = json_session {
1374        let value = serde_json::json!({
1375            "bundle_hash": prepared.bundle_hash,
1376            "signature_verified": prepared.signature_verified,
1377            "key_id": prepared.key_id,
1378            "cache_hit": prepared.cache_hit,
1379            "dry_run_verify": true,
1380        });
1381        return session.finalize_result(value, 0);
1382    }
1383    RunOutcome {
1384        stdout: String::new(),
1385        stderr,
1386        exit_code: 0,
1387    }
1388}
1389
1390fn render_return_value_error(value: &harn_vm::VmValue) -> String {
1391    let harn_vm::VmValue::EnumVariant {
1392        enum_name,
1393        variant,
1394        fields,
1395    } = value
1396    else {
1397        return String::new();
1398    };
1399    if enum_name.as_ref() != "Result" || variant.as_ref() != "Err" {
1400        return String::new();
1401    }
1402    let rendered = fields.first().map(|p| p.display()).unwrap_or_default();
1403    if rendered.is_empty() {
1404        "error\n".to_string()
1405    } else if rendered.ends_with('\n') {
1406        rendered
1407    } else {
1408        format!("{rendered}\n")
1409    }
1410}
1411
1412/// Connect to MCP servers declared in `harn.toml` and register them as
1413/// `mcp.<name>` globals on the VM. Connection failures are warned but do
1414/// not abort execution.
1415///
1416/// Servers with `lazy = true` are registered with the VM-side MCP
1417/// registry but NOT booted — their processes start the first time a
1418/// skill's `requires_mcp` list names them or user code calls
1419/// `mcp_ensure_active("name")` / `mcp_call(mcp.<name>, ...)`.
1420pub(crate) async fn connect_mcp_servers(
1421    servers: &[package::McpServerConfig],
1422    vm: &mut harn_vm::Vm,
1423) {
1424    use std::collections::BTreeMap;
1425    use std::rc::Rc;
1426    use std::time::Duration;
1427
1428    let mut mcp_dict: BTreeMap<String, harn_vm::VmValue> = BTreeMap::new();
1429    let mut registrations: Vec<harn_vm::RegisteredMcpServer> = Vec::new();
1430
1431    for server in servers {
1432        let resolved_auth = match mcp::resolve_auth_for_server(server).await {
1433            Ok(resolution) => resolution,
1434            Err(error) => {
1435                eprintln!(
1436                    "warning: mcp: failed to load auth for '{}': {}",
1437                    server.name, error
1438                );
1439                AuthResolution::None
1440            }
1441        };
1442        let spec = serde_json::json!({
1443            "name": server.name,
1444            "transport": server.transport.clone().unwrap_or_else(|| "stdio".to_string()),
1445            "command": server.command,
1446            "args": server.args,
1447            "env": server.env,
1448            "url": server.url,
1449            "auth_token": match resolved_auth {
1450                AuthResolution::Bearer(token) => Some(token),
1451                AuthResolution::None => server.auth_token.clone(),
1452            },
1453            "protocol_version": server.protocol_version,
1454            "proxy_server_name": server.proxy_server_name,
1455        });
1456
1457        // Register with the VM-side registry regardless of lazy flag —
1458        // skill activation and `mcp_ensure_active` look up specs there.
1459        registrations.push(harn_vm::RegisteredMcpServer {
1460            name: server.name.clone(),
1461            spec: spec.clone(),
1462            lazy: server.lazy,
1463            card: server.card.clone(),
1464            keep_alive: server.keep_alive_ms.map(Duration::from_millis),
1465        });
1466
1467        if server.lazy {
1468            eprintln!(
1469                "[harn] mcp: deferred '{}' (lazy, boots on first use)",
1470                server.name
1471            );
1472            continue;
1473        }
1474
1475        match harn_vm::connect_mcp_server_from_json(&spec).await {
1476            Ok(handle) => {
1477                eprintln!("[harn] mcp: connected to '{}'", server.name);
1478                harn_vm::mcp_install_active(&server.name, handle.clone());
1479                mcp_dict.insert(server.name.clone(), harn_vm::VmValue::McpClient(handle));
1480            }
1481            Err(e) => {
1482                eprintln!(
1483                    "warning: mcp: failed to connect to '{}': {}",
1484                    server.name, e
1485                );
1486            }
1487        }
1488    }
1489
1490    // Install registrations AFTER eager connects so `install_active`
1491    // above doesn't get overwritten.
1492    harn_vm::mcp_register_servers(registrations);
1493
1494    if !mcp_dict.is_empty() {
1495        vm.set_global("mcp", harn_vm::VmValue::Dict(Rc::new(mcp_dict)));
1496    }
1497}
1498
1499pub(crate) fn render_trace_summary() -> String {
1500    use std::fmt::Write;
1501    let entries = harn_vm::llm::take_trace();
1502    if entries.is_empty() {
1503        return String::new();
1504    }
1505    let mut out = String::new();
1506    let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
1507    let mut total_input = 0i64;
1508    let mut total_output = 0i64;
1509    let mut total_ms = 0u64;
1510    for (i, entry) in entries.iter().enumerate() {
1511        let _ = writeln!(
1512            out,
1513            "  #{}: {} | {} in + {} out tokens | {} ms",
1514            i + 1,
1515            entry.model,
1516            entry.input_tokens,
1517            entry.output_tokens,
1518            entry.duration_ms,
1519        );
1520        total_input += entry.input_tokens;
1521        total_output += entry.output_tokens;
1522        total_ms += entry.duration_ms;
1523    }
1524    let total_tokens = total_input + total_output;
1525    // Rough cost estimate using Sonnet 4 pricing ($3/MTok in, $15/MTok out).
1526    let cost = (total_input as f64 * 3.0 + total_output as f64 * 15.0) / 1_000_000.0;
1527    let _ = writeln!(
1528        out,
1529        "  \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, ~${:.4}\x1b[0m",
1530        entries.len(),
1531        if entries.len() == 1 { "" } else { "s" },
1532        total_tokens,
1533        total_input,
1534        total_output,
1535        total_ms,
1536        cost,
1537    );
1538    out
1539}
1540
1541/// Run a .harn file as an MCP server using the script-driven surface.
1542/// The pipeline must call `mcp_tools(registry)` (or the alias
1543/// `mcp_serve(registry)`) so the CLI can expose its tools, and may
1544/// register additional resources/prompts via `mcp_resource(...)` /
1545/// `mcp_resource_template(...)` / `mcp_prompt(...)`.
1546///
1547/// Dispatched into by `harn serve mcp <file>` when the script does not
1548/// define any `pub fn` exports — see `commands::serve::run_mcp_server`.
1549///
1550/// `card_source` — optional `--card` argument. Accepts either a path to
1551/// a JSON file or an inline JSON string. When present, the card is
1552/// embedded in the `initialize` response and exposed as the
1553/// `well-known://mcp-card` resource.
1554pub(crate) async fn run_file_mcp_serve(
1555    path: &str,
1556    card_source: Option<&str>,
1557    mode: RunFileMcpServeMode,
1558) {
1559    let mut diagnostics = String::new();
1560    let Some(LoadedChunk { source, chunk }) = compile_or_load_chunk_for_run(path, &mut diagnostics)
1561    else {
1562        eprint!("{diagnostics}");
1563        process::exit(1);
1564    };
1565    if !diagnostics.is_empty() {
1566        eprint!("{diagnostics}");
1567    }
1568
1569    let mut vm = harn_vm::Vm::new();
1570    harn_vm::register_vm_stdlib(&mut vm);
1571    crate::install_default_hostlib(&mut vm);
1572    let source_parent = std::path::Path::new(path)
1573        .parent()
1574        .unwrap_or(std::path::Path::new("."));
1575    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1576    let store_base = project_root.as_deref().unwrap_or(source_parent);
1577    harn_vm::register_store_builtins(&mut vm, store_base);
1578    harn_vm::register_metadata_builtins(&mut vm, store_base);
1579    let pipeline_name = std::path::Path::new(path)
1580        .file_stem()
1581        .and_then(|s| s.to_str())
1582        .unwrap_or("default");
1583    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1584    vm.set_source_info(path, &source);
1585    if let Some(ref root) = project_root {
1586        vm.set_project_root(root);
1587    }
1588    if let Some(p) = std::path::Path::new(path).parent() {
1589        if !p.as_os_str().is_empty() {
1590            vm.set_source_dir(p);
1591        }
1592    }
1593
1594    // Same skill discovery as `harn run` — see comment there.
1595    let loaded = load_skills(&SkillLoaderInputs {
1596        cli_dirs: Vec::new(),
1597        source_path: Some(std::path::PathBuf::from(path)),
1598    });
1599    emit_loader_warnings(&loaded.loader_warnings);
1600    install_skills_global(&mut vm, &loaded);
1601
1602    let extensions = package::load_runtime_extensions(Path::new(path));
1603    package::install_runtime_extensions(&extensions);
1604    if let Some(manifest) = extensions.root_manifest.as_ref() {
1605        if !manifest.mcp.is_empty() {
1606            connect_mcp_servers(&manifest.mcp, &mut vm).await;
1607        }
1608    }
1609    if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1610        eprintln!("error: failed to install manifest triggers: {error}");
1611        process::exit(1);
1612    }
1613    if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1614        eprintln!("error: failed to install manifest hooks: {error}");
1615        process::exit(1);
1616    }
1617
1618    let local = tokio::task::LocalSet::new();
1619    local
1620        .run_until(async {
1621            match vm.execute(&chunk).await {
1622                Ok(_) => {}
1623                Err(e) => {
1624                    eprint!("{}", vm.format_runtime_error(&e));
1625                    process::exit(1);
1626                }
1627            }
1628
1629            // Pipeline output goes to stderr — stdout is the MCP transport.
1630            let output = vm.output();
1631            if !output.is_empty() {
1632                eprint!("{output}");
1633            }
1634
1635            let registry = match harn_vm::take_mcp_serve_registry() {
1636                Some(r) => r,
1637                None => {
1638                    eprintln!("error: pipeline did not call mcp_serve(registry)");
1639                    eprintln!("hint: call mcp_serve(tools) at the end of your pipeline");
1640                    process::exit(1);
1641                }
1642            };
1643
1644            let tools = match harn_vm::tool_registry_to_mcp_tools(&registry) {
1645                Ok(t) => t,
1646                Err(e) => {
1647                    eprintln!("error: {e}");
1648                    process::exit(1);
1649                }
1650            };
1651
1652            let resources = harn_vm::take_mcp_serve_resources();
1653            let resource_templates = harn_vm::take_mcp_serve_resource_templates();
1654            let prompts = harn_vm::take_mcp_serve_prompts();
1655
1656            let server_name = std::path::Path::new(path)
1657                .file_stem()
1658                .and_then(|s| s.to_str())
1659                .unwrap_or("harn")
1660                .to_string();
1661
1662            let mut caps = Vec::new();
1663            if !tools.is_empty() {
1664                caps.push(format!(
1665                    "{} tool{}",
1666                    tools.len(),
1667                    if tools.len() == 1 { "" } else { "s" }
1668                ));
1669            }
1670            let total_resources = resources.len() + resource_templates.len();
1671            if total_resources > 0 {
1672                caps.push(format!(
1673                    "{total_resources} resource{}",
1674                    if total_resources == 1 { "" } else { "s" }
1675                ));
1676            }
1677            if !prompts.is_empty() {
1678                caps.push(format!(
1679                    "{} prompt{}",
1680                    prompts.len(),
1681                    if prompts.len() == 1 { "" } else { "s" }
1682                ));
1683            }
1684            eprintln!(
1685                "[harn] serve mcp: serving {} as '{server_name}'",
1686                caps.join(", ")
1687            );
1688
1689            let mut server =
1690                harn_vm::McpServer::new(server_name, tools, resources, resource_templates, prompts);
1691            if let Some(source) = card_source {
1692                match resolve_card_source(source) {
1693                    Ok(card) => server = server.with_server_card(card),
1694                    Err(e) => {
1695                        eprintln!("error: --card: {e}");
1696                        process::exit(1);
1697                    }
1698                }
1699            }
1700            match mode {
1701                RunFileMcpServeMode::Stdio => {
1702                    if let Err(e) = server.run(&mut vm).await {
1703                        eprintln!("error: MCP server error: {e}");
1704                        process::exit(1);
1705                    }
1706                }
1707                RunFileMcpServeMode::Http {
1708                    options,
1709                    auth_policy,
1710                } => {
1711                    if let Err(e) = crate::commands::serve::run_script_mcp_http_server(
1712                        server,
1713                        vm,
1714                        options,
1715                        auth_policy,
1716                    )
1717                    .await
1718                    {
1719                        eprintln!("error: MCP server error: {e}");
1720                        process::exit(1);
1721                    }
1722                }
1723            }
1724        })
1725        .await;
1726}
1727
1728/// Accept either a path to a JSON file or an inline JSON blob and
1729/// return the parsed `serde_json::Value`. Used by `--card`. Disambiguates
1730/// by peeking at the first non-whitespace character: `{` → inline JSON,
1731/// anything else → path.
1732pub(crate) fn resolve_card_source(source: &str) -> Result<serde_json::Value, String> {
1733    let trimmed = source.trim_start();
1734    if trimmed.starts_with('{') || trimmed.starts_with('[') {
1735        return serde_json::from_str(source).map_err(|e| format!("inline JSON parse error: {e}"));
1736    }
1737    let path = std::path::Path::new(source);
1738    harn_vm::load_server_card_from_path(path).map_err(|e| format!("{e}"))
1739}
1740
1741pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
1742    use notify::{Event, EventKind, RecursiveMode, Watcher};
1743
1744    let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
1745        eprintln!("Error: {e}");
1746        process::exit(1);
1747    });
1748    let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
1749
1750    eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
1751    run_file(
1752        path,
1753        false,
1754        denied_builtins.clone(),
1755        Vec::new(),
1756        CliLlmMockMode::Off,
1757        None,
1758        RunProfileOptions::default(),
1759    )
1760    .await;
1761
1762    let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
1763    let _watcher = {
1764        let tx = tx.clone();
1765        let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
1766            if let Ok(event) = res {
1767                if matches!(
1768                    event.kind,
1769                    EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
1770                ) {
1771                    let has_harn = event
1772                        .paths
1773                        .iter()
1774                        .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
1775                    if has_harn {
1776                        let _ = tx.blocking_send(());
1777                    }
1778                }
1779            }
1780        })
1781        .unwrap_or_else(|e| {
1782            eprintln!("Error setting up file watcher: {e}");
1783            process::exit(1);
1784        });
1785        watcher
1786            .watch(watch_dir, RecursiveMode::Recursive)
1787            .unwrap_or_else(|e| {
1788                eprintln!("Error watching directory: {e}");
1789                process::exit(1);
1790            });
1791        watcher // keep alive
1792    };
1793
1794    eprintln!(
1795        "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
1796        watch_dir.display()
1797    );
1798
1799    loop {
1800        rx.recv().await;
1801        // Debounce: let bursts of events settle for 200ms before re-running.
1802        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1803        while rx.try_recv().is_ok() {}
1804
1805        eprintln!();
1806        eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
1807        run_file(
1808            path,
1809            false,
1810            denied_builtins.clone(),
1811            Vec::new(),
1812            CliLlmMockMode::Off,
1813            None,
1814            RunProfileOptions::default(),
1815        )
1816        .await;
1817    }
1818}
1819
1820#[cfg(test)]
1821mod tests {
1822    use super::{
1823        execute_explain_cost, execute_run, split_eval_header, CliLlmMockMode, RunProfileOptions,
1824        StdoutPassthroughGuard,
1825    };
1826    use std::collections::HashSet;
1827
1828    #[test]
1829    fn split_eval_header_no_imports_returns_full_body() {
1830        let (header, body) = split_eval_header("println(1 + 2)");
1831        assert_eq!(header, "");
1832        assert_eq!(body, "println(1 + 2)");
1833    }
1834
1835    #[test]
1836    fn split_eval_header_lifts_leading_imports() {
1837        let code = "import \"./lib\"\nimport { x } from \"std/math\"\nprintln(x)";
1838        let (header, body) = split_eval_header(code);
1839        assert_eq!(header, "import \"./lib\"\nimport { x } from \"std/math\"");
1840        assert_eq!(body, "println(x)");
1841    }
1842
1843    #[test]
1844    fn split_eval_header_keeps_pub_import_and_comments_in_header() {
1845        let code = "// header comment\npub import { y } from \"./lib\"\n\nfoo()";
1846        let (header, body) = split_eval_header(code);
1847        assert_eq!(
1848            header,
1849            "// header comment\npub import { y } from \"./lib\"\n"
1850        );
1851        assert_eq!(body, "foo()");
1852    }
1853
1854    #[test]
1855    fn split_eval_header_does_not_lift_imports_after_other_statements() {
1856        let code = "let a = 1\nimport \"./lib\"";
1857        let (header, body) = split_eval_header(code);
1858        assert_eq!(header, "");
1859        assert_eq!(body, "let a = 1\nimport \"./lib\"");
1860    }
1861
1862    #[test]
1863    fn cli_llm_mock_roundtrips_logprobs() {
1864        let mock = harn_vm::llm::parse_llm_mock_value(&serde_json::json!({
1865            "text": "visible",
1866            "logprobs": [{"token": "visible", "logprob": 0.0}]
1867        }))
1868        .expect("parse mock");
1869        assert_eq!(mock.logprobs.len(), 1);
1870
1871        let line = harn_vm::llm::serialize_llm_mock(mock).expect("serialize mock");
1872        let value: serde_json::Value = serde_json::from_str(&line).expect("json line");
1873        assert_eq!(value["logprobs"][0]["token"].as_str(), Some("visible"));
1874
1875        let reparsed = harn_vm::llm::parse_llm_mock_value(&value).expect("reparse mock");
1876        assert_eq!(reparsed.logprobs.len(), 1);
1877        assert_eq!(reparsed.logprobs[0]["logprob"].as_f64(), Some(0.0));
1878    }
1879
1880    #[test]
1881    fn stdout_passthrough_guard_restores_previous_state() {
1882        let original = harn_vm::set_stdout_passthrough(false);
1883        {
1884            let _guard = StdoutPassthroughGuard::enable();
1885            assert!(harn_vm::set_stdout_passthrough(true));
1886        }
1887        assert!(!harn_vm::set_stdout_passthrough(original));
1888    }
1889
1890    #[test]
1891    fn execute_explain_cost_does_not_execute_script() {
1892        let temp = tempfile::TempDir::new().expect("temp dir");
1893        let script = temp.path().join("main.harn");
1894        std::fs::write(
1895            &script,
1896            r#"
1897pipeline main() {
1898  write_file("executed.txt", "bad")
1899  llm_call("hello", nil, {provider: "mock", model: "mock"})
1900}
1901"#,
1902        )
1903        .expect("write script");
1904
1905        let outcome = execute_explain_cost(&script.to_string_lossy());
1906
1907        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1908        assert!(outcome.stdout.contains("LLM cost estimate"));
1909        assert!(
1910            !temp.path().join("executed.txt").exists(),
1911            "--explain-cost must not execute pipeline side effects"
1912        );
1913    }
1914
1915    #[cfg(feature = "hostlib")]
1916    #[tokio::test]
1917    async fn execute_run_installs_hostlib_gate() {
1918        let temp = tempfile::NamedTempFile::new().expect("temp file");
1919        std::fs::write(
1920            temp.path(),
1921            r#"
1922pipeline main() {
1923  let _ = hostlib_enable("tools:deterministic")
1924  println("enabled")
1925}
1926"#,
1927        )
1928        .expect("write script");
1929
1930        let outcome = execute_run(
1931            &temp.path().to_string_lossy(),
1932            false,
1933            HashSet::new(),
1934            Vec::new(),
1935            Vec::new(),
1936            CliLlmMockMode::Off,
1937            None,
1938            RunProfileOptions::default(),
1939        )
1940        .await;
1941
1942        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1943        assert_eq!(outcome.stdout.trim(), "enabled");
1944    }
1945
1946    #[cfg(all(feature = "hostlib", unix))]
1947    #[tokio::test]
1948    async fn execute_run_can_read_hostlib_command_artifacts() {
1949        let temp = tempfile::NamedTempFile::new().expect("temp file");
1950        std::fs::write(
1951            temp.path(),
1952            r#"
1953pipeline main() {
1954  let _ = hostlib_enable("tools:deterministic")
1955  let result = hostlib_tools_run_command({
1956    argv: ["sh", "-c", "i=0; while [ $i -lt 2000 ]; do printf x; i=$((i+1)); done"],
1957    capture: {max_inline_bytes: 8},
1958    timeout_ms: 5000,
1959  })
1960  println(starts_with(result.command_id, "cmd_"))
1961  println(len(result.stdout))
1962  println(result.byte_count)
1963  let window = hostlib_tools_read_command_output({
1964    command_id: result.command_id,
1965    offset: 1990,
1966    length: 20,
1967  })
1968  println(len(window.content))
1969  println(window.eof)
1970}
1971"#,
1972        )
1973        .expect("write script");
1974
1975        let outcome = execute_run(
1976            &temp.path().to_string_lossy(),
1977            false,
1978            HashSet::new(),
1979            Vec::new(),
1980            Vec::new(),
1981            CliLlmMockMode::Off,
1982            None,
1983            RunProfileOptions::default(),
1984        )
1985        .await;
1986
1987        assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1988        assert_eq!(outcome.stdout.trim(), "true\n8\n2000\n10\ntrue");
1989    }
1990}