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;
8
9use harn_parser::DiagnosticSeverity;
10use harn_vm::event_log::EventLog;
11
12use crate::commands::mcp::{self, AuthResolution};
13use crate::package;
14use crate::parse_source_file;
15use crate::skill_loader::{
16 canonicalize_cli_dirs, emit_loader_warnings, install_skills_global, load_skills,
17 SkillLoaderInputs,
18};
19
20mod explain_cost;
21
22pub(crate) enum RunFileMcpServeMode {
23 Stdio,
24 Http {
25 options: harn_serve::McpHttpServeOptions,
26 auth_policy: harn_serve::AuthPolicy,
27 },
28}
29
30const CORE_BUILTINS: &[&str] = &[
32 "println",
33 "print",
34 "log",
35 "type_of",
36 "to_string",
37 "to_int",
38 "to_float",
39 "len",
40 "assert",
41 "assert_eq",
42 "assert_ne",
43 "json_parse",
44 "json_stringify",
45 "runtime_context",
46 "task_current",
47 "runtime_context_values",
48 "runtime_context_get",
49 "runtime_context_set",
50 "runtime_context_clear",
51];
52
53pub(crate) fn build_denied_builtins(
58 deny_csv: Option<&str>,
59 allow_csv: Option<&str>,
60) -> HashSet<String> {
61 if let Some(csv) = deny_csv {
62 csv.split(',')
63 .map(|s| s.trim().to_string())
64 .filter(|s| !s.is_empty())
65 .collect()
66 } else if let Some(csv) = allow_csv {
67 let allowed: HashSet<String> = csv
70 .split(',')
71 .map(|s| s.trim().to_string())
72 .filter(|s| !s.is_empty())
73 .collect();
74 let core: HashSet<&str> = CORE_BUILTINS.iter().copied().collect();
75
76 let mut tmp = harn_vm::Vm::new();
78 harn_vm::register_vm_stdlib(&mut tmp);
79 harn_vm::register_store_builtins(&mut tmp, std::path::Path::new("."));
80 harn_vm::register_metadata_builtins(&mut tmp, std::path::Path::new("."));
81
82 tmp.builtin_names()
83 .into_iter()
84 .filter(|name| !allowed.contains(name) && !core.contains(name.as_str()))
85 .collect()
86 } else {
87 HashSet::new()
88 }
89}
90
91fn typecheck_with_imports(
96 program: &[harn_parser::SNode],
97 path: &Path,
98 source: &str,
99) -> Vec<harn_parser::TypeDiagnostic> {
100 if let Err(error) = package::ensure_dependencies_materialized(path) {
101 eprintln!("error: {error}");
102 process::exit(1);
103 }
104 let graph = harn_modules::build(&[path.to_path_buf()]);
105 let mut checker = harn_parser::TypeChecker::new();
106 if let Some(imported) = graph.imported_names_for_file(path) {
107 checker = checker.with_imported_names(imported);
108 }
109 if let Some(imported) = graph.imported_type_declarations_for_file(path) {
110 checker = checker.with_imported_type_decls(imported);
111 }
112 checker.check_with_source(program, source)
113}
114
115pub(crate) fn prepare_eval_temp_file(
126 code: &str,
127) -> Result<(String, tempfile::NamedTempFile), String> {
128 let (header, body) = split_eval_header(code);
129 let wrapped = if header.is_empty() {
130 format!("pipeline main(task) {{\n{body}\n}}")
131 } else {
132 format!("{header}\npipeline main(task) {{\n{body}\n}}")
133 };
134
135 let tmp = create_eval_temp_file()?;
136 Ok((wrapped, tmp))
137}
138
139fn create_eval_temp_file() -> Result<tempfile::NamedTempFile, String> {
144 if let Some(dir) = std::env::current_dir().ok().as_deref() {
145 match tempfile::Builder::new()
148 .prefix(".harn-eval-")
149 .suffix(".harn")
150 .tempfile_in(dir)
151 {
152 Ok(tmp) => return Ok(tmp),
153 Err(error) => eprintln!(
154 "warning: harn run -e: could not create temp file in {}: {error}; \
155 relative imports will not resolve",
156 dir.display()
157 ),
158 }
159 }
160 tempfile::Builder::new()
161 .prefix("harn-eval-")
162 .suffix(".harn")
163 .tempfile()
164 .map_err(|e| format!("failed to create temp file for -e: {e}"))
165}
166
167fn split_eval_header(code: &str) -> (String, String) {
175 let mut header_end = 0usize;
176 let mut last_kept = 0usize;
177 for (idx, line) in code.lines().enumerate() {
178 let trimmed = line.trim_start();
179 if trimmed.is_empty() || trimmed.starts_with("//") {
180 header_end = idx + 1;
181 continue;
182 }
183 let is_import = trimmed.starts_with("import ")
184 || trimmed.starts_with("import\t")
185 || trimmed.starts_with("import\"")
186 || trimmed.starts_with("pub import ")
187 || trimmed.starts_with("pub import\t");
188 if is_import {
189 header_end = idx + 1;
190 last_kept = idx + 1;
191 } else {
192 break;
193 }
194 }
195 if last_kept == 0 {
196 return (String::new(), code.to_string());
197 }
198 let mut header_lines: Vec<&str> = Vec::new();
199 let mut body_lines: Vec<&str> = Vec::new();
200 for (idx, line) in code.lines().enumerate() {
201 if idx < header_end {
202 header_lines.push(line);
203 } else {
204 body_lines.push(line);
205 }
206 }
207 (header_lines.join("\n"), body_lines.join("\n"))
208}
209
210#[derive(Clone, Debug, Default, PartialEq, Eq)]
211pub enum CliLlmMockMode {
212 #[default]
213 Off,
214 Replay {
215 fixture_path: PathBuf,
216 },
217 Record {
218 fixture_path: PathBuf,
219 },
220}
221
222#[derive(Clone, Debug, Default, PartialEq, Eq)]
223pub struct RunAttestationOptions {
224 pub receipt_out: Option<PathBuf>,
225 pub agent_id: Option<String>,
226}
227
228#[derive(Clone, Debug, Default, PartialEq, Eq)]
233pub struct RunProfileOptions {
234 pub text: bool,
235 pub json_path: Option<PathBuf>,
236}
237
238impl RunProfileOptions {
239 pub fn is_enabled(&self) -> bool {
240 self.text || self.json_path.is_some()
241 }
242}
243
244#[derive(Clone, Debug, Default)]
248pub struct RunOutcome {
249 pub stdout: String,
250 pub stderr: String,
251 pub exit_code: i32,
252}
253
254fn load_cli_llm_mocks(path: &Path) -> Result<Vec<harn_vm::llm::LlmMock>, String> {
255 let content = fs::read_to_string(path)
256 .map_err(|error| format!("failed to read {}: {error}", path.display()))?;
257 let mut mocks = Vec::new();
258 for (idx, raw_line) in content.lines().enumerate() {
259 let line_no = idx + 1;
260 let line = raw_line.trim();
261 if line.is_empty() {
262 continue;
263 }
264 let value: serde_json::Value = serde_json::from_str(line).map_err(|error| {
265 format!(
266 "invalid JSON in {} line {}: {error}",
267 path.display(),
268 line_no
269 )
270 })?;
271 mocks.push(parse_cli_llm_mock_value(&value).map_err(|error| {
272 format!(
273 "invalid --llm-mock fixture in {} line {}: {error}",
274 path.display(),
275 line_no
276 )
277 })?);
278 }
279 Ok(mocks)
280}
281
282fn parse_cli_llm_mock_value(value: &serde_json::Value) -> Result<harn_vm::llm::LlmMock, String> {
283 let object = value
284 .as_object()
285 .ok_or_else(|| "fixture line must be a JSON object".to_string())?;
286
287 let match_pattern = optional_string_field(object, "match")?;
288 let consume_on_match = object
289 .get("consume_match")
290 .and_then(|value| value.as_bool())
291 .unwrap_or(false);
292 let text = optional_string_field(object, "text")?.unwrap_or_default();
293 let input_tokens = optional_i64_field(object, "input_tokens")?;
294 let output_tokens = optional_i64_field(object, "output_tokens")?;
295 let cache_read_tokens = optional_i64_field(object, "cache_read_tokens")?;
296 let cache_write_tokens = optional_i64_field(object, "cache_write_tokens")?
297 .or(optional_i64_field(object, "cache_creation_input_tokens")?);
298 let thinking = optional_string_field(object, "thinking")?;
299 let thinking_summary = optional_string_field(object, "thinking_summary")?;
300 let stop_reason = optional_string_field(object, "stop_reason")?;
301 let model = optional_string_field(object, "model")?.unwrap_or_else(|| "mock".to_string());
302 let provider = optional_string_field(object, "provider")?;
303 let blocks = optional_vec_field(object, "blocks")?;
304 let logprobs = optional_vec_field(object, "logprobs")?.unwrap_or_default();
305 let tool_calls = parse_cli_llm_tool_calls(object.get("tool_calls"))?;
306 let error = parse_cli_llm_mock_error(object.get("error"))?;
307
308 Ok(harn_vm::llm::LlmMock {
309 text,
310 tool_calls,
311 match_pattern,
312 consume_on_match,
313 input_tokens,
314 output_tokens,
315 cache_read_tokens,
316 cache_write_tokens,
317 thinking,
318 thinking_summary,
319 stop_reason,
320 model,
321 provider,
322 blocks,
323 logprobs,
324 error,
325 })
326}
327
328fn parse_cli_llm_tool_calls(
329 value: Option<&serde_json::Value>,
330) -> Result<Vec<serde_json::Value>, String> {
331 let Some(value) = value else {
332 return Ok(Vec::new());
333 };
334 let items = value
335 .as_array()
336 .ok_or_else(|| "tool_calls must be an array".to_string())?;
337 items
338 .iter()
339 .enumerate()
340 .map(|(idx, item)| {
341 normalize_cli_llm_tool_call(item).map_err(|error| format!("tool_calls[{idx}] {error}"))
342 })
343 .collect()
344}
345
346fn normalize_cli_llm_tool_call(value: &serde_json::Value) -> Result<serde_json::Value, String> {
347 let object = value
348 .as_object()
349 .ok_or_else(|| "must be a JSON object".to_string())?;
350 let name = object
351 .get("name")
352 .and_then(|value| value.as_str())
353 .ok_or_else(|| "is missing string field `name`".to_string())?;
354 let arguments = object
355 .get("arguments")
356 .cloned()
357 .or_else(|| object.get("args").cloned())
358 .unwrap_or_else(|| serde_json::json!({}));
359 Ok(serde_json::json!({
360 "name": name,
361 "arguments": arguments,
362 }))
363}
364
365fn parse_cli_llm_mock_error(
366 value: Option<&serde_json::Value>,
367) -> Result<Option<harn_vm::llm::MockError>, String> {
368 let Some(value) = value else {
369 return Ok(None);
370 };
371 if value.is_null() {
372 return Ok(None);
373 }
374 let object = value.as_object().ok_or_else(|| {
375 "error must be an object {category, message, retry_after_ms?}".to_string()
376 })?;
377 let category_str = object
378 .get("category")
379 .and_then(|value| value.as_str())
380 .ok_or_else(|| "error.category is required".to_string())?;
381 let category = harn_vm::ErrorCategory::parse(category_str);
382 if category.as_str() != category_str {
383 return Err(format!("unknown error category `{category_str}`"));
384 }
385 let message = object
386 .get("message")
387 .and_then(|value| value.as_str())
388 .unwrap_or_default()
389 .to_string();
390 let retry_after_ms = match object.get("retry_after_ms") {
391 None | Some(serde_json::Value::Null) => None,
392 Some(serde_json::Value::Number(n)) => match n.as_u64() {
393 Some(v) => Some(v),
394 None => return Err("error.retry_after_ms must be a non-negative integer".to_string()),
395 },
396 Some(_) => return Err("error.retry_after_ms must be a non-negative integer".to_string()),
397 };
398 Ok(Some(harn_vm::llm::MockError {
399 category,
400 message,
401 retry_after_ms,
402 }))
403}
404
405fn optional_string_field(
406 object: &serde_json::Map<String, serde_json::Value>,
407 key: &str,
408) -> Result<Option<String>, String> {
409 match object.get(key) {
410 None | Some(serde_json::Value::Null) => Ok(None),
411 Some(serde_json::Value::String(value)) => Ok(Some(value.clone())),
412 Some(_) => Err(format!("`{key}` must be a string")),
413 }
414}
415
416fn optional_i64_field(
417 object: &serde_json::Map<String, serde_json::Value>,
418 key: &str,
419) -> Result<Option<i64>, String> {
420 match object.get(key) {
421 None | Some(serde_json::Value::Null) => Ok(None),
422 Some(value) => value
423 .as_i64()
424 .map(Some)
425 .ok_or_else(|| format!("`{key}` must be an integer")),
426 }
427}
428
429fn optional_vec_field(
430 object: &serde_json::Map<String, serde_json::Value>,
431 key: &str,
432) -> Result<Option<Vec<serde_json::Value>>, String> {
433 match object.get(key) {
434 None | Some(serde_json::Value::Null) => Ok(None),
435 Some(serde_json::Value::Array(items)) => Ok(Some(items.clone())),
436 Some(_) => Err(format!("`{key}` must be an array")),
437 }
438}
439
440pub fn install_cli_llm_mock_mode(mode: &CliLlmMockMode) -> Result<(), String> {
441 harn_vm::llm::clear_cli_llm_mock_mode();
442 match mode {
443 CliLlmMockMode::Off => Ok(()),
444 CliLlmMockMode::Replay { fixture_path } => {
445 let mocks = load_cli_llm_mocks(fixture_path)?;
446 harn_vm::llm::install_cli_llm_mocks(mocks);
447 Ok(())
448 }
449 CliLlmMockMode::Record { .. } => {
450 harn_vm::llm::enable_cli_llm_mock_recording();
451 Ok(())
452 }
453 }
454}
455
456pub fn persist_cli_llm_mock_recording(mode: &CliLlmMockMode) -> Result<(), String> {
457 let CliLlmMockMode::Record { fixture_path } = mode else {
458 return Ok(());
459 };
460 if let Some(parent) = fixture_path.parent() {
461 if !parent.as_os_str().is_empty() {
462 fs::create_dir_all(parent).map_err(|error| {
463 format!(
464 "failed to create fixture directory {}: {error}",
465 parent.display()
466 )
467 })?;
468 }
469 }
470
471 let lines = harn_vm::llm::take_cli_llm_recordings()
472 .into_iter()
473 .map(serialize_cli_llm_mock)
474 .collect::<Result<Vec<_>, _>>()?;
475 let body = if lines.is_empty() {
476 String::new()
477 } else {
478 format!("{}\n", lines.join("\n"))
479 };
480 fs::write(fixture_path, body)
481 .map_err(|error| format!("failed to write {}: {error}", fixture_path.display()))
482}
483
484fn serialize_cli_llm_mock(mock: harn_vm::llm::LlmMock) -> Result<String, String> {
485 let mut object = serde_json::Map::new();
486 if let Some(match_pattern) = mock.match_pattern {
487 object.insert(
488 "match".to_string(),
489 serde_json::Value::String(match_pattern),
490 );
491 }
492 if !mock.text.is_empty() {
493 object.insert("text".to_string(), serde_json::Value::String(mock.text));
494 }
495 if !mock.tool_calls.is_empty() {
496 let tool_calls = mock
497 .tool_calls
498 .into_iter()
499 .map(|tool_call| {
500 let object = tool_call
501 .as_object()
502 .ok_or_else(|| "recorded tool call must be an object".to_string())?;
503 let name = object
504 .get("name")
505 .and_then(|value| value.as_str())
506 .ok_or_else(|| "recorded tool call is missing `name`".to_string())?;
507 Ok(serde_json::json!({
508 "name": name,
509 "args": object
510 .get("arguments")
511 .cloned()
512 .unwrap_or_else(|| serde_json::json!({})),
513 }))
514 })
515 .collect::<Result<Vec<_>, String>>()?;
516 object.insert(
517 "tool_calls".to_string(),
518 serde_json::Value::Array(tool_calls),
519 );
520 }
521 if let Some(input_tokens) = mock.input_tokens {
522 object.insert(
523 "input_tokens".to_string(),
524 serde_json::Value::Number(input_tokens.into()),
525 );
526 }
527 if let Some(output_tokens) = mock.output_tokens {
528 object.insert(
529 "output_tokens".to_string(),
530 serde_json::Value::Number(output_tokens.into()),
531 );
532 }
533 if let Some(cache_read_tokens) = mock.cache_read_tokens {
534 object.insert(
535 "cache_read_tokens".to_string(),
536 serde_json::Value::Number(cache_read_tokens.into()),
537 );
538 }
539 if let Some(cache_write_tokens) = mock.cache_write_tokens {
540 object.insert(
541 "cache_write_tokens".to_string(),
542 serde_json::Value::Number(cache_write_tokens.into()),
543 );
544 object.insert(
545 "cache_creation_input_tokens".to_string(),
546 serde_json::Value::Number(cache_write_tokens.into()),
547 );
548 }
549 if let Some(thinking) = mock.thinking {
550 object.insert("thinking".to_string(), serde_json::Value::String(thinking));
551 }
552 if let Some(stop_reason) = mock.stop_reason {
553 object.insert(
554 "stop_reason".to_string(),
555 serde_json::Value::String(stop_reason),
556 );
557 }
558 object.insert("model".to_string(), serde_json::Value::String(mock.model));
559 if let Some(provider) = mock.provider {
560 object.insert("provider".to_string(), serde_json::Value::String(provider));
561 }
562 if let Some(blocks) = mock.blocks {
563 object.insert("blocks".to_string(), serde_json::Value::Array(blocks));
564 }
565 if !mock.logprobs.is_empty() {
566 object.insert(
567 "logprobs".to_string(),
568 serde_json::Value::Array(mock.logprobs),
569 );
570 }
571 if let Some(error) = mock.error {
572 object.insert(
573 "error".to_string(),
574 serde_json::json!({
575 "category": error.category.as_str(),
576 "message": error.message,
577 }),
578 );
579 }
580 serde_json::to_string(&serde_json::Value::Object(object))
581 .map_err(|error| format!("failed to serialize recorded fixture: {error}"))
582}
583
584pub(crate) async fn run_file(
585 path: &str,
586 trace: bool,
587 denied_builtins: HashSet<String>,
588 script_argv: Vec<String>,
589 llm_mock_mode: CliLlmMockMode,
590 attestation: Option<RunAttestationOptions>,
591 profile: RunProfileOptions,
592) {
593 run_file_with_skill_dirs(
594 path,
595 trace,
596 denied_builtins,
597 script_argv,
598 Vec::new(),
599 llm_mock_mode,
600 attestation,
601 profile,
602 )
603 .await;
604}
605
606pub(crate) fn run_explain_cost_file_with_skill_dirs(path: &str) {
607 let outcome = execute_explain_cost(path);
608 if !outcome.stderr.is_empty() {
609 io::stderr().write_all(outcome.stderr.as_bytes()).ok();
610 }
611 if !outcome.stdout.is_empty() {
612 io::stdout().write_all(outcome.stdout.as_bytes()).ok();
613 }
614 if outcome.exit_code != 0 {
615 process::exit(outcome.exit_code);
616 }
617}
618
619pub(crate) async fn run_file_with_skill_dirs(
620 path: &str,
621 trace: bool,
622 denied_builtins: HashSet<String>,
623 script_argv: Vec<String>,
624 skill_dirs_raw: Vec<String>,
625 llm_mock_mode: CliLlmMockMode,
626 attestation: Option<RunAttestationOptions>,
627 profile: RunProfileOptions,
628) {
629 let cancelled = install_signal_shutdown_handler();
631
632 let _stdout_passthrough = StdoutPassthroughGuard::enable();
633 let outcome = execute_run(
634 path,
635 trace,
636 denied_builtins,
637 script_argv,
638 skill_dirs_raw,
639 llm_mock_mode,
640 attestation,
641 profile,
642 )
643 .await;
644
645 if !outcome.stderr.is_empty() {
648 io::stderr().write_all(outcome.stderr.as_bytes()).ok();
649 }
650 if !outcome.stdout.is_empty() {
651 io::stdout().write_all(outcome.stdout.as_bytes()).ok();
652 }
653
654 let mut exit_code = outcome.exit_code;
655 if exit_code != 0 && cancelled.load(Ordering::SeqCst) {
656 exit_code = 124;
657 }
658 if exit_code != 0 {
659 process::exit(exit_code);
660 }
661}
662
663pub fn execute_explain_cost(path: &str) -> RunOutcome {
664 let stdout = String::new();
665 let mut stderr = String::new();
666
667 let (source, program) = parse_source_file(path);
668
669 let mut had_type_error = false;
670 let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
671 for diag in &type_diagnostics {
672 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
673 if matches!(diag.severity, DiagnosticSeverity::Error) {
674 had_type_error = true;
675 }
676 stderr.push_str(&rendered);
677 }
678 if had_type_error {
679 return RunOutcome {
680 stdout,
681 stderr,
682 exit_code: 1,
683 };
684 }
685
686 let extensions = package::load_runtime_extensions(Path::new(path));
687 package::install_runtime_extensions(&extensions);
688 RunOutcome {
689 stdout: explain_cost::render_explain_cost(path, &program),
690 stderr,
691 exit_code: 0,
692 }
693}
694
695struct StdoutPassthroughGuard {
696 previous: bool,
697}
698
699impl StdoutPassthroughGuard {
700 fn enable() -> Self {
701 Self {
702 previous: harn_vm::set_stdout_passthrough(true),
703 }
704 }
705}
706
707impl Drop for StdoutPassthroughGuard {
708 fn drop(&mut self) {
709 harn_vm::set_stdout_passthrough(self.previous);
710 }
711}
712
713fn install_signal_shutdown_handler() -> Arc<AtomicBool> {
714 let cancelled = Arc::new(AtomicBool::new(false));
715 let cancelled_clone = cancelled.clone();
716 tokio::spawn(async move {
717 #[cfg(unix)]
718 {
719 use tokio::signal::unix::{signal, SignalKind};
720 let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM handler");
721 let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT handler");
722 tokio::select! {
723 _ = sigterm.recv() => {},
724 _ = sigint.recv() => {},
725 }
726 cancelled_clone.store(true, Ordering::SeqCst);
727 eprintln!("[harn] signal received, flushing state...");
728 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
729 process::exit(124);
730 }
731 #[cfg(not(unix))]
732 {
733 let _ = tokio::signal::ctrl_c().await;
734 cancelled_clone.store(true, Ordering::SeqCst);
735 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
736 process::exit(124);
737 }
738 });
739 cancelled
740}
741
742pub async fn execute_run(
748 path: &str,
749 trace: bool,
750 denied_builtins: HashSet<String>,
751 script_argv: Vec<String>,
752 skill_dirs_raw: Vec<String>,
753 llm_mock_mode: CliLlmMockMode,
754 attestation: Option<RunAttestationOptions>,
755 profile: RunProfileOptions,
756) -> RunOutcome {
757 let mut stderr = String::new();
758 let mut stdout = String::new();
759
760 let (source, program) = parse_source_file(path);
761
762 let mut had_type_error = false;
763 let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
764 for diag in &type_diagnostics {
765 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
766 if matches!(diag.severity, DiagnosticSeverity::Error) {
767 had_type_error = true;
768 }
769 stderr.push_str(&rendered);
770 }
771 if had_type_error {
772 return RunOutcome {
773 stdout,
774 stderr,
775 exit_code: 1,
776 };
777 }
778
779 let chunk = match harn_vm::Compiler::new().compile(&program) {
780 Ok(c) => c,
781 Err(e) => {
782 stderr.push_str(&format!("error: compile error: {e}\n"));
783 return RunOutcome {
784 stdout,
785 stderr,
786 exit_code: 1,
787 };
788 }
789 };
790
791 if trace {
792 harn_vm::llm::enable_tracing();
793 }
794 if profile.is_enabled() {
795 harn_vm::tracing::set_tracing_enabled(true);
796 }
797 if let Err(error) = install_cli_llm_mock_mode(&llm_mock_mode) {
798 stderr.push_str(&format!("error: {error}\n"));
799 return RunOutcome {
800 stdout,
801 stderr,
802 exit_code: 1,
803 };
804 }
805
806 let mut vm = harn_vm::Vm::new();
807 harn_vm::register_vm_stdlib(&mut vm);
808 crate::install_default_hostlib(&mut vm);
809 let source_parent = std::path::Path::new(path)
810 .parent()
811 .unwrap_or(std::path::Path::new("."));
812 let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
814 let store_base = project_root.as_deref().unwrap_or(source_parent);
815 let attestation_started_at_ms = now_ms();
816 let attestation_log = if attestation.is_some() {
817 Some(harn_vm::event_log::install_memory_for_current_thread(256))
818 } else {
819 None
820 };
821 if let Some(log) = attestation_log.as_ref() {
822 append_run_provenance_event(
823 log,
824 "started",
825 serde_json::json!({
826 "pipeline": path,
827 "argv": &script_argv,
828 "project_root": store_base.display().to_string(),
829 }),
830 )
831 .await;
832 }
833 harn_vm::register_store_builtins(&mut vm, store_base);
834 harn_vm::register_metadata_builtins(&mut vm, store_base);
835 let pipeline_name = std::path::Path::new(path)
836 .file_stem()
837 .and_then(|s| s.to_str())
838 .unwrap_or("default");
839 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
840 vm.set_source_info(path, &source);
841 if !denied_builtins.is_empty() {
842 vm.set_denied_builtins(denied_builtins);
843 }
844 if let Some(ref root) = project_root {
845 vm.set_project_root(root);
846 }
847
848 if let Some(p) = std::path::Path::new(path).parent() {
849 if !p.as_os_str().is_empty() {
850 vm.set_source_dir(p);
851 }
852 }
853
854 let cli_dirs = canonicalize_cli_dirs(&skill_dirs_raw, None);
857 let loaded = load_skills(&SkillLoaderInputs {
858 cli_dirs,
859 source_path: Some(std::path::PathBuf::from(path)),
860 });
861 emit_loader_warnings(&loaded.loader_warnings);
862 install_skills_global(&mut vm, &loaded);
863
864 let argv_values: Vec<harn_vm::VmValue> = script_argv
867 .iter()
868 .map(|s| harn_vm::VmValue::String(std::rc::Rc::from(s.as_str())))
869 .collect();
870 vm.set_global(
871 "argv",
872 harn_vm::VmValue::List(std::rc::Rc::new(argv_values)),
873 );
874
875 let extensions = package::load_runtime_extensions(Path::new(path));
876 package::install_runtime_extensions(&extensions);
877 if let Some(manifest) = extensions.root_manifest.as_ref() {
878 if !manifest.mcp.is_empty() {
879 connect_mcp_servers(&manifest.mcp, &mut vm).await;
880 }
881 }
882 if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
883 stderr.push_str(&format!(
884 "error: failed to install manifest triggers: {error}\n"
885 ));
886 return RunOutcome {
887 stdout,
888 stderr,
889 exit_code: 1,
890 };
891 }
892 if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
893 stderr.push_str(&format!(
894 "error: failed to install manifest hooks: {error}\n"
895 ));
896 return RunOutcome {
897 stdout,
898 stderr,
899 exit_code: 1,
900 };
901 }
902
903 let local = tokio::task::LocalSet::new();
905 let execution = local
906 .run_until(async {
907 match vm.execute(&chunk).await {
908 Ok(value) => Ok((vm.output(), value)),
909 Err(e) => Err(vm.format_runtime_error(&e)),
910 }
911 })
912 .await;
913 if let Err(error) = persist_cli_llm_mock_recording(&llm_mock_mode) {
914 stderr.push_str(&format!("error: {error}\n"));
915 return RunOutcome {
916 stdout,
917 stderr,
918 exit_code: 1,
919 };
920 }
921
922 let buffered_stderr = harn_vm::take_stderr_buffer();
924 stderr.push_str(&buffered_stderr);
925
926 let exit_code = match &execution {
927 Ok((_, return_value)) => exit_code_from_return_value(return_value),
928 Err(_) => 1,
929 };
930
931 if let (Some(options), Some(log)) = (attestation.as_ref(), attestation_log.as_ref()) {
932 if let Err(error) = emit_run_attestation(
933 log,
934 path,
935 store_base,
936 attestation_started_at_ms,
937 exit_code,
938 options,
939 &mut stderr,
940 )
941 .await
942 {
943 stderr.push_str(&format!(
944 "error: failed to emit provenance receipt: {error}\n"
945 ));
946 return RunOutcome {
947 stdout,
948 stderr,
949 exit_code: 1,
950 };
951 }
952 harn_vm::event_log::reset_active_event_log();
953 }
954
955 match execution {
956 Ok((output, return_value)) => {
957 stdout.push_str(output);
958 if trace {
959 stderr.push_str(&render_trace_summary());
960 }
961 if profile.is_enabled() {
962 if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
963 stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
964 }
965 }
966 if exit_code != 0 {
967 stderr.push_str(&render_return_value_error(&return_value));
968 }
969 RunOutcome {
970 stdout,
971 stderr,
972 exit_code,
973 }
974 }
975 Err(rendered_error) => {
976 stderr.push_str(&rendered_error);
977 if profile.is_enabled() {
978 if let Err(error) = render_and_persist_profile(&profile, &mut stderr) {
979 stderr.push_str(&format!("warning: failed to write profile: {error}\n"));
980 }
981 }
982 RunOutcome {
983 stdout,
984 stderr,
985 exit_code: 1,
986 }
987 }
988 }
989}
990
991fn render_and_persist_profile(
992 options: &RunProfileOptions,
993 stderr: &mut String,
994) -> Result<(), String> {
995 let spans = harn_vm::tracing::peek_spans();
996 let profile = harn_vm::profile::build(&spans);
997 if options.text {
998 stderr.push_str(&harn_vm::profile::render(&profile));
999 }
1000 if let Some(path) = options.json_path.as_ref() {
1001 if let Some(parent) = path.parent() {
1002 if !parent.as_os_str().is_empty() {
1003 fs::create_dir_all(parent)
1004 .map_err(|error| format!("create {}: {error}", parent.display()))?;
1005 }
1006 }
1007 let json = serde_json::to_string_pretty(&profile)
1008 .map_err(|error| format!("serialize profile: {error}"))?;
1009 fs::write(path, json).map_err(|error| format!("write {}: {error}", path.display()))?;
1010 }
1011 Ok(())
1012}
1013
1014async fn append_run_provenance_event(
1015 log: &Arc<harn_vm::event_log::AnyEventLog>,
1016 kind: &str,
1017 payload: serde_json::Value,
1018) {
1019 let Ok(topic) = harn_vm::event_log::Topic::new("run.provenance") else {
1020 return;
1021 };
1022 let _ = log
1023 .append(&topic, harn_vm::event_log::LogEvent::new(kind, payload))
1024 .await;
1025}
1026
1027async fn emit_run_attestation(
1028 log: &Arc<harn_vm::event_log::AnyEventLog>,
1029 path: &str,
1030 store_base: &Path,
1031 started_at_ms: i64,
1032 exit_code: i32,
1033 options: &RunAttestationOptions,
1034 stderr: &mut String,
1035) -> Result<(), String> {
1036 let finished_at_ms = now_ms();
1037 let status = if exit_code == 0 { "success" } else { "failure" };
1038 append_run_provenance_event(
1039 log,
1040 "finished",
1041 serde_json::json!({
1042 "pipeline": path,
1043 "status": status,
1044 "exit_code": exit_code,
1045 }),
1046 )
1047 .await;
1048 log.flush()
1049 .await
1050 .map_err(|error| format!("failed to flush attestation event log: {error}"))?;
1051 let secret_provider = harn_vm::secrets::configured_default_chain("harn.provenance")
1052 .map_err(|error| format!("failed to configure provenance secrets: {error}"))?;
1053 let (signing_key, key_id) =
1054 harn_vm::load_or_generate_agent_signing_key(&secret_provider, options.agent_id.as_deref())
1055 .await
1056 .map_err(|error| format!("failed to load provenance signing key: {error}"))?;
1057 let receipt = harn_vm::build_signed_receipt(
1058 log,
1059 harn_vm::ReceiptBuildOptions {
1060 pipeline: path.to_string(),
1061 status: status.to_string(),
1062 started_at_ms,
1063 finished_at_ms,
1064 exit_code,
1065 producer_name: "harn-cli".to_string(),
1066 producer_version: env!("CARGO_PKG_VERSION").to_string(),
1067 },
1068 &signing_key,
1069 key_id,
1070 )
1071 .await
1072 .map_err(|error| format!("failed to build provenance receipt: {error}"))?;
1073 let receipt_path = receipt_output_path(store_base, options, &receipt.receipt_id);
1074 if let Some(parent) = receipt_path.parent() {
1075 fs::create_dir_all(parent)
1076 .map_err(|error| format!("failed to create {}: {error}", parent.display()))?;
1077 }
1078 let encoded = serde_json::to_vec_pretty(&receipt)
1079 .map_err(|error| format!("failed to encode provenance receipt: {error}"))?;
1080 fs::write(&receipt_path, encoded)
1081 .map_err(|error| format!("failed to write {}: {error}", receipt_path.display()))?;
1082 stderr.push_str(&format!("provenance receipt: {}\n", receipt_path.display()));
1083 Ok(())
1084}
1085
1086fn receipt_output_path(
1087 store_base: &Path,
1088 options: &RunAttestationOptions,
1089 receipt_id: &str,
1090) -> PathBuf {
1091 if let Some(path) = options.receipt_out.as_ref() {
1092 return path.clone();
1093 }
1094 harn_vm::runtime_paths::state_root(store_base)
1095 .join("receipts")
1096 .join(format!("{receipt_id}.json"))
1097}
1098
1099fn now_ms() -> i64 {
1100 std::time::SystemTime::now()
1101 .duration_since(std::time::UNIX_EPOCH)
1102 .map(|duration| duration.as_millis() as i64)
1103 .unwrap_or(0)
1104}
1105
1106fn exit_code_from_return_value(value: &harn_vm::VmValue) -> i32 {
1113 use harn_vm::VmValue;
1114 match value {
1115 VmValue::Int(n) => (*n).clamp(0, 255) as i32,
1116 VmValue::EnumVariant {
1117 enum_name,
1118 variant,
1119 fields,
1120 } if enum_name.as_ref() == "Result" && variant.as_ref() == "Err" => 1,
1121 _ => 0,
1122 }
1123}
1124
1125fn render_return_value_error(value: &harn_vm::VmValue) -> String {
1126 let harn_vm::VmValue::EnumVariant {
1127 enum_name,
1128 variant,
1129 fields,
1130 } = value
1131 else {
1132 return String::new();
1133 };
1134 if enum_name.as_ref() != "Result" || variant.as_ref() != "Err" {
1135 return String::new();
1136 }
1137 let rendered = fields.first().map(|p| p.display()).unwrap_or_default();
1138 if rendered.is_empty() {
1139 "error\n".to_string()
1140 } else if rendered.ends_with('\n') {
1141 rendered
1142 } else {
1143 format!("{rendered}\n")
1144 }
1145}
1146
1147pub(crate) async fn connect_mcp_servers(
1156 servers: &[package::McpServerConfig],
1157 vm: &mut harn_vm::Vm,
1158) {
1159 use std::collections::BTreeMap;
1160 use std::rc::Rc;
1161 use std::time::Duration;
1162
1163 let mut mcp_dict: BTreeMap<String, harn_vm::VmValue> = BTreeMap::new();
1164 let mut registrations: Vec<harn_vm::RegisteredMcpServer> = Vec::new();
1165
1166 for server in servers {
1167 let resolved_auth = match mcp::resolve_auth_for_server(server).await {
1168 Ok(resolution) => resolution,
1169 Err(error) => {
1170 eprintln!(
1171 "warning: mcp: failed to load auth for '{}': {}",
1172 server.name, error
1173 );
1174 AuthResolution::None
1175 }
1176 };
1177 let spec = serde_json::json!({
1178 "name": server.name,
1179 "transport": server.transport.clone().unwrap_or_else(|| "stdio".to_string()),
1180 "command": server.command,
1181 "args": server.args,
1182 "env": server.env,
1183 "url": server.url,
1184 "auth_token": match resolved_auth {
1185 AuthResolution::Bearer(token) => Some(token),
1186 AuthResolution::None => server.auth_token.clone(),
1187 },
1188 "protocol_version": server.protocol_version,
1189 "proxy_server_name": server.proxy_server_name,
1190 });
1191
1192 registrations.push(harn_vm::RegisteredMcpServer {
1195 name: server.name.clone(),
1196 spec: spec.clone(),
1197 lazy: server.lazy,
1198 card: server.card.clone(),
1199 keep_alive: server.keep_alive_ms.map(Duration::from_millis),
1200 });
1201
1202 if server.lazy {
1203 eprintln!(
1204 "[harn] mcp: deferred '{}' (lazy, boots on first use)",
1205 server.name
1206 );
1207 continue;
1208 }
1209
1210 match harn_vm::connect_mcp_server_from_json(&spec).await {
1211 Ok(handle) => {
1212 eprintln!("[harn] mcp: connected to '{}'", server.name);
1213 harn_vm::mcp_install_active(&server.name, handle.clone());
1214 mcp_dict.insert(server.name.clone(), harn_vm::VmValue::McpClient(handle));
1215 }
1216 Err(e) => {
1217 eprintln!(
1218 "warning: mcp: failed to connect to '{}': {}",
1219 server.name, e
1220 );
1221 }
1222 }
1223 }
1224
1225 harn_vm::mcp_register_servers(registrations);
1228
1229 if !mcp_dict.is_empty() {
1230 vm.set_global("mcp", harn_vm::VmValue::Dict(Rc::new(mcp_dict)));
1231 }
1232}
1233
1234fn render_trace_summary() -> String {
1235 use std::fmt::Write;
1236 let entries = harn_vm::llm::take_trace();
1237 if entries.is_empty() {
1238 return String::new();
1239 }
1240 let mut out = String::new();
1241 let _ = writeln!(out, "\n\x1b[2m─── LLM trace ───\x1b[0m");
1242 let mut total_input = 0i64;
1243 let mut total_output = 0i64;
1244 let mut total_ms = 0u64;
1245 for (i, entry) in entries.iter().enumerate() {
1246 let _ = writeln!(
1247 out,
1248 " #{}: {} | {} in + {} out tokens | {} ms",
1249 i + 1,
1250 entry.model,
1251 entry.input_tokens,
1252 entry.output_tokens,
1253 entry.duration_ms,
1254 );
1255 total_input += entry.input_tokens;
1256 total_output += entry.output_tokens;
1257 total_ms += entry.duration_ms;
1258 }
1259 let total_tokens = total_input + total_output;
1260 let cost = (total_input as f64 * 3.0 + total_output as f64 * 15.0) / 1_000_000.0;
1262 let _ = writeln!(
1263 out,
1264 " \x1b[1m{} call{}, {} tokens ({}in + {}out), {} ms, ~${:.4}\x1b[0m",
1265 entries.len(),
1266 if entries.len() == 1 { "" } else { "s" },
1267 total_tokens,
1268 total_input,
1269 total_output,
1270 total_ms,
1271 cost,
1272 );
1273 out
1274}
1275
1276pub(crate) async fn run_file_mcp_serve(
1290 path: &str,
1291 card_source: Option<&str>,
1292 mode: RunFileMcpServeMode,
1293) {
1294 let (source, program) = crate::parse_source_file(path);
1295
1296 let type_diagnostics = typecheck_with_imports(&program, Path::new(path), &source);
1297 for diag in &type_diagnostics {
1298 match diag.severity {
1299 DiagnosticSeverity::Error => {
1300 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
1301 eprint!("{rendered}");
1302 process::exit(1);
1303 }
1304 DiagnosticSeverity::Warning => {
1305 let rendered = harn_parser::diagnostic::render_type_diagnostic(&source, path, diag);
1306 eprint!("{rendered}");
1307 }
1308 }
1309 }
1310
1311 let chunk = match harn_vm::Compiler::new().compile(&program) {
1312 Ok(c) => c,
1313 Err(e) => {
1314 eprintln!("error: compile error: {e}");
1315 process::exit(1);
1316 }
1317 };
1318
1319 let mut vm = harn_vm::Vm::new();
1320 harn_vm::register_vm_stdlib(&mut vm);
1321 crate::install_default_hostlib(&mut vm);
1322 let source_parent = std::path::Path::new(path)
1323 .parent()
1324 .unwrap_or(std::path::Path::new("."));
1325 let project_root = harn_vm::stdlib::process::find_project_root(source_parent);
1326 let store_base = project_root.as_deref().unwrap_or(source_parent);
1327 harn_vm::register_store_builtins(&mut vm, store_base);
1328 harn_vm::register_metadata_builtins(&mut vm, store_base);
1329 let pipeline_name = std::path::Path::new(path)
1330 .file_stem()
1331 .and_then(|s| s.to_str())
1332 .unwrap_or("default");
1333 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
1334 vm.set_source_info(path, &source);
1335 if let Some(ref root) = project_root {
1336 vm.set_project_root(root);
1337 }
1338 if let Some(p) = std::path::Path::new(path).parent() {
1339 if !p.as_os_str().is_empty() {
1340 vm.set_source_dir(p);
1341 }
1342 }
1343
1344 let loaded = load_skills(&SkillLoaderInputs {
1346 cli_dirs: Vec::new(),
1347 source_path: Some(std::path::PathBuf::from(path)),
1348 });
1349 emit_loader_warnings(&loaded.loader_warnings);
1350 install_skills_global(&mut vm, &loaded);
1351
1352 let extensions = package::load_runtime_extensions(Path::new(path));
1353 package::install_runtime_extensions(&extensions);
1354 if let Some(manifest) = extensions.root_manifest.as_ref() {
1355 if !manifest.mcp.is_empty() {
1356 connect_mcp_servers(&manifest.mcp, &mut vm).await;
1357 }
1358 }
1359 if let Err(error) = package::install_manifest_triggers(&mut vm, &extensions).await {
1360 eprintln!("error: failed to install manifest triggers: {error}");
1361 process::exit(1);
1362 }
1363 if let Err(error) = package::install_manifest_hooks(&mut vm, &extensions).await {
1364 eprintln!("error: failed to install manifest hooks: {error}");
1365 process::exit(1);
1366 }
1367
1368 let local = tokio::task::LocalSet::new();
1369 local
1370 .run_until(async {
1371 match vm.execute(&chunk).await {
1372 Ok(_) => {}
1373 Err(e) => {
1374 eprint!("{}", vm.format_runtime_error(&e));
1375 process::exit(1);
1376 }
1377 }
1378
1379 let output = vm.output();
1381 if !output.is_empty() {
1382 eprint!("{output}");
1383 }
1384
1385 let registry = match harn_vm::take_mcp_serve_registry() {
1386 Some(r) => r,
1387 None => {
1388 eprintln!("error: pipeline did not call mcp_serve(registry)");
1389 eprintln!("hint: call mcp_serve(tools) at the end of your pipeline");
1390 process::exit(1);
1391 }
1392 };
1393
1394 let tools = match harn_vm::tool_registry_to_mcp_tools(®istry) {
1395 Ok(t) => t,
1396 Err(e) => {
1397 eprintln!("error: {e}");
1398 process::exit(1);
1399 }
1400 };
1401
1402 let resources = harn_vm::take_mcp_serve_resources();
1403 let resource_templates = harn_vm::take_mcp_serve_resource_templates();
1404 let prompts = harn_vm::take_mcp_serve_prompts();
1405
1406 let server_name = std::path::Path::new(path)
1407 .file_stem()
1408 .and_then(|s| s.to_str())
1409 .unwrap_or("harn")
1410 .to_string();
1411
1412 let mut caps = Vec::new();
1413 if !tools.is_empty() {
1414 caps.push(format!(
1415 "{} tool{}",
1416 tools.len(),
1417 if tools.len() == 1 { "" } else { "s" }
1418 ));
1419 }
1420 let total_resources = resources.len() + resource_templates.len();
1421 if total_resources > 0 {
1422 caps.push(format!(
1423 "{total_resources} resource{}",
1424 if total_resources == 1 { "" } else { "s" }
1425 ));
1426 }
1427 if !prompts.is_empty() {
1428 caps.push(format!(
1429 "{} prompt{}",
1430 prompts.len(),
1431 if prompts.len() == 1 { "" } else { "s" }
1432 ));
1433 }
1434 eprintln!(
1435 "[harn] serve mcp: serving {} as '{server_name}'",
1436 caps.join(", ")
1437 );
1438
1439 let mut server =
1440 harn_vm::McpServer::new(server_name, tools, resources, resource_templates, prompts);
1441 if let Some(source) = card_source {
1442 match resolve_card_source(source) {
1443 Ok(card) => server = server.with_server_card(card),
1444 Err(e) => {
1445 eprintln!("error: --card: {e}");
1446 process::exit(1);
1447 }
1448 }
1449 }
1450 match mode {
1451 RunFileMcpServeMode::Stdio => {
1452 if let Err(e) = server.run(&mut vm).await {
1453 eprintln!("error: MCP server error: {e}");
1454 process::exit(1);
1455 }
1456 }
1457 RunFileMcpServeMode::Http {
1458 options,
1459 auth_policy,
1460 } => {
1461 if let Err(e) = crate::commands::serve::run_script_mcp_http_server(
1462 server,
1463 vm,
1464 options,
1465 auth_policy,
1466 )
1467 .await
1468 {
1469 eprintln!("error: MCP server error: {e}");
1470 process::exit(1);
1471 }
1472 }
1473 }
1474 })
1475 .await;
1476}
1477
1478pub(crate) fn resolve_card_source(source: &str) -> Result<serde_json::Value, String> {
1483 let trimmed = source.trim_start();
1484 if trimmed.starts_with('{') || trimmed.starts_with('[') {
1485 return serde_json::from_str(source).map_err(|e| format!("inline JSON parse error: {e}"));
1486 }
1487 let path = std::path::Path::new(source);
1488 harn_vm::load_server_card_from_path(path).map_err(|e| format!("{e}"))
1489}
1490
1491pub(crate) async fn run_watch(path: &str, denied_builtins: HashSet<String>) {
1492 use notify::{Event, EventKind, RecursiveMode, Watcher};
1493
1494 let abs_path = std::fs::canonicalize(path).unwrap_or_else(|e| {
1495 eprintln!("Error: {e}");
1496 process::exit(1);
1497 });
1498 let watch_dir = abs_path.parent().unwrap_or(Path::new("."));
1499
1500 eprintln!("\x1b[2m[watch] running {path}...\x1b[0m");
1501 run_file(
1502 path,
1503 false,
1504 denied_builtins.clone(),
1505 Vec::new(),
1506 CliLlmMockMode::Off,
1507 None,
1508 RunProfileOptions::default(),
1509 )
1510 .await;
1511
1512 let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
1513 let _watcher = {
1514 let tx = tx.clone();
1515 let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
1516 if let Ok(event) = res {
1517 if matches!(
1518 event.kind,
1519 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
1520 ) {
1521 let has_harn = event
1522 .paths
1523 .iter()
1524 .any(|p| p.extension().is_some_and(|ext| ext == "harn"));
1525 if has_harn {
1526 let _ = tx.blocking_send(());
1527 }
1528 }
1529 }
1530 })
1531 .unwrap_or_else(|e| {
1532 eprintln!("Error setting up file watcher: {e}");
1533 process::exit(1);
1534 });
1535 watcher
1536 .watch(watch_dir, RecursiveMode::Recursive)
1537 .unwrap_or_else(|e| {
1538 eprintln!("Error watching directory: {e}");
1539 process::exit(1);
1540 });
1541 watcher };
1543
1544 eprintln!(
1545 "\x1b[2m[watch] watching {} for .harn changes (ctrl-c to stop)\x1b[0m",
1546 watch_dir.display()
1547 );
1548
1549 loop {
1550 rx.recv().await;
1551 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1553 while rx.try_recv().is_ok() {}
1554
1555 eprintln!();
1556 eprintln!("\x1b[2m[watch] change detected, re-running {path}...\x1b[0m");
1557 run_file(
1558 path,
1559 false,
1560 denied_builtins.clone(),
1561 Vec::new(),
1562 CliLlmMockMode::Off,
1563 None,
1564 RunProfileOptions::default(),
1565 )
1566 .await;
1567 }
1568}
1569
1570#[cfg(test)]
1571mod tests {
1572 use super::{
1573 execute_explain_cost, execute_run, parse_cli_llm_mock_value, serialize_cli_llm_mock,
1574 split_eval_header, CliLlmMockMode, RunProfileOptions, StdoutPassthroughGuard,
1575 };
1576 use std::collections::HashSet;
1577
1578 #[test]
1579 fn split_eval_header_no_imports_returns_full_body() {
1580 let (header, body) = split_eval_header("println(1 + 2)");
1581 assert_eq!(header, "");
1582 assert_eq!(body, "println(1 + 2)");
1583 }
1584
1585 #[test]
1586 fn split_eval_header_lifts_leading_imports() {
1587 let code = "import \"./lib\"\nimport { x } from \"std/math\"\nprintln(x)";
1588 let (header, body) = split_eval_header(code);
1589 assert_eq!(header, "import \"./lib\"\nimport { x } from \"std/math\"");
1590 assert_eq!(body, "println(x)");
1591 }
1592
1593 #[test]
1594 fn split_eval_header_keeps_pub_import_and_comments_in_header() {
1595 let code = "// header comment\npub import { y } from \"./lib\"\n\nfoo()";
1596 let (header, body) = split_eval_header(code);
1597 assert_eq!(
1598 header,
1599 "// header comment\npub import { y } from \"./lib\"\n"
1600 );
1601 assert_eq!(body, "foo()");
1602 }
1603
1604 #[test]
1605 fn split_eval_header_does_not_lift_imports_after_other_statements() {
1606 let code = "let a = 1\nimport \"./lib\"";
1607 let (header, body) = split_eval_header(code);
1608 assert_eq!(header, "");
1609 assert_eq!(body, "let a = 1\nimport \"./lib\"");
1610 }
1611
1612 #[test]
1613 fn cli_llm_mock_roundtrips_logprobs() {
1614 let mock = parse_cli_llm_mock_value(&serde_json::json!({
1615 "text": "visible",
1616 "logprobs": [{"token": "visible", "logprob": 0.0}]
1617 }))
1618 .expect("parse mock");
1619 assert_eq!(mock.logprobs.len(), 1);
1620
1621 let line = serialize_cli_llm_mock(mock).expect("serialize mock");
1622 let value: serde_json::Value = serde_json::from_str(&line).expect("json line");
1623 assert_eq!(value["logprobs"][0]["token"].as_str(), Some("visible"));
1624
1625 let reparsed = parse_cli_llm_mock_value(&value).expect("reparse mock");
1626 assert_eq!(reparsed.logprobs.len(), 1);
1627 assert_eq!(reparsed.logprobs[0]["logprob"].as_f64(), Some(0.0));
1628 }
1629
1630 #[test]
1631 fn stdout_passthrough_guard_restores_previous_state() {
1632 let original = harn_vm::set_stdout_passthrough(false);
1633 {
1634 let _guard = StdoutPassthroughGuard::enable();
1635 assert!(harn_vm::set_stdout_passthrough(true));
1636 }
1637 assert!(!harn_vm::set_stdout_passthrough(original));
1638 }
1639
1640 #[test]
1641 fn execute_explain_cost_does_not_execute_script() {
1642 let temp = tempfile::TempDir::new().expect("temp dir");
1643 let script = temp.path().join("main.harn");
1644 std::fs::write(
1645 &script,
1646 r#"
1647pipeline main() {
1648 write_file("executed.txt", "bad")
1649 llm_call("hello", nil, {provider: "mock", model: "mock"})
1650}
1651"#,
1652 )
1653 .expect("write script");
1654
1655 let outcome = execute_explain_cost(&script.to_string_lossy());
1656
1657 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1658 assert!(outcome.stdout.contains("LLM cost estimate"));
1659 assert!(
1660 !temp.path().join("executed.txt").exists(),
1661 "--explain-cost must not execute pipeline side effects"
1662 );
1663 }
1664
1665 #[cfg(feature = "hostlib")]
1666 #[tokio::test]
1667 async fn execute_run_installs_hostlib_gate() {
1668 let temp = tempfile::NamedTempFile::new().expect("temp file");
1669 std::fs::write(
1670 temp.path(),
1671 r#"
1672pipeline main() {
1673 let _ = hostlib_enable("tools:deterministic")
1674 println("enabled")
1675}
1676"#,
1677 )
1678 .expect("write script");
1679
1680 let outcome = execute_run(
1681 &temp.path().to_string_lossy(),
1682 false,
1683 HashSet::new(),
1684 Vec::new(),
1685 Vec::new(),
1686 CliLlmMockMode::Off,
1687 None,
1688 RunProfileOptions::default(),
1689 )
1690 .await;
1691
1692 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1693 assert_eq!(outcome.stdout.trim(), "enabled");
1694 }
1695
1696 #[cfg(all(feature = "hostlib", unix))]
1697 #[tokio::test]
1698 async fn execute_run_can_read_hostlib_command_artifacts() {
1699 let temp = tempfile::NamedTempFile::new().expect("temp file");
1700 std::fs::write(
1701 temp.path(),
1702 r#"
1703pipeline main() {
1704 let _ = hostlib_enable("tools:deterministic")
1705 let result = hostlib_tools_run_command({
1706 argv: ["sh", "-c", "i=0; while [ $i -lt 2000 ]; do printf x; i=$((i+1)); done"],
1707 capture: {max_inline_bytes: 8},
1708 timeout_ms: 5000,
1709 })
1710 println(starts_with(result.command_id, "cmd_"))
1711 println(len(result.stdout))
1712 println(result.byte_count)
1713 let window = hostlib_tools_read_command_output({
1714 command_id: result.command_id,
1715 offset: 1990,
1716 length: 20,
1717 })
1718 println(len(window.content))
1719 println(window.eof)
1720}
1721"#,
1722 )
1723 .expect("write script");
1724
1725 let outcome = execute_run(
1726 &temp.path().to_string_lossy(),
1727 false,
1728 HashSet::new(),
1729 Vec::new(),
1730 Vec::new(),
1731 CliLlmMockMode::Off,
1732 None,
1733 RunProfileOptions::default(),
1734 )
1735 .await;
1736
1737 assert_eq!(outcome.exit_code, 0, "stderr:\n{}", outcome.stderr);
1738 assert_eq!(outcome.stdout.trim(), "true\n8\n2000\n10\ntrue");
1739 }
1740}