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