1use 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
22const 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
36pub 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
53pub 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
64fn legacy_state_dir() -> Option<PathBuf> {
69 let home = dirs::home_dir()?;
70 let mid = home.join(".orchestrator").join("state");
72 if mid.is_dir() {
73 return Some(mid);
74 }
75 let old = home.join(".orchestrator").join(".orchestrator");
77 if old.is_dir() {
78 return Some(old);
79 }
80 None
81}
82
83pub 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 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 Ok(new_path)
99}
100
101pub fn config_path() -> anyhow::Result<PathBuf> {
103 Ok(orch_home()?.join("config.yml"))
104}
105
106pub 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
127pub fn service_version_path() -> anyhow::Result<PathBuf> {
133 Ok(state_dir()?.join("service.version"))
134}
135
136pub 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
143pub 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
159pub 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
224pub fn worktrees_dir() -> anyhow::Result<PathBuf> {
226 let dir = orch_home()?.join("worktrees");
227 ensure_dir(&dir)?;
228 Ok(dir)
229}
230
231pub fn contexts_dir() -> anyhow::Result<PathBuf> {
233 let dir = orch_home()?.join("contexts");
234 ensure_dir(&dir)?;
235 Ok(dir)
236}
237
238pub fn projects_dir() -> anyhow::Result<PathBuf> {
240 let dir = orch_home()?.join("projects");
241 ensure_dir(&dir)?;
242 Ok(dir)
243}
244
245pub fn skills_dir() -> anyhow::Result<PathBuf> {
247 let dir = orch_home()?.join("skills");
248 ensure_dir(&dir)?;
249 Ok(dir)
250}
251
252pub 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
262pub 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
271pub 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
283pub 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
294pub 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
312pub 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 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 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 Some(v) => unsafe { env::set_var("ORCH_HOME", v) },
352 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 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 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 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}