Skip to main content

apm/cmd/
workers.rs

1use anyhow::{bail, Result};
2use apm_core::{config::Config, denial, 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
16pub fn run_diag(root: &Path, ticket_id: &str) -> Result<()> {
17    let (wt, id) = worktree_for_ticket(root, ticket_id)?;
18    let log_path = wt.join(".apm-worker.log");
19    let summary_path = wt.join(".apm-worker.summary.json");
20
21    let summary = if summary_path.exists() {
22        denial::read_summary(&summary_path)
23            .ok_or_else(|| anyhow::anyhow!("failed to parse {}", summary_path.display()))?
24    } else if log_path.exists() {
25        denial::scan_transcript(&log_path, &wt, &id)
26    } else {
27        bail!(
28            "no worker log or summary found for ticket {id} (expected {} or {})",
29            log_path.display(),
30            summary_path.display()
31        );
32    };
33
34    print_diag_report(&summary, &log_path);
35    Ok(())
36}
37
38fn print_diag_report(summary: &denial::DenialSummary, log_path: &std::path::Path) {
39    // Use the log_path recorded in the summary if it looks valid, otherwise
40    // fall back to the path we derived from the worktree.
41    let log_display = if !summary.log_path.is_empty() {
42        summary.log_path.clone()
43    } else {
44        log_path.to_string_lossy().into_owned()
45    };
46
47    #[allow(clippy::print_stdout)]
48    {
49        println!("Worker denial report — {}", summary.ticket_id);
50        println!("Log: {log_display}");
51        println!();
52
53        if summary.denial_count == 0 {
54            println!("No denials detected.");
55            return;
56        }
57
58        let apm_count = summary.denials.iter()
59            .filter(|d| d.classification == denial::DenialClass::ApmCommandDenial)
60            .count();
61        let outside_count = summary.denials.iter()
62            .filter(|d| d.classification == denial::DenialClass::OutsideWorktree)
63            .count();
64        let unknown_count = summary.denials.iter()
65            .filter(|d| d.classification == denial::DenialClass::UnknownPattern)
66            .count();
67
68        println!("Total denials: {}", summary.denial_count);
69        println!("  apm_command_denial : {apm_count}");
70        println!("  outside_worktree   : {outside_count}");
71        println!("  unknown_pattern    : {unknown_count}");
72
73        if apm_count > 0 {
74            println!();
75            println!("APM command denials (allowlist gaps):");
76            let unique_cmds = denial::collect_unique_apm_commands(summary);
77            for cmd in &unique_cmds {
78                // Find the first entry for this command to get its timestamp
79                let ts = summary.denials.iter()
80                    .find(|d| d.classification == denial::DenialClass::ApmCommandDenial && d.input == *cmd)
81                    .map(|d| d.timestamp.as_str())
82                    .unwrap_or("");
83                if ts.is_empty() {
84                    println!("  {cmd}");
85                } else {
86                    println!("  {cmd}  ({ts})");
87                }
88                println!("  \u{2192} Add \"Bash({cmd}*)\" to .claude/settings.json");
89                println!("    and to APM_ALLOW_ENTRIES in apm-core/src/init.rs");
90            }
91        }
92    }
93}
94
95fn list(root: &Path) -> Result<()> {
96    let config = Config::load(root)?;
97    let ended_states: std::collections::HashSet<&str> = config
98        .workflow
99        .states
100        .iter()
101        .filter(|s| s.terminal || s.worker_end)
102        .map(|s| s.id.as_str())
103        .collect();
104    let worktrees = worktree::list_ticket_worktrees(root)?;
105    let tickets = ticket::load_all_from_git(root, &config.tickets.dir).unwrap_or_default();
106
107    struct Row {
108        id: String,
109        title: String,
110        pid: String,
111        state: String,
112        elapsed: String,
113    }
114
115    let mut rows: Vec<Row> = Vec::new();
116
117    for (wt_path, branch) in &worktrees {
118        let pid_path = wt_path.join(".apm-worker.pid");
119        if !pid_path.exists() {
120            continue;
121        }
122
123        let (pid, pidfile) = match worker::read_pid_file(&pid_path) {
124            Ok(w) => w,
125            Err(_) => continue,
126        };
127
128        let alive = worker::is_alive(pid);
129
130        let t = tickets.iter().find(|t| {
131            t.frontmatter.branch.as_deref() == Some(branch.as_str())
132                || ticket_fmt::branch_name_from_path(&t.path).as_deref() == Some(branch.as_str())
133        });
134
135        let title = t.map(|t| t.frontmatter.title.as_str()).unwrap_or("—").to_string();
136        let state = if alive {
137            t.map(|t| t.frontmatter.state.as_str()).unwrap_or("—").to_string()
138        } else {
139            let ticket_state = t.map(|t| t.frontmatter.state.as_str()).unwrap_or("");
140            if ended_states.contains(ticket_state) {
141                ticket_state.to_string()
142            } else {
143                "crashed".to_string()
144            }
145        };
146
147        let pid_col = if alive {
148            pid.to_string()
149        } else {
150            "—".to_string()
151        };
152
153        let elapsed = if alive {
154            worker::elapsed_since(&pidfile.started_at)
155        } else {
156            "—".to_string()
157        };
158
159        rows.push(Row {
160            id: pidfile.ticket_id.clone(),
161            title,
162            pid: pid_col,
163            state,
164            elapsed,
165        });
166    }
167
168    if rows.is_empty() {
169        println!("No workers running.");
170        return Ok(());
171    }
172
173    let id_w = rows.iter().map(|r| r.id.len()).max().unwrap_or(2).max(2);
174    let title_w = rows.iter().map(|r| r.title.len()).max().unwrap_or(5).max(5);
175    let pid_w = rows.iter().map(|r| r.pid.len()).max().unwrap_or(3).max(3);
176    let state_w = rows.iter().map(|r| r.state.len()).max().unwrap_or(5).max(5);
177    let elapsed_w = rows.iter().map(|r| r.elapsed.len()).max().unwrap_or(7).max(7);
178
179    println!(
180        "{:<id_w$}  {:<title_w$}  {:<pid_w$}  {:<state_w$}  {:<elapsed_w$}",
181        "ID", "TITLE", "PID", "STATE", "ELAPSED",
182        id_w = id_w,
183        title_w = title_w,
184        pid_w = pid_w,
185        state_w = state_w,
186        elapsed_w = elapsed_w,
187    );
188
189    for r in &rows {
190        println!(
191            "{:<id_w$}  {:<title_w$}  {:<pid_w$}  {:<state_w$}  {:<elapsed_w$}",
192            r.id, r.title, r.pid, r.state, r.elapsed,
193            id_w = id_w,
194            title_w = title_w,
195            pid_w = pid_w,
196            state_w = state_w,
197            elapsed_w = elapsed_w,
198        );
199    }
200
201    Ok(())
202}
203
204fn tail_log(root: &Path, id_arg: &str) -> Result<()> {
205    let (wt, id) = worktree_for_ticket(root, id_arg)?;
206    let log_path = wt.join(".apm-worker.log");
207    if !log_path.exists() {
208        bail!("no log file for ticket {id}");
209    }
210    let status = std::process::Command::new("tail")
211        .args(["-n", "50", "-f", &log_path.to_string_lossy()])
212        .status()?;
213    if !status.success() {
214        bail!("tail exited with non-zero status");
215    }
216    Ok(())
217}
218
219fn kill(root: &Path, id_arg: &str) -> Result<()> {
220    let (wt, id) = worktree_for_ticket(root, id_arg)?;
221    let pid_path = wt.join(".apm-worker.pid");
222    if !pid_path.exists() {
223        bail!("worker for ticket {id} is not running (no .apm-worker.pid)");
224    }
225    let (pid, _) = worker::read_pid_file(&pid_path)?;
226    if !worker::is_alive(pid) {
227        let _ = std::fs::remove_file(&pid_path);
228        bail!("worker for ticket {id} is not running (stale PID {})", pid);
229    }
230    let status = std::process::Command::new("kill")
231        .args(["-TERM", &pid.to_string()])
232        .status()?;
233    if !status.success() {
234        bail!("failed to send SIGTERM to PID {}", pid);
235    }
236    println!("killed worker for ticket #{id} (PID {})", pid);
237    Ok(())
238}
239
240#[cfg(test)]
241mod tests {
242    fn make_ended_states(ids: &[&'static str]) -> std::collections::HashSet<&'static str> {
243        ids.iter().cloned().collect()
244    }
245
246    fn dead_worker_state(ticket_state: &str, ended_states: &std::collections::HashSet<&str>) -> String {
247        if ended_states.contains(ticket_state) {
248            ticket_state.to_string()
249        } else {
250            "crashed".to_string()
251        }
252    }
253
254    #[test]
255    fn dead_worker_end_state_shows_state() {
256        let ended = make_ended_states(&["specd", "implemented"]);
257        assert_eq!(dead_worker_state("specd", &ended), "specd");
258        assert_eq!(dead_worker_state("implemented", &ended), "implemented");
259    }
260
261    #[test]
262    fn dead_terminal_state_shows_state() {
263        let ended = make_ended_states(&["closed", "specd", "implemented"]);
264        assert_eq!(dead_worker_state("closed", &ended), "closed");
265    }
266
267    #[test]
268    fn dead_non_ended_state_shows_crashed() {
269        let ended = make_ended_states(&["specd", "implemented", "closed"]);
270        assert_eq!(dead_worker_state("in_progress", &ended), "crashed");
271        assert_eq!(dead_worker_state("ready", &ended), "crashed");
272        assert_eq!(dead_worker_state("", &ended), "crashed");
273    }
274}