Skip to main content

harn_cli/commands/
playground.rs

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/// Inputs to `execute_playground_inputs` — the in-process sibling of
24/// `harn playground`. Tests construct this directly instead of going through
25/// clap. The binary entry (`run_command`) builds this from `PlaygroundArgs`.
26#[derive(Clone, Debug)]
27pub struct PlaygroundInputs {
28    pub host: PathBuf,
29    pub script: PathBuf,
30    pub task: String,
31    /// Optional `provider:model` override, in the same format `--llm` accepts.
32    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
70/// In-process entry point for `harn playground`. Returns the captured stdout
71/// the CLI dispatcher would have printed, or a rendered error string.
72///
73/// This is the path tests use to exercise the playground driver without
74/// spawning the `harn` binary. Watch mode is not supported here — it has no
75/// terminal output contract worth asserting on.
76pub 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    // A playground invocation is a complete VM run. Clear runtime-owned
250    // thread-local state before a watch-mode rerun or the next in-process
251    // invocation can inherit mocks, policies, event sinks, or other ambient
252    // state from this run.
253    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        // execute_playground runs a pipeline through the process-global
489        // run_events sink, so it needs run_event_sink_lock as well as env_lock.
490        // Acquire env before sink (the order documented at both lock modules)
491        // so dual-lock tests cannot deadlock.
492        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        // execute_playground runs a pipeline through the process-global
557        // run_events sink, so it needs run_event_sink_lock as well as env_lock.
558        // Acquire env before sink (the order documented at both lock modules)
559        // so dual-lock tests cannot deadlock.
560        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        // execute_playground runs a pipeline through the process-global
600        // run_events sink, so it needs run_event_sink_lock as well as env_lock.
601        // Acquire env before sink (the order documented at both lock modules)
602        // so dual-lock tests cannot deadlock.
603        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        // execute_playground runs a pipeline through the process-global
654        // run_events sink, so it needs run_event_sink_lock as well as env_lock.
655        // Acquire env before sink (the order documented at both lock modules)
656        // so dual-lock tests cannot deadlock.
657        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}