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::parse_source_file;
17use crate::skill_loader::{
18 canonicalize_cli_dirs, emit_loader_warnings, install_skills_global, load_skills,
19 SkillLoaderInputs,
20};
21
22mod explain_cost;
23pub mod harnpack;
24mod interrupts;
25pub mod json_events;
26mod lifecycle;
27mod manifest_runtime;
28
29use self::harnpack::{HarnpackError, HarnpackRunOptions, PreparedHarnpack};
30use self::interrupts::{
31 install_signal_shutdown_handler, start_run_deadline_watchdog, RunDeadlineGuard,
32};
33use self::json_events::NdjsonEmitter;
34pub use self::lifecycle::RunProfileOptions;
35use self::lifecycle::{RunExecution, TerminalRun};
36pub(crate) use self::manifest_runtime::connect_mcp_servers;
37
38#[derive(Clone, Default)]
40pub struct RunJsonOptions {
41 pub quiet: bool,
44}
45
46#[derive(Clone, Debug)]
48pub struct RunSummaryOptions {
49 pub sink: RunJsonSink,
50}
51
52#[derive(Clone, Debug)]
53pub struct RunPhaseOptions {
54 pub sink: RunJsonSink,
55}
56
57#[derive(Clone, Debug)]
58pub struct RunRusageOptions {
59 pub sink: RunJsonSink,
60}
61
62#[derive(Clone, Debug, Default)]
63pub struct RunAuxOptions {
64 pub summary: Option<RunSummaryOptions>,
65 pub phase: Option<RunPhaseOptions>,
66 pub rusage: Option<RunRusageOptions>,
67}
68
69#[derive(Clone, Debug, Default)]
70pub struct RunControlOptions {
71 pub timeout: Option<Duration>,
72}
73
74#[derive(Clone, Debug)]
75pub struct RunJsonSink {
76 pub target: RunJsonSinkTarget,
77 pub fd_flag: &'static str,
78}
79
80#[derive(Clone, Debug)]
81pub enum RunJsonSinkTarget {
82 Stderr,
86 File(PathBuf),
87 Fd(i32),
88}
89
90#[derive(Serialize)]
91struct RunSummary<'a> {
92 schema_version: u32,
93 event: &'static str,
94 wall_time_ms: u64,
95 exit_code: i32,
96 llm: RunSummaryLlm,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 profile: Option<&'a harn_vm::profile::RunProfile>,
99}
100
101#[derive(Serialize)]
102struct RunSummaryLlm {
103 call_count: i64,
104 input_tokens: i64,
105 output_tokens: i64,
106 time_ms: i64,
107 cost_usd: f64,
108}
109
110pub const RUN_SUMMARY_SCHEMA_VERSION: u32 = 1;
111pub const RUN_PHASE_SCHEMA_VERSION: u32 = 2;
112pub const RUN_RUSAGE_SCHEMA_VERSION: u32 = 1;
113
114#[derive(Serialize)]
115struct RunPhaseEvent {
116 schema_version: u32,
117 event: &'static str,
118 phases: Vec<PhaseRecord>,
119}
120
121#[derive(Serialize)]
122struct RunRusageEvent {
123 schema_version: u32,
124 event: &'static str,
125 cpu_ms: u64,
126}
127
128pub(crate) fn run_summary_options_from_args(
129 args: &crate::cli::RunArgs,
130) -> Option<RunSummaryOptions> {
131 args.emit_summary_json.then(|| RunSummaryOptions {
132 sink: build_run_json_sink(args.summary_file.clone(), args.summary_fd, "--summary-fd"),
133 })
134}
135
136pub(crate) fn run_aux_options_from_args(args: &crate::cli::RunArgs) -> RunAuxOptions {
137 RunAuxOptions {
138 summary: run_summary_options_from_args(args),
139 phase: run_phase_options_from_args(args),
140 rusage: run_rusage_options_from_args(args),
141 }
142}
143
144pub(crate) fn run_control_options_from_args(args: &crate::cli::RunArgs) -> RunControlOptions {
145 RunControlOptions {
146 timeout: args.timeout,
147 }
148}
149
150pub(crate) fn run_phase_options_from_args(args: &crate::cli::RunArgs) -> Option<RunPhaseOptions> {
151 args.emit_phase_json.then(|| RunPhaseOptions {
152 sink: build_run_json_sink(args.phase_file.clone(), args.phase_fd, "--phase-fd"),
153 })
154}
155
156pub(crate) fn run_rusage_options_from_args(args: &crate::cli::RunArgs) -> Option<RunRusageOptions> {
157 args.emit_rusage_json.then(|| RunRusageOptions {
158 sink: build_run_json_sink(args.rusage_file.clone(), args.rusage_fd, "--rusage-fd"),
159 })
160}
161
162fn build_run_json_sink(
163 file: Option<PathBuf>,
164 fd: Option<i32>,
165 fd_flag: &'static str,
166) -> RunJsonSink {
167 RunJsonSink {
168 target: if let Some(path) = file {
169 RunJsonSinkTarget::File(path)
170 } else if let Some(fd) = fd {
171 RunJsonSinkTarget::Fd(fd)
172 } else {
173 RunJsonSinkTarget::Stderr
174 },
175 fd_flag,
176 }
177}
178
179pub(crate) enum RunFileMcpServeMode {
180 Stdio,
181 Http(Box<RunFileMcpServeHttp>),
182}
183
184pub(crate) struct RunFileMcpServeHttp {
185 pub options: harn_serve::McpHttpServeOptions,
186 pub auth_policy: harn_serve::AuthPolicy,
187}
188
189const CORE_BUILTINS: &[&str] = &[
191 "println",
192 "print",
193 "log",
194 "type_of",
195 "to_string",
196 "to_int",
197 "to_float",
198 "len",
199 "assert",
200 "assert_eq",
201 "assert_ne",
202 "json_parse",
203 "json_stringify",
204 "runtime_context",
205 "task_current",
206 "runtime_context_values",
207 "runtime_context_get",
208 "runtime_context_set",
209 "runtime_context_clear",
210];
211
212pub(crate) fn build_denied_builtins(
217 deny_csv: Option<&str>,
218 allow_csv: Option<&str>,
219) -> HashSet<String> {
220 if let Some(csv) = deny_csv {
221 csv.split(',')
222 .map(|s| s.trim().to_string())
223 .filter(|s| !s.is_empty())
224 .collect()
225 } else if let Some(csv) = allow_csv {
226 let allowed: HashSet<String> = csv
229 .split(',')
230 .map(|s| s.trim().to_string())
231 .filter(|s| !s.is_empty())
232 .collect();
233 let core: HashSet<&str> = CORE_BUILTINS.iter().copied().collect();
234
235 let mut tmp = harn_vm::Vm::new();
237 harn_vm::register_vm_stdlib(&mut tmp);
238 harn_vm::register_store_builtins(&mut tmp, std::path::Path::new("."));
239 harn_vm::register_metadata_builtins(&mut tmp, std::path::Path::new("."));
240
241 tmp.builtin_names()
242 .into_iter()
243 .filter(|name| !allowed.contains(name) && !core.contains(name.as_str()))
244 .collect()
245 } else {
246 HashSet::new()
247 }
248}
249
250pub(crate) struct LoadedChunk {
254 pub(crate) source: String,
255 pub(crate) chunk: harn_vm::Chunk,
256}
257
258pub(crate) fn compile_or_load_chunk_for_run(
270 path: &str,
271 stderr: &mut String,
272) -> Option<LoadedChunk> {
273 compile_or_load_chunk_with_timing(path, stderr, None)
274}
275
276#[allow(clippy::needless_option_as_deref)]
286pub(crate) fn compile_or_load_chunk_with_timing(
287 path: &str,
288 stderr: &mut String,
289 mut timing: Option<&mut RunTiming>,
290) -> Option<LoadedChunk> {
291 let source = match fs::read_to_string(path) {
292 Ok(s) => s,
293 Err(e) => {
294 stderr.push_str(&format!("Error reading {path}: {e}\n"));
295 return None;
296 }
297 };
298 if let Some(t) = timing.as_deref_mut() {
299 t.input_bytes = source.len() as u64;
300 }
301
302 let compile_phase_start = Instant::now();
303 let lookup = harn_vm::bytecode_cache::load(Path::new(path), &source);
304 if let Some(chunk) = lookup.chunk {
305 if let Some(t) = timing.as_deref_mut() {
306 t.cache_hit = true;
307 t.bytecode_compile = compile_phase_start.elapsed();
308 }
309 return Some(LoadedChunk { source, chunk });
310 }
311 if let Some(t) = timing.as_deref_mut() {
312 t.cache_hit = false;
313 }
314
315 let parse_start = Instant::now();
316 let program = parse_source_for_run(path, &source, stderr)?;
317 if let Some(t) = timing.as_deref_mut() {
318 t.parse = parse_start.elapsed();
319 }
320
321 let typecheck_start = Instant::now();
322 let mut had_type_error = false;
323 let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
324 for diag in &type_diagnostics {
325 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
326 if matches!(diag.severity, DiagnosticSeverity::Error) {
327 had_type_error = true;
328 }
329 stderr.push_str(&rendered);
330 }
331 if let Some(t) = timing.as_deref_mut() {
332 t.typecheck = typecheck_start.elapsed();
333 }
334 if had_type_error {
335 return None;
336 }
337
338 let compile_step_start = Instant::now();
339 let chunk = match harn_vm::Compiler::new().compile(&program) {
340 Ok(c) => c,
341 Err(e) => {
342 stderr.push_str(&format!("error: compile error: {e}\n"));
343 return None;
344 }
345 };
346
347 if let Err(err) = harn_vm::bytecode_cache::store(&lookup.key, &chunk) {
352 if std::env::var_os(crate::dispatch::CACHE_DEBUG_ENV).is_some() {
353 eprintln!("[harn] bytecode cache write skipped: {err}");
354 }
355 }
356 if let Some(t) = timing.as_deref_mut() {
357 t.bytecode_compile = compile_step_start.elapsed();
358 }
359
360 Some(LoadedChunk { source, chunk })
361}
362
363fn parse_source_for_run(
364 path: &str,
365 source: &str,
366 stderr: &mut String,
367) -> Option<Vec<harn_parser::SNode>> {
368 crate::ensure_builtin_signatures_installed();
369
370 let mut lexer = harn_lexer::Lexer::new(source);
371 let tokens = match lexer.tokenize() {
372 Ok(tokens) => tokens,
373 Err(error) => {
374 let diagnostic = harn_parser::diagnostic::render_diagnostic_with_code(
375 source,
376 path,
377 &error_span_from_lex(&error),
378 "error",
379 harn_parser::diagnostic::lexer_error_code(&error),
380 &error.to_string(),
381 Some("here"),
382 None,
383 );
384 stderr.push_str(&diagnostic);
385 return None;
386 }
387 };
388
389 let mut parser = harn_parser::Parser::new(tokens);
390 match parser.parse() {
391 Ok(program) => Some(program),
392 Err(error) => {
393 if parser.all_errors().is_empty() {
394 render_parse_error(path, source, &error, stderr);
395 } else {
396 for error in parser.all_errors() {
397 render_parse_error(path, source, error, stderr);
398 }
399 }
400 None
401 }
402 }
403}
404
405fn render_parse_error(
406 path: &str,
407 source: &str,
408 error: &harn_parser::ParserError,
409 stderr: &mut String,
410) {
411 let span = error_span_from_parse(error);
412 let diagnostic = harn_parser::diagnostic::render_diagnostic_with_code(
413 source,
414 path,
415 &span,
416 "error",
417 harn_parser::diagnostic::parser_error_code(error),
418 &harn_parser::diagnostic::parser_error_message(error),
419 Some(harn_parser::diagnostic::parser_error_label(error)),
420 harn_parser::diagnostic::parser_error_help(error),
421 );
422 stderr.push_str(&diagnostic);
423}
424
425fn error_span_from_lex(error: &harn_lexer::LexerError) -> harn_lexer::Span {
426 match error {
427 harn_lexer::LexerError::UnexpectedCharacter(_, span)
428 | harn_lexer::LexerError::UnterminatedString(span)
429 | harn_lexer::LexerError::IntegerLiteralOutOfRange(_, span)
430 | harn_lexer::LexerError::UnterminatedBlockComment(span) => *span,
431 }
432}
433
434fn error_span_from_parse(error: &harn_parser::ParserError) -> harn_lexer::Span {
435 match error {
436 harn_parser::ParserError::Unexpected { span, .. } => *span,
437 harn_parser::ParserError::UnexpectedEof { span, .. } => *span,
438 }
439}
440
441fn typecheck_with_imports(
446 program: &[harn_parser::SNode],
447 path: &Path,
448 source: &str,
449) -> Vec<harn_parser::TypeDiagnostic> {
450 if let Err(error) = package::ensure_dependencies_materialized(path) {
451 eprintln!("error: {error}");
452 process::exit(1);
453 }
454 let checker = crate::typecheck_imports::checker_with_resolved_imports(
455 harn_parser::TypeChecker::new(),
456 path,
457 );
458 checker.check_with_source(program, source)
459}
460
461pub(crate) fn prepare_eval_temp_file(
472 code: &str,
473) -> Result<(String, tempfile::NamedTempFile), String> {
474 let wrapped = eval_source_for_code(code);
475 let tmp = create_eval_temp_file()?;
476 Ok((wrapped, tmp))
477}
478
479fn eval_source_for_code(code: &str) -> String {
480 if eval_code_parses_as_program(code) {
481 return code.to_string();
482 }
483 let (header, body) = split_eval_header(code);
484 if header.is_empty() {
485 format!("pipeline main(task) {{\n{body}\n}}")
486 } else {
487 format!("{header}\npipeline main(task) {{\n{body}\n}}")
488 }
489}
490
491fn eval_code_parses_as_program(code: &str) -> bool {
492 harn_parser::parse_source(code)
493 .map(|program| {
494 program.iter().any(|node| {
495 let (_, inner) = harn_parser::peel_attributes(node);
496 matches!(&inner.node, harn_parser::Node::Pipeline { .. })
497 })
498 })
499 .unwrap_or(false)
500}
501
502fn create_eval_temp_file() -> Result<tempfile::NamedTempFile, String> {
507 if let Some(dir) = std::env::current_dir().ok().as_deref() {
508 match tempfile::Builder::new()
511 .prefix(".harn-eval-")
512 .suffix(".harn")
513 .tempfile_in(dir)
514 {
515 Ok(tmp) => return Ok(tmp),
516 Err(error) => eprintln!(
517 "warning: harn run -e: could not create temp file in {}: {error}; \
518 relative imports will not resolve",
519 dir.display()
520 ),
521 }
522 }
523 tempfile::Builder::new()
524 .prefix("harn-eval-")
525 .suffix(".harn")
526 .tempfile()
527 .map_err(|e| format!("failed to create temp file for -e: {e}"))
528}
529
530fn split_eval_header(code: &str) -> (String, String) {
538 let mut header_end = 0usize;
539 let mut last_kept = 0usize;
540 for (idx, line) in code.lines().enumerate() {
541 let trimmed = line.trim_start();
542 if trimmed.is_empty() || trimmed.starts_with("//") {
543 header_end = idx + 1;
544 continue;
545 }
546 let is_import = trimmed.starts_with("import ")
547 || trimmed.starts_with("import\t")
548 || trimmed.starts_with("import\"")
549 || trimmed.starts_with("pub import ")
550 || trimmed.starts_with("pub import\t");
551 if is_import {
552 header_end = idx + 1;
553 last_kept = idx + 1;
554 } else {
555 break;
556 }
557 }
558 if last_kept == 0 {
559 return (String::new(), code.to_string());
560 }
561 let mut header_lines: Vec<&str> = Vec::new();
562 let mut body_lines: Vec<&str> = Vec::new();
563 for (idx, line) in code.lines().enumerate() {
564 if idx < header_end {
565 header_lines.push(line);
566 } else {
567 body_lines.push(line);
568 }
569 }
570 (header_lines.join("\n"), body_lines.join("\n"))
571}
572
573#[derive(Clone, Debug, Default, PartialEq, Eq)]
574pub enum CliLlmMockMode {
575 #[default]
576 Off,
577 Replay {
578 fixture_path: PathBuf,
579 },
580 Record {
581 fixture_path: PathBuf,
582 },
583}
584
585#[derive(Clone, Debug, Default, PartialEq, Eq)]
586pub struct RunAttestationOptions {
587 pub receipt_out: Option<PathBuf>,
588 pub agent_id: Option<String>,
589}
590
591#[derive(Clone, Debug, PartialEq, Eq)]
592pub struct RunSandboxOptions {
593 pub enabled: bool,
595 pub workspace_root: Option<PathBuf>,
599 pub write_roots: Vec<PathBuf>,
603 pub read_only_roots: Vec<PathBuf>,
606}
607
608impl Default for RunSandboxOptions {
609 fn default() -> Self {
610 Self {
611 enabled: true,
612 workspace_root: None,
613 write_roots: Vec::new(),
614 read_only_roots: Vec::new(),
615 }
616 }
617}
618
619impl RunSandboxOptions {
620 pub fn disabled() -> Self {
622 Self {
623 enabled: false,
624 workspace_root: None,
625 write_roots: Vec::new(),
626 read_only_roots: Vec::new(),
627 }
628 }
629
630 pub fn with_workspace_root(mut self, workspace_root: impl Into<PathBuf>) -> Self {
632 self.workspace_root = Some(workspace_root.into());
633 self
634 }
635
636 pub fn with_write_roots<I>(mut self, write_roots: I) -> Self
638 where
639 I: IntoIterator<Item = PathBuf>,
640 {
641 self.write_roots = write_roots.into_iter().collect();
642 self
643 }
644
645 pub fn with_read_only_roots<I>(mut self, read_only_roots: I) -> Self
647 where
648 I: IntoIterator<Item = PathBuf>,
649 {
650 self.read_only_roots = read_only_roots.into_iter().collect();
651 self
652 }
653}
654
655#[derive(Clone)]
656pub struct RunInterruptTokens {
657 pub cancel_token: Arc<AtomicBool>,
658 pub signal_token: Arc<Mutex<Option<String>>>,
659}
660
661struct ExecuteRunInputs<'a> {
662 path: &'a str,
663 trace: bool,
664 denied_builtins: HashSet<String>,
665 script_argv: Vec<String>,
666 skill_dirs_raw: Vec<String>,
667 llm_mock_mode: CliLlmMockMode,
668 attestation: Option<RunAttestationOptions>,
669 profile: RunProfileOptions,
670 sandbox: RunSandboxOptions,
671 interrupt_tokens: Option<RunInterruptTokens>,
672 json: Option<(RunJsonOptions, Box<dyn io::Write + Send>)>,
673 aux: RunAuxOptions,
674 timing: Option<&'a mut RunTiming>,
675 harnpack: HarnpackRunOptions,
676}
677
678#[derive(Clone, Debug, Default)]
682pub struct RunOutcome {
683 pub stdout: String,
684 pub stderr: String,
685 pub exit_code: i32,
686}
687
688pub fn install_cli_llm_mock_mode(mode: &CliLlmMockMode) -> Result<(), String> {
689 harn_vm::llm::clear_cli_llm_mock_mode();
690 match mode {
691 CliLlmMockMode::Off => Ok(()),
692 CliLlmMockMode::Replay { fixture_path } => {
693 let fixture = harn_vm::llm::load_llm_mocks_jsonl(fixture_path)?;
694 harn_vm::llm::install_cli_llm_mock_fixture(fixture);
695 Ok(())
696 }
697 CliLlmMockMode::Record { .. } => {
698 harn_vm::llm::enable_cli_llm_mock_recording();
699 Ok(())
700 }
701 }
702}
703
704pub fn persist_cli_llm_mock_recording(mode: &CliLlmMockMode) -> Result<(), String> {
705 let CliLlmMockMode::Record { fixture_path } = mode else {
706 harn_vm::llm::clear_cli_llm_mock_mode();
707 return Ok(());
708 };
709 if let Some(parent) = fixture_path.parent() {
710 if !parent.as_os_str().is_empty() {
711 fs::create_dir_all(parent).map_err(|error| {
712 format!(
713 "failed to create fixture directory {}: {error}",
714 parent.display()
715 )
716 })?;
717 }
718 }
719
720 let lines = harn_vm::llm::take_cli_llm_recordings()
721 .into_iter()
722 .map(harn_vm::llm::serialize_llm_mock)
723 .collect::<Result<Vec<_>, _>>()?;
724 let body = if lines.is_empty() {
725 String::new()
726 } else {
727 format!("{}\n", lines.join("\n"))
728 };
729 let result = fs::write(fixture_path, body)
730 .map_err(|error| format!("failed to write {}: {error}", fixture_path.display()));
731 harn_vm::llm::clear_cli_llm_mock_mode();
732 result
733}
734
735pub(crate) async fn run_file(
736 path: &str,
737 trace: bool,
738 denied_builtins: HashSet<String>,
739 script_argv: Vec<String>,
740 llm_mock_mode: CliLlmMockMode,
741 attestation: Option<RunAttestationOptions>,
742 profile: RunProfileOptions,
743) {
744 run_file_with_skill_dirs(
745 path,
746 trace,
747 denied_builtins,
748 script_argv,
749 Vec::new(),
750 llm_mock_mode,
751 attestation,
752 profile,
753 RunSandboxOptions::default(),
754 None,
755 RunAuxOptions::default(),
756 RunControlOptions::default(),
757 HarnpackRunOptions::default(),
758 )
759 .await;
760}
761
762pub(crate) fn run_explain_cost_file_with_skill_dirs(path: &str) {
763 let outcome = execute_explain_cost(path);
764 if !outcome.stderr.is_empty() {
765 io::stderr().write_all(outcome.stderr.as_bytes()).ok();
766 }
767 if !outcome.stdout.is_empty() {
768 io::stdout().write_all(outcome.stdout.as_bytes()).ok();
769 }
770 if outcome.exit_code != 0 {
771 process::exit(outcome.exit_code);
772 }
773}
774
775#[allow(clippy::too_many_arguments)]
776pub(crate) async fn run_file_with_skill_dirs(
777 path: &str,
778 trace: bool,
779 denied_builtins: HashSet<String>,
780 script_argv: Vec<String>,
781 skill_dirs_raw: Vec<String>,
782 llm_mock_mode: CliLlmMockMode,
783 attestation: Option<RunAttestationOptions>,
784 profile: RunProfileOptions,
785 sandbox: RunSandboxOptions,
786 json: Option<RunJsonOptions>,
787 aux: RunAuxOptions,
788 control: RunControlOptions,
789 harnpack: HarnpackRunOptions,
790) {
791 let interrupt_tokens = install_signal_shutdown_handler();
793 let deadline_guard = control
794 .timeout
795 .map(|timeout| start_run_deadline_watchdog(timeout, interrupt_tokens.clone()));
796
797 let _stdout_passthrough = StdoutPassthroughGuard::enable();
798 let json_with_stdout =
799 json.map(|opts| (opts, Box::new(io::stdout()) as Box<dyn io::Write + Send>));
800 let outcome = execute_run_inner(ExecuteRunInputs {
801 path,
802 trace,
803 denied_builtins,
804 script_argv,
805 skill_dirs_raw,
806 llm_mock_mode,
807 attestation,
808 profile,
809 sandbox,
810 interrupt_tokens: Some(interrupt_tokens.clone()),
811 json: json_with_stdout,
812 aux,
813 timing: None,
814 harnpack,
815 })
816 .await;
817 if let Some(guard) = &deadline_guard {
818 guard.finish();
819 }
820
821 if !outcome.stderr.is_empty() {
824 io::stderr().write_all(outcome.stderr.as_bytes()).ok();
825 }
826 if !outcome.stdout.is_empty() {
827 io::stdout().write_all(outcome.stdout.as_bytes()).ok();
828 }
829
830 let mut exit_code = outcome.exit_code;
831 if deadline_guard
832 .as_ref()
833 .is_some_and(RunDeadlineGuard::timed_out)
834 || (exit_code != 0 && interrupt_tokens.cancel_token.load(Ordering::SeqCst))
835 {
836 exit_code = 124;
837 }
838 if exit_code != 0 {
839 process::exit(exit_code);
840 }
841}
842
843#[allow(clippy::too_many_arguments)]
844pub(crate) async fn run_resume_with_skill_dirs(
845 target: &str,
846 trace: bool,
847 denied_builtins: HashSet<String>,
848 resume_argv: Vec<String>,
849 skill_dirs_raw: Vec<String>,
850 llm_mock_mode: CliLlmMockMode,
851 attestation: Option<RunAttestationOptions>,
852 profile: RunProfileOptions,
853 sandbox: RunSandboxOptions,
854 json: Option<RunJsonOptions>,
855 aux: RunAuxOptions,
856 control: RunControlOptions,
857) {
858 let source = r#"import { resume_agent, wait_agent } from "std/agent/workers"
859
860pipeline main(task) {
861 const input = if len(argv) > 1 {
862 argv[1]
863 } else {
864 nil
865 }
866 const handle = resume_agent(argv[0], input, true)
867 return wait_agent(handle)
868}
869"#;
870 let tmp = create_eval_temp_file().unwrap_or_else(|e| {
871 eprintln!("error: {e}");
872 process::exit(1);
873 });
874 let tmp_path = tmp.path().to_path_buf();
875 if let Err(error) = fs::write(&tmp_path, source) {
876 eprintln!("error: failed to write temp file for --resume: {error}");
877 process::exit(1);
878 }
879 let mut argv = Vec::with_capacity(resume_argv.len() + 1);
880 argv.push(target.to_string());
881 argv.extend(resume_argv);
882 let tmp_str = tmp_path.to_string_lossy().into_owned();
883 run_file_with_skill_dirs(
884 &tmp_str,
885 trace,
886 denied_builtins,
887 argv,
888 skill_dirs_raw,
889 llm_mock_mode,
890 attestation,
891 profile,
892 sandbox,
893 json,
894 aux,
895 control,
896 HarnpackRunOptions::default(),
897 )
898 .await;
899}
900
901pub fn execute_explain_cost(path: &str) -> RunOutcome {
902 let stdout = String::new();
903 let mut stderr = String::new();
904
905 let (source, program) = parse_source_file(path);
906
907 let mut had_type_error = false;
908 let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
909 for diag in &type_diagnostics {
910 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
911 if matches!(diag.severity, DiagnosticSeverity::Error) {
912 had_type_error = true;
913 }
914 stderr.push_str(&rendered);
915 }
916 if had_type_error {
917 return RunOutcome {
918 stdout,
919 stderr,
920 exit_code: 1,
921 };
922 }
923
924 let extensions = package::load_runtime_extensions(Path::new(path));
925 package::install_runtime_extensions(&extensions);
926 RunOutcome {
927 stdout: explain_cost::render_explain_cost(path, &program),
928 stderr,
929 exit_code: 0,
930 }
931}
932
933pub(crate) struct StdoutPassthroughGuard {
934 previous: bool,
935}
936
937impl StdoutPassthroughGuard {
938 pub(crate) fn enable() -> Self {
939 Self {
940 previous: harn_vm::set_stdout_passthrough(true),
941 }
942 }
943}
944
945impl Drop for StdoutPassthroughGuard {
946 fn drop(&mut self) {
947 harn_vm::set_stdout_passthrough(self.previous);
948 }
949}
950
951struct ExecutionPolicyGuard;
952
953impl Drop for ExecutionPolicyGuard {
954 fn drop(&mut self) {
955 harn_vm::orchestration::pop_execution_policy();
956 }
957}
958
959struct RunSandboxScope {
960 _execution_policy: Option<ExecutionPolicyGuard>,
961 _egress_policy: Option<harn_vm::egress::ExplicitEgressPolicyGuard>,
962 _ssrf_guard: Option<harn_vm::egress::SsrfGuardScope>,
963}
964
965impl RunSandboxScope {
966 fn disabled() -> Self {
967 Self {
968 _execution_policy: None,
969 _egress_policy: None,
970 _ssrf_guard: None,
971 }
972 }
973}
974
975fn install_run_sandbox_scope(
976 options: &RunSandboxOptions,
977 workspace_root: &Path,
978 stderr: &mut String,
979) -> RunSandboxScope {
980 if !options.enabled {
981 stderr.push_str(
982 "warning: harn run --no-sandbox disables filesystem, process, and egress sandbox defaults\n",
983 );
984 return RunSandboxScope::disabled();
985 }
986
987 let execution_policy = if harn_vm::orchestration::current_execution_policy().is_none() {
988 harn_vm::orchestration::push_execution_policy(default_run_capability_policy(
989 workspace_root,
990 &options.write_roots,
991 &options.read_only_roots,
992 ));
993 Some(ExecutionPolicyGuard)
994 } else {
995 None
996 };
997 let egress_policy = Some(harn_vm::egress::require_explicit_egress_policy_for_host());
998 let ssrf_guard = Some(harn_vm::egress::require_ssrf_guard_for_host());
1002
1003 RunSandboxScope {
1004 _execution_policy: execution_policy,
1005 _egress_policy: egress_policy,
1006 _ssrf_guard: ssrf_guard,
1007 }
1008}
1009
1010fn default_run_capability_policy(
1011 workspace_root: &Path,
1012 write_roots: &[PathBuf],
1013 read_only_roots: &[PathBuf],
1014) -> harn_vm::orchestration::CapabilityPolicy {
1015 let mut workspace_roots = Vec::with_capacity(1 + write_roots.len());
1016 workspace_roots.push(
1017 normalize_run_workspace_root(workspace_root)
1018 .display()
1019 .to_string(),
1020 );
1021 workspace_roots.extend(
1022 write_roots
1023 .iter()
1024 .map(|path| normalize_run_workspace_root(path.as_path()))
1025 .map(|path| path.display().to_string()),
1026 );
1027
1028 harn_vm::orchestration::CapabilityPolicy {
1029 workspace_roots,
1030 read_only_roots: read_only_roots
1031 .iter()
1032 .map(|path| normalize_run_workspace_root(path.as_path()))
1033 .map(|path| path.display().to_string())
1034 .collect(),
1035 side_effect_level: Some("process_exec".to_string()),
1036 sandbox_profile: harn_vm::orchestration::SandboxProfile::Worktree,
1037 ..harn_vm::orchestration::CapabilityPolicy::default()
1038 }
1039}
1040
1041fn normalize_run_workspace_root(path: &Path) -> PathBuf {
1042 if path.is_absolute() {
1043 return path.to_path_buf();
1044 }
1045 std::env::current_dir()
1046 .map(|cwd| cwd.join(path))
1047 .unwrap_or_else(|_| path.to_path_buf())
1048}
1049
1050fn default_run_workspace_root(project_root: Option<&Path>, source_parent: &Path) -> PathBuf {
1051 project_root
1052 .map(Path::to_path_buf)
1053 .or_else(|| std::env::current_dir().ok())
1054 .unwrap_or_else(|| source_parent.to_path_buf())
1055}
1056
1057fn run_sandbox_attestation(sandbox: &RunSandboxOptions) -> serde_json::Value {
1058 let active_policy = harn_vm::orchestration::current_execution_policy();
1059 let active = active_policy.is_some();
1060 let workspace_roots = active_policy
1061 .as_ref()
1062 .map(|policy| policy.workspace_roots.clone())
1063 .unwrap_or_default();
1064 let read_only_roots = active_policy
1065 .as_ref()
1066 .map(|policy| policy.read_only_roots.clone())
1067 .unwrap_or_default();
1068 let profile = active_policy
1069 .as_ref()
1070 .map(|policy| policy.sandbox_profile.as_str())
1071 .unwrap_or("unrestricted");
1072 let egress = if sandbox.enabled {
1073 "explicit_policy_required"
1074 } else if active {
1075 "host_policy"
1076 } else {
1077 "unrestricted"
1078 };
1079 let write_roots = sandbox
1080 .write_roots
1081 .iter()
1082 .map(|path| normalize_run_workspace_root(path).display().to_string())
1083 .collect::<Vec<_>>();
1084
1085 serde_json::json!({
1086 "run_default_enabled": sandbox.enabled,
1087 "active": active,
1088 "workspace_roots": workspace_roots,
1089 "write_roots": write_roots,
1090 "read_only_roots": read_only_roots,
1091 "profile": profile,
1092 "egress": egress,
1093 })
1094}
1095
1096pub async fn execute_run(
1110 path: &str,
1111 trace: bool,
1112 denied_builtins: HashSet<String>,
1113 script_argv: Vec<String>,
1114 skill_dirs_raw: Vec<String>,
1115 llm_mock_mode: CliLlmMockMode,
1116 attestation: Option<RunAttestationOptions>,
1117 profile: RunProfileOptions,
1118) -> RunOutcome {
1119 crate::ensure_builtin_signatures_installed();
1120 execute_run_with_harnpack_and_sandbox_options(
1121 path,
1122 trace,
1123 denied_builtins,
1124 script_argv,
1125 skill_dirs_raw,
1126 llm_mock_mode,
1127 attestation,
1128 profile,
1129 RunSandboxOptions::default(),
1130 HarnpackRunOptions::default(),
1131 )
1132 .await
1133}
1134
1135#[allow(clippy::too_many_arguments)]
1139pub async fn execute_run_with_sandbox_options(
1140 path: &str,
1141 trace: bool,
1142 denied_builtins: HashSet<String>,
1143 script_argv: Vec<String>,
1144 skill_dirs_raw: Vec<String>,
1145 llm_mock_mode: CliLlmMockMode,
1146 attestation: Option<RunAttestationOptions>,
1147 profile: RunProfileOptions,
1148 sandbox: RunSandboxOptions,
1149) -> RunOutcome {
1150 execute_run_with_harnpack_and_sandbox_options(
1151 path,
1152 trace,
1153 denied_builtins,
1154 script_argv,
1155 skill_dirs_raw,
1156 llm_mock_mode,
1157 attestation,
1158 profile,
1159 sandbox,
1160 HarnpackRunOptions::default(),
1161 )
1162 .await
1163}
1164
1165#[allow(clippy::too_many_arguments)]
1170pub async fn execute_run_with_harnpack_options(
1171 path: &str,
1172 trace: bool,
1173 denied_builtins: HashSet<String>,
1174 script_argv: Vec<String>,
1175 skill_dirs_raw: Vec<String>,
1176 llm_mock_mode: CliLlmMockMode,
1177 attestation: Option<RunAttestationOptions>,
1178 profile: RunProfileOptions,
1179 harnpack: HarnpackRunOptions,
1180) -> RunOutcome {
1181 execute_run_with_harnpack_and_sandbox_options(
1182 path,
1183 trace,
1184 denied_builtins,
1185 script_argv,
1186 skill_dirs_raw,
1187 llm_mock_mode,
1188 attestation,
1189 profile,
1190 RunSandboxOptions::default(),
1191 harnpack,
1192 )
1193 .await
1194}
1195
1196#[allow(clippy::too_many_arguments)]
1197async fn execute_run_with_harnpack_and_sandbox_options(
1198 path: &str,
1199 trace: bool,
1200 denied_builtins: HashSet<String>,
1201 script_argv: Vec<String>,
1202 skill_dirs_raw: Vec<String>,
1203 llm_mock_mode: CliLlmMockMode,
1204 attestation: Option<RunAttestationOptions>,
1205 profile: RunProfileOptions,
1206 sandbox: RunSandboxOptions,
1207 harnpack: HarnpackRunOptions,
1208) -> RunOutcome {
1209 execute_run_inner(ExecuteRunInputs {
1210 path,
1211 trace,
1212 denied_builtins,
1213 script_argv,
1214 skill_dirs_raw,
1215 llm_mock_mode,
1216 attestation,
1217 profile,
1218 sandbox,
1219 interrupt_tokens: None,
1220 json: None,
1221 aux: RunAuxOptions::default(),
1222 timing: None,
1223 harnpack,
1224 })
1225 .await
1226}
1227
1228#[allow(clippy::too_many_arguments)]
1234pub async fn execute_run_json(
1235 path: &str,
1236 trace: bool,
1237 denied_builtins: HashSet<String>,
1238 script_argv: Vec<String>,
1239 skill_dirs_raw: Vec<String>,
1240 llm_mock_mode: CliLlmMockMode,
1241 attestation: Option<RunAttestationOptions>,
1242 profile: RunProfileOptions,
1243 out: Box<dyn io::Write + Send>,
1244 options: RunJsonOptions,
1245) -> RunOutcome {
1246 execute_run_inner(ExecuteRunInputs {
1247 path,
1248 trace,
1249 denied_builtins,
1250 script_argv,
1251 skill_dirs_raw,
1252 llm_mock_mode,
1253 attestation,
1254 profile,
1255 sandbox: RunSandboxOptions::default(),
1256 interrupt_tokens: None,
1257 json: Some((options, out)),
1258 aux: RunAuxOptions::default(),
1259 timing: None,
1260 harnpack: HarnpackRunOptions::default(),
1261 })
1262 .await
1263}
1264
1265pub(crate) async fn execute_run_with_timing(
1269 path: &str,
1270 script_argv: Vec<String>,
1271 timing: Option<&mut RunTiming>,
1272 sandbox: RunSandboxOptions,
1273) -> RunOutcome {
1274 execute_run_inner(ExecuteRunInputs {
1275 path,
1276 trace: false,
1277 denied_builtins: HashSet::new(),
1278 script_argv,
1279 skill_dirs_raw: Vec::new(),
1280 llm_mock_mode: CliLlmMockMode::Off,
1281 attestation: None,
1282 profile: RunProfileOptions::default(),
1283 sandbox,
1284 interrupt_tokens: None,
1285 json: None,
1286 aux: RunAuxOptions::default(),
1287 timing,
1288 harnpack: HarnpackRunOptions::default(),
1289 })
1290 .await
1291}
1292
1293fn entry_source_dir(path: &str) -> std::path::PathBuf {
1307 match std::path::Path::new(path).parent() {
1308 Some(parent) if !parent.as_os_str().is_empty() => parent.to_path_buf(),
1309 _ => std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")),
1310 }
1311}
1312
1313#[allow(clippy::needless_option_as_deref)]
1316async fn execute_run_inner(inputs: ExecuteRunInputs<'_>) -> RunOutcome {
1317 let ExecuteRunInputs {
1318 path,
1319 trace,
1320 denied_builtins,
1321 script_argv,
1322 skill_dirs_raw,
1323 llm_mock_mode,
1324 attestation,
1325 profile,
1326 sandbox,
1327 interrupt_tokens,
1328 json,
1329 aux,
1330 timing,
1331 harnpack,
1332 } = inputs;
1333 let RunAuxOptions {
1334 summary,
1335 phase,
1336 rusage,
1337 } = aux;
1338 let run_started = Instant::now();
1339 let cpu_started_ms = rusage.as_ref().map(|_| time::cpu_ms());
1340 let mut owned_timing = if timing.is_none() && (phase.is_some() || rusage.is_some()) {
1341 Some(RunTiming::default())
1342 } else {
1343 None
1344 };
1345 let mut timing = timing.or(owned_timing.as_mut());
1346
1347 let json_session = json.map(|(options, out)| JsonRunSession::install(options, out));
1353
1354 let mut stderr = String::new();
1355 let mut stdout = String::new();
1356
1357 let owned_run_path: String;
1362 let resolved_path: &str = if harnpack::looks_like_harnpack(Path::new(path)) {
1363 let outcome = match harnpack::prepare_harnpack(Path::new(path), &harnpack, &mut stderr) {
1364 Ok(prepared) => prepared,
1365 Err(err) => {
1366 return finalize_harnpack_error(
1367 stderr,
1368 json_session,
1369 summary.as_ref(),
1370 phase.as_ref(),
1371 rusage.as_ref(),
1372 run_started,
1373 err,
1374 );
1375 }
1376 };
1377 harn_vm::run_events::emit(harn_vm::run_events::RunEvent::PackRun {
1378 bundle_hash: outcome.bundle_hash.clone(),
1379 signature_verified: outcome.signature_verified,
1380 key_id: outcome.key_id.clone(),
1381 cache_hit: outcome.cache_hit,
1382 dry_run_verify: harnpack.dry_run_verify,
1383 });
1384 if harnpack.dry_run_verify {
1385 return finalize_harnpack_dry_run(
1386 stderr,
1387 json_session,
1388 summary.as_ref(),
1389 phase.as_ref(),
1390 rusage.as_ref(),
1391 run_started,
1392 cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1393 &outcome,
1394 );
1395 }
1396 owned_run_path = outcome.entrypoint_path.to_string_lossy().into_owned();
1397 owned_run_path.as_str()
1398 } else {
1399 path
1400 };
1401
1402 let Some(LoadedChunk { source, chunk }) =
1403 compile_or_load_chunk_with_timing(resolved_path, &mut stderr, timing.as_deref_mut())
1404 else {
1405 let message = stderr.clone();
1406 return finalize_run_error(
1407 stdout,
1408 stderr,
1409 json_session,
1410 summary.as_ref(),
1411 phase.as_ref(),
1412 rusage.as_ref(),
1413 run_started,
1414 None,
1415 timing.as_deref(),
1416 0,
1417 cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1418 "compile_error",
1419 message,
1420 );
1421 };
1422 let path = resolved_path;
1423
1424 let setup_start = Instant::now();
1425 if trace || summary.is_some() {
1426 harn_vm::llm::enable_tracing();
1427 }
1428 if profile.is_enabled() || phase.is_some() {
1429 harn_vm::tracing::set_tracing_enabled(true);
1430 }
1431 if profile.is_enabled() {
1432 harn_vm::builtin_profile::enable();
1437 }
1438 if let Err(error) = install_cli_llm_mock_mode(&llm_mock_mode) {
1439 stderr.push_str(&format!("error: {error}\n"));
1440 time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
1441 return finalize_run_error(
1442 stdout,
1443 stderr,
1444 json_session,
1445 summary.as_ref(),
1446 phase.as_ref(),
1447 rusage.as_ref(),
1448 run_started,
1449 None,
1450 timing.as_deref(),
1451 0,
1452 cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1453 "llm_mock_install",
1454 error,
1455 );
1456 }
1457
1458 let mut vm = harn_vm::Vm::new();
1459 if let Some(timing) = timing.as_deref_mut() {
1460 timing.module_phases = Some(vm.enable_module_phase_timing());
1461 }
1462 if let Some(interrupt_tokens) = interrupt_tokens {
1463 vm.install_interrupt_signal_token(interrupt_tokens.signal_token);
1464 vm.install_cancel_token(interrupt_tokens.cancel_token);
1465 }
1466 harn_vm::register_vm_stdlib(&mut vm);
1467 crate::install_default_hostlib(&mut vm);
1468 let source_parent = std::path::Path::new(path)
1469 .parent()
1470 .unwrap_or(std::path::Path::new("."));
1471 let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1473 let store_base = project_root.as_deref().unwrap_or(source_parent);
1474 let sandbox_root = sandbox
1475 .workspace_root
1476 .clone()
1477 .unwrap_or_else(|| default_run_workspace_root(project_root.as_deref(), source_parent));
1478 let _sandbox_scope = install_run_sandbox_scope(&sandbox, &sandbox_root, &mut stderr);
1479 let attestation_started_at_ms = now_ms();
1480 let attestation_log = if attestation.is_some() {
1481 Some(harn_vm::event_log::install_memory_for_current_thread(256))
1482 } else {
1483 None
1484 };
1485 if let Some(log) = attestation_log.as_ref() {
1486 append_run_provenance_event(
1487 log,
1488 "started",
1489 serde_json::json!({
1490 "pipeline": path,
1491 "argv": &script_argv,
1492 "project_root": store_base.display().to_string(),
1493 "sandbox": run_sandbox_attestation(&sandbox),
1494 }),
1495 )
1496 .await;
1497 }
1498 harn_vm::register_store_builtins(&mut vm, store_base);
1499 harn_vm::register_metadata_builtins(&mut vm, store_base);
1500 let pipeline_name = std::path::Path::new(path)
1501 .file_stem()
1502 .and_then(|s| s.to_str())
1503 .unwrap_or("default");
1504 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1505 vm.set_source_info(path, &source);
1506 let lazy_manifest_handlers = !denied_builtins.is_empty();
1507 if lazy_manifest_handlers {
1508 vm.set_denied_builtins(denied_builtins);
1509 }
1510 if let Some(ref root) = project_root {
1511 vm.set_project_root(root);
1512 }
1513
1514 vm.set_source_dir(&entry_source_dir(path));
1519
1520 let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
1523 let loaded = load_skills(&SkillLoaderInputs {
1524 cli_dirs,
1525 source_path: Some(std::path::PathBuf::from(path)),
1526 });
1527 emit_loader_warnings(&loaded.loader_warnings);
1528 install_skills_global(&mut vm, &loaded);
1529
1530 let argv_values: Vec<harn_vm::VmValue> = script_argv
1533 .iter()
1534 .map(|s| harn_vm::VmValue::String(arcstr::ArcStr::from(s.as_str())))
1535 .collect();
1536 vm.set_global(
1537 "argv",
1538 harn_vm::VmValue::List(std::sync::Arc::new(argv_values)),
1539 );
1540
1541 let runtime_harness =
1545 match crate::default_harness_for_manifest_or_base_dir(Path::new(path), store_base) {
1546 Ok(harness) => harness,
1547 Err(error) => {
1548 stderr.push_str(&format!(
1549 "error: failed to configure harness secret provider: {error}\n"
1550 ));
1551 time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
1552 return finalize_run_error(
1553 stdout,
1554 stderr,
1555 json_session,
1556 summary.as_ref(),
1557 phase.as_ref(),
1558 rusage.as_ref(),
1559 run_started,
1560 None,
1561 timing.as_deref(),
1562 0,
1563 cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1564 "harness_secret_provider",
1565 error,
1566 );
1567 }
1568 };
1569 vm.set_harness(runtime_harness);
1570
1571 if let Err(error) =
1574 manifest_runtime::install_manifest_runtime(Path::new(path), &mut vm, lazy_manifest_handlers)
1575 .await
1576 {
1577 stderr.push_str(&format!(
1578 "error: failed to install {}: {error}\n",
1579 error.label()
1580 ));
1581 time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
1582 return finalize_run_error(
1583 stdout,
1584 stderr,
1585 json_session,
1586 summary.as_ref(),
1587 phase.as_ref(),
1588 rusage.as_ref(),
1589 run_started,
1590 None,
1591 timing.as_deref(),
1592 0,
1593 cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1594 error.phase(),
1595 error.to_string(),
1596 );
1597 }
1598
1599 let local = tokio::task::LocalSet::new();
1600 time::record_run_setup_elapsed(timing.as_deref_mut(), setup_start);
1601 let main_start = Instant::now();
1602 vm.set_source_dir(&entry_source_dir(path));
1610 let execution = local
1611 .run_until(async {
1612 match vm.execute(&chunk).await {
1613 Ok(value) => RunExecution::Terminal(TerminalRun::Returned(value)),
1614 Err(error) => match error.process_exit_code() {
1615 Some(code) => RunExecution::Terminal(TerminalRun::ProcessExited(code)),
1616 None => RunExecution::Failed(vm.format_runtime_error(&error)),
1617 },
1618 }
1619 })
1620 .await;
1621 let output = vm.output();
1622 if let Some(t) = timing.as_deref_mut() {
1623 t.run_main = main_start.elapsed();
1624 }
1625 if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
1626 stderr.push_str(&format!("error: {error}\n"));
1627 let profile_rollup = if profile.is_enabled() {
1628 Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1629 } else {
1630 None
1631 };
1632 return finalize_run_error(
1633 stdout,
1634 stderr,
1635 json_session,
1636 summary.as_ref(),
1637 phase.as_ref(),
1638 rusage.as_ref(),
1639 run_started,
1640 profile_rollup.as_ref(),
1641 timing.as_deref(),
1642 harn_vm::tracing::peek_spans().len() as u64,
1643 cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1644 "llm_mock_record",
1645 error,
1646 );
1647 }
1648
1649 let buffered_stderr = harn_vm::take_stderr_buffer();
1651 stderr.push_str(&buffered_stderr);
1652
1653 let exit_code = match &execution {
1654 RunExecution::Terminal(terminal) => terminal.exit_code(),
1655 RunExecution::Failed(_) => 1,
1656 };
1657
1658 if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
1659 if let Err(error) = emit_run_attestation(
1660 log,
1661 path,
1662 store_base,
1663 attestation_started_at_ms,
1664 exit_code,
1665 options,
1666 &mut stderr,
1667 )
1668 .await
1669 {
1670 stderr.push_str(&format!(
1671 "error: failed to emit provenance receipt: {error}\n"
1672 ));
1673 let profile_rollup = if profile.is_enabled() {
1674 Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1675 } else {
1676 None
1677 };
1678 return finalize_run_error(
1679 stdout,
1680 stderr,
1681 json_session,
1682 summary.as_ref(),
1683 phase.as_ref(),
1684 rusage.as_ref(),
1685 run_started,
1686 profile_rollup.as_ref(),
1687 timing.as_deref(),
1688 harn_vm::tracing::peek_spans().len() as u64,
1689 cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start)),
1690 "attestation",
1691 error,
1692 );
1693 }
1694 harn_vm::event_log::reset_active_event_log();
1695 }
1696
1697 match execution {
1698 RunExecution::Terminal(terminal) => {
1699 stdout.push_str(output);
1700 let main_events = harn_vm::tracing::peek_spans().len() as u64;
1701 let cpu_ms_total = cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start));
1702 let profile_rollup = if profile.is_enabled() {
1703 Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1704 } else {
1705 None
1706 };
1707 let summary_llm = summary.as_ref().map(|_| run_summary_llm_snapshot());
1708 if trace {
1709 stderr.push_str(&render_trace_summary());
1710 }
1711 if let Some(profile_rollup) = profile_rollup.as_ref() {
1712 if let Err(error) =
1713 render_and_persist_profile_rollup(&profile, profile_rollup, &mut stderr)
1714 {
1715 stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1716 }
1717 }
1718 if let Some(diagnostic) = terminal.nonzero_return_diagnostic() {
1719 stderr.push_str(&diagnostic);
1720 }
1721 let aux_emission = emit_run_aux_for_exit(
1722 summary.as_ref(),
1723 phase.as_ref(),
1724 rusage.as_ref(),
1725 run_started,
1726 exit_code,
1727 profile_rollup.as_ref(),
1728 summary_llm,
1729 timing.as_deref(),
1730 main_events,
1731 cpu_ms_total,
1732 json_session.is_some(),
1733 &mut stderr,
1734 );
1735 if let Some(session) = json_session {
1736 if let Some(error) = aux_emission.error {
1737 let mut outcome = session.finalize_error(
1738 "run_aux",
1739 format!("failed to emit auxiliary run JSON: {error}"),
1740 1,
1741 );
1742 outcome.stderr = aux_emission.stderr;
1743 return outcome;
1744 }
1745 let value = terminal.json_value();
1746 let mut outcome = session.finalize_result(value, aux_emission.exit_code);
1747 outcome.stderr = aux_emission.stderr;
1748 return outcome;
1749 }
1750 RunOutcome {
1751 stdout,
1752 stderr,
1753 exit_code: aux_emission.exit_code,
1754 }
1755 }
1756 RunExecution::Failed(rendered_error) => {
1757 stderr.push_str(&rendered_error);
1758 let main_events = harn_vm::tracing::peek_spans().len() as u64;
1759 let cpu_ms_total = cpu_started_ms.map(|start| time::cpu_ms().saturating_sub(start));
1760 let profile_rollup = if profile.is_enabled() {
1761 Some(harn_vm::profile::build(&harn_vm::tracing::peek_spans()))
1762 } else {
1763 None
1764 };
1765 if let Some(profile_rollup) = profile_rollup.as_ref() {
1766 if let Err(error) =
1767 render_and_persist_profile_rollup(&profile, profile_rollup, &mut stderr)
1768 {
1769 stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
1770 }
1771 }
1772 let aux_emission = emit_run_aux_for_exit(
1773 summary.as_ref(),
1774 phase.as_ref(),
1775 rusage.as_ref(),
1776 run_started,
1777 1,
1778 profile_rollup.as_ref(),
1779 None,
1780 timing.as_deref(),
1781 main_events,
1782 cpu_ms_total,
1783 json_session.is_some(),
1784 &mut stderr,
1785 );
1786 if let Some(session) = json_session {
1787 let mut outcome =
1788 session.finalize_error("runtime", rendered_error, aux_emission.exit_code);
1789 outcome.stderr = aux_emission.stderr;
1790 return outcome;
1791 }
1792 RunOutcome {
1793 stdout,
1794 stderr,
1795 exit_code: aux_emission.exit_code,
1796 }
1797 }
1798 }
1799}
1800
1801fn render_and_persist_profile_rollup(
1802 options: &RunProfileOptions,
1803 profile: &harn_vm::profile::RunProfile,
1804 stderr: &mut String,
1805) -> Result<(), String> {
1806 if options.text {
1807 stderr.push_str(&harn_vm::profile::render(profile));
1808 }
1809 if let Some(path) = options.json_path.as_ref() {
1810 if let Some(parent) = path.parent() {
1811 if !parent.as_os_str().is_empty() {
1812 fs::create_dir_all(parent)
1813 .map_err(|error| format!("create {}: {error}", parent.display()))?;
1814 }
1815 }
1816 let json = serde_json::to_string_pretty(profile)
1817 .map_err(|error| format!("serialize profile: {error}"))?;
1818 fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
1819 }
1820 Ok(())
1821}
1822
1823fn build_run_summary<'a>(
1824 started: Instant,
1825 exit_code: i32,
1826 profile: Option<&'a harn_vm::profile::RunProfile>,
1827 llm: RunSummaryLlm,
1828) -> RunSummary<'a> {
1829 RunSummary {
1830 schema_version: RUN_SUMMARY_SCHEMA_VERSION,
1831 event: "run_summary",
1832 wall_time_ms: started.elapsed().as_millis().min(u128::from(u64::MAX)) as u64,
1833 exit_code,
1834 llm,
1835 profile,
1836 }
1837}
1838
1839fn run_summary_llm_snapshot() -> RunSummaryLlm {
1840 let (input_tokens, output_tokens, time_ms, call_count) = harn_vm::llm::peek_trace_summary();
1841 let cost_usd = harn_vm::llm::peek_total_cost();
1842 RunSummaryLlm {
1843 call_count,
1844 input_tokens,
1845 output_tokens,
1846 time_ms,
1847 cost_usd: if cost_usd.is_finite() { cost_usd } else { 0.0 },
1848 }
1849}
1850
1851struct RunAuxEmission {
1852 stderr: String,
1853 exit_code: i32,
1854 error: Option<String>,
1855}
1856
1857#[allow(clippy::too_many_arguments)]
1858fn emit_run_aux_for_exit(
1859 summary: Option<&RunSummaryOptions>,
1860 phase: Option<&RunPhaseOptions>,
1861 rusage: Option<&RunRusageOptions>,
1862 started: Instant,
1863 exit_code: i32,
1864 profile: Option<&harn_vm::profile::RunProfile>,
1865 llm: Option<RunSummaryLlm>,
1866 timing: Option<&RunTiming>,
1867 main_events: u64,
1868 cpu_ms_total: Option<u64>,
1869 json_mode: bool,
1870 stderr: &mut String,
1871) -> RunAuxEmission {
1872 let mut aux_stderr = String::new();
1873 let mut final_exit_code = exit_code;
1874 let mut aux_error = None;
1875 let aux_target = if json_mode { &mut aux_stderr } else { stderr };
1876 let default_timing = RunTiming::default();
1877 let timing = timing.unwrap_or(&default_timing);
1878
1879 if let Some(options) = summary {
1880 let llm = llm.unwrap_or_else(run_summary_llm_snapshot);
1881 let summary = build_run_summary(started, exit_code, profile, llm);
1882 if let Err(error) = emit_raw_json_line(&options.sink, &summary, "run summary", aux_target) {
1883 record_aux_error(
1884 &mut final_exit_code,
1885 &mut aux_error,
1886 aux_target,
1887 "run summary",
1888 error,
1889 );
1890 }
1891 }
1892 if let Some(options) = phase {
1893 let phase_event = RunPhaseEvent {
1894 schema_version: RUN_PHASE_SCHEMA_VERSION,
1895 event: "run_phase",
1896 phases: time::build_phase_records(timing, main_events),
1897 };
1898 if let Err(error) = emit_raw_json_line(&options.sink, &phase_event, "run phase", aux_target)
1899 {
1900 record_aux_error(
1901 &mut final_exit_code,
1902 &mut aux_error,
1903 aux_target,
1904 "run phase",
1905 error,
1906 );
1907 }
1908 }
1909 if let Some(options) = rusage {
1910 let rusage_event = RunRusageEvent {
1911 schema_version: RUN_RUSAGE_SCHEMA_VERSION,
1912 event: "run_rusage",
1913 cpu_ms: cpu_ms_total.unwrap_or(0),
1914 };
1915 if let Err(error) =
1916 emit_raw_json_line(&options.sink, &rusage_event, "run rusage", aux_target)
1917 {
1918 record_aux_error(
1919 &mut final_exit_code,
1920 &mut aux_error,
1921 aux_target,
1922 "run rusage",
1923 error,
1924 );
1925 }
1926 }
1927
1928 RunAuxEmission {
1929 stderr: aux_stderr,
1930 exit_code: final_exit_code,
1931 error: aux_error,
1932 }
1933}
1934
1935fn record_aux_error(
1936 final_exit_code: &mut i32,
1937 aux_error: &mut Option<String>,
1938 stderr: &mut String,
1939 label: &str,
1940 error: String,
1941) {
1942 stderr.push_str(&format!("error: failed to emit {label}: {error}\n"));
1943 if *final_exit_code == 0 {
1944 *final_exit_code = 1;
1945 }
1946 if aux_error.is_none() {
1947 *aux_error = Some(error);
1948 }
1949}
1950
1951fn emit_raw_json_line(
1952 sink: &RunJsonSink,
1953 value: &impl Serialize,
1954 label: &str,
1955 stderr: &mut String,
1956) -> Result<(), String> {
1957 let line =
1958 serde_json::to_string(value).map_err(|error| format!("serialize {label}: {error}"))? + "\n";
1959 match &sink.target {
1960 RunJsonSinkTarget::Stderr => {
1961 stderr.push_str(&line);
1962 Ok(())
1963 }
1964 RunJsonSinkTarget::File(path) => write_raw_json_file(path, &line),
1965 RunJsonSinkTarget::Fd(fd) => write_raw_json_fd(*fd, &line, sink.fd_flag),
1966 }
1967}
1968
1969fn write_raw_json_file(path: &Path, line: &str) -> Result<(), String> {
1970 if let Some(parent) = path.parent() {
1971 if !parent.as_os_str().is_empty() {
1972 fs::create_dir_all(parent)
1973 .map_err(|error| format!("create {}: {error}", parent.display()))?;
1974 }
1975 }
1976 fs::write(path, line).map_err(|error| format!("write {}: {error}", path.display()))
1977}
1978
1979#[cfg(unix)]
1980fn write_raw_json_fd(fd: i32, line: &str, flag: &str) -> Result<(), String> {
1981 use std::fs::File;
1982 use std::os::unix::io::FromRawFd;
1983
1984 if fd < 0 {
1985 return Err(format!("invalid {flag} {fd}: must be non-negative"));
1986 }
1987 let duped = unsafe { libc::dup(fd) };
1988 if duped < 0 {
1989 return Err(format!(
1990 "duplicate {flag} {fd}: {}",
1991 io::Error::last_os_error()
1992 ));
1993 }
1994 let mut file = unsafe { File::from_raw_fd(duped) };
1995 file.write_all(line.as_bytes())
1996 .and_then(|_| file.flush())
1997 .map_err(|error| format!("write {flag} {fd}: {error}"))
1998}
1999
2000#[cfg(not(unix))]
2001fn write_raw_json_fd(_fd: i32, _line: &str, flag: &str) -> Result<(), String> {
2002 Err(format!("{flag} is only supported on Unix platforms"))
2003}
2004
2005async fn append_run_provenance_event(
2006 log: &Arc<harn_vm::event_log::AnyEventLog>,
2007 kind: &str,
2008 payload: serde_json::Value,
2009) {
2010 let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
2011 return;
2012 };
2013 let _ = log
2014 .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
2015 .await;
2016}
2017
2018async fn emit_run_attestation(
2019 log: &Arc<harn_vm::event_log::AnyEventLog>,
2020 path: &str,
2021 store_base: &Path,
2022 started_at_ms: i64,
2023 exit_code: i32,
2024 options: &RunAttestationOptions,
2025 stderr: &mut String,
2026) -> Result<(), String> {
2027 let finished_at_ms = now_ms();
2028 let status = if exit_code == 0 { "success" } else { "failure" };
2029 append_run_provenance_event(
2030 log,
2031 "finished",
2032 serde_json::json!({
2033 "pipeline": path,
2034 "status": status,
2035 "exit_code": exit_code,
2036 }),
2037 )
2038 .await;
2039 log.flush()
2040 .await
2041 .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
2042 let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
2043 .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
2044 let (signing_key, key_id) =
2045 harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
2046 .await
2047 .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
2048 let receipt = harn_vm::build_signed_receipt(
2049 log,
2050 harn_vm::ReceiptBuildOptions {
2051 pipeline: path.to_string(),
2052 status: status.to_string(),
2053 started_at_ms,
2054 finished_at_ms,
2055 exit_code,
2056 producer_name: "harn-cli".to_string(),
2057 producer_version: env!("CARGO_PKG_VERSION").to_string(),
2058 },
2059 &signing_key,
2060 key_id,
2061 )
2062 .await
2063 .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
2064 let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
2065 if let Some(parent) = receipt_path.parent() {
2066 fs::create_dir_all(parent)
2067 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
2068 }
2069 let encoded = serde_json::to_vec_pretty(&receipt)
2070 .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
2071 fs::write(&receipt_path, encoded)
2072 .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
2073 stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
2074 Ok(())
2075}
2076
2077fn receipt_output_path(
2078 store_base: &Path,
2079 options: &RunAttestationOptions,
2080 receipt_id: &str,
2081) -> PathBuf {
2082 if let Some(path) = options.receipt_out.as_ref() {
2083 return path.clone();
2084 }
2085 harn_vm::runtime_paths::state_root(store_base)
2086 .join("receipts")
2087 .join(format!("{receipt_id}.json"))
2088}
2089
2090fn now_ms() -> i64 {
2091 std::time::SystemTime::now()
2092 .duration_since(std::time::UNIX_EPOCH)
2093 .map(|duration| duration.as_millis() as i64)
2094 .unwrap_or(0)
2095}
2096
2097fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
2104 use harn_vm::VmValue;
2105 match value {
2106 VmValue::Int(n) => (*n).clamp(0, 255) as i32,
2107 VmValue::EnumVariant(enum_variant) if enum_variant.is_variant("Result", "Err") => 1,
2108 _ => 0,
2109 }
2110}
2111
2112struct JsonRunSession {
2126 emitter: self::json_events::NdjsonEmitter,
2127 prior_sink: Option<Arc<dyn harn_vm::run_events::RunEventSink>>,
2128}
2129
2130impl JsonRunSession {
2131 fn install(options: RunJsonOptions, out: Box<dyn io::Write + Send>) -> Self {
2132 let emitter = NdjsonEmitter::new(out, options.quiet);
2133 let prior_sink = harn_vm::run_events::install_sink(emitter.sink());
2134 Self {
2135 emitter,
2136 prior_sink,
2137 }
2138 }
2139
2140 fn finalize_result(self, value: serde_json::Value, exit_code: i32) -> RunOutcome {
2141 self.emitter.emit_result(value, exit_code);
2142 RunOutcome {
2143 stdout: String::new(),
2144 stderr: String::new(),
2145 exit_code,
2146 }
2147 }
2148
2149 fn finalize_error(
2150 self,
2151 code: impl Into<String>,
2152 message: impl Into<String>,
2153 exit_code: i32,
2154 ) -> RunOutcome {
2155 self.emitter.emit_error(code, message);
2156 RunOutcome {
2157 stdout: String::new(),
2158 stderr: String::new(),
2159 exit_code,
2160 }
2161 }
2162}
2163
2164impl Drop for JsonRunSession {
2165 fn drop(&mut self) {
2166 match self.prior_sink.take() {
2167 Some(prior) => {
2168 harn_vm::run_events::install_sink(prior);
2169 }
2170 None => harn_vm::run_events::clear_sink(),
2171 }
2172 }
2173}
2174
2175#[allow(clippy::too_many_arguments)]
2176fn finalize_run_error(
2177 stdout: String,
2178 mut stderr: String,
2179 json_session: Option<JsonRunSession>,
2180 summary: Option<&RunSummaryOptions>,
2181 phase: Option<&RunPhaseOptions>,
2182 rusage: Option<&RunRusageOptions>,
2183 started: Instant,
2184 profile: Option<&harn_vm::profile::RunProfile>,
2185 timing: Option<&RunTiming>,
2186 main_events: u64,
2187 cpu_ms_total: Option<u64>,
2188 code: impl Into<String>,
2189 message: impl Into<String>,
2190) -> RunOutcome {
2191 let aux_emission = emit_run_aux_for_exit(
2192 summary,
2193 phase,
2194 rusage,
2195 started,
2196 1,
2197 profile,
2198 None,
2199 timing,
2200 main_events,
2201 cpu_ms_total,
2202 json_session.is_some(),
2203 &mut stderr,
2204 );
2205 if let Some(session) = json_session {
2206 let mut outcome = session.finalize_error(code, message, aux_emission.exit_code);
2207 outcome.stderr = aux_emission.stderr;
2208 return outcome;
2209 }
2210 RunOutcome {
2211 stdout,
2212 stderr,
2213 exit_code: aux_emission.exit_code,
2214 }
2215}
2216
2217fn finalize_harnpack_error(
2222 mut stderr: String,
2223 json_session: Option<JsonRunSession>,
2224 summary: Option<&RunSummaryOptions>,
2225 phase: Option<&RunPhaseOptions>,
2226 rusage: Option<&RunRusageOptions>,
2227 started: Instant,
2228 err: HarnpackError,
2229) -> RunOutcome {
2230 let code = err.code;
2231 let message = err.message;
2232 stderr.push_str(&format!("error: {message}\n"));
2233 finalize_run_error(
2234 String::new(),
2235 stderr,
2236 json_session,
2237 summary,
2238 phase,
2239 rusage,
2240 started,
2241 None,
2242 None,
2243 0,
2244 None,
2245 code,
2246 message,
2247 )
2248}
2249
2250fn finalize_harnpack_dry_run(
2255 mut stderr: String,
2256 json_session: Option<JsonRunSession>,
2257 summary_options: Option<&RunSummaryOptions>,
2258 phase_options: Option<&RunPhaseOptions>,
2259 rusage_options: Option<&RunRusageOptions>,
2260 started: Instant,
2261 cpu_ms_total: Option<u64>,
2262 prepared: &PreparedHarnpack,
2263) -> RunOutcome {
2264 let summary = format!(
2265 "[harn] harnpack verify ok: bundle_hash={}, signature_verified={}, cache_hit={}\n",
2266 prepared.bundle_hash, prepared.signature_verified, prepared.cache_hit
2267 );
2268 stderr.push_str(&summary);
2269 let aux_emission = emit_run_aux_for_exit(
2270 summary_options,
2271 phase_options,
2272 rusage_options,
2273 started,
2274 0,
2275 None,
2276 None,
2277 None,
2278 0,
2279 cpu_ms_total,
2280 json_session.is_some(),
2281 &mut stderr,
2282 );
2283 if let Some(session) = json_session {
2284 if let Some(error) = aux_emission.error {
2285 let mut outcome = session.finalize_error(
2286 "run_aux",
2287 format!("failed to emit auxiliary run JSON: {error}"),
2288 1,
2289 );
2290 outcome.stderr = aux_emission.stderr;
2291 return outcome;
2292 }
2293 let value = serde_json::json!({
2294 "bundle_hash": prepared.bundle_hash,
2295 "signature_verified": prepared.signature_verified,
2296 "key_id": prepared.key_id,
2297 "cache_hit": prepared.cache_hit,
2298 "dry_run_verify": true,
2299 });
2300 let mut outcome = session.finalize_result(value, aux_emission.exit_code);
2301 outcome.stderr = aux_emission.stderr;
2302 return outcome;
2303 }
2304 RunOutcome {
2305 stdout: String::new(),
2306 stderr,
2307 exit_code: aux_emission.exit_code,
2308 }
2309}
2310
2311fn render_return_value_error(value: &harn_vm::VmValue) -> String {
2312 let harn_vm::VmValue::EnumVariant(enum_variant) = value else {
2313 return String::new();
2314 };
2315 if !enum_variant.is_variant("Result", "Err") {
2316 return String::new();
2317 }
2318 let rendered = enum_variant
2319 .fields
2320 .first()
2321 .map(|p| p.display())
2322 .unwrap_or_default();
2323 if rendered.is_empty() {
2324 "error\n".to_string()
2325 } else if rendered.ends_with('\n') {
2326 rendered
2327 } else {
2328 format!("{rendered}\n")
2329 }
2330}
2331
2332pub(crate) fn render_trace_summary() -> String {
2333 use std::fmt::Write;
2334 let entries = harn_vm::llm::take_trace();
2335 if entries.is_empty() {
2336 return String::new();
2337 }
2338 let mut out = String::new();
2339 let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
2340 let mut total_input = 0i64;
2341 let mut total_output = 0i64;
2342 let mut total_ms = 0u64;
2343 for (i, entry) in entries.iter().enumerate() {
2344 let _ = writeln!(
2345 out,
2346 " #{}: {} | {} in + {} out tokens | {} ms",
2347 i + 1,
2348 entry.model,
2349 entry.input_tokens,
2350 entry.output_tokens,
2351 entry.duration_ms,
2352 );
2353 total_input += entry.input_tokens;
2354 total_output += entry.output_tokens;
2355 total_ms += entry.duration_ms;
2356 }
2357 let total_tokens = total_input + total_output;
2358 let cost = (total_input as f64 * 3.0 + total_output as f64 * 15.0) / 1_000_000.0;
2360 let _ = writeln!(
2361 out,
2362 " \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, ~${:.4}\x1b[0m",
2363 entries.len(),
2364 if entries.len() == 1 { "" } else { "s" },
2365 total_tokens,
2366 total_input,
2367 total_output,
2368 total_ms,
2369 cost,
2370 );
2371 out
2372}
2373
2374pub(crate) async fn run_file_mcp_serve(
2388 path: &str,
2389 card_source: Option<&str>,
2390 mode: RunFileMcpServeMode,
2391) {
2392 let mut diagnostics = String::new();
2393 let Some(LoadedChunk { source, chunk }) = compile_or_load_chunk_for_run(path, &mut diagnostics)
2394 else {
2395 eprint!("{diagnostics}");
2396 process::exit(1);
2397 };
2398 if !diagnostics.is_empty() {
2399 eprint!("{diagnostics}");
2400 }
2401
2402 let mut vm = harn_vm::Vm::new();
2403 harn_vm::register_vm_stdlib(&mut vm);
2404 crate::install_default_hostlib(&mut vm);
2405 let source_parent = std::path::Path::new(path)
2406 .parent()
2407 .unwrap_or(std::path::Path::new("."));
2408 let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
2409 let store_base = project_root.as_deref().unwrap_or(source_parent);
2410 harn_vm::register_store_builtins(&mut vm, store_base);
2411 harn_vm::register_metadata_builtins(&mut vm, store_base);
2412 let pipeline_name = std::path::Path::new(path)
2413 .file_stem()
2414 .and_then(|s| s.to_str())
2415 .unwrap_or("default");
2416 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
2417 vm.set_source_info(path, &source);
2418 if let Some(ref root) = project_root {
2419 vm.set_project_root(root);
2420 }
2421 vm.set_source_dir(&entry_source_dir(path));
2425
2426 let loaded = load_skills(&SkillLoaderInputs {
2428 cli_dirs: Vec::new(),
2429 source_path: Some(std::path::PathBuf::from(path)),
2430 });
2431 emit_loader_warnings(&loaded.loader_warnings);
2432 install_skills_global(&mut vm, &loaded);
2433
2434 if let Err(error) =
2435 manifest_runtime::install_manifest_runtime(Path::new(path), &mut vm, false).await
2436 {
2437 eprintln!("error: failed to install {}: {error}", error.label());
2438 process::exit(1);
2439 }
2440
2441 vm.set_source_dir(&entry_source_dir(path));
2446 let local = tokio::task::LocalSet::new();
2447 local
2448 .run_until(async {
2449 match vm.execute(&chunk).await {
2450 Ok(_) => {}
2451 Err(error) => crate::commands::serve::exit_after_mcp_pipeline_error(&vm, &error),
2452 }
2453
2454 let output = vm.output();
2456 if !output.is_empty() {
2457 eprint!("{output}");
2458 }
2459
2460 let registry = match harn_vm::take_mcp_serve_registry() {
2461 Some(r) => r,
2462 None => {
2463 eprintln!("error: pipeline did not call mcp_serve(registry)");
2464 eprintln!("hint: call mcp_serve(tools) at the end of your pipeline");
2465 process::exit(1);
2466 }
2467 };
2468
2469 let tools = match harn_vm::tool_registry_to_mcp_tools(®istry) {
2470 Ok(t) => t,
2471 Err(e) => {
2472 eprintln!("error: {e}");
2473 process::exit(1);
2474 }
2475 };
2476
2477 let resources = harn_vm::take_mcp_serve_resources();
2478 let resource_templates = harn_vm::take_mcp_serve_resource_templates();
2479 let prompts = harn_vm::take_mcp_serve_prompts();
2480 let metadata = harn_vm::take_mcp_serve_metadata();
2481
2482 let mut server_name = std::path::Path::new(path)
2483 .file_stem()
2484 .and_then(|s| s.to_str())
2485 .unwrap_or("harn")
2486 .to_string();
2487 if let Some(name) = metadata
2488 .as_ref()
2489 .and_then(|metadata| metadata.name.as_ref())
2490 {
2491 server_name = name.clone();
2492 }
2493
2494 let mut caps = Vec::new();
2495 if !tools.is_empty() {
2496 caps.push(format!(
2497 "{} tool{}",
2498 tools.len(),
2499 if tools.len() == 1 { "" } else { "s" }
2500 ));
2501 }
2502 let total_resources = resources.len() + resource_templates.len();
2503 if total_resources > 0 {
2504 caps.push(format!(
2505 "{total_resources} resource{}",
2506 if total_resources == 1 { "" } else { "s" }
2507 ));
2508 }
2509 if !prompts.is_empty() {
2510 caps.push(format!(
2511 "{} prompt{}",
2512 prompts.len(),
2513 if prompts.len() == 1 { "" } else { "s" }
2514 ));
2515 }
2516 eprintln!(
2517 "[harn] serve mcp: serving {} as '{server_name}'",
2518 caps.join(", ")
2519 );
2520
2521 let mut server =
2522 harn_vm::McpServer::new(server_name, tools, resources, resource_templates, prompts);
2523 if let Some(metadata) = metadata {
2524 server = server.with_metadata(metadata);
2525 }
2526 if let Some(source) = card_source {
2527 match resolve_card_source(source) {
2528 Ok(card) => server = server.with_server_card(card),
2529 Err(e) => {
2530 eprintln!("error: --card: {e}");
2531 process::exit(1);
2532 }
2533 }
2534 }
2535 match mode {
2536 RunFileMcpServeMode::Stdio => {
2537 if let Err(e) = server.run(&mut vm).await {
2538 eprintln!("error: MCP server error: {e}");
2539 process::exit(1);
2540 }
2541 }
2542 RunFileMcpServeMode::Http(http) => {
2543 let RunFileMcpServeHttp {
2544 options,
2545 auth_policy,
2546 } = *http;
2547 if let Err(e) = crate::commands::serve::run_script_mcp_http_server(
2548 server,
2549 vm,
2550 options,
2551 auth_policy,
2552 )
2553 .await
2554 {
2555 eprintln!("error: MCP server error: {e}");
2556 process::exit(1);
2557 }
2558 }
2559 }
2560 })
2561 .await;
2562}
2563
2564pub(crate) fn resolve_card_source(source: &str) -> Result<serde_json::Value, String> {
2569 let trimmed = source.trim_start();
2570 if trimmed.starts_with('{') || trimmed.starts_with('[') {
2571 return serde_json::from_str(source).map_err(|e| format!("inline JSON parse error: {e}"));
2572 }
2573 let path = std::path::Path::new(source);
2574 harn_vm::load_server_card_from_path(path).map_err(|e| format!("{e}"))
2575}
2576
2577pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
2578 use notify::{Event, EventKind, RecursiveMode, Watcher};
2579
2580 let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
2581 eprintln!("Error: {e}");
2582 process::exit(1);
2583 });
2584 let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
2585
2586 eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
2587 run_file(
2588 path,
2589 false,
2590 denied_builtins.clone(),
2591 Vec::new(),
2592 CliLlmMockMode::Off,
2593 None,
2594 RunProfileOptions::default(),
2595 )
2596 .await;
2597
2598 let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
2599 let _watcher = {
2600 let tx = tx.clone();
2601 let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
2602 if let Ok(event) = res {
2603 if matches!(
2604 event.kind,
2605 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
2606 ) {
2607 let has_harn = event
2608 .paths
2609 .iter()
2610 .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
2611 if has_harn {
2612 let _ = tx.blocking_send(());
2613 }
2614 }
2615 }
2616 })
2617 .unwrap_or_else(|e| {
2618 eprintln!("Error setting up file watcher: {e}");
2619 process::exit(1);
2620 });
2621 watcher
2622 .watch(watch_dir, RecursiveMode::Recursive)
2623 .unwrap_or_else(|e| {
2624 eprintln!("Error watching directory: {e}");
2625 process::exit(1);
2626 });
2627 watcher };
2629
2630 eprintln!(
2631 "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
2632 watch_dir.display()
2633 );
2634
2635 loop {
2636 rx.recv().await;
2637 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
2639 while rx.try_recv().is_ok() {}
2640
2641 eprintln!();
2642 eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
2643 run_file(
2644 path,
2645 false,
2646 denied_builtins.clone(),
2647 Vec::new(),
2648 CliLlmMockMode::Off,
2649 None,
2650 RunProfileOptions::default(),
2651 )
2652 .await;
2653 }
2654}
2655
2656#[cfg(test)]
2657mod tests;