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