Skip to main content

apm/cmd/
workers.rs

1use anyhow::{bail, Result};
2use apm_core::{config::Config, ticket, ticket_fmt, worker, worktree};
3use std::path::Path;
4use crate::util::worktree_for_ticket;
5
6pub fn run(root: &Path, log_id: Option<&str>, kill_id: Option<&str>) -> Result<()> {
7    if let Some(id_arg) = kill_id {
8        return kill(root, id_arg);
9    }
10    if let Some(id_arg) = log_id {
11        return tail_log(root, id_arg);
12    }
13    list(root)
14}
15
16fn list(root: &Path) -> Result<()> {
17    let config = Config::load(root)?;
18    let ended_states: std::collections::HashSet<&str> = config
19        .workflow
20        .states
21        .iter()
22        .filter(|s| s.terminal || s.worker_end)
23        .map(|s| s.id.as_str())
24        .collect();
25    let worktrees = worktree::list_ticket_worktrees(root)?;
26    let tickets = ticket::load_all_from_git(root, &config.tickets.dir).unwrap_or_default();
27
28    struct Row {
29        id: String,
30        title: String,
31        pid: String,
32        state: String,
33        elapsed: String,
34    }
35
36    let mut rows: Vec<Row> = Vec::new();
37
38    for (wt_path, branch) in &worktrees {
39        let pid_path = wt_path.join(".apm-worker.pid");
40        if !pid_path.exists() {
41            continue;
42        }
43
44        let (pid, pidfile) = match worker::read_pid_file(&pid_path) {
45            Ok(w) => w,
46            Err(_) => continue,
47        };
48
49        let alive = worker::is_alive(pid);
50
51        let t = tickets.iter().find(|t| {
52            t.frontmatter.branch.as_deref() == Some(branch.as_str())
53                || ticket_fmt::branch_name_from_path(&t.path).as_deref() == Some(branch.as_str())
54        });
55
56        let title = t.map(|t| t.frontmatter.title.as_str()).unwrap_or("—").to_string();
57        let state = if alive {
58            t.map(|t| t.frontmatter.state.as_str()).unwrap_or("—").to_string()
59        } else {
60            let ticket_state = t.map(|t| t.frontmatter.state.as_str()).unwrap_or("");
61            if ended_states.contains(ticket_state) {
62                ticket_state.to_string()
63            } else {
64                "crashed".to_string()
65            }
66        };
67
68        let pid_col = if alive {
69            pid.to_string()
70        } else {
71            "—".to_string()
72        };
73
74        let elapsed = if alive {
75            worker::elapsed_since(&pidfile.started_at)
76        } else {
77            "—".to_string()
78        };
79
80        rows.push(Row {
81            id: pidfile.ticket_id.clone(),
82            title,
83            pid: pid_col,
84            state,
85            elapsed,
86        });
87    }
88
89    if rows.is_empty() {
90        println!("No workers running.");
91        return Ok(());
92    }
93
94    let id_w = rows.iter().map(|r| r.id.len()).max().unwrap_or(2).max(2);
95    let title_w = rows.iter().map(|r| r.title.len()).max().unwrap_or(5).max(5);
96    let pid_w = rows.iter().map(|r| r.pid.len()).max().unwrap_or(3).max(3);
97    let state_w = rows.iter().map(|r| r.state.len()).max().unwrap_or(5).max(5);
98    let elapsed_w = rows.iter().map(|r| r.elapsed.len()).max().unwrap_or(7).max(7);
99
100    println!(
101        "{:<id_w$}  {:<title_w$}  {:<pid_w$}  {:<state_w$}  {:<elapsed_w$}",
102        "ID", "TITLE", "PID", "STATE", "ELAPSED",
103        id_w = id_w,
104        title_w = title_w,
105        pid_w = pid_w,
106        state_w = state_w,
107        elapsed_w = elapsed_w,
108    );
109
110    for r in &rows {
111        println!(
112            "{:<id_w$}  {:<title_w$}  {:<pid_w$}  {:<state_w$}  {:<elapsed_w$}",
113            r.id, r.title, r.pid, r.state, r.elapsed,
114            id_w = id_w,
115            title_w = title_w,
116            pid_w = pid_w,
117            state_w = state_w,
118            elapsed_w = elapsed_w,
119        );
120    }
121
122    Ok(())
123}
124
125fn tail_log(root: &Path, id_arg: &str) -> Result<()> {
126    let (wt, id) = worktree_for_ticket(root, id_arg)?;
127    let log_path = wt.join(".apm-worker.log");
128    if !log_path.exists() {
129        bail!("no log file for ticket {id}");
130    }
131    let status = std::process::Command::new("tail")
132        .args(["-n", "50", "-f", &log_path.to_string_lossy()])
133        .status()?;
134    if !status.success() {
135        bail!("tail exited with non-zero status");
136    }
137    Ok(())
138}
139
140fn kill(root: &Path, id_arg: &str) -> Result<()> {
141    let (wt, id) = worktree_for_ticket(root, id_arg)?;
142    let pid_path = wt.join(".apm-worker.pid");
143    if !pid_path.exists() {
144        bail!("worker for ticket {id} is not running (no .apm-worker.pid)");
145    }
146    let (pid, _) = worker::read_pid_file(&pid_path)?;
147    if !worker::is_alive(pid) {
148        let _ = std::fs::remove_file(&pid_path);
149        bail!("worker for ticket {id} is not running (stale PID {})", pid);
150    }
151    let status = std::process::Command::new("kill")
152        .args(["-TERM", &pid.to_string()])
153        .status()?;
154    if !status.success() {
155        bail!("failed to send SIGTERM to PID {}", pid);
156    }
157    println!("killed worker for ticket #{id} (PID {})", pid);
158    Ok(())
159}
160
161#[cfg(test)]
162mod tests {
163    fn make_ended_states(ids: &[&'static str]) -> std::collections::HashSet<&'static str> {
164        ids.iter().cloned().collect()
165    }
166
167    fn dead_worker_state(ticket_state: &str, ended_states: &std::collections::HashSet<&str>) -> String {
168        if ended_states.contains(ticket_state) {
169            ticket_state.to_string()
170        } else {
171            "crashed".to_string()
172        }
173    }
174
175    #[test]
176    fn dead_worker_end_state_shows_state() {
177        let ended = make_ended_states(&["specd", "implemented"]);
178        assert_eq!(dead_worker_state("specd", &ended), "specd");
179        assert_eq!(dead_worker_state("implemented", &ended), "implemented");
180    }
181
182    #[test]
183    fn dead_terminal_state_shows_state() {
184        let ended = make_ended_states(&["closed", "specd", "implemented"]);
185        assert_eq!(dead_worker_state("closed", &ended), "closed");
186    }
187
188    #[test]
189    fn dead_non_ended_state_shows_crashed() {
190        let ended = make_ended_states(&["specd", "implemented", "closed"]);
191        assert_eq!(dead_worker_state("in_progress", &ended), "crashed");
192        assert_eq!(dead_worker_state("ready", &ended), "crashed");
193        assert_eq!(dead_worker_state("", &ended), "crashed");
194    }
195}