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 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 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}