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 UnknownPattern,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct DenialEntry {
73 pub timestamp: String,
75 pub tool: String,
77 pub input: String,
80 pub classification: DenialClass,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct DenialSummary {
86 pub ticket_id: String,
87 pub worker_exited_at: String,
89 pub log_path: String,
91 pub denial_count: usize,
92 pub denials: Vec<DenialEntry>,
93}
94
95pub fn scan_transcript(log_path: &Path, worktree: &Path, ticket_id: &str) -> DenialSummary {
100 let content = match std::fs::read_to_string(log_path) {
101 Ok(c) => c,
102 Err(_) => {
103 return empty_summary(log_path, ticket_id);
104 }
105 };
106
107 let mut tool_uses: HashMap<String, (String, serde_json::Value, String)> = HashMap::new();
112
113 for line in content.lines() {
114 let v: serde_json::Value = match serde_json::from_str(line) {
115 Ok(v) => v,
116 Err(_) => continue,
117 };
118 if v["type"] != "assistant" {
119 continue;
120 }
121 let ts = v["timestamp"].as_str().unwrap_or("").to_string();
122 if let Some(arr) = v["message"]["content"].as_array() {
123 for item in arr {
124 if item["type"] != "tool_use" {
125 continue;
126 }
127 let id = item["id"].as_str().unwrap_or("").to_string();
128 if id.is_empty() {
129 continue;
130 }
131 let name = item["name"].as_str().unwrap_or("").to_string();
132 let input = item["input"].clone();
133 tool_uses.insert(id, (name, input, ts.clone()));
134 }
135 }
136 }
137
138 let canon_worktree = std::fs::canonicalize(worktree)
140 .unwrap_or_else(|_| worktree.to_path_buf());
141
142 let mut denials: Vec<DenialEntry> = Vec::new();
143
144 for line in content.lines() {
145 let v: serde_json::Value = match serde_json::from_str(line) {
146 Ok(v) => v,
147 Err(_) => continue,
148 };
149 if v["type"] != "user" {
150 continue;
151 }
152 let result_ts = v["timestamp"].as_str().unwrap_or("").to_string();
153 let Some(arr) = v["message"]["content"].as_array() else { continue };
154
155 for item in arr {
156 if item["type"] != "tool_result" {
157 continue;
158 }
159 if item["is_error"] != true {
160 continue;
161 }
162 let content_str = match item["content"].as_str() {
164 Some(s) => s,
165 None => continue,
166 };
167 if content_str.starts_with("Exit code ") {
168 continue;
169 }
170
171 let tool_use_id = item["tool_use_id"].as_str().unwrap_or("");
172 let Some((tool_name, input_obj, _)) = tool_uses.get(tool_use_id) else { continue };
173
174 let (input_str, classification) =
175 classify_denial(tool_name, input_obj, &canon_worktree, worktree);
176
177 denials.push(DenialEntry {
178 timestamp: result_ts.clone(),
179 tool: tool_name.clone(),
180 input: truncate_str(&input_str, 200),
181 classification,
182 });
183 }
184 }
185
186 DenialSummary {
187 ticket_id: ticket_id.to_string(),
188 worker_exited_at: chrono::Utc::now().to_rfc3339(),
189 log_path: log_path.to_string_lossy().into_owned(),
190 denial_count: denials.len(),
191 denials,
192 }
193}
194
195pub fn write_summary(summary_path: &Path, summary: &DenialSummary) {
199 match serde_json::to_string_pretty(summary) {
200 Ok(json) => {
201 if let Err(e) = std::fs::write(summary_path, json) {
202 crate::logger::log("worker-diag", &format!("write_summary failed: {e}"));
203 }
204 }
205 Err(e) => {
206 crate::logger::log("worker-diag", &format!("write_summary serialize failed: {e}"));
207 }
208 }
209}
210
211pub fn read_summary(summary_path: &Path) -> Option<DenialSummary> {
214 let content = std::fs::read_to_string(summary_path).ok()?;
215 serde_json::from_str(&content).ok()
216}
217
218pub fn summary_path_for(log_path: &Path) -> std::path::PathBuf {
222 if log_path.extension().and_then(|e| e.to_str()) == Some("log") {
223 log_path.with_extension("summary.json")
224 } else {
225 let mut p = log_path.to_path_buf();
226 let name = p.file_name()
227 .map(|n| format!("{}.summary.json", n.to_string_lossy()))
228 .unwrap_or_else(|| "summary.json".to_string());
229 p.set_file_name(name);
230 p
231 }
232}
233
234pub fn collect_unique_apm_commands(summary: &DenialSummary) -> Vec<String> {
237 let mut seen = std::collections::HashSet::new();
238 summary
239 .denials
240 .iter()
241 .filter(|d| d.classification == DenialClass::ApmCommandDenial)
242 .filter(|d| seen.insert(d.input.clone()))
243 .map(|d| d.input.clone())
244 .collect()
245}
246
247fn empty_summary(log_path: &Path, ticket_id: &str) -> DenialSummary {
250 DenialSummary {
251 ticket_id: ticket_id.to_string(),
252 worker_exited_at: chrono::Utc::now().to_rfc3339(),
253 log_path: log_path.to_string_lossy().into_owned(),
254 denial_count: 0,
255 denials: Vec::new(),
256 }
257}
258
259fn classify_denial(
261 tool: &str,
262 input_obj: &serde_json::Value,
263 canon_worktree: &Path,
264 raw_worktree: &Path,
265) -> (String, DenialClass) {
266 match tool {
267 "Bash" => {
268 let command = input_obj["command"].as_str().unwrap_or("").to_string();
269 let class = if command.trim().starts_with("apm ") {
270 DenialClass::ApmCommandDenial
271 } else {
272 DenialClass::UnknownPattern
273 };
274 (command, class)
275 }
276 "Edit" | "Write" => {
277 let file_path_str = input_obj["file_path"].as_str().unwrap_or("");
278 let class = if !file_path_str.is_empty()
279 && is_outside_worktree(file_path_str, canon_worktree, raw_worktree)
280 {
281 DenialClass::OutsideWorktree
282 } else {
283 DenialClass::UnknownPattern
284 };
285 let input_str = serde_json::to_string(input_obj).unwrap_or_default();
286 (input_str, class)
287 }
288 _ => {
289 let input_str = serde_json::to_string(input_obj).unwrap_or_default();
290 (input_str, DenialClass::UnknownPattern)
291 }
292 }
293}
294
295fn is_outside_worktree(file_path_str: &str, canon_worktree: &Path, raw_worktree: &Path) -> bool {
304 let file_path = Path::new(file_path_str);
305
306 let resolved: PathBuf = if file_path.is_absolute() {
307 std::fs::canonicalize(file_path).unwrap_or_else(|_| file_path.to_path_buf())
308 } else {
309 let joined = raw_worktree.join(file_path);
310 std::fs::canonicalize(&joined).unwrap_or(joined)
311 };
312
313 !resolved.starts_with(canon_worktree)
314}
315
316fn truncate_str(s: &str, max_bytes: usize) -> String {
317 if s.len() <= max_bytes {
318 s.to_string()
319 } else {
320 let mut end = max_bytes;
322 while !s.is_char_boundary(end) {
323 end -= 1;
324 }
325 s[..end].to_string()
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 fn fixture_path(name: &str) -> std::path::PathBuf {
334 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
335 .join("tests/fixtures")
336 .join(name)
337 }
338
339 #[test]
340 fn test_apm_command_denial() {
341 let log_path = fixture_path("transcript_apm_denial.jsonl");
342 let worktree = Path::new("/fake/worktree");
343 let summary = scan_transcript(&log_path, worktree, "testticket");
344
345 assert_eq!(summary.denial_count, 1, "expected 1 denial");
346 assert_eq!(summary.denials[0].classification, DenialClass::ApmCommandDenial);
347 assert_eq!(summary.denials[0].tool, "Bash");
348 assert!(
349 summary.denials[0].input.starts_with("apm "),
350 "input should start with 'apm ', got: {:?}",
351 summary.denials[0].input
352 );
353 }
354
355 #[test]
356 fn test_no_denials() {
357 let log_path = fixture_path("transcript_no_denials.jsonl");
358 let worktree = Path::new("/fake/worktree");
359 let summary = scan_transcript(&log_path, worktree, "testticket");
360
361 assert_eq!(summary.denial_count, 0, "expected 0 denials");
362 assert!(summary.denials.is_empty());
363 }
364
365 #[test]
366 fn test_outside_worktree() {
367 let log_path = fixture_path("transcript_outside_worktree.jsonl");
368 let worktree = Path::new("/fake/worktree");
369 let summary = scan_transcript(&log_path, worktree, "testticket");
370
371 assert_eq!(summary.denial_count, 1, "expected 1 denial");
372 assert_eq!(summary.denials[0].classification, DenialClass::OutsideWorktree);
373 }
374
375 #[test]
376 fn test_missing_transcript_returns_empty_summary() {
377 let log_path = Path::new("/nonexistent/path/log.jsonl");
378 let summary = scan_transcript(log_path, Path::new("/fake/worktree"), "t1");
379 assert_eq!(summary.denial_count, 0);
380 }
381
382 #[test]
383 fn test_regular_error_not_classified_as_denial() {
384 let content = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"false"}}]}}
386{"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"}
387"#;
388 let dir = tempfile::tempdir().unwrap();
389 let log = dir.path().join("test.jsonl");
390 std::fs::write(&log, content).unwrap();
391 let summary = scan_transcript(&log, dir.path(), "t");
392 assert_eq!(summary.denial_count, 0);
393 }
394
395 #[test]
396 fn test_truncate_str_at_boundary() {
397 let s = "apm state xyz implemented";
398 let truncated = truncate_str(s, 10);
399 assert_eq!(truncated.len(), 10);
400 assert!(s.starts_with(&truncated));
401 }
402
403 #[test]
404 fn test_write_and_read_summary_roundtrip() {
405 let dir = tempfile::tempdir().unwrap();
406 let summary = DenialSummary {
407 ticket_id: "abc123".to_string(),
408 worker_exited_at: "2026-01-01T00:00:00Z".to_string(),
409 log_path: "/fake/log".to_string(),
410 denial_count: 1,
411 denials: vec![DenialEntry {
412 timestamp: "2026-01-01T00:00:00Z".to_string(),
413 tool: "Bash".to_string(),
414 input: "apm state abc implemented".to_string(),
415 classification: DenialClass::ApmCommandDenial,
416 }],
417 };
418 let path = dir.path().join("summary.json");
419 write_summary(&path, &summary);
420 let loaded = read_summary(&path).expect("should be readable");
421 assert_eq!(loaded.ticket_id, "abc123");
422 assert_eq!(loaded.denial_count, 1);
423 assert_eq!(loaded.denials[0].classification, DenialClass::ApmCommandDenial);
424 }
425}