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