1use std::collections::HashMap;
54use std::path::{Path, PathBuf};
55use serde::{Deserialize, Serialize};
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "snake_case")]
60pub enum DenialClass {
61 ApmCommandDenial,
64 OutsideWorktree,
66 RequiresApproval,
69 UnknownPattern,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct DenialEntry {
76 pub timestamp: String,
78 pub tool: String,
80 pub input: String,
83 pub classification: DenialClass,
84}
85
86#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct DenialSummary {
89 pub ticket_id: String,
90 pub worker_exited_at: String,
92 pub log_path: String,
94 pub denial_count: usize,
95 pub denials: Vec<DenialEntry>,
96}
97
98pub fn scan_transcript(log_path: &Path, worktree: &Path, ticket_id: &str) -> DenialSummary {
103 let content = match std::fs::read_to_string(log_path) {
104 Ok(c) => c,
105 Err(_) => {
106 return empty_summary(log_path, ticket_id);
107 }
108 };
109
110 let mut tool_uses: HashMap<String, (String, serde_json::Value, String)> = HashMap::new();
115
116 for line in content.lines() {
117 let v: serde_json::Value = match serde_json::from_str(line) {
118 Ok(v) => v,
119 Err(_) => continue,
120 };
121 if v["type"] != "assistant" {
122 continue;
123 }
124 let ts = v["timestamp"].as_str().unwrap_or("").to_string();
125 if let Some(arr) = v["message"]["content"].as_array() {
126 for item in arr {
127 if item["type"] != "tool_use" {
128 continue;
129 }
130 let id = item["id"].as_str().unwrap_or("").to_string();
131 if id.is_empty() {
132 continue;
133 }
134 let name = item["name"].as_str().unwrap_or("").to_string();
135 let input = item["input"].clone();
136 tool_uses.insert(id, (name, input, ts.clone()));
137 }
138 }
139 }
140
141 let canon_worktree = std::fs::canonicalize(worktree)
143 .unwrap_or_else(|_| worktree.to_path_buf());
144
145 let mut denials: Vec<DenialEntry> = Vec::new();
146
147 for line in content.lines() {
148 let v: serde_json::Value = match serde_json::from_str(line) {
149 Ok(v) => v,
150 Err(_) => continue,
151 };
152 if v["type"] != "user" {
153 continue;
154 }
155 let result_ts = v["timestamp"].as_str().unwrap_or("").to_string();
156 let Some(arr) = v["message"]["content"].as_array() else { continue };
157
158 for item in arr {
159 if item["type"] != "tool_result" {
160 continue;
161 }
162 if item["is_error"] != true {
163 continue;
164 }
165 let content_str = match item["content"].as_str() {
167 Some(s) => s,
168 None => continue,
169 };
170 if content_str.starts_with("Exit code ") {
171 continue;
172 }
173 if content_str.contains("Cancelled: parallel tool call") {
175 continue;
176 }
177 if content_str.contains("requires approval") {
179 let tool_use_id = item["tool_use_id"].as_str().unwrap_or("");
180 let Some((tool_name, input_obj, _)) = tool_uses.get(tool_use_id) else { continue };
181 let input_str = if tool_name == "Bash" {
182 input_obj["command"].as_str().unwrap_or("").to_string()
183 } else {
184 serde_json::to_string(input_obj).unwrap_or_default()
185 };
186 denials.push(DenialEntry {
187 timestamp: result_ts.clone(),
188 tool: tool_name.clone(),
189 input: truncate_str(&input_str, 200),
190 classification: DenialClass::RequiresApproval,
191 });
192 continue;
193 }
194
195 let tool_use_id = item["tool_use_id"].as_str().unwrap_or("");
196 let Some((tool_name, input_obj, _)) = tool_uses.get(tool_use_id) else { continue };
197
198 let (input_str, classification) =
199 classify_denial(tool_name, input_obj, &canon_worktree, worktree);
200
201 denials.push(DenialEntry {
202 timestamp: result_ts.clone(),
203 tool: tool_name.clone(),
204 input: truncate_str(&input_str, 200),
205 classification,
206 });
207 }
208 }
209
210 DenialSummary {
211 ticket_id: ticket_id.to_string(),
212 worker_exited_at: chrono::Utc::now().to_rfc3339(),
213 log_path: log_path.to_string_lossy().into_owned(),
214 denial_count: denials.len(),
215 denials,
216 }
217}
218
219pub fn write_summary(summary_path: &Path, summary: &DenialSummary) {
223 match serde_json::to_string_pretty(summary) {
224 Ok(json) => {
225 if let Err(e) = std::fs::write(summary_path, json) {
226 crate::logger::log("worker-diag", &format!("write_summary failed: {e}"));
227 }
228 }
229 Err(e) => {
230 crate::logger::log("worker-diag", &format!("write_summary serialize failed: {e}"));
231 }
232 }
233}
234
235pub fn read_summary(summary_path: &Path) -> Option<DenialSummary> {
238 let content = std::fs::read_to_string(summary_path).ok()?;
239 serde_json::from_str(&content).ok()
240}
241
242pub fn summary_path_for(log_path: &Path) -> std::path::PathBuf {
246 if log_path.extension().and_then(|e| e.to_str()) == Some("log") {
247 log_path.with_extension("summary.json")
248 } else {
249 let mut p = log_path.to_path_buf();
250 let name = p.file_name()
251 .map(|n| format!("{}.summary.json", n.to_string_lossy()))
252 .unwrap_or_else(|| "summary.json".to_string());
253 p.set_file_name(name);
254 p
255 }
256}
257
258pub fn collect_unique_apm_commands(summary: &DenialSummary) -> Vec<String> {
261 let mut seen = std::collections::HashSet::new();
262 summary
263 .denials
264 .iter()
265 .filter(|d| d.classification == DenialClass::ApmCommandDenial)
266 .filter(|d| seen.insert(d.input.clone()))
267 .map(|d| d.input.clone())
268 .collect()
269}
270
271fn empty_summary(log_path: &Path, ticket_id: &str) -> DenialSummary {
274 DenialSummary {
275 ticket_id: ticket_id.to_string(),
276 worker_exited_at: chrono::Utc::now().to_rfc3339(),
277 log_path: log_path.to_string_lossy().into_owned(),
278 denial_count: 0,
279 denials: Vec::new(),
280 }
281}
282
283fn classify_denial(
285 tool: &str,
286 input_obj: &serde_json::Value,
287 canon_worktree: &Path,
288 raw_worktree: &Path,
289) -> (String, DenialClass) {
290 match tool {
291 "Bash" => {
292 let command = input_obj["command"].as_str().unwrap_or("").to_string();
293 let class = if command.trim().starts_with("apm ") {
294 DenialClass::ApmCommandDenial
295 } else {
296 DenialClass::UnknownPattern
297 };
298 (command, class)
299 }
300 "Edit" | "Write" => {
301 let file_path_str = input_obj["file_path"].as_str().unwrap_or("");
302 let class = if !file_path_str.is_empty()
303 && is_outside_worktree(file_path_str, canon_worktree, raw_worktree)
304 {
305 DenialClass::OutsideWorktree
306 } else {
307 DenialClass::UnknownPattern
308 };
309 let input_str = serde_json::to_string(input_obj).unwrap_or_default();
310 (input_str, class)
311 }
312 _ => {
313 let input_str = serde_json::to_string(input_obj).unwrap_or_default();
314 (input_str, DenialClass::UnknownPattern)
315 }
316 }
317}
318
319fn is_outside_worktree(file_path_str: &str, canon_worktree: &Path, raw_worktree: &Path) -> bool {
328 let file_path = Path::new(file_path_str);
329
330 let resolved: PathBuf = if file_path.is_absolute() {
331 std::fs::canonicalize(file_path).unwrap_or_else(|_| file_path.to_path_buf())
332 } else {
333 let joined = raw_worktree.join(file_path);
334 std::fs::canonicalize(&joined).unwrap_or(joined)
335 };
336
337 !resolved.starts_with(canon_worktree)
338}
339
340fn truncate_str(s: &str, max_bytes: usize) -> String {
341 if s.len() <= max_bytes {
342 s.to_string()
343 } else {
344 let mut end = max_bytes;
346 while !s.is_char_boundary(end) {
347 end -= 1;
348 }
349 s[..end].to_string()
350 }
351}
352
353#[cfg(test)]
354mod tests {
355 use super::*;
356
357 fn fixture_path(name: &str) -> std::path::PathBuf {
358 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
359 .join("tests/fixtures")
360 .join(name)
361 }
362
363 #[test]
364 fn test_apm_command_denial() {
365 let log_path = fixture_path("transcript_apm_denial.jsonl");
366 let worktree = Path::new("/fake/worktree");
367 let summary = scan_transcript(&log_path, worktree, "testticket");
368
369 assert_eq!(summary.denial_count, 1, "expected 1 denial");
370 assert_eq!(summary.denials[0].classification, DenialClass::ApmCommandDenial);
371 assert_eq!(summary.denials[0].tool, "Bash");
372 assert!(
373 summary.denials[0].input.starts_with("apm "),
374 "input should start with 'apm ', got: {:?}",
375 summary.denials[0].input
376 );
377 }
378
379 #[test]
380 fn test_no_denials() {
381 let log_path = fixture_path("transcript_no_denials.jsonl");
382 let worktree = Path::new("/fake/worktree");
383 let summary = scan_transcript(&log_path, worktree, "testticket");
384
385 assert_eq!(summary.denial_count, 0, "expected 0 denials");
386 assert!(summary.denials.is_empty());
387 }
388
389 #[test]
390 fn test_outside_worktree() {
391 let log_path = fixture_path("transcript_outside_worktree.jsonl");
392 let worktree = Path::new("/fake/worktree");
393 let summary = scan_transcript(&log_path, worktree, "testticket");
394
395 assert_eq!(summary.denial_count, 1, "expected 1 denial");
396 assert_eq!(summary.denials[0].classification, DenialClass::OutsideWorktree);
397 }
398
399 #[test]
400 fn test_missing_transcript_returns_empty_summary() {
401 let log_path = Path::new("/nonexistent/path/log.jsonl");
402 let summary = scan_transcript(log_path, Path::new("/fake/worktree"), "t1");
403 assert_eq!(summary.denial_count, 0);
404 }
405
406 #[test]
407 fn test_regular_error_not_classified_as_denial() {
408 let content = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"false"}}]}}
410{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"Exit code 1"}]},"timestamp":"2026-01-01T00:00:00Z"}
411"#;
412 let dir = tempfile::tempdir().unwrap();
413 let log = dir.path().join("test.jsonl");
414 std::fs::write(&log, content).unwrap();
415 let summary = scan_transcript(&log, dir.path(), "t");
416 assert_eq!(summary.denial_count, 0);
417 }
418
419 #[test]
420 fn test_cancelled_parallel_not_a_denial() {
421 let content = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"apm instructions"}}]}}
422{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"Cancelled: parallel tool call Bash(apm instructions) errored"}]},"timestamp":"2026-01-01T00:00:00Z"}
423"#;
424 let dir = tempfile::tempdir().unwrap();
425 let log = dir.path().join("test.jsonl");
426 std::fs::write(&log, content).unwrap();
427 let summary = scan_transcript(&log, dir.path(), "t");
428 assert_eq!(summary.denial_count, 0);
429 assert!(summary.denials.is_empty());
430 }
431
432 #[test]
433 fn test_requires_approval_classified_as_requires_approval() {
434 let content = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"apm instructions"}}]}}
435{"type":"user","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"t1","is_error":true,"content":"This command requires approval"}]},"timestamp":"2026-01-01T00:00:00Z"}
436"#;
437 let dir = tempfile::tempdir().unwrap();
438 let log = dir.path().join("test.jsonl");
439 std::fs::write(&log, content).unwrap();
440 let summary = scan_transcript(&log, dir.path(), "t");
441 assert_eq!(summary.denial_count, 1);
442 assert_eq!(summary.denials[0].classification, DenialClass::RequiresApproval);
443 }
444
445 #[test]
446 fn test_truncate_str_at_boundary() {
447 let s = "apm state xyz implemented";
448 let truncated = truncate_str(s, 10);
449 assert_eq!(truncated.len(), 10);
450 assert!(s.starts_with(&truncated));
451 }
452
453 #[test]
454 fn test_write_and_read_summary_roundtrip() {
455 let dir = tempfile::tempdir().unwrap();
456 let summary = DenialSummary {
457 ticket_id: "abc123".to_string(),
458 worker_exited_at: "2026-01-01T00:00:00Z".to_string(),
459 log_path: "/fake/log".to_string(),
460 denial_count: 1,
461 denials: vec![DenialEntry {
462 timestamp: "2026-01-01T00:00:00Z".to_string(),
463 tool: "Bash".to_string(),
464 input: "apm state abc implemented".to_string(),
465 classification: DenialClass::ApmCommandDenial,
466 }],
467 };
468 let path = dir.path().join("summary.json");
469 write_summary(&path, &summary);
470 let loaded = read_summary(&path).expect("should be readable");
471 assert_eq!(loaded.ticket_id, "abc123");
472 assert_eq!(loaded.denial_count, 1);
473 assert_eq!(loaded.denials[0].classification, DenialClass::ApmCommandDenial);
474 }
475}