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
17pub(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
39pub(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
342pub 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
355pub(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}