Skip to main content

harn_vm/stdlib/
process.rs

1use std::cell::RefCell;
2use std::collections::BTreeMap;
3use std::path::PathBuf;
4use std::rc::Rc;
5
6use crate::orchestration::RunExecutionRecord;
7use crate::value::{VmError, VmValue};
8use crate::vm::Vm;
9
10const HARN_REPLAY_ENV: &str = "HARN_REPLAY";
11
12thread_local! {
13    pub(crate) static VM_SOURCE_DIR: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
14    static VM_EXECUTION_CONTEXT: RefCell<Option<RunExecutionRecord>> = const { RefCell::new(None) };
15}
16
17/// Set the source directory for the current thread (called by VM on file execution).
18pub(crate) fn set_thread_source_dir(dir: &std::path::Path) {
19    VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = Some(dir.to_path_buf()));
20}
21
22pub fn set_thread_execution_context(context: Option<RunExecutionRecord>) {
23    VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = context);
24}
25
26pub(crate) fn current_execution_context() -> Option<RunExecutionRecord> {
27    VM_EXECUTION_CONTEXT.with(|current| current.borrow().clone())
28}
29
30/// Reset thread-local process state (for test isolation).
31pub(crate) fn reset_process_state() {
32    VM_SOURCE_DIR.with(|sd| *sd.borrow_mut() = None);
33    VM_EXECUTION_CONTEXT.with(|current| *current.borrow_mut() = None);
34}
35
36pub fn execution_root_path() -> PathBuf {
37    current_execution_context()
38        .and_then(|context| context.cwd.map(PathBuf::from))
39        .or_else(|| std::env::current_dir().ok())
40        .unwrap_or_else(|| PathBuf::from("."))
41}
42
43pub fn source_root_path() -> PathBuf {
44    VM_SOURCE_DIR
45        .with(|sd| sd.borrow().clone())
46        .or_else(|| {
47            current_execution_context().and_then(|context| context.source_dir.map(PathBuf::from))
48        })
49        .or_else(|| current_execution_context().and_then(|context| context.cwd.map(PathBuf::from)))
50        .or_else(|| std::env::current_dir().ok())
51        .unwrap_or_else(|| PathBuf::from("."))
52}
53
54pub fn asset_root_path() -> PathBuf {
55    source_root_path()
56}
57
58fn env_override(name: &str) -> Option<String> {
59    (name == HARN_REPLAY_ENV && crate::triggers::dispatcher::current_dispatch_is_replay())
60        .then(|| "1".to_string())
61}
62
63fn read_env_value(name: &str) -> Option<String> {
64    env_override(name)
65        .or_else(|| current_execution_context().and_then(|context| context.env.get(name).cloned()))
66        .or_else(|| std::env::var(name).ok())
67}
68
69pub fn runtime_root_base() -> PathBuf {
70    find_project_root(&execution_root_path())
71        .or_else(|| find_project_root(&source_root_path()))
72        .unwrap_or_else(source_root_path)
73}
74
75pub fn resolve_source_relative_path(path: &str) -> PathBuf {
76    let candidate = PathBuf::from(path);
77    if candidate.is_absolute() {
78        return candidate;
79    }
80    execution_root_path().join(candidate)
81}
82
83pub fn resolve_source_asset_path(path: &str) -> PathBuf {
84    let candidate = PathBuf::from(path);
85    if candidate.is_absolute() {
86        return candidate;
87    }
88    asset_root_path().join(candidate)
89}
90
91pub(crate) fn register_process_builtins(vm: &mut Vm) {
92    vm.register_builtin("env", |args, _out| {
93        let name = args.first().map(|a| a.display()).unwrap_or_default();
94        if let Some(value) = read_env_value(&name) {
95            return Ok(VmValue::String(Rc::from(value)));
96        }
97        Ok(VmValue::Nil)
98    });
99
100    vm.register_builtin("env_or", |args, _out| {
101        let name = args.first().map(|a| a.display()).unwrap_or_default();
102        let default = args.get(1).cloned().unwrap_or(VmValue::Nil);
103        if let Some(value) = read_env_value(&name) {
104            return Ok(VmValue::String(Rc::from(value)));
105        }
106        Ok(default)
107    });
108
109    vm.register_builtin("timestamp", |_args, _out| {
110        use std::time::{SystemTime, UNIX_EPOCH};
111        let secs = SystemTime::now()
112            .duration_since(UNIX_EPOCH)
113            .map(|d| d.as_secs_f64())
114            .unwrap_or(0.0);
115        Ok(VmValue::Float(secs))
116    });
117
118    vm.register_builtin("exit", |args, _out| {
119        let code = args.first().and_then(|a| a.as_int()).unwrap_or(0);
120        std::process::exit(code as i32);
121    });
122
123    vm.register_builtin("exec", |args, _out| {
124        if args.is_empty() {
125            return Err(VmError::Thrown(VmValue::String(Rc::from(
126                "exec: command is required",
127            ))));
128        }
129        let cmd = args[0].display();
130        let cmd_args: Vec<String> = args[1..].iter().map(|a| a.display()).collect();
131        let output = exec_command(None, &cmd, &cmd_args)
132            .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
133        Ok(vm_output_to_value(output))
134    });
135
136    vm.register_builtin("shell", |args, _out| {
137        let cmd = args.first().map(|a| a.display()).unwrap_or_default();
138        if cmd.is_empty() {
139            return Err(VmError::Thrown(VmValue::String(Rc::from(
140                "shell: command string is required",
141            ))));
142        }
143        let shell = if cfg!(target_os = "windows") {
144            "cmd"
145        } else {
146            "sh"
147        };
148        let flag = if cfg!(target_os = "windows") {
149            "/C"
150        } else {
151            "-c"
152        };
153        let output = exec_shell(None, shell, flag, &cmd)
154            .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
155        Ok(vm_output_to_value(output))
156    });
157
158    vm.register_builtin("exec_at", |args, _out| {
159        if args.len() < 2 {
160            return Err(VmError::Thrown(VmValue::String(Rc::from(
161                "exec_at: directory and command are required",
162            ))));
163        }
164        let dir = args[0].display();
165        let cmd = args[1].display();
166        let cmd_args: Vec<String> = args[2..].iter().map(|a| a.display()).collect();
167        let output = exec_command(Some(dir.as_str()), &cmd, &cmd_args)
168            .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
169        Ok(vm_output_to_value(output))
170    });
171
172    vm.register_builtin("shell_at", |args, _out| {
173        if args.len() < 2 {
174            return Err(VmError::Thrown(VmValue::String(Rc::from(
175                "shell_at: directory and command string are required",
176            ))));
177        }
178        let dir = args[0].display();
179        let cmd = args[1].display();
180        if cmd.is_empty() {
181            return Err(VmError::Thrown(VmValue::String(Rc::from(
182                "shell_at: command string is required",
183            ))));
184        }
185        let shell = if cfg!(target_os = "windows") {
186            "cmd"
187        } else {
188            "sh"
189        };
190        let flag = if cfg!(target_os = "windows") {
191            "/C"
192        } else {
193            "-c"
194        };
195        let output = exec_shell(Some(dir.as_str()), shell, flag, &cmd)
196            .map_err(|e| VmError::Thrown(VmValue::String(Rc::from(e))))?;
197        Ok(vm_output_to_value(output))
198    });
199
200    vm.register_builtin("elapsed", |_args, _out| {
201        static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
202        let start = START.get_or_init(std::time::Instant::now);
203        Ok(VmValue::Int(start.elapsed().as_millis() as i64))
204    });
205
206    vm.register_builtin("username", |_args, _out| {
207        let user = std::env::var("USER")
208            .or_else(|_| std::env::var("USERNAME"))
209            .unwrap_or_default();
210        Ok(VmValue::String(Rc::from(user)))
211    });
212
213    vm.register_builtin("hostname", |_args, _out| {
214        let name = std::env::var("HOSTNAME")
215            .or_else(|_| std::env::var("COMPUTERNAME"))
216            .or_else(|_| {
217                std::process::Command::new("hostname")
218                    .output()
219                    .ok()
220                    .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
221                    .ok_or(std::env::VarError::NotPresent)
222            })
223            .unwrap_or_default();
224        Ok(VmValue::String(Rc::from(name)))
225    });
226
227    vm.register_builtin("platform", |_args, _out| {
228        let os = if cfg!(target_os = "macos") {
229            "darwin"
230        } else if cfg!(target_os = "linux") {
231            "linux"
232        } else if cfg!(target_os = "windows") {
233            "windows"
234        } else {
235            std::env::consts::OS
236        };
237        Ok(VmValue::String(Rc::from(os)))
238    });
239
240    vm.register_builtin("arch", |_args, _out| {
241        Ok(VmValue::String(Rc::from(std::env::consts::ARCH)))
242    });
243
244    vm.register_builtin("home_dir", |_args, _out| {
245        let home = std::env::var("HOME")
246            .or_else(|_| std::env::var("USERPROFILE"))
247            .unwrap_or_default();
248        Ok(VmValue::String(Rc::from(home)))
249    });
250
251    vm.register_builtin("pid", |_args, _out| {
252        Ok(VmValue::Int(std::process::id() as i64))
253    });
254
255    vm.register_builtin("date_iso", |_args, _out| {
256        use crate::stdlib::datetime::vm_civil_from_timestamp;
257        let now = std::time::SystemTime::now()
258            .duration_since(std::time::UNIX_EPOCH)
259            .unwrap_or_default();
260        let total_secs = now.as_secs();
261        let millis = now.subsec_millis();
262        let (y, m, d, hour, minute, second, _) = vm_civil_from_timestamp(total_secs);
263        Ok(VmValue::String(Rc::from(format!(
264            "{y:04}-{m:02}-{d:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z"
265        ))))
266    });
267
268    vm.register_builtin("cwd", |_args, _out| {
269        let dir = current_execution_context()
270            .and_then(|context| context.cwd)
271            .or_else(|| {
272                std::env::current_dir()
273                    .ok()
274                    .map(|p| p.to_string_lossy().into_owned())
275            })
276            .unwrap_or_default();
277        Ok(VmValue::String(Rc::from(dir)))
278    });
279
280    vm.register_builtin("execution_root", |_args, _out| {
281        Ok(VmValue::String(Rc::from(
282            execution_root_path().to_string_lossy().into_owned(),
283        )))
284    });
285
286    vm.register_builtin("asset_root", |_args, _out| {
287        Ok(VmValue::String(Rc::from(
288            asset_root_path().to_string_lossy().into_owned(),
289        )))
290    });
291
292    vm.register_builtin("runtime_paths", |_args, _out| {
293        let runtime_base = runtime_root_base();
294        let mut paths = BTreeMap::new();
295        paths.insert(
296            "execution_root".to_string(),
297            VmValue::String(Rc::from(
298                execution_root_path().to_string_lossy().into_owned(),
299            )),
300        );
301        paths.insert(
302            "asset_root".to_string(),
303            VmValue::String(Rc::from(asset_root_path().to_string_lossy().into_owned())),
304        );
305        paths.insert(
306            "state_root".to_string(),
307            VmValue::String(Rc::from(
308                crate::runtime_paths::state_root(&runtime_base)
309                    .to_string_lossy()
310                    .into_owned(),
311            )),
312        );
313        paths.insert(
314            "run_root".to_string(),
315            VmValue::String(Rc::from(
316                crate::runtime_paths::run_root(&runtime_base)
317                    .to_string_lossy()
318                    .into_owned(),
319            )),
320        );
321        paths.insert(
322            "worktree_root".to_string(),
323            VmValue::String(Rc::from(
324                crate::runtime_paths::worktree_root(&runtime_base)
325                    .to_string_lossy()
326                    .into_owned(),
327            )),
328        );
329        Ok(VmValue::Dict(Rc::new(paths)))
330    });
331}
332
333/// Find the project root by walking up from a base directory looking for harn.toml.
334pub fn find_project_root(base: &std::path::Path) -> Option<std::path::PathBuf> {
335    let mut dir = base.to_path_buf();
336    loop {
337        if dir.join("harn.toml").exists() {
338            return Some(dir);
339        }
340        if !dir.pop() {
341            return None;
342        }
343    }
344}
345
346/// Register builtins that depend on source directory context.
347pub(crate) fn register_path_builtins(vm: &mut Vm) {
348    vm.register_builtin("source_dir", |_args, _out| {
349        let dir = VM_SOURCE_DIR.with(|sd| sd.borrow().clone());
350        match dir {
351            Some(d) => Ok(VmValue::String(Rc::from(d.to_string_lossy().into_owned()))),
352            None => {
353                let cwd = std::env::current_dir()
354                    .map(|p| p.to_string_lossy().into_owned())
355                    .unwrap_or_default();
356                Ok(VmValue::String(Rc::from(cwd)))
357            }
358        }
359    });
360
361    vm.register_builtin("project_root", |_args, _out| {
362        let base = current_execution_context()
363            .and_then(|context| context.cwd.map(PathBuf::from))
364            .or_else(|| VM_SOURCE_DIR.with(|sd| sd.borrow().clone()))
365            .or_else(|| std::env::current_dir().ok())
366            .unwrap_or_else(|| PathBuf::from("."));
367        match find_project_root(&base) {
368            Some(root) => Ok(VmValue::String(Rc::from(
369                root.to_string_lossy().into_owned(),
370            ))),
371            None => Ok(VmValue::Nil),
372        }
373    });
374}
375
376fn vm_output_to_value(output: std::process::Output) -> VmValue {
377    let mut result = BTreeMap::new();
378    result.insert(
379        "stdout".to_string(),
380        VmValue::String(Rc::from(
381            String::from_utf8_lossy(&output.stdout).to_string().as_str(),
382        )),
383    );
384    result.insert(
385        "stderr".to_string(),
386        VmValue::String(Rc::from(
387            String::from_utf8_lossy(&output.stderr).to_string().as_str(),
388        )),
389    );
390    result.insert(
391        "status".to_string(),
392        VmValue::Int(output.status.code().unwrap_or(-1) as i64),
393    );
394    result.insert(
395        "success".to_string(),
396        VmValue::Bool(output.status.success()),
397    );
398    VmValue::Dict(Rc::new(result))
399}
400
401fn exec_command(
402    dir: Option<&str>,
403    cmd: &str,
404    args: &[String],
405) -> Result<std::process::Output, String> {
406    let mut command = std::process::Command::new(cmd);
407    command.args(args);
408    apply_execution_context(&mut command, dir);
409    command.output().map_err(|e| format!("exec failed: {e}"))
410}
411
412fn exec_shell(
413    dir: Option<&str>,
414    shell: &str,
415    flag: &str,
416    script: &str,
417) -> Result<std::process::Output, String> {
418    let mut command = std::process::Command::new(shell);
419    command.arg(flag).arg(script);
420    apply_execution_context(&mut command, dir);
421    command.output().map_err(|e| format!("shell failed: {e}"))
422}
423
424fn apply_execution_context(command: &mut std::process::Command, dir: Option<&str>) {
425    if let Some(dir) = dir {
426        command.current_dir(resolve_command_dir(dir));
427    } else if let Some(context) = current_execution_context() {
428        if let Some(cwd) = context.cwd.filter(|cwd| !cwd.is_empty()) {
429            command.current_dir(cwd);
430        }
431        if !context.env.is_empty() {
432            command.envs(context.env);
433        }
434    }
435    if let Some(value) = env_override(HARN_REPLAY_ENV) {
436        command.env(HARN_REPLAY_ENV, value);
437    }
438}
439
440fn resolve_command_dir(dir: &str) -> PathBuf {
441    let candidate = PathBuf::from(dir);
442    if candidate.is_absolute() {
443        return candidate;
444    }
445    if let Some(cwd) = current_execution_context().and_then(|context| context.cwd) {
446        return PathBuf::from(cwd).join(candidate);
447    }
448    if let Some(source_dir) = VM_SOURCE_DIR.with(|sd| sd.borrow().clone()) {
449        return source_dir.join(candidate);
450    }
451    candidate
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457
458    #[test]
459    fn resolve_source_relative_path_ignores_thread_source_dir_without_execution_context() {
460        let dir = std::env::temp_dir().join(format!("harn-process-{}", uuid::Uuid::now_v7()));
461        std::fs::create_dir_all(&dir).unwrap();
462        let current_dir = std::env::current_dir().unwrap();
463        set_thread_source_dir(&dir);
464        let resolved = resolve_source_relative_path("templates/prompt.txt");
465        assert_eq!(resolved, current_dir.join("templates/prompt.txt"));
466        reset_process_state();
467        let _ = std::fs::remove_dir_all(&dir);
468    }
469
470    #[test]
471    fn resolve_source_relative_path_prefers_execution_cwd_over_source_dir() {
472        let cwd = std::env::temp_dir().join(format!("harn-process-cwd-{}", uuid::Uuid::now_v7()));
473        let source_dir =
474            std::env::temp_dir().join(format!("harn-process-source-{}", uuid::Uuid::now_v7()));
475        std::fs::create_dir_all(&cwd).unwrap();
476        std::fs::create_dir_all(&source_dir).unwrap();
477        set_thread_source_dir(&source_dir);
478        set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
479            cwd: Some(cwd.to_string_lossy().into_owned()),
480            source_dir: Some(source_dir.to_string_lossy().into_owned()),
481            env: BTreeMap::new(),
482            adapter: None,
483            repo_path: None,
484            worktree_path: None,
485            branch: None,
486            base_ref: None,
487            cleanup: None,
488        }));
489        let resolved = resolve_source_relative_path("templates/prompt.txt");
490        assert_eq!(resolved, cwd.join("templates/prompt.txt"));
491        reset_process_state();
492        let _ = std::fs::remove_dir_all(&cwd);
493        let _ = std::fs::remove_dir_all(&source_dir);
494    }
495
496    #[test]
497    fn resolve_source_asset_path_prefers_execution_source_dir_over_cwd() {
498        let cwd = std::env::temp_dir().join(format!("harn-asset-cwd-{}", uuid::Uuid::now_v7()));
499        let source_dir =
500            std::env::temp_dir().join(format!("harn-asset-source-{}", uuid::Uuid::now_v7()));
501        std::fs::create_dir_all(&cwd).unwrap();
502        std::fs::create_dir_all(&source_dir).unwrap();
503        set_thread_source_dir(&source_dir);
504        set_thread_execution_context(Some(crate::orchestration::RunExecutionRecord {
505            cwd: Some(cwd.to_string_lossy().into_owned()),
506            source_dir: Some(source_dir.to_string_lossy().into_owned()),
507            env: BTreeMap::new(),
508            adapter: None,
509            repo_path: None,
510            worktree_path: None,
511            branch: None,
512            base_ref: None,
513            cleanup: None,
514        }));
515        let resolved = resolve_source_asset_path("templates/prompt.txt");
516        assert_eq!(resolved, source_dir.join("templates/prompt.txt"));
517        reset_process_state();
518        let _ = std::fs::remove_dir_all(&cwd);
519        let _ = std::fs::remove_dir_all(&source_dir);
520    }
521
522    #[test]
523    fn exec_context_sets_default_cwd_and_env() {
524        let dir = std::env::temp_dir().join(format!("harn-process-ctx-{}", uuid::Uuid::now_v7()));
525        std::fs::create_dir_all(&dir).unwrap();
526        std::fs::write(dir.join("marker.txt"), "ok").unwrap();
527        set_thread_execution_context(Some(RunExecutionRecord {
528            cwd: Some(dir.to_string_lossy().into_owned()),
529            env: BTreeMap::from([("HARN_PROCESS_TEST".to_string(), "present".to_string())]),
530            ..Default::default()
531        }));
532        let output = exec_shell(
533            None,
534            "sh",
535            "-c",
536            "printf '%s:' \"$HARN_PROCESS_TEST\" && test -f marker.txt",
537        )
538        .unwrap();
539        assert!(output.status.success());
540        assert_eq!(String::from_utf8_lossy(&output.stdout), "present:");
541        reset_process_state();
542        let _ = std::fs::remove_dir_all(&dir);
543    }
544
545    #[test]
546    fn exec_at_resolves_relative_to_execution_cwd() {
547        let dir = std::env::temp_dir().join(format!("harn-process-rel-{}", uuid::Uuid::now_v7()));
548        std::fs::create_dir_all(dir.join("nested")).unwrap();
549        std::fs::write(dir.join("nested").join("marker.txt"), "ok").unwrap();
550        set_thread_execution_context(Some(RunExecutionRecord {
551            cwd: Some(dir.to_string_lossy().into_owned()),
552            ..Default::default()
553        }));
554        let output = exec_shell(Some("nested"), "sh", "-c", "test -f marker.txt").unwrap();
555        assert!(output.status.success());
556        reset_process_state();
557        let _ = std::fs::remove_dir_all(&dir);
558    }
559
560    #[test]
561    fn runtime_paths_uses_configurable_state_roots() {
562        let base =
563            std::env::temp_dir().join(format!("harn-process-runtime-{}", uuid::Uuid::now_v7()));
564        std::fs::create_dir_all(&base).unwrap();
565        std::env::set_var(crate::runtime_paths::HARN_STATE_DIR_ENV, ".custom-harn");
566        std::env::set_var(crate::runtime_paths::HARN_RUN_DIR_ENV, ".custom-runs");
567        std::env::set_var(
568            crate::runtime_paths::HARN_WORKTREE_DIR_ENV,
569            ".custom-worktrees",
570        );
571        set_thread_execution_context(Some(RunExecutionRecord {
572            cwd: Some(base.to_string_lossy().into_owned()),
573            ..Default::default()
574        }));
575
576        let mut vm = crate::vm::Vm::new();
577        register_process_builtins(&mut vm);
578        let mut out = String::new();
579        let builtin = vm
580            .builtins
581            .get("runtime_paths")
582            .expect("runtime_paths builtin");
583        let paths = match builtin(&[], &mut out).unwrap() {
584            VmValue::Dict(map) => map,
585            other => panic!("expected dict, got {other:?}"),
586        };
587        assert_eq!(
588            paths.get("state_root").unwrap().display(),
589            base.join(".custom-harn").display().to_string()
590        );
591        assert_eq!(
592            paths.get("run_root").unwrap().display(),
593            base.join(".custom-runs").display().to_string()
594        );
595        assert_eq!(
596            paths.get("worktree_root").unwrap().display(),
597            base.join(".custom-worktrees").display().to_string()
598        );
599
600        reset_process_state();
601        std::env::remove_var(crate::runtime_paths::HARN_STATE_DIR_ENV);
602        std::env::remove_var(crate::runtime_paths::HARN_RUN_DIR_ENV);
603        std::env::remove_var(crate::runtime_paths::HARN_WORKTREE_DIR_ENV);
604        let _ = std::fs::remove_dir_all(&base);
605    }
606}