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