1use anyhow::Context;
8use std::path::{Path, PathBuf};
9
10const 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
24pub 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
41pub 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
52fn legacy_state_dir() -> Option<PathBuf> {
57 let home = dirs::home_dir()?;
58 let mid = home.join(".orchestrator").join("state");
60 if mid.is_dir() {
61 return Some(mid);
62 }
63 let old = home.join(".orchestrator").join(".orchestrator");
65 if old.is_dir() {
66 return Some(old);
67 }
68 None
69}
70
71pub 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 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 Ok(new_path)
87}
88
89pub fn config_path() -> anyhow::Result<PathBuf> {
91 Ok(orch_home()?.join("config.yml"))
92}
93
94pub 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
117pub fn service_version_path() -> anyhow::Result<std::path::PathBuf> {
123 Ok(state_dir()?.join("service.version"))
124}
125
126pub 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
133pub 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
152pub 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
217pub fn worktrees_dir() -> anyhow::Result<PathBuf> {
219 let dir = orch_home()?.join("worktrees");
220 ensure_dir(&dir)?;
221 Ok(dir)
222}
223
224pub fn contexts_dir() -> anyhow::Result<PathBuf> {
226 let dir = orch_home()?.join("contexts");
227 ensure_dir(&dir)?;
228 Ok(dir)
229}
230
231pub fn projects_dir() -> anyhow::Result<PathBuf> {
233 let dir = orch_home()?.join("projects");
234 ensure_dir(&dir)?;
235 Ok(dir)
236}
237
238pub fn skills_dir() -> anyhow::Result<PathBuf> {
240 let dir = orch_home()?.join("skills");
241 ensure_dir(&dir)?;
242 Ok(dir)
243}
244
245pub 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
255pub 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
264pub 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
276pub 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
287pub 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
305pub 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 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 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 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}