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