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