Skip to main content

orch/
home.rs

1//! Home directory utilities.
2//!
3//! All orch state lives under `~/.orch/`. This is completely separate from
4//! the legacy bash orchestrator's `~/.orchestrator/` directory — both tools can
5//! run side by side without conflicts.
6
7use anyhow::Context;
8use std::env;
9use std::fs as std_fs;
10use std::path;
11use std::path::{Path, PathBuf};
12use std::process;
13use std::process::Command;
14use std::thread;
15use std::time::Duration;
16use std::time::Instant;
17use tokio::fs;
18use tokio::runtime::Handle;
19use tokio::runtime::RuntimeFlavor;
20use tokio::task;
21
22/// The home directory name.
23const HOME_DIR: &str = ".orch";
24
25fn ensure_dir(path: &Path) -> anyhow::Result<()> {
26    match Handle::try_current() {
27        Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => {
28            task::block_in_place(|| std_fs::create_dir_all(path))?;
29        }
30        _ => std_fs::create_dir_all(path)?,
31    }
32
33    Ok(())
34}
35
36/// Get the orch home directory path (~/.orch/).
37///
38/// Can be overridden via the `ORCH_HOME` environment variable. This is useful
39/// in tests and CI environments to avoid reading/writing the real state directory.
40pub fn orch_home() -> anyhow::Result<PathBuf> {
41    if let Ok(dir) = env::var("ORCH_HOME") {
42        let path = PathBuf::from(dir);
43        ensure_dir(&path)?;
44        return Ok(path);
45    }
46    let home =
47        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("cannot determine home directory"))?;
48    let path = home.join(HOME_DIR);
49    ensure_dir(&path)?;
50    Ok(path)
51}
52
53/// Get the orch state directory path (~/.orch/state/).
54///
55/// This is where runtime state like logs, prompts, and PID files are stored.
56/// Used across the codebase for runtime state file resolution.
57pub fn state_dir() -> anyhow::Result<PathBuf> {
58    let home = orch_home()?;
59    let state = home.join("state");
60    ensure_dir(&state)?;
61    Ok(state)
62}
63
64/// Legacy state directories for backward-compatible reads.
65///
66/// Checks legacy `~/.orchestrator/state/` first, then `~/.orchestrator/.orchestrator/`.
67/// Never writes to these locations.
68fn legacy_state_dir() -> Option<PathBuf> {
69    let home = dirs::home_dir()?;
70    // Check legacy ~/.orchestrator/state/ (intermediate migration path)
71    let mid = home.join(".orchestrator").join("state");
72    if mid.is_dir() {
73        return Some(mid);
74    }
75    // Check legacy ~/.orchestrator/.orchestrator/ (original nested path)
76    let old = home.join(".orchestrator").join(".orchestrator");
77    if old.is_dir() {
78        return Some(old);
79    }
80    None
81}
82
83/// Resolve a file inside the state directory, falling back to the legacy
84/// location when the file doesn't exist at the new path yet.
85pub fn state_file(name: &str) -> anyhow::Result<PathBuf> {
86    let new_path = state_dir()?.join(name);
87    if new_path.exists() {
88        return Ok(new_path);
89    }
90    // Check legacy location
91    if let Some(legacy) = legacy_state_dir() {
92        let old_path = legacy.join(name);
93        if old_path.exists() {
94            return Ok(old_path);
95        }
96    }
97    // Return new path even if it doesn't exist yet (for writes)
98    Ok(new_path)
99}
100
101/// Get the path to the global config file (~/.orch/config.yml).
102pub fn config_path() -> anyhow::Result<PathBuf> {
103    Ok(orch_home()?.join("config.yml"))
104}
105
106/// Get the path to the tasks database file (~/.orch/orch.db).
107///
108/// Also migrates legacy `orchestrator.db` → `orch.db` transparently if the old file
109/// exists and the new one does not.
110pub async fn db_path() -> anyhow::Result<PathBuf> {
111    let home = orch_home()?;
112    let new_path = home.join("orch.db");
113    let old_path = home.join("orchestrator.db");
114    if old_path.exists() && !new_path.exists() {
115        fs::rename(&old_path, &new_path).await.with_context(|| {
116            format!(
117                "migrating database from {} to {}",
118                old_path.display(),
119                new_path.display()
120            )
121        })?;
122        tracing::info!("migrated database: legacy orchestrator.db -> orch.db");
123    }
124    Ok(new_path)
125}
126
127/// Get the path to the service version file (~/.orch/state/service.version).
128///
129/// Written by the engine at startup, deleted on graceful shutdown.
130/// Format: "{pid}\t{version}" — the PID lets readers verify the owning process is alive.
131/// Used by service status and diagnostics to detect CLI/service drift.
132pub fn service_version_path() -> anyhow::Result<PathBuf> {
133    Ok(state_dir()?.join("service.version"))
134}
135
136/// Returns `true` if the given PID is alive and is an `orch serve` process.
137pub fn pid_is_orch_serve(pid: u32) -> bool {
138    process_args(pid)
139        .as_deref()
140        .is_some_and(command_line_is_orch_serve)
141}
142
143/// Find all PIDs of running `orch serve` processes.
144pub fn find_orch_serve_pids(exclude_self: bool) -> Vec<u32> {
145    let output = Command::new("pgrep").args(["-x", "orch"]).output().ok();
146
147    let self_pid = process::id();
148    match output {
149        Some(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
150            .lines()
151            .filter_map(|line| line.trim().parse::<u32>().ok())
152            .filter(|pid| !exclude_self || *pid != self_pid)
153            .filter(|pid| pid_is_orch_serve(*pid))
154            .collect(),
155        _ => Vec::new(),
156    }
157}
158
159/// Send SIGTERM to other running `orch serve` processes, then SIGKILL stragglers.
160pub fn kill_other_orch_serve_processes(timeout: Duration) -> u32 {
161    let others = find_orch_serve_pids(true);
162    if others.is_empty() {
163        return 0;
164    }
165
166    for pid in &others {
167        tracing::info!(pid, "sending SIGTERM to stale orch serve process");
168        send_signal(*pid, "TERM");
169    }
170
171    let mut remaining = wait_for_orch_serve_exit(others.clone(), timeout);
172    for pid in &remaining {
173        tracing::warn!(pid, "stale orch serve ignored SIGTERM; sending SIGKILL");
174        send_signal(*pid, "KILL");
175    }
176
177    remaining = wait_for_orch_serve_exit(remaining, Duration::from_secs(1));
178    for pid in &remaining {
179        tracing::error!(pid, "stale orch serve survived SIGKILL");
180    }
181
182    u32::try_from(others.len() - remaining.len()).unwrap_or(u32::MAX)
183}
184
185fn process_args(pid: u32) -> Option<String> {
186    let output = Command::new("ps")
187        .args(["-p", &pid.to_string(), "-o", "args="])
188        .output()
189        .ok()?;
190    output
191        .status
192        .success()
193        .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string())
194        .filter(|args| !args.is_empty())
195}
196
197fn command_line_is_orch_serve(cmdline: &str) -> bool {
198    let mut args = cmdline.split_whitespace();
199    let Some(program) = args.next() else {
200        return false;
201    };
202    Path::new(program)
203        .file_name()
204        .and_then(|name| name.to_str())
205        .is_some_and(|name| name == "orch")
206        && args.any(|arg| arg == "serve")
207}
208
209fn send_signal(pid: u32, signal: &str) {
210    let _ = Command::new("kill")
211        .args([format!("-{signal}"), pid.to_string()])
212        .output();
213}
214
215fn wait_for_orch_serve_exit(mut pids: Vec<u32>, timeout: Duration) -> Vec<u32> {
216    let start = Instant::now();
217    while start.elapsed() < timeout && !pids.is_empty() {
218        thread::sleep(Duration::from_millis(200));
219        pids.retain(|pid| pid_is_orch_serve(*pid));
220    }
221    pids
222}
223
224/// Get the path to the worktrees directory (~/.orch/worktrees/).
225pub fn worktrees_dir() -> anyhow::Result<PathBuf> {
226    let dir = orch_home()?.join("worktrees");
227    ensure_dir(&dir)?;
228    Ok(dir)
229}
230
231/// Get the path to the contexts directory (~/.orch/contexts/).
232pub fn contexts_dir() -> anyhow::Result<PathBuf> {
233    let dir = orch_home()?.join("contexts");
234    ensure_dir(&dir)?;
235    Ok(dir)
236}
237
238/// Get the path to the projects directory (~/.orch/projects/).
239pub fn projects_dir() -> anyhow::Result<PathBuf> {
240    let dir = orch_home()?.join("projects");
241    ensure_dir(&dir)?;
242    Ok(dir)
243}
244
245/// Get the path to the skills directory (~/.orch/skills/).
246pub fn skills_dir() -> anyhow::Result<PathBuf> {
247    let dir = orch_home()?.join("skills");
248    ensure_dir(&dir)?;
249    Ok(dir)
250}
251
252/// Get the per-repo state directory: `~/.orch/state/{owner}/{repo}/`.
253///
254/// Falls back to the flat `~/.orch/state/` if no repo is configured.
255pub fn repo_state_dir(repo: &str) -> anyhow::Result<PathBuf> {
256    let state = state_dir()?;
257    let dir = state.join(repo.replace('/', path::MAIN_SEPARATOR_STR));
258    ensure_dir(&dir)?;
259    Ok(dir)
260}
261
262/// Get the per-task directory: `~/.orch/state/{owner}/{repo}/tasks/{id}/`.
263///
264/// Creates the directory on demand.
265pub fn task_dir(repo: &str, task_id: &str) -> anyhow::Result<PathBuf> {
266    let dir = repo_state_dir(repo)?.join("tasks").join(task_id);
267    ensure_dir(&dir)?;
268    Ok(dir)
269}
270
271/// Async version of [`task_dir`] — uses `tokio::fs::create_dir_all` to avoid
272/// blocking the Tokio executor when creating a new directory.
273pub async fn task_dir_async(repo: &str, task_id: &str) -> anyhow::Result<PathBuf> {
274    let state = state_dir()?;
275    let dir = state
276        .join(repo.replace('/', path::MAIN_SEPARATOR_STR))
277        .join("tasks")
278        .join(task_id);
279    fs::create_dir_all(&dir).await?;
280    Ok(dir)
281}
282
283/// Get the per-task attempt directory: `~/.orch/state/{owner}/{repo}/tasks/{id}/attempts/{n}/`.
284///
285/// Creates the directory on demand.
286pub fn task_attempt_dir(repo: &str, task_id: &str, attempt: u32) -> anyhow::Result<PathBuf> {
287    let dir = task_dir(repo, task_id)?
288        .join("attempts")
289        .join(attempt.to_string());
290    ensure_dir(&dir)?;
291    Ok(dir)
292}
293
294/// Async version of [`task_attempt_dir`] — uses `tokio::fs::create_dir_all` to
295/// avoid blocking the Tokio executor when creating a new directory.
296pub async fn task_attempt_dir_async(
297    repo: &str,
298    task_id: &str,
299    attempt: u32,
300) -> anyhow::Result<PathBuf> {
301    let state = state_dir()?;
302    let dir = state
303        .join(repo.replace('/', path::MAIN_SEPARATOR_STR))
304        .join("tasks")
305        .join(task_id)
306        .join("attempts")
307        .join(attempt.to_string());
308    fs::create_dir_all(&dir).await?;
309    Ok(dir)
310}
311
312/// Pre-create all standard orch directories using async I/O.
313///
314/// Call once at engine startup before the Tokio thread pool is busy so that
315/// subsequent synchronous callers of [`orch_home`], [`state_dir`], etc. find
316/// the directories already present and their `create_dir_all` calls become
317/// fast no-ops (a single `stat` syscall on an existing path).
318pub async fn init_dirs() -> anyhow::Result<()> {
319    let home = orch_home()?;
320    fs::create_dir_all(&home).await?;
321    fs::create_dir_all(home.join("state")).await?;
322    fs::create_dir_all(home.join("worktrees")).await?;
323    fs::create_dir_all(home.join("contexts")).await?;
324    fs::create_dir_all(home.join("projects")).await?;
325    fs::create_dir_all(home.join("skills")).await?;
326    Ok(())
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use tempfile::TempDir;
333
334    /// Helper to set ORCH_HOME to a temp dir for the duration of a test.
335    ///
336    /// Returns the temp dir (keep alive for the test) and the previous value of
337    /// ORCH_HOME so the caller can restore it in the test teardown.
338    fn with_temp_orch_home() -> (TempDir, Option<String>) {
339        let temp = TempDir::new().unwrap();
340        let orch_home = temp.path().join(".orch");
341        std_fs::create_dir_all(&orch_home).unwrap();
342        let prev = env::var("ORCH_HOME").ok();
343        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
344        unsafe { env::set_var("ORCH_HOME", &orch_home) };
345        (temp, prev)
346    }
347
348    fn restore_orch_home(prev: Option<String>) {
349        match prev {
350            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
351            Some(v) => unsafe { env::set_var("ORCH_HOME", v) },
352            // SAFETY: nextest runs each test in its own process, so no other test touches the environment
353            None => unsafe { env::remove_var("ORCH_HOME") },
354        }
355    }
356
357    #[test]
358    fn test_orch_home_env_override() {
359        let temp = TempDir::new().unwrap();
360        let custom_dir = temp.path().join(".orch-custom");
361        let prev = env::var("ORCH_HOME").ok();
362        // SAFETY: nextest runs each test in its own process, so no other test touches the environment
363        unsafe { env::set_var("ORCH_HOME", &custom_dir) };
364
365        let result = orch_home().unwrap();
366        assert_eq!(result, custom_dir);
367        assert!(result.exists());
368
369        restore_orch_home(prev);
370    }
371
372    #[test]
373    fn test_task_dir_creates_path() {
374        // Use ORCH_HOME so we don't touch the developer's real home dir.
375        let (_temp, prev) = with_temp_orch_home();
376
377        let dir = task_dir("test-owner/test-repo", "42").unwrap();
378        assert!(dir.exists());
379        assert!(dir.ends_with("test-owner/test-repo/tasks/42"));
380
381        restore_orch_home(prev);
382    }
383
384    #[test]
385    fn test_task_attempt_dir_creates_path() {
386        // Use ORCH_HOME so we don't touch the developer's real home dir.
387        let (_temp, prev) = with_temp_orch_home();
388
389        let dir = task_attempt_dir("test-owner/test-repo", "42", 1).unwrap();
390        assert!(dir.exists());
391        assert!(dir.ends_with("test-owner/test-repo/tasks/42/attempts/1"));
392
393        restore_orch_home(prev);
394    }
395
396    #[test]
397    fn test_repo_state_dir_separates_repos() {
398        let (_temp, prev) = with_temp_orch_home();
399
400        let dir_a = repo_state_dir("owner/repo-a").unwrap();
401        let dir_b = repo_state_dir("owner/repo-b").unwrap();
402        assert_ne!(dir_a, dir_b);
403
404        restore_orch_home(prev);
405    }
406
407    #[test]
408    fn pid_is_orch_serve_returns_false_for_nonexistent_pid() {
409        assert!(!pid_is_orch_serve(99999));
410    }
411
412    #[test]
413    fn command_line_is_orch_serve_matches_real_invocations() {
414        assert!(command_line_is_orch_serve("/opt/homebrew/bin/orch serve"));
415        assert!(command_line_is_orch_serve(
416            "/opt/homebrew/Cellar/orch/0.73.18/bin/orch serve --foreground"
417        ));
418        assert!(!command_line_is_orch_serve("/opt/homebrew/bin/orch -V"));
419        assert!(!command_line_is_orch_serve("/bin/sh -c orch serve"));
420        assert!(!command_line_is_orch_serve("/tmp/not-orch serve"));
421    }
422
423    #[test]
424    fn find_orch_serve_pids_does_not_panic() {
425        let pids = find_orch_serve_pids(false);
426        assert!(pids.iter().all(|pid| pid_is_orch_serve(*pid)));
427    }
428
429    #[test]
430    fn find_orch_serve_pids_excludes_self() {
431        let self_pid = process::id();
432        let pids = find_orch_serve_pids(true);
433        assert!(!pids.contains(&self_pid));
434    }
435}