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