Skip to main content

harn_cli/commands/run/
mod.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::{Duration, Instant};
9
10use harn_parser::DiagnosticSeverity;
11use harn_vm::event_log::EventLog;
12use serde::Serialize;
13
14use crate::commands::time::{self, PhaseRecord, RunTiming};
15use crate::package;
16use crate::skill_loader::{
17    canonicalize_cli_dirs, emit_loader_warnings, install_skills_global, load_skills,
18    SkillLoaderInputs,
19};
20
21mod chunk_loading;
22pub(crate) mod environment;
23mod eval_source;
24mod explain_cost;
25pub mod harnpack;
26mod interrupts;
27pub mod json_events;
28mod lifecycle;
29mod llm_mock;
30mod manifest_runtime;
31pub(crate) mod sandbox;
32
33pub(crate) use self::chunk_loading::{
34    compile_or_load_chunk_for_run, compile_or_load_chunk_with_timing, LoadedChunk,
35};
36use self::chunk_loading::{parse_source_for_run, typecheck_with_imports};
37pub(crate) use self::environment::{EnvironmentPolicyArg, EnvironmentPolicyConfig};
38use self::eval_source::create_eval_temp_file;
39pub(crate) use self::eval_source::prepare_eval_temp_file;
40#[cfg(test)]
41use self::eval_source::{eval_source_for_code, split_eval_header};
42use self::harnpack::{HarnpackError, HarnpackRunOptions, PreparedHarnpack};
43use self::interrupts::{
44    install_signal_shutdown_handler, start_run_deadline_watchdog, RunDeadlineGuard,
45};
46use self::json_events::NdjsonEmitter;
47pub use self::lifecycle::RunProfileOptions;
48use self::lifecycle::{RunExecution, TerminalRun};
49pub use self::llm_mock::*;
50pub(crate) use self::manifest_runtime::connect_mcp_servers;
51#[cfg(test)]
52use self::sandbox::default_run_capability_policy;
53pub use self::sandbox::RunSandboxOptions;
54use self::sandbox::{
55    default_run_workspace_root, install_run_sandbox_scope, run_sandbox_attestation,
56};
57
58/// JSON event-stream configuration for `--json` runs.
59#[derive(Clone, Default)]
60pub struct RunJsonOptions {
61    /// Suppress `stdout` / `stderr` events. Transcript, tool, hook,
62    /// persona, and the terminal result/error events still flow.
63    pub quiet: bool,
64}
65
66/// Post-run summary configuration for `harn run --emit-summary-json`.
67#[derive(Clone, Debug)]
68pub struct RunSummaryOptions {
69    pub sink: RunJsonSink,
70}
71
72#[derive(Clone, Debug)]
73pub struct RunPhaseOptions {
74    pub sink: RunJsonSink,
75}
76
77#[derive(Clone, Debug)]
78pub struct RunRusageOptions {
79    pub sink: RunJsonSink,
80}
81
82#[derive(Clone, Debug, Default)]
83pub struct RunAuxOptions {
84    pub summary: Option<RunSummaryOptions>,
85    pub phase: Option<RunPhaseOptions>,
86    pub rusage: Option<RunRusageOptions>,
87}
88
89#[derive(Clone, Debug, Default)]
90pub struct RunControlOptions {
91    pub timeout: Option<Duration>,
92}
93
94#[derive(Clone, Debug)]
95pub struct RunJsonSink {
96    pub target: RunJsonSinkTarget,
97    pub fd_flag: &'static str,
98}
99
100#[derive(Clone, Debug)]
101pub enum RunJsonSinkTarget {
102    /// Append the summary to the captured stderr buffer so it remains
103    /// terminal after all diagnostics that `run_file_with_skill_dirs`
104    /// flushes on return.
105    Stderr,
106    File(PathBuf),
107    Fd(i32),
108}
109
110#[derive(Serialize)]
111struct RunSummary<'a> {
112    schema_version: u32,
113    event: &'static str,
114    wall_time_ms: u64,
115    exit_code: i32,
116    llm: RunSummaryLlm,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    profile: Option<&'a harn_vm::profile::RunProfile>,
119}
120
121#[derive(Serialize)]
122struct RunSummaryLlm {
123    call_count: i64,
124    input_tokens: i64,
125    output_tokens: i64,
126    time_ms: i64,
127    cost_usd: f64,
128}
129
130pub const RUN_SUMMARY_SCHEMA_VERSION: u32 = 1;
131pub const RUN_PHASE_SCHEMA_VERSION: u32 = 2;
132pub const RUN_RUSAGE_SCHEMA_VERSION: u32 = 1;
133
134#[derive(Serialize)]
135struct RunPhaseEvent {
136    schema_version: u32,
137    event: &'static str,
138    phases: Vec<PhaseRecord>,
139}
140
141#[derive(Serialize)]
142struct RunRusageEvent {
143    schema_version: u32,
144    event: &'static str,
145    cpu_ms: u64,
146}
147
148pub(crate) fn run_summary_options_from_args(
149    args: &crate::cli::RunArgs,
150) -> Option<RunSummaryOptions> {
151    args.emit_summary_json.then(|| RunSummaryOptions {
152        sink: build_run_json_sink(args.summary_file.clone(), args.summary_fd, "--summary-fd"),
153    })
154}
155
156pub(crate) fn run_aux_options_from_args(args: &crate::cli::RunArgs) -> RunAuxOptions {
157    RunAuxOptions {
158        summary: run_summary_options_from_args(args),
159        phase: run_phase_options_from_args(args),
160        rusage: run_rusage_options_from_args(args),
161    }
162}
163
164pub(crate) fn run_control_options_from_args(args: &crate::cli::RunArgs) -> RunControlOptions {
165    RunControlOptions {
166        timeout: args.timeout,
167    }
168}
169
170pub(crate) fn run_phase_options_from_args(args: &crate::cli::RunArgs) -> Option<RunPhaseOptions> {
171    args.emit_phase_json.then(|| RunPhaseOptions {
172        sink: build_run_json_sink(args.phase_file.clone(), args.phase_fd, "--phase-fd"),
173    })
174}
175
176pub(crate) fn run_rusage_options_from_args(args: &crate::cli::RunArgs) -> Option<RunRusageOptions> {
177    args.emit_rusage_json.then(|| RunRusageOptions {
178        sink: build_run_json_sink(args.rusage_file.clone(), args.rusage_fd, "--rusage-fd"),
179    })
180}
181
182fn build_run_json_sink(
183    file: Option<PathBuf>,
184    fd: Option<i32>,
185    fd_flag: &'static str,
186) -> RunJsonSink {
187    RunJsonSink {
188        target: if let Some(path) = file {
189            RunJsonSinkTarget::File(path)
190        } else if let Some(fd) = fd {
191            RunJsonSinkTarget::Fd(fd)
192        } else {
193            RunJsonSinkTarget::Stderr
194        },
195        fd_flag,
196    }
197}
198
199pub(crate) enum RunFileMcpServeMode {
200    Stdio,
201    Http(Box<RunFileMcpServeHttp>),
202}
203
204pub(crate) struct RunFileMcpServeHttp {
205    pub options: harn_serve::McpHttpServeOptions,
206    pub auth_policy: harn_serve::AuthPolicy,
207}
208
209/// Core builtins that are never denied, even when using `--allow`.
210const CORE_BUILTINS: &[&str] = &[
211    "println",
212    "print",
213    "log",
214    "type_of",
215    "to_string",
216    "to_int",
217    "to_float",
218    "len",
219    "assert",
220    "assert_eq",
221    "assert_ne",
222    "json_parse",
223    "json_stringify",
224    "runtime_context",
225    "task_current",
226    "runtime_context_values",
227    "runtime_context_get",
228    "runtime_context_set",
229    "runtime_context_clear",
230];
231
232/// Build the set of denied builtin names from `--deny` or `--allow` flags.
233///
234/// - `--deny a,b,c` denies exactly those names.
235/// - `--allow a,b,c` denies everything *except* the listed names and the core builtins.
236pub(crate) fn build_denied_builtins(
237    deny_csv: Option<&str>,
238    allow_csv: Option<&str>,
239) -> HashSet<String> {
240    if let Some(csv) = deny_csv {
241        csv.split(',')
242            .map(|s| s.trim().to_string())
243            .filter(|s| !s.is_empty())
244            .collect()
245    } else if let Some(csv) = allow_csv {
246        // With --allow, we mark every registered stdlib builtin as denied
247        // *except* those in the allow list and the core builtins.
248        let allowed: HashSet<String> = csv
249            .split(',')
250            .map(|s| s.trim().to_string())
251            .filter(|s| !s.is_empty())
252            .collect();
253        let core: HashSet<&str> = CORE_BUILTINS.iter().copied().collect();
254
255        // Create a temporary VM with stdlib registered to enumerate all builtin names.
256        let mut tmp = harn_vm::Vm::new();
257        harn_vm::register_vm_stdlib(&mut tmp);
258        harn_vm::register_store_builtins(&mut tmp, std::path::Path::new("."));
259        harn_vm::register_metadata_builtins(&mut tmp, std::path::Path::new("."));
260
261        tmp.builtin_names()
262            .into_iter()
263            .filter(|name| !allowed.contains(name) && !core.contains(name.as_str()))
264            .collect()
265    } else {
266        HashSet::new()
267    }
268}
269
270#[derive(Clone, Debug, Default, PartialEq, Eq)]
271pub struct RunAttestationOptions {
272    pub receipt_out: Option<PathBuf>,
273    pub agent_id: Option<String>,
274}
275
276#[derive(Clone)]
277pub struct RunInterruptTokens {
278    pub cancel_token: Arc<AtomicBool>,
279    pub signal_token: Arc<Mutex<Option<String>>>,
280}
281
282struct ExecuteRunInputs<'a> {
283    path: &'a str,
284    trace: bool,
285    denied_builtins: HashSet<String>,
286    script_argv: Vec<String>,
287    skill_dirs_raw: Vec<String>,
288    llm_mock_mode: CliLlmMockMode,
289    attestation: Option<RunAttestationOptions>,
290    profile: RunProfileOptions,
291    sandbox: RunSandboxOptions,
292    interrupt_tokens: Option<RunInterruptTokens>,
293    json: Option<JsonRunSession>,
294    aux: RunAuxOptions,
295    timing: Option<&'a mut RunTiming>,
296    harnpack: HarnpackRunOptions,
297}
298
299/// Captured outcome of an in-process `execute_run` invocation. Tests use this
300/// instead of spawning the `harn` binary; the binary entry point translates
301/// it into real stdout/stderr writes + `process::exit`.
302#[derive(Clone, Debug, Default)]
303pub struct RunOutcome {
304    pub stdout: String,
305    pub stderr: String,
306    pub exit_code: i32,
307}
308
309pub(crate) async fn run_file(
310    path: &str,
311    trace: bool,
312    denied_builtins: HashSet<String>,
313    script_argv: Vec<String>,
314    llm_mock_mode: CliLlmMockMode,
315    attestation: Option<RunAttestationOptions>,
316    profile: RunProfileOptions,
317) {
318    let exit_code = run_file_with_skill_dirs(
319        path,
320        trace,
321        denied_builtins,
322        script_argv,
323        Vec::new(),
324        llm_mock_mode,
325        attestation,
326        profile,
327        RunSandboxOptions::default(),
328        None,
329        RunAuxOptions::default(),
330        RunControlOptions::default(),
331        HarnpackRunOptions::default(),
332    )
333    .await;
334    if exit_code != 0 {
335        process::exit(exit_code);
336    }
337}
338
339pub(crate) fn run_explain_cost_file_with_skill_dirs(path: &str) -> i32 {
340    let outcome = execute_explain_cost(path);
341    if !outcome.stderr.is_empty() {
342        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
343    }
344    if !outcome.stdout.is_empty() {
345        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
346    }
347    outcome.exit_code
348}
349
350#[allow(clippy::too_many_arguments)]
351pub(crate) async fn run_file_with_skill_dirs(
352    path: &str,
353    trace: bool,
354    denied_builtins: HashSet<String>,
355    script_argv: Vec<String>,
356    skill_dirs_raw: Vec<String>,
357    llm_mock_mode: CliLlmMockMode,
358    attestation: Option<RunAttestationOptions>,
359    profile: RunProfileOptions,
360    sandbox: RunSandboxOptions,
361    json: Option<RunJsonOptions>,
362    aux: RunAuxOptions,
363    control: RunControlOptions,
364    harnpack: HarnpackRunOptions,
365) -> i32 {
366    // Graceful shutdown: flush run records before exit on SIGINT/SIGTERM.
367    let interrupt_tokens = install_signal_shutdown_handler();
368    let deadline_guard = control
369        .timeout
370        .map(|timeout| start_run_deadline_watchdog(timeout, interrupt_tokens.clone()));
371
372    let _stdout_passthrough = StdoutPassthroughGuard::enable();
373    let json_session = json.map(|options| {
374        JsonRunSession::new(options, Box::new(io::stdout()) as Box<dyn io::Write + Send>)
375    });
376    let outcome = execute_run_inner(ExecuteRunInputs {
377        path,
378        trace,
379        denied_builtins,
380        script_argv,
381        skill_dirs_raw,
382        llm_mock_mode,
383        attestation,
384        profile,
385        sandbox,
386        interrupt_tokens: Some(interrupt_tokens.clone()),
387        json: json_session,
388        aux,
389        timing: None,
390        harnpack,
391    })
392    .await;
393    if let Some(guard) = &deadline_guard {
394        guard.finish();
395    }
396
397    // `harn run` streams normal program stdout during execution. Any stdout
398    // left here came from older capture paths, so flush it after diagnostics.
399    if !outcome.stderr.is_empty() {
400        io::stderr().write_all(outcome.stderr.as_bytes()).ok();
401    }
402    if !outcome.stdout.is_empty() {
403        io::stdout().write_all(outcome.stdout.as_bytes()).ok();
404    }
405
406    let mut exit_code = outcome.exit_code;
407    if deadline_guard
408        .as_ref()
409        .is_some_and(RunDeadlineGuard::timed_out)
410        || (exit_code != 0 && interrupt_tokens.cancel_token.load(Ordering::SeqCst))
411    {
412        exit_code = 124;
413    }
414    exit_code
415}
416
417#[allow(clippy::too_many_arguments)]
418pub(crate) async fn run_resume_with_skill_dirs(
419    target: &str,
420    trace: bool,
421    denied_builtins: HashSet<String>,
422    resume_argv: Vec<String>,
423    skill_dirs_raw: Vec<String>,
424    llm_mock_mode: CliLlmMockMode,
425    attestation: Option<RunAttestationOptions>,
426    profile: RunProfileOptions,
427    sandbox: RunSandboxOptions,
428    json: Option<RunJsonOptions>,
429    aux: RunAuxOptions,
430    control: RunControlOptions,
431) -> i32 {
432    let source = r#"import { resume_agent, wait_agent } from "std/agent/workers"
433
434pipeline main(task) {
435  const input = if len(argv) > 1 {
436    argv[1]
437  } else {
438    nil
439  }
440  const handle = resume_agent(argv[0], input, true)
441  return wait_agent(handle)
442}
443"#;
444    let tmp = match create_eval_temp_file() {
445        Ok(tmp) => tmp,
446        Err(error) => {
447            eprintln!("error: {error}");
448            return 1;
449        }
450    };
451    let tmp_path = tmp.path().to_path_buf();
452    if let Err(error) = fs::write(&tmp_path, source) {
453        eprintln!("error: failed to write temp file for --resume: {error}");
454        return 1;
455    }
456    let mut argv = Vec::with_capacity(resume_argv.len() + 1);
457    argv.push(target.to_string());
458    argv.extend(resume_argv);
459    let tmp_str = tmp_path.to_string_lossy().into_owned();
460    run_file_with_skill_dirs(
461        &tmp_str,
462        trace,
463        denied_builtins,
464        argv,
465        skill_dirs_raw,
466        llm_mock_mode,
467        attestation,
468        profile,
469        sandbox,
470        json,
471        aux,
472        control,
473        HarnpackRunOptions::default(),
474    )
475    .await
476}
477
478pub fn execute_explain_cost(path: &str) -> RunOutcome {
479    let stdout = String::new();
480    let mut stderr = String::new();
481
482    let source = match fs::read_to_string(path) {
483        Ok(source) => source,
484        Err(error) => {
485            stderr.push_str(&format!("Error reading {path}: {error}\n"));
486            return RunOutcome {
487                stdout,
488                stderr,
489                exit_code: 1,
490            };
491        }
492    };
493    let program = match parse_source_for_run(path, &source, &mut stderr) {
494        Some(program) => program,
495        None => {
496            return RunOutcome {
497                stdout,
498                stderr,
499                exit_code: 1,
500            };
501        }
502    };
503
504    let mut had_type_error = false;
505    let type_diagnostics = match typecheck_with_imports(&program, Path::new(path), &source) {
506        Ok(diagnostics) => diagnostics,
507        Err(error) => {
508            stderr.push_str(&format!("error: {error}\n"));
509            return RunOutcome {
510                stdout,
511                stderr,
512                exit_code: 1,
513            };
514        }
515    };
516    for diag in &type_diagnostics {
517        let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
518        if matches!(diag.severity, DiagnosticSeverity::Error) {
519            had_type_error = true;
520        }
521        stderr.push_str(&rendered);
522    }
523    if had_type_error {
524        return RunOutcome {
525            stdout,
526            stderr,
527            exit_code: 1,
528        };
529    }
530
531    let extensions = package::load_runtime_extensions(Path::new(path));
532    package::install_runtime_extensions(&extensions);
533    RunOutcome {
534        stdout: explain_cost::render_explain_cost(path, &program),
535        stderr,
536        exit_code: 0,
537    }
538}
539
540pub(crate) struct StdoutPassthroughGuard {
541    previous: bool,
542}
543
544impl StdoutPassthroughGuard {
545    pub(crate) fn enable() -> Self {
546        Self {
547            previous: harn_vm::set_stdout_passthrough(true),
548        }
549    }
550}
551
552impl Drop for StdoutPassthroughGuard {
553    fn drop(&mut self) {
554        harn_vm::set_stdout_passthrough(self.previous);
555    }
556}
557
558// User-facing copy on Ctrl-C. We want the operator to know that a brief
559// pause after the first signal is expected (the VM rewinds the active
560// instruction, drops in-flight async ops like a hanging Ollama request,
561// and unwinds frames before the runtime exits) so they don't reflexively
562// reach for a second Ctrl-C and force-kill the process. The "Ctrl-C
563// again to force-exit" hint is load-bearing — earlier runs of harn
564// released to the fleet showed operators routinely double-tapping the
565// shortcut and losing the chance to inspect the error trace.
566/// In-process equivalent of `run_file_with_skill_dirs`. Returns the captured
567/// stdout, stderr, and what exit code the binary entry would have used,
568/// instead of writing to real stdout/stderr or calling `process::exit`.
569///
570/// Tests should call this directly. The `harn run` binary path wraps it.
571pub async fn execute_run(
572    path: &str,
573    trace: bool,
574    denied_builtins: HashSet<String>,
575    script_argv: Vec<String>,
576    skill_dirs_raw: Vec<String>,
577    llm_mock_mode: CliLlmMockMode,
578    attestation: Option<RunAttestationOptions>,
579    profile: RunProfileOptions,
580) -> RunOutcome {
581    crate::ensure_builtin_signatures_installed();
582    execute_run_with_harnpack_and_sandbox_options(
583        path,
584        trace,
585        denied_builtins,
586        script_argv,
587        skill_dirs_raw,
588        llm_mock_mode,
589        attestation,
590        profile,
591        RunSandboxOptions::default(),
592        HarnpackRunOptions::default(),
593    )
594    .await
595}
596
597/// [`execute_run`] with an explicit sandbox policy override for in-process
598/// callers whose source path is intentionally outside the workspace they
599/// operate on.
600#[allow(clippy::too_many_arguments)]
601pub async fn execute_run_with_sandbox_options(
602    path: &str,
603    trace: bool,
604    denied_builtins: HashSet<String>,
605    script_argv: Vec<String>,
606    skill_dirs_raw: Vec<String>,
607    llm_mock_mode: CliLlmMockMode,
608    attestation: Option<RunAttestationOptions>,
609    profile: RunProfileOptions,
610    sandbox: RunSandboxOptions,
611) -> RunOutcome {
612    execute_run_with_harnpack_and_sandbox_options(
613        path,
614        trace,
615        denied_builtins,
616        script_argv,
617        skill_dirs_raw,
618        llm_mock_mode,
619        attestation,
620        profile,
621        sandbox,
622        HarnpackRunOptions::default(),
623    )
624    .await
625}
626
627/// [`execute_run`] for callers that want to opt-in to the `.harnpack`
628/// verify-replay-execute path. Used by `harn run <bundle.harnpack>`
629/// integration tests and by the binary entry once it has parsed the
630/// `--allow-unsigned` / `--dry-run-verify` flags.
631#[allow(clippy::too_many_arguments)]
632pub async fn execute_run_with_harnpack_options(
633    path: &str,
634    trace: bool,
635    denied_builtins: HashSet<String>,
636    script_argv: Vec<String>,
637    skill_dirs_raw: Vec<String>,
638    llm_mock_mode: CliLlmMockMode,
639    attestation: Option<RunAttestationOptions>,
640    profile: RunProfileOptions,
641    harnpack: HarnpackRunOptions,
642) -> RunOutcome {
643    execute_run_with_harnpack_and_sandbox_options(
644        path,
645        trace,
646        denied_builtins,
647        script_argv,
648        skill_dirs_raw,
649        llm_mock_mode,
650        attestation,
651        profile,
652        RunSandboxOptions::default(),
653        harnpack,
654    )
655    .await
656}
657
658#[allow(clippy::too_many_arguments)]
659async fn execute_run_with_harnpack_and_sandbox_options(
660    path: &str,
661    trace: bool,
662    denied_builtins: HashSet<String>,
663    script_argv: Vec<String>,
664    skill_dirs_raw: Vec<String>,
665    llm_mock_mode: CliLlmMockMode,
666    attestation: Option<RunAttestationOptions>,
667    profile: RunProfileOptions,
668    sandbox: RunSandboxOptions,
669    harnpack: HarnpackRunOptions,
670) -> RunOutcome {
671    execute_run_inner(ExecuteRunInputs {
672        path,
673        trace,
674        denied_builtins,
675        script_argv,
676        skill_dirs_raw,
677        llm_mock_mode,
678        attestation,
679        profile,
680        sandbox,
681        interrupt_tokens: None,
682        json: None,
683        aux: RunAuxOptions::default(),
684        timing: None,
685        harnpack,
686    })
687    .await
688}
689
690/// `execute_run` variant for `--json` mode. Returns once the run is
691/// complete; the NDJSON event stream — including the terminal `result`
692/// or `error` event — has already been written to `out` and flushed.
693/// `out` must be `Send` because the run-event sink may be called from
694/// any worker thread the VM spawns.
695#[allow(clippy::too_many_arguments)]
696pub async fn execute_run_json(
697    path: &str,
698    trace: bool,
699    denied_builtins: HashSet<String>,
700    script_argv: Vec<String>,
701    skill_dirs_raw: Vec<String>,
702    llm_mock_mode: CliLlmMockMode,
703    attestation: Option<RunAttestationOptions>,
704    profile: RunProfileOptions,
705    out: Box<dyn io::Write + Send>,
706    options: RunJsonOptions,
707) -> RunOutcome {
708    execute_run_inner(ExecuteRunInputs {
709        path,
710        trace,
711        denied_builtins,
712        script_argv,
713        skill_dirs_raw,
714        llm_mock_mode,
715        attestation,
716        profile,
717        sandbox: RunSandboxOptions::default(),
718        interrupt_tokens: None,
719        json: Some(JsonRunSession::new(options, out)),
720        aux: RunAuxOptions::default(),
721        timing: None,
722        harnpack: HarnpackRunOptions::default(),
723    })
724    .await
725}
726
727/// Run a `.harn` file with the default builtin/argv set and record
728/// phase timings into `timing`. Used by `harn time run` so the
729/// instrumented run shares the exact code path as plain `harn run`.
730pub(crate) async fn execute_run_with_timing(
731    path: &str,
732    script_argv: Vec<String>,
733    timing: Option<&mut RunTiming>,
734    sandbox: RunSandboxOptions,
735) -> RunOutcome {
736    execute_run_inner(ExecuteRunInputs {
737        path,
738        trace: false,
739        denied_builtins: HashSet::new(),
740        script_argv,
741        skill_dirs_raw: Vec::new(),
742        llm_mock_mode: CliLlmMockMode::Off,
743        attestation: None,
744        profile: RunProfileOptions::default(),
745        sandbox,
746        interrupt_tokens: None,
747        json: None,
748        aux: RunAuxOptions::default(),
749        timing,
750        harnpack: HarnpackRunOptions::default(),
751    })
752    .await
753}
754
755/// Directory that anchors the entry script's source-relative and `@asset`
756/// resolution.
757///
758/// Returns the script's parent directory, or the current working directory
759/// when the path is a bare filename (empty parent) — e.g. `cd project &&
760/// harn run main.harn`. The old code skipped setting the source dir in that
761/// case, which left the resting thread-local source dir unset (`None`). A
762/// dependency provider-connector contract load during `harn run` startup then
763/// repointed the thread-local at a dependency generation's `src` and, because the
764/// restore-on-return path is a no-op over an unset baseline, left it there —
765/// so the entry pipeline's first `render("@alias/...")` resolved against the
766/// dependency's `harn.toml` instead of the project root. Always establishing
767/// the entry dir keeps that resolution anchored on the project.
768fn entry_source_dir(path: &str) -> std::path::PathBuf {
769    match std::path::Path::new(path).parent() {
770        Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
771        _ => std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
772    }
773}
774
775// See [`compile_or_load_chunk_with_timing`] for why `as_deref_mut` is
776// the intentional reborrow pattern here.
777#[allow(clippy::needless_option_as_deref)]
778async fn execute_run_inner(inputs: ExecuteRunInputs<'_>) -> RunOutcome {
779    let mut inputs = inputs;
780    let json_session = inputs.json.take();
781    let Some(json_session) = json_session else {
782        return execute_run_inner_scoped(inputs, None).await;
783    };
784    let sink = json_session.sink();
785    harn_vm::run_events::scope(sink, execute_run_inner_scoped(inputs, Some(json_session))).await
786}
787
788async fn execute_run_inner_scoped(
789    inputs: ExecuteRunInputs<'_>,
790    json_session: Option<JsonRunSession>,
791) -> RunOutcome {
792    let ExecuteRunInputs {
793        path,
794        trace,
795        denied_builtins,
796        script_argv,
797        skill_dirs_raw,
798        llm_mock_mode,
799        attestation,
800        profile,
801        sandbox,
802        interrupt_tokens,
803        json: _,
804        aux,
805        timing,
806        harnpack,
807    } = inputs;
808    let RunAuxOptions {
809        summary,
810        phase,
811        rusage,
812    } = aux;
813    let run_started = Instant::now();
814    let cpu_started_ms = rusage.as_ref().map(|_| time::cpu_ms());
815    let mut owned_timing = if timing.is_none() && (phase.is_some() || rusage.is_some()) {
816        Some(RunTiming::default())
817    } else {
818        None
819    };
820    let mut timing = timing.or(owned_timing.as_mut());
821
822    let mut stderr = String::new();
823    let mut stdout = String::new();
824
825    // `.harnpack` preflight: verify signature + replay archive into the
826    // content-addressed cache before we touch the chunk loader. The
827    // outcome path (entrypoint inside the unpacked tree) replaces the
828    // CLI-supplied `path` for everything below.
829    let owned_run_path: String;
830    let resolved_path: &str = if harnpack::looks_like_harnpack(Path::new(path)) {
831        let outcome = match harnpack::prepare_harnpack(Path::new(path), &harnpack, &mut stderr) {
832            Ok(prepared) => prepared,
833            Err(err) => {
834                return finalize_harnpack_error(
835                    stderr,
836                    json_session,
837                    summary.as_ref(),
838                    phase.as_ref(),
839                    rusage.as_ref(),
840                    run_started,
841                    err,
842                );
843            }
844        };
845        harn_vm::run_events::emit(harn_vm::run_events::RunEvent::PackRun {
846            bundle_hash: outcome.bundle_hash.clone(),
847            signature_verified: outcome.signature_verified,
848            key_id: outcome.key_id.clone(),
849            cache_hit: outcome.cache_hit,
850            dry_run_verify: harnpack.dry_run_verify,
851        });
852        if harnpack.dry_run_verify {
853            return finalize_harnpack_dry_run(
854                stderr,
855                json_session,
856                summary.as_ref(),
857                phase.as_ref(),
858                rusage.as_ref(),
859                run_started,
860                cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
861                &outcome,
862            );
863        }
864        owned_run_path = outcome.entrypoint_path.to_string_lossy().into_owned();
865        owned_run_path.as_str()
866    } else {
867        path
868    };
869
870    let Some(LoadedChunk {
871        source,
872        chunk,
873        link_table,
874    }) = compile_or_load_chunk_with_timing(resolved_path, &mut stderr, timing.as_deref_mut())
875    else {
876        let message = stderr.clone();
877        return finalize_run_error(
878            stdout,
879            stderr,
880            json_session,
881            summary.as_ref(),
882            phase.as_ref(),
883            rusage.as_ref(),
884            run_started,
885            None,
886            timing.as_deref(),
887            0,
888            cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
889            "compile_error",
890            message,
891        );
892    };
893    let path = resolved_path;
894
895    let setup_start = Instant::now();
896    if trace || summary.is_some() {
897        harn_vm::llm::enable_tracing();
898    }
899    if profile.is_enabled() || phase.is_some() {
900        harn_vm::tracing::set_tracing_enabled(true);
901    }
902    // Per-builtin recording is only paid for when a profile is asked for: the
903    // categorical buckets fold every non-LLM, non-tool builtin into
904    // `residual`, which cannot name what a slow run is waiting on. The guard
905    // lives for the rest of this function, so recording ends with the run on
906    // every exit path below.
907    let _builtin_profile_guard = profile.is_enabled().then(harn_vm::builtin_profile::enable);
908    if let Err(error) = install_cli_llm_mock_mode(&llm_mock_mode) {
909        stderr.push_str(&format!("error: {error}\n"));
910        time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
911        return finalize_run_error(
912            stdout,
913            stderr,
914            json_session,
915            summary.as_ref(),
916            phase.as_ref(),
917            rusage.as_ref(),
918            run_started,
919            None,
920            timing.as_deref(),
921            0,
922            cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
923            "llm_mock_install",
924            error,
925        );
926    }
927
928    let mut vm = harn_vm::Vm::new();
929    vm.set_graph_link_table(link_table);
930    if let Some(timing) = timing.as_deref_mut() {
931        timing.module_phases = Some(vm.enable_module_phase_timing());
932    }
933    if let Some(interrupt_tokens) = interrupt_tokens {
934        vm.install_interrupt_signal_token(interrupt_tokens.signal_token);
935        vm.install_cancel_token(interrupt_tokens.cancel_token);
936    }
937    harn_vm::register_vm_stdlib(&mut vm);
938    crate::install_default_hostlib(&mut vm);
939    let source_parent = std::path::Path::new(path)
940        .parent()
941        .unwrap_or(std::path::Path::new("."));
942    // Metadata/store rooted at harn.toml when present; source dir otherwise.
943    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
944    let store_base = project_root.as_deref().unwrap_or(source_parent);
945    let sandbox_root = sandbox
946        .workspace_root
947        .clone()
948        .unwrap_or_else(|| default_run_workspace_root(project_root.as_deref(), source_parent));
949    let _sandbox_scope = install_run_sandbox_scope(&sandbox, &sandbox_root, &mut stderr);
950
951    // Launch the session's environment policy so this run's
952    // subprocesses build their environment through the closed allowlist +
953    // grants resolver (harn#4992). A launch failure — a missing launcher
954    // variable, or a grant on an isolated policy — fails the run loudly rather
955    // than silently dropping the credential. Held for the run's duration; on
956    // drop the ambient environment policy is cleared.
957    let (_environment_scope, environment_policy, grant_receipts) =
958        match environment::launch_scope(&sandbox.environment, &mut stderr) {
959            Ok(launched) => launched,
960            Err(error) => {
961                stderr.push_str(&format!("error: {error}\n"));
962                time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
963                let code = error.code();
964                return finalize_run_error(
965                    stdout,
966                    stderr,
967                    json_session,
968                    summary.as_ref(),
969                    phase.as_ref(),
970                    rusage.as_ref(),
971                    run_started,
972                    None,
973                    timing.as_deref(),
974                    0,
975                    cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
976                    code,
977                    error.to_string(),
978                );
979            }
980        };
981
982    let attestation_started_at_ms = now_ms();
983    let attestation_log = if attestation.is_some() {
984        Some(harn_vm::event_log::install_memory_for_current_thread(256))
985    } else {
986        None
987    };
988    if let Some(log) = attestation_log.as_ref() {
989        append_run_provenance_event(
990            log,
991            "started",
992            serde_json::json!({
993                "pipeline": path,
994                "argv": &script_argv,
995                "project_root": store_base.display().to_string(),
996                "sandbox": run_sandbox_attestation(&sandbox),
997                "environment_policy": environment_policy.as_str(),
998                // Non-secret grant receipts; never the granted value.
999                "environment_grants": &grant_receipts,
1000            }),
1001        )
1002        .await;
1003    }
1004    harn_vm::register_store_builtins(&mut vm, store_base);
1005    harn_vm::register_metadata_builtins(&mut vm, store_base);
1006    let pipeline_name = std::path::Path::new(path)
1007        .file_stem()
1008        .and_then(|s| s.to_str())
1009        .unwrap_or("default");
1010    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1011    vm.set_source_info(path, &source);
1012    let lazy_manifest_handlers = !denied_builtins.is_empty();
1013    if lazy_manifest_handlers {
1014        vm.set_denied_builtins(denied_builtins);
1015    }
1016    if let Some(ref root) = project_root {
1017        vm.set_project_root(root);
1018    }
1019
1020    // Establish the entry script's directory as the resting source dir. When
1021    // `path` is a bare filename (empty parent) — e.g. `cd project && harn run
1022    // main.harn` — anchor on the current working directory instead of skipping,
1023    // so the resting source dir is never left unset. See `entry_source_dir`.
1024    vm.set_source_dir(&entry_source_dir(path));
1025
1026    // Load filesystem + manifest skills before the pipeline runs so
1027    // `skills` is populated with a pre-discovered registry (see #73).
1028    let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
1029    let loaded = load_skills(&SkillLoaderInputs {
1030        cli_dirs,
1031        source_path: Some(std::path::PathBuf::from(path)),
1032    });
1033    emit_loader_warnings(&loaded.loader_warnings);
1034    install_skills_global(&mut vm, &loaded);
1035
1036    // `harn run script.harn -- a b c` yields `argv == ["a", "b", "c"]`.
1037    // Always set so scripts can rely on `len(argv)`.
1038    let argv_values: Vec<harn_vm::VmValue> = script_argv
1039        .iter()
1040        .map(|s| harn_vm::VmValue::String(arcstr::ArcStr::from(s.as_str())))
1041        .collect();
1042    vm.set_global(
1043        "argv",
1044        harn_vm::VmValue::List(std::sync::Arc::new(argv_values)),
1045    );
1046
1047    // Install the script's `Harness` capability handle so the auto-call
1048    // emitted by `Compiler::compile()` for `fn main(harness: Harness)`
1049    // entrypoints can read it.
1050    let runtime_harness =
1051        match crate::default_harness_for_manifest_or_base_dir(Path::new(path), store_base) {
1052            Ok(harness) => harness,
1053            Err(error) => {
1054                stderr.push_str(&format!(
1055                    "error: failed to configure harness secret provider: {error}\n"
1056                ));
1057                time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
1058                return finalize_run_error(
1059                    stdout,
1060                    stderr,
1061                    json_session,
1062                    summary.as_ref(),
1063                    phase.as_ref(),
1064                    rusage.as_ref(),
1065                    run_started,
1066                    None,
1067                    timing.as_deref(),
1068                    0,
1069                    cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1070                    "harness_secret_provider",
1071                    error,
1072                );
1073            }
1074        };
1075    vm.set_harness(runtime_harness);
1076
1077    // An explicit allow/deny policy belongs to the requested target. Defer
1078    // unrelated manifest handler graphs until they actually fire under this VM.
1079    if let Err(error) =
1080        manifest_runtime::install_manifest_runtime(Path::new(path), &mut vm, lazy_manifest_handlers)
1081            .await
1082    {
1083        stderr.push_str(&format!(
1084            "error: failed to install {}: {error}\n",
1085            error.label()
1086        ));
1087        time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
1088        return finalize_run_error(
1089            stdout,
1090            stderr,
1091            json_session,
1092            summary.as_ref(),
1093            phase.as_ref(),
1094            rusage.as_ref(),
1095            run_started,
1096            None,
1097            timing.as_deref(),
1098            0,
1099            cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1100            error.phase(),
1101            error.to_string(),
1102        );
1103    }
1104
1105    let local = tokio::task::LocalSet::new();
1106    time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
1107    let main_start = Instant::now();
1108    // Re-anchor the entry source dir immediately before executing the entry
1109    // pipeline. The manifest/dependency setup above (provider-connector
1110    // contract loads, hook-handler module loads) transiently repoints the
1111    // thread-local source dir and does not guarantee it is restored to the
1112    // entry's dir — a dependency provider connector under
1113    // a dependency generation would otherwise leave the entry pipeline's first
1114    // `render("@alias/...")` resolving against the dependency's `harn.toml`.
1115    vm.set_source_dir(&entry_source_dir(path));
1116    let execution = local
1117        .run_until(async {
1118            match vm.execute(&chunk).await {
1119                Ok(value) => RunExecution::Terminal(TerminalRun::Returned(value)),
1120                Err(error) => match error.process_exit_code() {
1121                    Some(code) => RunExecution::Terminal(TerminalRun::ProcessExited(code)),
1122                    None => RunExecution::Failed(vm.format_runtime_error(&error)),
1123                },
1124            }
1125        })
1126        .await;
1127    let output = vm.output();
1128    if let Some(t) = timing.as_deref_mut() {
1129        t.run_main = main_start.elapsed();
1130    }
1131    if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
1132        stderr.push_str(&format!("error: {error}\n"));
1133        let profile_rollup = if profile.is_enabled() {
1134            Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1135        } else {
1136            None
1137        };
1138        return finalize_run_error(
1139            stdout,
1140            stderr,
1141            json_session,
1142            summary.as_ref(),
1143            phase.as_ref(),
1144            rusage.as_ref(),
1145            run_started,
1146            profile_rollup.as_ref(),
1147            timing.as_deref(),
1148            harn_vm::tracing::peek_spans().len() as u64,
1149            cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1150            "llm_mock_record",
1151            error,
1152        );
1153    }
1154
1155    // Always drain any captured stderr accumulated during execution.
1156    let buffered_stderr = harn_vm::take_stderr_buffer();
1157    stderr.push_str(&buffered_stderr);
1158
1159    let exit_code = match &execution {
1160        RunExecution::Terminal(terminal) => terminal.exit_code(),
1161        RunExecution::Failed(_) => 1,
1162    };
1163
1164    if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
1165        if let Err(error) = emit_run_attestation(
1166            log,
1167            path,
1168            store_base,
1169            attestation_started_at_ms,
1170            exit_code,
1171            options,
1172            &mut stderr,
1173        )
1174        .await
1175        {
1176            stderr.push_str(&format!(
1177                "error: failed to emit provenance receipt: {error}\n"
1178            ));
1179            let profile_rollup = if profile.is_enabled() {
1180                Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1181            } else {
1182                None
1183            };
1184            return finalize_run_error(
1185                stdout,
1186                stderr,
1187                json_session,
1188                summary.as_ref(),
1189                phase.as_ref(),
1190                rusage.as_ref(),
1191                run_started,
1192                profile_rollup.as_ref(),
1193                timing.as_deref(),
1194                harn_vm::tracing::peek_spans().len() as u64,
1195                cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1196                "attestation",
1197                error,
1198            );
1199        }
1200        harn_vm::event_log::reset_active_event_log();
1201    }
1202
1203    match execution {
1204        RunExecution::Terminal(terminal) => {
1205            stdout.push_str(output);
1206            let main_events = harn_vm::tracing::peek_spans().len() as u64;
1207            let cpu_ms_total = cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start));
1208            let profile_rollup = if profile.is_enabled() {
1209                Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1210            } else {
1211                None
1212            };
1213            let summary_llm = summary.as_ref().map(|_| run_summary_llm_snapshot());
1214            if trace {
1215                stderr.push_str(&render_trace_summary());
1216            }
1217            if let Some(profile_rollup) = profile_rollup.as_ref() {
1218                if let Err(error) =
1219                    render_and_persist_profile_rollup(&profile, profile_rollup, &mut stderr)
1220                {
1221                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1222                }
1223            }
1224            if let Some(diagnostic) = terminal.nonzero_return_diagnostic() {
1225                stderr.push_str(&diagnostic);
1226            }
1227            let aux_emission = emit_run_aux_for_exit(
1228                summary.as_ref(),
1229                phase.as_ref(),
1230                rusage.as_ref(),
1231                run_started,
1232                exit_code,
1233                profile_rollup.as_ref(),
1234                summary_llm,
1235                timing.as_deref(),
1236                main_events,
1237                cpu_ms_total,
1238                json_session.is_some(),
1239                &mut stderr,
1240            );
1241            if let Some(session) = json_session {
1242                if let Some(error) = aux_emission.error {
1243                    let mut outcome = session.finalize_error(
1244                        "run_aux",
1245                        format!("failed to emit auxiliary run JSON: {error}"),
1246                        1,
1247                    );
1248                    outcome.stderr = aux_emission.stderr;
1249                    return outcome;
1250                }
1251                let value = terminal.json_value();
1252                let mut outcome = session.finalize_result(value, aux_emission.exit_code);
1253                outcome.stderr = aux_emission.stderr;
1254                return outcome;
1255            }
1256            RunOutcome {
1257                stdout,
1258                stderr,
1259                exit_code: aux_emission.exit_code,
1260            }
1261        }
1262        RunExecution::Failed(rendered_error) => {
1263            stderr.push_str(&rendered_error);
1264            let main_events = harn_vm::tracing::peek_spans().len() as u64;
1265            let cpu_ms_total = cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start));
1266            let profile_rollup = if profile.is_enabled() {
1267                Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1268            } else {
1269                None
1270            };
1271            if let Some(profile_rollup) = profile_rollup.as_ref() {
1272                if let Err(error) =
1273                    render_and_persist_profile_rollup(&profile, profile_rollup, &mut stderr)
1274                {
1275                    stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1276                }
1277            }
1278            let aux_emission = emit_run_aux_for_exit(
1279                summary.as_ref(),
1280                phase.as_ref(),
1281                rusage.as_ref(),
1282                run_started,
1283                1,
1284                profile_rollup.as_ref(),
1285                None,
1286                timing.as_deref(),
1287                main_events,
1288                cpu_ms_total,
1289                json_session.is_some(),
1290                &mut stderr,
1291            );
1292            if let Some(session) = json_session {
1293                let mut outcome =
1294                    session.finalize_error("runtime", rendered_error, aux_emission.exit_code);
1295                outcome.stderr = aux_emission.stderr;
1296                return outcome;
1297            }
1298            RunOutcome {
1299                stdout,
1300                stderr,
1301                exit_code: aux_emission.exit_code,
1302            }
1303        }
1304    }
1305}
1306
1307fn render_and_persist_profile_rollup(
1308    options: &RunProfileOptions,
1309    profile: &harn_vm::profile::RunProfile,
1310    stderr: &mut String,
1311) -> Result<(), String> {
1312    if options.text {
1313        stderr.push_str(&harn_vm::profile::render(profile));
1314    }
1315    if let Some(path) = options.json_path.as_ref() {
1316        if let Some(parent) = path.parent() {
1317            if !parent.as_os_str().is_empty() {
1318                fs::create_dir_all(parent)
1319                    .map_err(|error| format!("create {}: {error}", parent.display()))?;
1320            }
1321        }
1322        let json = serde_json::to_string_pretty(profile)
1323            .map_err(|error| format!("serialize profile: {error}"))?;
1324        fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
1325    }
1326    Ok(())
1327}
1328
1329fn build_run_summary<'a>(
1330    started: Instant,
1331    exit_code: i32,
1332    profile: Option<&'a harn_vm::profile::RunProfile>,
1333    llm: RunSummaryLlm,
1334) -> RunSummary<'a> {
1335    RunSummary {
1336        schema_version: RUN_SUMMARY_SCHEMA_VERSION,
1337        event: "run_summary",
1338        wall_time_ms: started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64,
1339        exit_code,
1340        llm,
1341        profile,
1342    }
1343}
1344
1345fn run_summary_llm_snapshot() -> RunSummaryLlm {
1346    let (input_tokens, output_tokens, time_ms, call_count) = harn_vm::llm::peek_trace_summary();
1347    let cost_usd = harn_vm::llm::peek_total_cost();
1348    RunSummaryLlm {
1349        call_count,
1350        input_tokens,
1351        output_tokens,
1352        time_ms,
1353        cost_usd: if cost_usd.is_finite() { cost_usd } else { 0.0 },
1354    }
1355}
1356
1357struct RunAuxEmission {
1358    stderr: String,
1359    exit_code: i32,
1360    error: Option<String>,
1361}
1362
1363#[allow(clippy::too_many_arguments)]
1364fn emit_run_aux_for_exit(
1365    summary: Option<&RunSummaryOptions>,
1366    phase: Option<&RunPhaseOptions>,
1367    rusage: Option<&RunRusageOptions>,
1368    started: Instant,
1369    exit_code: i32,
1370    profile: Option<&harn_vm::profile::RunProfile>,
1371    llm: Option<RunSummaryLlm>,
1372    timing: Option<&RunTiming>,
1373    main_events: u64,
1374    cpu_ms_total: Option<u64>,
1375    json_mode: bool,
1376    stderr: &mut String,
1377) -> RunAuxEmission {
1378    let mut aux_stderr = String::new();
1379    let mut final_exit_code = exit_code;
1380    let mut aux_error = None;
1381    let aux_target = if json_mode { &mut aux_stderr } else { stderr };
1382    let default_timing = RunTiming::default();
1383    let timing = timing.unwrap_or(&default_timing);
1384
1385    if let Some(options) = summary {
1386        let llm = llm.unwrap_or_else(run_summary_llm_snapshot);
1387        let summary = build_run_summary(started, exit_code, profile, llm);
1388        if let Err(error) = emit_raw_json_line(&options.sink, &summary, "run summary", aux_target) {
1389            record_aux_error(
1390                &mut final_exit_code,
1391                &mut aux_error,
1392                aux_target,
1393                "run summary",
1394                error,
1395            );
1396        }
1397    }
1398    if let Some(options) = phase {
1399        let phase_event = RunPhaseEvent {
1400            schema_version: RUN_PHASE_SCHEMA_VERSION,
1401            event: "run_phase",
1402            phases: time::build_phase_records(timing, main_events),
1403        };
1404        if let Err(error) = emit_raw_json_line(&options.sink, &phase_event, "run phase", aux_target)
1405        {
1406            record_aux_error(
1407                &mut final_exit_code,
1408                &mut aux_error,
1409                aux_target,
1410                "run phase",
1411                error,
1412            );
1413        }
1414    }
1415    if let Some(options) = rusage {
1416        let rusage_event = RunRusageEvent {
1417            schema_version: RUN_RUSAGE_SCHEMA_VERSION,
1418            event: "run_rusage",
1419            cpu_ms: cpu_ms_total.unwrap_or(0),
1420        };
1421        if let Err(error) =
1422            emit_raw_json_line(&options.sink, &rusage_event, "run rusage", aux_target)
1423        {
1424            record_aux_error(
1425                &mut final_exit_code,
1426                &mut aux_error,
1427                aux_target,
1428                "run rusage",
1429                error,
1430            );
1431        }
1432    }
1433
1434    RunAuxEmission {
1435        stderr: aux_stderr,
1436        exit_code: final_exit_code,
1437        error: aux_error,
1438    }
1439}
1440
1441fn record_aux_error(
1442    final_exit_code: &mut i32,
1443    aux_error: &mut Option<String>,
1444    stderr: &mut String,
1445    label: &str,
1446    error: String,
1447) {
1448    stderr.push_str(&format!("error: failed to emit {label}: {error}\n"));
1449    if *final_exit_code == 0 {
1450        *final_exit_code = 1;
1451    }
1452    if aux_error.is_none() {
1453        *aux_error = Some(error);
1454    }
1455}
1456
1457fn emit_raw_json_line(
1458    sink: &RunJsonSink,
1459    value: &impl Serialize,
1460    label: &str,
1461    stderr: &mut String,
1462) -> Result<(), String> {
1463    let line =
1464        serde_json::to_string(value).map_err(|error| format!("serialize {label}: {error}"))? + "\n";
1465    match &sink.target {
1466        RunJsonSinkTarget::Stderr => {
1467            stderr.push_str(&line);
1468            Ok(())
1469        }
1470        RunJsonSinkTarget::File(path) => write_raw_json_file(path, &line),
1471        RunJsonSinkTarget::Fd(fd) => write_raw_json_fd(*fd, &line, sink.fd_flag),
1472    }
1473}
1474
1475fn write_raw_json_file(path: &Path, line: &str) -> Result<(), String> {
1476    if let Some(parent) = path.parent() {
1477        if !parent.as_os_str().is_empty() {
1478            fs::create_dir_all(parent)
1479                .map_err(|error| format!("create {}: {error}", parent.display()))?;
1480        }
1481    }
1482    fs::write(path, line).map_err(|error| format!("write {}: {error}", path.display()))
1483}
1484
1485#[cfg(unix)]
1486fn write_raw_json_fd(fd: i32, line: &str, flag: &str) -> Result<(), String> {
1487    use std::fs::File;
1488    use std::os::unix::io::FromRawFd;
1489
1490    if fd < 0 {
1491        return Err(format!("invalid {flag} {fd}: must be non-negative"));
1492    }
1493    let duped = unsafe { libc::dup(fd) };
1494    if duped < 0 {
1495        return Err(format!(
1496            "duplicate {flag} {fd}: {}",
1497            io::Error::last_os_error()
1498        ));
1499    }
1500    let mut file = unsafe { File::from_raw_fd(duped) };
1501    file.write_all(line.as_bytes())
1502        .and_then(|_| file.flush())
1503        .map_err(|error| format!("write {flag} {fd}: {error}"))
1504}
1505
1506#[cfg(not(unix))]
1507fn write_raw_json_fd(_fd: i32, _line: &str, flag: &str) -> Result<(), String> {
1508    Err(format!("{flag} is only supported on Unix platforms"))
1509}
1510
1511async fn append_run_provenance_event(
1512    log: &Arc<harn_vm::event_log::AnyEventLog>,
1513    kind: &str,
1514    payload: serde_json::Value,
1515) {
1516    let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
1517        return;
1518    };
1519    let _ = log
1520        .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
1521        .await;
1522}
1523
1524async fn emit_run_attestation(
1525    log: &Arc<harn_vm::event_log::AnyEventLog>,
1526    path: &str,
1527    store_base: &Path,
1528    started_at_ms: i64,
1529    exit_code: i32,
1530    options: &RunAttestationOptions,
1531    stderr: &mut String,
1532) -> Result<(), String> {
1533    let finished_at_ms = now_ms();
1534    let status = if exit_code == 0 { "success" } else { "failure" };
1535    append_run_provenance_event(
1536        log,
1537        "finished",
1538        serde_json::json!({
1539            "pipeline": path,
1540            "status": status,
1541            "exit_code": exit_code,
1542        }),
1543    )
1544    .await;
1545    log.flush()
1546        .await
1547        .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
1548    let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
1549        .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
1550    let (signing_key, key_id) =
1551        harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
1552            .await
1553            .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
1554    let receipt = harn_vm::build_signed_receipt(
1555        log,
1556        harn_vm::ReceiptBuildOptions {
1557            pipeline: path.to_string(),
1558            status: status.to_string(),
1559            started_at_ms,
1560            finished_at_ms,
1561            exit_code,
1562            producer_name: "harn-cli".to_string(),
1563            producer_version: env!("CARGO_PKG_VERSION").to_string(),
1564        },
1565        &signing_key,
1566        key_id,
1567    )
1568    .await
1569    .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
1570    let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
1571    if let Some(parent) = receipt_path.parent() {
1572        fs::create_dir_all(parent)
1573            .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1574    }
1575    let encoded = serde_json::to_vec_pretty(&receipt)
1576        .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
1577    fs::write(&receipt_path, encoded)
1578        .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
1579    stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
1580    Ok(())
1581}
1582
1583fn receipt_output_path(
1584    store_base: &Path,
1585    options: &RunAttestationOptions,
1586    receipt_id: &str,
1587) -> PathBuf {
1588    if let Some(path) = options.receipt_out.as_ref() {
1589        return path.clone();
1590    }
1591    harn_vm::runtime_paths::state_root(store_base)
1592        .join("receipts")
1593        .join(format!("{receipt_id}.json"))
1594}
1595
1596fn now_ms() -> i64 {
1597    std::time::SystemTime::now()
1598        .duration_since(std::time::UNIX_EPOCH)
1599        .map(|duration| duration.as_millis() as i64)
1600        .unwrap_or(0)
1601}
1602
1603/// Map a script's top-level return value to a process exit code.
1604///
1605/// - `int n`             → exit n (clamped to 0..=255)
1606/// - `Result::Ok(_)`     → exit 0
1607/// - `Result::Err(_)`    → exit 1
1608/// - anything else       → exit 0
1609fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
1610    use harn_vm::VmValue;
1611    match value {
1612        VmValue::Int(n) => (*n).clamp(0, 255) as i32,
1613        VmValue::EnumVariant(enum_variant) if enum_variant.is_variant("Result", "Err") => 1,
1614        _ => 0,
1615    }
1616}
1617
1618/// State for a single `harn run --json` invocation. `execute_run_inner`
1619/// attaches its sink to the run's ambient execution scope, including setup and
1620/// terminal handling, so every observable event stays in this stream.
1621///
1622/// `finalize_result` / `finalize_error` emit the terminal event and
1623/// build a [`RunOutcome`] whose stdout/stderr captured-buffer fields
1624/// stay **empty** — the canonical stream is on `out`.
1625/// `outcome.exit_code` still carries the process exit code so the
1626/// binary entry can `process::exit(...)`.
1627struct JsonRunSession {
1628    emitter: self::json_events::NdjsonEmitter,
1629}
1630
1631impl JsonRunSession {
1632    fn new(options: RunJsonOptions, out: Box<dyn io::Write + Send>) -> Self {
1633        Self {
1634            emitter: NdjsonEmitter::new(out, options.quiet),
1635        }
1636    }
1637
1638    fn sink(&self) -> Arc<dyn harn_vm::run_events::RunEventSink> {
1639        self.emitter.sink()
1640    }
1641
1642    fn finalize_result(self, value: serde_json::Value, exit_code: i32) -> RunOutcome {
1643        self.emitter.emit_result(value, exit_code);
1644        RunOutcome {
1645            stdout: String::new(),
1646            stderr: String::new(),
1647            exit_code,
1648        }
1649    }
1650
1651    fn finalize_error(
1652        self,
1653        code: impl Into<String>,
1654        message: impl Into<String>,
1655        exit_code: i32,
1656    ) -> RunOutcome {
1657        self.emitter.emit_error(code, message);
1658        RunOutcome {
1659            stdout: String::new(),
1660            stderr: String::new(),
1661            exit_code,
1662        }
1663    }
1664}
1665
1666#[allow(clippy::too_many_arguments)]
1667fn finalize_run_error(
1668    stdout: String,
1669    mut stderr: String,
1670    json_session: Option<JsonRunSession>,
1671    summary: Option<&RunSummaryOptions>,
1672    phase: Option<&RunPhaseOptions>,
1673    rusage: Option<&RunRusageOptions>,
1674    started: Instant,
1675    profile: Option<&harn_vm::profile::RunProfile>,
1676    timing: Option<&RunTiming>,
1677    main_events: u64,
1678    cpu_ms_total: Option<u64>,
1679    code: impl Into<String>,
1680    message: impl Into<String>,
1681) -> RunOutcome {
1682    let aux_emission = emit_run_aux_for_exit(
1683        summary,
1684        phase,
1685        rusage,
1686        started,
1687        1,
1688        profile,
1689        None,
1690        timing,
1691        main_events,
1692        cpu_ms_total,
1693        json_session.is_some(),
1694        &mut stderr,
1695    );
1696    if let Some(session) = json_session {
1697        let mut outcome = session.finalize_error(code, message, aux_emission.exit_code);
1698        outcome.stderr = aux_emission.stderr;
1699        return outcome;
1700    }
1701    RunOutcome {
1702        stdout,
1703        stderr,
1704        exit_code: aux_emission.exit_code,
1705    }
1706}
1707
1708/// Translate a preflight failure into either the `--json` error event
1709/// stream or a plain stderr message plus exit-code 1. Keeps the
1710/// `.harnpack` verify path's error reporting consistent with the rest
1711/// of `harn run`.
1712fn finalize_harnpack_error(
1713    mut stderr: String,
1714    json_session: Option<JsonRunSession>,
1715    summary: Option<&RunSummaryOptions>,
1716    phase: Option<&RunPhaseOptions>,
1717    rusage: Option<&RunRusageOptions>,
1718    started: Instant,
1719    err: HarnpackError,
1720) -> RunOutcome {
1721    let code = err.code;
1722    let message = err.message;
1723    stderr.push_str(&format!("error: {message}\n"));
1724    finalize_run_error(
1725        String::new(),
1726        stderr,
1727        json_session,
1728        summary,
1729        phase,
1730        rusage,
1731        started,
1732        None,
1733        None,
1734        0,
1735        None,
1736        code,
1737        message,
1738    )
1739}
1740
1741/// Successful `--dry-run-verify` path. Reports the bundle hash and
1742/// signature outcome on stderr (since stdout belongs to the script) and
1743/// emits a terminal `result` event when `--json` is active so consumers
1744/// see the run complete.
1745fn finalize_harnpack_dry_run(
1746    mut stderr: String,
1747    json_session: Option<JsonRunSession>,
1748    summary_options: Option<&RunSummaryOptions>,
1749    phase_options: Option<&RunPhaseOptions>,
1750    rusage_options: Option<&RunRusageOptions>,
1751    started: Instant,
1752    cpu_ms_total: Option<u64>,
1753    prepared: &PreparedHarnpack,
1754) -> RunOutcome {
1755    let summary = format!(
1756        "[harn] harnpack verify ok: bundle_hash={}, signature_verified={}, cache_hit={}\n",
1757        prepared.bundle_hash, prepared.signature_verified, prepared.cache_hit
1758    );
1759    stderr.push_str(&summary);
1760    let aux_emission = emit_run_aux_for_exit(
1761        summary_options,
1762        phase_options,
1763        rusage_options,
1764        started,
1765        0,
1766        None,
1767        None,
1768        None,
1769        0,
1770        cpu_ms_total,
1771        json_session.is_some(),
1772        &mut stderr,
1773    );
1774    if let Some(session) = json_session {
1775        if let Some(error) = aux_emission.error {
1776            let mut outcome = session.finalize_error(
1777                "run_aux",
1778                format!("failed to emit auxiliary run JSON: {error}"),
1779                1,
1780            );
1781            outcome.stderr = aux_emission.stderr;
1782            return outcome;
1783        }
1784        let value = serde_json::json!({
1785            "bundle_hash": prepared.bundle_hash,
1786            "signature_verified": prepared.signature_verified,
1787            "key_id": prepared.key_id,
1788            "cache_hit": prepared.cache_hit,
1789            "dry_run_verify": true,
1790        });
1791        let mut outcome = session.finalize_result(value, aux_emission.exit_code);
1792        outcome.stderr = aux_emission.stderr;
1793        return outcome;
1794    }
1795    RunOutcome {
1796        stdout: String::new(),
1797        stderr,
1798        exit_code: aux_emission.exit_code,
1799    }
1800}
1801
1802fn render_return_value_error(value: &harn_vm::VmValue) -> String {
1803    let harn_vm::VmValue::EnumVariant(enum_variant) = value else {
1804        return String::new();
1805    };
1806    if !enum_variant.is_variant("Result", "Err") {
1807        return String::new();
1808    }
1809    let rendered = enum_variant
1810        .fields
1811        .first()
1812        .map(|p| p.display())
1813        .unwrap_or_default();
1814    if rendered.is_empty() {
1815        "error\n".to_string()
1816    } else if rendered.ends_with('\n') {
1817        rendered
1818    } else {
1819        format!("{rendered}\n")
1820    }
1821}
1822
1823pub(crate) fn render_trace_summary() -> String {
1824    use std::fmt::Write;
1825    let entries = harn_vm::llm::take_trace();
1826    if entries.is_empty() {
1827        return String::new();
1828    }
1829    let mut out = String::new();
1830    let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
1831    let mut total_input = 0i64;
1832    let mut total_output = 0i64;
1833    let mut total_ms = 0u64;
1834    for (i, entry) in entries.iter().enumerate() {
1835        let _ = writeln!(
1836            out,
1837            "  #{}: {} | {} in + {} out tokens | {} ms",
1838            i + 1,
1839            entry.model,
1840            entry.input_tokens,
1841            entry.output_tokens,
1842            entry.duration_ms,
1843        );
1844        total_input += entry.input_tokens;
1845        total_output += entry.output_tokens;
1846        total_ms += entry.duration_ms;
1847    }
1848    let total_tokens = total_input + total_output;
1849    // Rough cost estimate using Sonnet 4 pricing ($3/MTok in, $15/MTok out).
1850    let cost = (total_input as f64 * 3.0 + total_output as f64 * 15.0) / 1_000_000.0;
1851    let _ = writeln!(
1852        out,
1853        "  \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, ~${:.4}\x1b[0m",
1854        entries.len(),
1855        if entries.len() == 1 { "" } else { "s" },
1856        total_tokens,
1857        total_input,
1858        total_output,
1859        total_ms,
1860        cost,
1861    );
1862    out
1863}
1864
1865/// Run a .harn file as an MCP server using the script-driven surface.
1866/// The pipeline must call `mcp_tools(registry)` (or the alias
1867/// `mcp_serve(registry)`) so the CLI can expose its tools, and may
1868/// register additional resources/prompts via `mcp_resource(...)` /
1869/// `mcp_resource_template(...)` / `mcp_prompt(...)`.
1870///
1871/// Dispatched into by `harn serve mcp <file>` when the script does not
1872/// define any `pub fn` exports — see `commands::serve::run_mcp_server`.
1873///
1874/// `card_source` — optional `--card` argument. Accepts either a path to
1875/// a JSON file or an inline JSON string. When present, the card is
1876/// embedded in the `initialize` response and exposed as the
1877/// `well-known://mcp-card` resource.
1878pub(crate) async fn run_file_mcp_serve(
1879    path: &str,
1880    card_source: Option<&str>,
1881    mode: RunFileMcpServeMode,
1882) {
1883    let mut diagnostics = String::new();
1884    let Some(LoadedChunk {
1885        source,
1886        chunk,
1887        link_table,
1888    }) = compile_or_load_chunk_for_run(path, &mut diagnostics)
1889    else {
1890        eprint!("{diagnostics}");
1891        process::exit(1);
1892    };
1893    if !diagnostics.is_empty() {
1894        eprint!("{diagnostics}");
1895    }
1896
1897    let mut vm = harn_vm::Vm::new();
1898    vm.set_graph_link_table(link_table);
1899    harn_vm::register_vm_stdlib(&mut vm);
1900    crate::install_default_hostlib(&mut vm);
1901    let source_parent = std::path::Path::new(path)
1902        .parent()
1903        .unwrap_or(std::path::Path::new("."));
1904    let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1905    let store_base = project_root.as_deref().unwrap_or(source_parent);
1906    harn_vm::register_store_builtins(&mut vm, store_base);
1907    harn_vm::register_metadata_builtins(&mut vm, store_base);
1908    let pipeline_name = std::path::Path::new(path)
1909        .file_stem()
1910        .and_then(|s| s.to_str())
1911        .unwrap_or("default");
1912    harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1913    vm.set_source_info(path, &source);
1914    if let Some(ref root) = project_root {
1915        vm.set_project_root(root);
1916    }
1917    // Anchor on the entry script's directory (cwd when the path is a bare
1918    // filename); never leave the resting source dir unset. See
1919    // `entry_source_dir`.
1920    vm.set_source_dir(&entry_source_dir(path));
1921
1922    // Same skill discovery as `harn run` — see comment there.
1923    let loaded = load_skills(&SkillLoaderInputs {
1924        cli_dirs: Vec::new(),
1925        source_path: Some(std::path::PathBuf::from(path)),
1926    });
1927    emit_loader_warnings(&loaded.loader_warnings);
1928    install_skills_global(&mut vm, &loaded);
1929
1930    if let Err(error) =
1931        manifest_runtime::install_manifest_runtime(Path::new(path), &mut vm, false).await
1932    {
1933        eprintln!("error: failed to install {}: {error}", error.label());
1934        process::exit(1);
1935    }
1936
1937    // Re-anchor the entry source dir immediately before executing the entry
1938    // pipeline, so manifest/dependency setup can't leave a leaked source dir
1939    // in place for the pipeline's first `render("@alias/...")`. See the sibling
1940    // `execute_run_inner`.
1941    vm.set_source_dir(&entry_source_dir(path));
1942    let local = tokio::task::LocalSet::new();
1943    local
1944        .run_until(async {
1945            match vm.execute(&chunk).await {
1946                Ok(_) => {}
1947                Err(error) => crate::commands::serve::exit_after_mcp_pipeline_error(&vm, &error),
1948            }
1949
1950            // Pipeline output goes to stderr — stdout is the MCP transport.
1951            let output = vm.output();
1952            if !output.is_empty() {
1953                eprint!("{output}");
1954            }
1955
1956            let registry = match harn_vm::take_mcp_serve_registry() {
1957                Some(r) => r,
1958                None => {
1959                    eprintln!("error: pipeline did not call mcp_serve(registry)");
1960                    eprintln!("hint: call mcp_serve(tools) at the end of your pipeline");
1961                    process::exit(1);
1962                }
1963            };
1964
1965            let tools = match harn_vm::tool_registry_to_mcp_tools(&registry) {
1966                Ok(t) => t,
1967                Err(e) => {
1968                    eprintln!("error: {e}");
1969                    process::exit(1);
1970                }
1971            };
1972
1973            let resources = harn_vm::take_mcp_serve_resources();
1974            let resource_templates = harn_vm::take_mcp_serve_resource_templates();
1975            let prompts = harn_vm::take_mcp_serve_prompts();
1976            let metadata = harn_vm::take_mcp_serve_metadata();
1977
1978            let mut server_name = std::path::Path::new(path)
1979                .file_stem()
1980                .and_then(|s| s.to_str())
1981                .unwrap_or("harn")
1982                .to_string();
1983            if let Some(name) = metadata
1984                .as_ref()
1985                .and_then(|metadata| metadata.name.as_ref())
1986            {
1987                server_name = name.clone();
1988            }
1989
1990            let mut caps = Vec::new();
1991            if !tools.is_empty() {
1992                caps.push(format!(
1993                    "{} tool{}",
1994                    tools.len(),
1995                    if tools.len() == 1 { "" } else { "s" }
1996                ));
1997            }
1998            let total_resources = resources.len() + resource_templates.len();
1999            if total_resources > 0 {
2000                caps.push(format!(
2001                    "{total_resources} resource{}",
2002                    if total_resources == 1 { "" } else { "s" }
2003                ));
2004            }
2005            if !prompts.is_empty() {
2006                caps.push(format!(
2007                    "{} prompt{}",
2008                    prompts.len(),
2009                    if prompts.len() == 1 { "" } else { "s" }
2010                ));
2011            }
2012            eprintln!(
2013                "[harn] serve mcp: serving {} as '{server_name}'",
2014                caps.join(", ")
2015            );
2016
2017            let mut server =
2018                harn_vm::McpServer::new(server_name, tools, resources, resource_templates, prompts);
2019            if let Some(metadata) = metadata {
2020                server = server.with_metadata(metadata);
2021            }
2022            if let Some(source) = card_source {
2023                match resolve_card_source(source) {
2024                    Ok(card) => server = server.with_server_card(card),
2025                    Err(e) => {
2026                        eprintln!("error: --card: {e}");
2027                        process::exit(1);
2028                    }
2029                }
2030            }
2031            match mode {
2032                RunFileMcpServeMode::Stdio => {
2033                    if let Err(e) = server.run(&mut vm).await {
2034                        eprintln!("error: MCP server error: {e}");
2035                        process::exit(1);
2036                    }
2037                }
2038                RunFileMcpServeMode::Http(http) => {
2039                    let RunFileMcpServeHttp {
2040                        options,
2041                        auth_policy,
2042                    } = *http;
2043                    if let Err(e) = crate::commands::serve::run_script_mcp_http_server(
2044                        server,
2045                        vm,
2046                        options,
2047                        auth_policy,
2048                    )
2049                    .await
2050                    {
2051                        eprintln!("error: MCP server error: {e}");
2052                        process::exit(1);
2053                    }
2054                }
2055            }
2056        })
2057        .await;
2058}
2059
2060/// Accept either a path to a JSON file or an inline JSON blob and
2061/// return the parsed `serde_json::Value`. Used by `--card`. Disambiguates
2062/// by peeking at the first non-whitespace character: `{` → inline JSON,
2063/// anything else → path.
2064pub(crate) fn resolve_card_source(source: &str) -> Result<serde_json::Value, String> {
2065    let trimmed = source.trim_start();
2066    if trimmed.starts_with('{') || trimmed.starts_with('[') {
2067        return serde_json::from_str(source).map_err(|e| format!("inline JSON parse error: {e}"));
2068    }
2069    let path = std::path::Path::new(source);
2070    harn_vm::load_server_card_from_path(path).map_err(|e| format!("{e}"))
2071}
2072
2073pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
2074    use notify::{Event, EventKind, RecursiveMode, Watcher};
2075
2076    let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
2077        eprintln!("Error: {e}");
2078        process::exit(1);
2079    });
2080    let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
2081
2082    eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
2083    run_file(
2084        path,
2085        false,
2086        denied_builtins.clone(),
2087        Vec::new(),
2088        CliLlmMockMode::Off,
2089        None,
2090        RunProfileOptions::default(),
2091    )
2092    .await;
2093
2094    let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
2095    let _watcher = {
2096        let tx = tx.clone();
2097        let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
2098            if let Ok(event) = res {
2099                if matches!(
2100                    event.kind,
2101                    EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
2102                ) {
2103                    let has_harn = event
2104                        .paths
2105                        .iter()
2106                        .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
2107                    if has_harn {
2108                        let _ = tx.blocking_send(());
2109                    }
2110                }
2111            }
2112        })
2113        .unwrap_or_else(|e| {
2114            eprintln!("Error setting up file watcher: {e}");
2115            process::exit(1);
2116        });
2117        watcher
2118            .watch(watch_dir, RecursiveMode::Recursive)
2119            .unwrap_or_else(|e| {
2120                eprintln!("Error watching directory: {e}");
2121                process::exit(1);
2122            });
2123        watcher // keep alive
2124    };
2125
2126    eprintln!(
2127        "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
2128        watch_dir.display()
2129    );
2130
2131    loop {
2132        rx.recv().await;
2133        // Debounce: let bursts of events settle for 200ms before re-running.
2134        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2135        while rx.try_recv().is_ok() {}
2136
2137        eprintln!();
2138        eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
2139        run_file(
2140            path,
2141            false,
2142            denied_builtins.clone(),
2143            Vec::new(),
2144            CliLlmMockMode::Off,
2145            None,
2146            RunProfileOptions::default(),
2147        )
2148        .await;
2149    }
2150}
2151
2152#[cfg(test)]
2153mod tests;