1use std::collections::HashSet;
2use std::io::{self, Write};
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use harn_parser::{DiagnosticSeverity, Node, SNode, TypeChecker};
7
8use crate::cli::PlaygroundArgs;
9use crate::commands::run::{
10 connect_mcp_servers, install_cli_llm_mock_mode, persist_cli_llm_mock_recording, CliLlmMockMode,
11};
12use crate::package;
13use crate::skill_loader::{
14 emit_loader_warnings, install_skills_global, load_skills, SkillLoaderInputs,
15};
16
17#[derive(Clone, Debug, PartialEq, Eq)]
18struct LlmOverride {
19 provider: String,
20 model: String,
21}
22
23#[derive(Clone, Debug)]
27pub struct PlaygroundInputs {
28 pub host: PathBuf,
29 pub script: PathBuf,
30 pub task: String,
31 pub llm: Option<String>,
33 pub llm_mock_mode: CliLlmMockMode,
34}
35
36#[derive(Clone, Debug)]
37struct PlaygroundConfig {
38 host: PathBuf,
39 script: PathBuf,
40 task: String,
41 llm: Option<LlmOverride>,
42 llm_mock_mode: CliLlmMockMode,
43}
44
45pub(crate) async fn run_command(
46 args: PlaygroundArgs,
47 llm_mock_mode: CliLlmMockMode,
48) -> Result<(), String> {
49 let config = PlaygroundConfig {
50 host: canonicalize_or_err(&args.host)?,
51 script: canonicalize_or_err(&args.script)?,
52 task: args.task.unwrap_or_default(),
53 llm: args.llm.as_deref().map(parse_llm_override).transpose()?,
54 llm_mock_mode,
55 };
56
57 if args.watch {
58 run_watch(&config).await
59 } else {
60 let output = execute_playground(&config).await?;
61 if !output.is_empty() {
62 io::stdout()
63 .write_all(output.as_bytes())
64 .map_err(|error| format!("failed to write playground output: {error}"))?;
65 }
66 Ok(())
67 }
68}
69
70pub async fn execute_playground_inputs(inputs: PlaygroundInputs) -> Result<String, String> {
77 let llm = inputs.llm.as_deref().map(parse_llm_override).transpose()?;
78 let config = PlaygroundConfig {
79 host: canonicalize_or_err(inputs.host.to_string_lossy().as_ref())?,
80 script: canonicalize_or_err(inputs.script.to_string_lossy().as_ref())?,
81 task: inputs.task,
82 llm,
83 llm_mock_mode: inputs.llm_mock_mode,
84 };
85 execute_playground(&config).await
86}
87
88async fn run_watch(config: &PlaygroundConfig) -> Result<(), String> {
89 use notify::{Event, EventKind, RecursiveMode, Watcher};
90
91 eprintln!(
92 "\x1b[2m[playground] running {} with host {}...\x1b[0m",
93 config.script.display(),
94 config.host.display()
95 );
96 emit_run_result(execute_playground(config).await);
97
98 let roots = watch_roots(&config.host, &config.script);
99 let (tx, mut rx) = tokio::sync::mpsc::channel::<()>(1);
100 let _watcher = {
101 let tx = tx.clone();
102 let mut watcher = notify::recommended_watcher(move |res: Result<Event, _>| {
103 if let Ok(event) = res {
104 if matches!(
105 event.kind,
106 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Remove(_)
107 ) {
108 let has_harn = event
109 .paths
110 .iter()
111 .any(|path| path.extension().is_some_and(|ext| ext == "harn"));
112 if has_harn {
113 let _ = tx.blocking_send(());
114 }
115 }
116 }
117 })
118 .map_err(|error| format!("failed to create playground watcher: {error}"))?;
119
120 for root in &roots {
121 watcher
122 .watch(root, RecursiveMode::Recursive)
123 .map_err(|error| format!("failed to watch {}: {error}", root.display()))?;
124 }
125 watcher
126 };
127
128 eprintln!(
129 "\x1b[2m[playground] watching {} (ctrl-c to stop)\x1b[0m",
130 roots
131 .iter()
132 .map(|path| path.display().to_string())
133 .collect::<Vec<_>>()
134 .join(", ")
135 );
136
137 loop {
138 rx.recv().await;
139 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
140 while rx.try_recv().is_ok() {}
141
142 eprintln!();
143 eprintln!(
144 "\x1b[2m[playground] change detected, re-running {}...\x1b[0m",
145 config.script.display()
146 );
147 emit_run_result(execute_playground(config).await);
148 }
149}
150
151fn emit_run_result(result: Result<String, String>) {
152 match result {
153 Ok(output) => {
154 if !output.is_empty() {
155 let _ = io::stdout().write_all(output.as_bytes());
156 }
157 }
158 Err(error) => eprint!("{error}"),
159 }
160}
161
162async fn execute_playground(config: &PlaygroundConfig) -> Result<String, String> {
163 let (host_source, host_program) = crate::parse_source_file(&config.host.to_string_lossy());
164 typecheck_program(&host_source, &host_program, &config.host, &HashSet::new())?;
165 let host_exports = exported_host_functions(&host_program);
166
167 let (script_source, script_program) =
168 crate::parse_source_file(&config.script.to_string_lossy());
169 typecheck_program(
170 &script_source,
171 &script_program,
172 &config.script,
173 &host_exports,
174 )?;
175
176 let chunk = crate::compiler_for_source(&config.script, &script_source)
177 .compile(&script_program)
178 .map_err(|error| format!("error: compile error: {error}\n"))?;
179
180 let env_guard = ScopedEnv::apply(config);
181 let source_parent = config
182 .script
183 .parent()
184 .unwrap_or_else(|| Path::new("."))
185 .to_path_buf();
186 let project_root = harn_vm::stdlib::process::find_project_root(&source_parent);
187 let store_base = project_root.as_deref().unwrap_or(source_parent.as_path());
188 let execution_cwd = std::env::current_dir()
189 .unwrap_or_else(|_| PathBuf::from("."))
190 .to_string_lossy()
191 .into_owned();
192 let source_dir = source_parent.to_string_lossy().into_owned();
193
194 let local = tokio::task::LocalSet::new();
195 let result = local
196 .run_until(async {
197 install_cli_llm_mock_mode(&config.llm_mock_mode)
198 .map_err(|error| format!("error: {error}\n"))?;
199 let host_vm = configured_vm(
200 &config.host,
201 &host_source,
202 project_root.as_deref(),
203 store_base,
204 )
205 .await?;
206 let bridge = Arc::new(
207 harn_vm::bridge::HostBridge::from_harn_module(host_vm, &config.host)
208 .await
209 .map_err(|error| format!("error: {error}\n"))?,
210 );
211
212 let mut vm = configured_vm(
213 &config.script,
214 &script_source,
215 project_root.as_deref(),
216 store_base,
217 )
218 .await?;
219 vm.set_bridge(bridge.clone());
220 harn_vm::llm::install_current_host_bridge(bridge.clone());
221 harn_vm::stdlib::process::set_thread_execution_context(Some(
222 harn_vm::orchestration::RunExecutionRecord {
223 cwd: Some(execution_cwd),
224 project_root: project_root
225 .as_ref()
226 .map(|root| root.to_string_lossy().into_owned()),
227 source_dir: Some(source_dir),
228 env: std::collections::BTreeMap::new(),
229 adapter: None,
230 repo_path: None,
231 worktree_path: None,
232 branch: None,
233 base_ref: None,
234 cleanup: None,
235 grants: Vec::new(),
236 },
237 ));
238 let execution_result = match vm.execute(&chunk).await {
239 Ok(_) => Ok(vm.output().to_string()),
240 Err(error) => Err(vm.format_runtime_error(&error)),
241 };
242 harn_vm::llm::clear_current_host_bridge();
243 harn_vm::stdlib::process::set_thread_execution_context(None);
244 persist_cli_llm_mock_recording(&config.llm_mock_mode)
245 .map_err(|error| format!("error: {error}\n"))?;
246 execution_result
247 })
248 .await;
249 harn_vm::reset_thread_local_state();
254 drop(env_guard);
255 result
256}
257
258async fn configured_vm(
259 path: &Path,
260 source: &str,
261 project_root: Option<&Path>,
262 store_base: &Path,
263) -> Result<harn_vm::Vm, String> {
264 let mut vm = harn_vm::Vm::new();
265 harn_vm::register_vm_stdlib(&mut vm);
266 crate::install_default_hostlib(&mut vm);
267 harn_vm::register_store_builtins(&mut vm, store_base);
268 harn_vm::register_metadata_builtins(&mut vm, store_base);
269 let pipeline_name = path
270 .file_stem()
271 .and_then(|stem| stem.to_str())
272 .unwrap_or("default");
273 harn_vm::register_checkpoint_builtins(&mut vm, store_base, pipeline_name);
274 vm.set_source_info(&path.to_string_lossy(), source);
275 if let Some(root) = project_root {
276 vm.set_project_root(root);
277 }
278 if let Some(parent) = path.parent() {
279 if !parent.as_os_str().is_empty() {
280 vm.set_source_dir(parent);
281 }
282 }
283 vm.set_global(
284 "argv",
285 harn_vm::VmValue::List(std::sync::Arc::new(Vec::new())),
286 );
287 vm.set_harness(harn_vm::Harness::real());
288
289 let loaded = load_skills(&SkillLoaderInputs {
290 cli_dirs: Vec::new(),
291 source_path: Some(path.to_path_buf()),
292 });
293 emit_loader_warnings(&loaded.loader_warnings);
294 install_skills_global(&mut vm, &loaded);
295
296 let extensions = package::load_runtime_extensions(path);
297 package::install_runtime_extensions(&extensions);
298 if let Some(manifest) = extensions.root_manifest.as_ref() {
299 if !manifest.mcp.is_empty() {
300 connect_mcp_servers(&manifest.mcp, &mut vm).await;
301 }
302 }
303 package::install_manifest_triggers(&mut vm, &extensions)
304 .await
305 .map_err(|error| format!("failed to install manifest triggers: {error}"))?;
306
307 Ok(vm)
308}
309
310fn typecheck_program(
311 source: &str,
312 program: &[SNode],
313 path: &Path,
314 extra_names: &HashSet<String>,
315) -> Result<(), String> {
316 let graph = harn_modules::build(&[path.to_path_buf()]);
317 let mut checker = TypeChecker::new();
318 let mut imported = graph.imported_names_for_file(path).unwrap_or_default();
319 imported.extend(extra_names.iter().cloned());
320 if !imported.is_empty() {
321 checker = checker.with_imported_names(imported);
322 }
323 if let Some(imported) = graph.imported_type_declarations_for_file(path) {
324 checker = checker.with_imported_type_decls(imported);
325 }
326 if let Some(imported) = graph.imported_callable_declarations_for_file(path) {
327 checker = checker.with_imported_callable_decls(imported);
328 }
329
330 let diagnostics = checker.check(program);
331 let mut rendered = String::new();
332 let mut had_error = false;
333 for diagnostic in &diagnostics {
334 if diagnostic.severity == DiagnosticSeverity::Error {
335 had_error = true;
336 }
337 rendered.push_str(&harn_parser::diagnostic::render_type_diagnostic(
338 source,
339 &path.to_string_lossy(),
340 diagnostic,
341 ));
342 }
343
344 if had_error {
345 return Err(rendered);
346 }
347 if !rendered.is_empty() {
348 eprint!("{rendered}");
349 }
350 Ok(())
351}
352
353fn exported_host_functions(program: &[SNode]) -> HashSet<String> {
354 let mut public_names = HashSet::new();
355 let mut all_names = HashSet::new();
356 let mut has_pub_fn = false;
357
358 for node in program {
359 let inner = match &node.node {
360 Node::AttributedDecl { inner, .. } => inner.as_ref(),
361 _ => node,
362 };
363 let Node::FnDecl { name, is_pub, .. } = &inner.node else {
364 continue;
365 };
366 all_names.insert(name.clone());
367 if *is_pub {
368 has_pub_fn = true;
369 public_names.insert(name.clone());
370 }
371 }
372
373 if has_pub_fn {
374 public_names
375 } else {
376 all_names
377 }
378}
379
380fn watch_roots(host: &Path, script: &Path) -> Vec<PathBuf> {
381 let mut roots = Vec::new();
382 for candidate in [
383 host.parent().unwrap_or_else(|| Path::new(".")),
384 script.parent().unwrap_or_else(|| Path::new(".")),
385 ] {
386 if !roots.iter().any(|existing| existing == candidate) {
387 roots.push(candidate.to_path_buf());
388 }
389 }
390 roots
391}
392
393fn parse_llm_override(raw: &str) -> Result<LlmOverride, String> {
394 let (provider, model) = raw
395 .split_once(':')
396 .ok_or_else(|| "playground --llm expects provider:model".to_string())?;
397 let provider = provider.trim();
398 let model = model.trim();
399 if provider.is_empty() || model.is_empty() {
400 return Err("playground --llm expects provider:model".to_string());
401 }
402 Ok(LlmOverride {
403 provider: provider.to_string(),
404 model: model.to_string(),
405 })
406}
407
408fn canonicalize_or_err(path: &str) -> Result<PathBuf, String> {
409 std::fs::canonicalize(path).map_err(|error| format!("failed to resolve {path}: {error}"))
410}
411
412struct ScopedEnv {
413 previous: Vec<(String, Option<String>)>,
414}
415
416impl ScopedEnv {
417 fn apply(config: &PlaygroundConfig) -> Self {
418 let mut previous = Vec::new();
419 Self::set("HARN_TASK", Some(config.task.as_str()), &mut previous);
420 if let Some(llm) = &config.llm {
421 Self::set(
422 "HARN_LLM_PROVIDER",
423 Some(llm.provider.as_str()),
424 &mut previous,
425 );
426 Self::set("HARN_LLM_MODEL", Some(llm.model.as_str()), &mut previous);
427 }
428 Self { previous }
429 }
430
431 fn set(key: &str, value: Option<&str>, previous: &mut Vec<(String, Option<String>)>) {
432 previous.push((key.to_string(), std::env::var(key).ok()));
433 match value {
434 Some(value) => std::env::set_var(key, value),
435 None => std::env::remove_var(key),
436 }
437 }
438}
439
440impl Drop for ScopedEnv {
441 fn drop(&mut self) {
442 for (key, previous) in self.previous.iter().rev() {
443 match previous {
444 Some(value) => std::env::set_var(key, value),
445 None => std::env::remove_var(key),
446 }
447 }
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 fn write_file(path: &Path, contents: &str) {
456 if let Some(parent) = path.parent() {
457 std::fs::create_dir_all(parent).unwrap();
458 }
459 std::fs::write(path, contents).unwrap();
460 }
461
462 #[test]
463 fn exported_host_functions_prefers_pub_names() {
464 let temp = tempfile::tempdir().unwrap();
465 let path = temp.path().join("host_pub.harn");
466 let source = r"
467fn helper() {}
468pub fn run_shell(command) { return command }
469pub fn request_permission(tool_name, request_args) { return true }
470";
471 write_file(&path, source);
472 let (_, program) = crate::parse_source_file(path.to_string_lossy().as_ref());
473 let names = exported_host_functions(&program);
474 assert!(names.contains("run_shell"));
475 assert!(names.contains("request_permission"));
476 assert!(!names.contains("helper"));
477 }
478
479 #[test]
480 fn parse_llm_override_splits_provider_and_model() {
481 let parsed = parse_llm_override("ollama:qwen2.5-coder:latest").unwrap();
482 assert_eq!(parsed.provider, "ollama");
483 assert_eq!(parsed.model, "qwen2.5-coder:latest");
484 }
485
486 #[tokio::test(flavor = "current_thread")]
487 async fn playground_executes_host_backed_script() {
488 let _env_guard = crate::tests::common::env_lock::lock_env().lock().await;
493 let _sink_guard =
494 crate::tests::common::run_event_sink_lock::lock_run_event_sink_async().await;
495 let temp = tempfile::tempdir().unwrap();
496 let host = temp.path().join("host.harn");
497 let script = temp.path().join("pipeline.harn");
498 write_file(
499 &host,
500 r#"
501pub fn build_prompt(task) {
502 return "prompt: " + task
503}
504"#,
505 );
506 write_file(
507 &script,
508 r#"
509pipeline default(task) {
510 llm_mock({text: "done"})
511 const result = llm_call(build_prompt(env_or("HARN_TASK", "")), "You are concise.")
512 llm_mock({text: "stale if the run is not reset"})
513 __io_println(result.text)
514}
515"#,
516 );
517
518 let output = execute_playground(&PlaygroundConfig {
519 host: host.clone(),
520 script: script.clone(),
521 task: "ship it".to_string(),
522 llm: Some(LlmOverride {
523 provider: "mock".to_string(),
524 model: "mock".to_string(),
525 }),
526 llm_mock_mode: CliLlmMockMode::Off,
527 })
528 .await
529 .unwrap();
530
531 assert!(output.contains("done"));
532
533 write_file(
534 &script,
535 r"
536pipeline default(task) {
537 const snapshot = llm_mock_snapshot()
538 __io_println(len(keys(snapshot.queue_remaining)))
539}
540",
541 );
542 let next_output = execute_playground(&PlaygroundConfig {
543 host,
544 script,
545 task: "ship it again".to_string(),
546 llm: None,
547 llm_mock_mode: CliLlmMockMode::Off,
548 })
549 .await
550 .unwrap();
551 assert_eq!(next_output.trim(), "0");
552 }
553
554 #[tokio::test(flavor = "current_thread")]
555 async fn playground_reports_missing_capability_with_caller_context() {
556 let _env_guard = crate::tests::common::env_lock::lock_env().lock().await;
561 let _sink_guard =
562 crate::tests::common::run_event_sink_lock::lock_run_event_sink_async().await;
563 let temp = tempfile::tempdir().unwrap();
564 let host = temp.path().join("host.harn");
565 let script = temp.path().join("pipeline.harn");
566 write_file(
567 &host,
568 r#"
569pub fn helper() {
570 return "ok"
571}
572"#,
573 );
574 write_file(
575 &script,
576 r#"
577pipeline default(task) {
578 run_shell("pwd")
579}
580"#,
581 );
582
583 let error = execute_playground(&PlaygroundConfig {
584 host,
585 script,
586 task: String::new(),
587 llm: None,
588 llm_mock_mode: CliLlmMockMode::Off,
589 })
590 .await
591 .unwrap_err();
592
593 assert!(error.contains("run_shell"));
594 assert!(error.contains("pipeline.harn:3:3"));
595 }
596
597 #[tokio::test(flavor = "current_thread")]
598 async fn playground_replays_cli_llm_mock_fixtures() {
599 let _env_guard = crate::tests::common::env_lock::lock_env().lock().await;
604 let _sink_guard =
605 crate::tests::common::run_event_sink_lock::lock_run_event_sink_async().await;
606 let temp = tempfile::tempdir().unwrap();
607 let host = temp.path().join("host.harn");
608 let script = temp.path().join("pipeline.harn");
609 let fixtures = temp.path().join("fixtures.jsonl");
610 write_file(
611 &host,
612 r#"
613pub fn build_prompt(task) {
614 return "prompt: " + task
615}
616"#,
617 );
618 write_file(
619 &script,
620 r#"
621pipeline default(task) {
622 const result = llm_call(build_prompt(env_or("HARN_TASK", "")), "You are concise.")
623 __io_println(result.text)
624}
625"#,
626 );
627 write_file(
628 &fixtures,
629 r#"{"text":"fixture replay","model":"fixture-model"}
630"#,
631 );
632
633 let output = execute_playground(&PlaygroundConfig {
634 host,
635 script,
636 task: "ship it".to_string(),
637 llm: Some(LlmOverride {
638 provider: "anthropic".to_string(),
639 model: "claude-sonnet".to_string(),
640 }),
641 llm_mock_mode: CliLlmMockMode::Replay {
642 fixture_path: fixtures,
643 },
644 })
645 .await
646 .unwrap();
647
648 assert!(output.contains("fixture replay"));
649 }
650
651 #[tokio::test(flavor = "current_thread")]
652 async fn playground_replays_cli_llm_mock_error_envelopes() {
653 let _env_guard = crate::tests::common::env_lock::lock_env().lock().await;
658 let _sink_guard =
659 crate::tests::common::run_event_sink_lock::lock_run_event_sink_async().await;
660 let temp = tempfile::tempdir().unwrap();
661 let host = temp.path().join("host.harn");
662 let script = temp.path().join("pipeline.harn");
663 let fixtures = temp.path().join("fixtures.jsonl");
664 write_file(&host, "");
665 write_file(
666 &script,
667 r#"
668pipeline default(task) {
669 const first = llm_call_safe("first", nil, {provider: "mock", model: "mock-model"})
670 __io_println(first.ok)
671 __io_println(first.error.status)
672 __io_println(first.error.kind)
673 __io_println(first.error.reason)
674 const second = llm_call("second", nil, {provider: "mock", model: "mock-model"})
675 __io_println(second.text)
676}
677"#,
678 );
679 write_file(
680 &fixtures,
681 r#"{"error":{"status":503,"kind":"transient","reason":"upstream_unavailable"}}
682{"text":"recovered","tool_calls":[]}
683"#,
684 );
685
686 let output = execute_playground(&PlaygroundConfig {
687 host,
688 script,
689 task: String::new(),
690 llm: None,
691 llm_mock_mode: CliLlmMockMode::Replay {
692 fixture_path: fixtures,
693 },
694 })
695 .await
696 .unwrap();
697
698 assert!(output.contains("false\n503\ntransient\nupstream_unavailable\nrecovered"));
699 }
700}