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(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
30pub(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
333pub 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
346pub(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}