1#![cfg_attr(not(test), allow(dead_code))]
9
10use std::fs::{File, OpenOptions};
11use std::io::{BufWriter, Write};
12use std::path::{Path, PathBuf};
13use std::sync::Mutex;
14
15use anyhow::{Context, Result};
16use serde::Serialize;
17use tracing::debug;
18
19fn now_iso8601() -> String {
21 let now = std::time::SystemTime::now();
24 let duration = now
25 .duration_since(std::time::UNIX_EPOCH)
26 .unwrap_or_default();
27 let secs = duration.as_secs();
28
29 format!("{secs}")
31}
32
33#[derive(Debug, Clone, Serialize)]
35pub struct LogEntry {
36 pub timestamp: String,
38 #[serde(flatten)]
40 pub event: LogEvent,
41}
42
43#[allow(dead_code)]
45#[derive(Debug, Clone, Serialize)]
46#[serde(tag = "event", content = "data")]
47#[serde(rename_all = "snake_case")]
48pub enum LogEvent {
49 TaskRead {
51 task_id: u32,
52 title: String,
53 status: String,
54 },
55 WorktreeCreated {
57 task_id: u32,
58 path: String,
59 branch: String,
60 },
61 PhaseWorktreeCreated {
63 phase: String,
64 path: String,
65 branch: String,
66 base_branch: String,
67 },
68 PhaseWorktreeRetained {
70 phase: String,
71 path: String,
72 branch: String,
73 reason: String,
74 },
75 PhaseWorktreeCleaned {
77 phase: String,
78 path: String,
79 branch: String,
80 },
81 AgentLaunched {
83 agent: String,
84 program: String,
85 args: Vec<String>,
86 work_dir: String,
87 },
88 LaunchContextSnapshot {
90 phase: String,
91 agent: String,
92 instructions_path: String,
93 phase_doc_path: String,
94 config_source: String,
95 snapshot_path: String,
96 snapshot: String,
97 },
98 PhaseSelectionDecision {
100 phase: String,
101 order_key: String,
102 selected: bool,
103 reason: String,
104 },
105 ReviewPacketGenerated {
107 phase: String,
108 packet_path: String,
109 diff_command: String,
110 summary_path: Option<String>,
111 statements_count: usize,
112 execution_log_path: String,
113 },
114 ReviewDecision {
116 phase: String,
117 decision: String,
118 feedback: Option<String>,
119 },
120 ReworkCycleStarted {
122 phase: String,
123 attempt: u32,
124 max_retries: u32,
125 feedback: String,
126 },
127 PromptDetected { kind: String, matched_text: String },
129 AutoResponse { prompt: String, response: String },
131 UserInput { length: usize },
133 TestExecuted {
135 command: String,
136 passed: bool,
137 exit_code: Option<i32>,
138 },
139 TestResult {
141 attempt: u32,
142 passed: bool,
143 output_lines: usize,
144 },
145 Commit { hash: String, message: String },
147 Merge { source: String, target: String },
149 PolicyDecision { decision: String, prompt: String },
151 AgentOutput { line: String },
153 RunCompleted { summary: String },
155 RunFailed { reason: String },
157 CompletionDecision {
159 phase: String,
160 passed: bool,
161 board_all_done: bool,
162 milestone_done: bool,
163 summary_exists: bool,
164 dod_passed: bool,
165 executor_stable: bool,
166 reasons: Vec<String>,
167 summary_path: Option<String>,
168 dod_command: String,
169 dod_executed: bool,
170 dod_exit_code: Option<i32>,
171 dod_output_lines: usize,
172 },
173 SessionStarted { phase: String },
175 SessionEnded { result: String },
177}
178
179pub struct ExecutionLog {
181 writer: Mutex<BufWriter<File>>,
182 #[allow(dead_code)]
183 path: PathBuf,
184}
185
186impl ExecutionLog {
187 pub fn new(path: &Path) -> Result<Self> {
192 if let Some(parent) = path.parent() {
193 std::fs::create_dir_all(parent)
194 .with_context(|| format!("failed to create log directory: {}", parent.display()))?;
195 }
196
197 let file = OpenOptions::new()
198 .create(true)
199 .append(true)
200 .open(path)
201 .with_context(|| format!("failed to open log file: {}", path.display()))?;
202
203 Ok(Self {
204 writer: Mutex::new(BufWriter::new(file)),
205 path: path.to_path_buf(),
206 })
207 }
208
209 pub fn log(&self, event: LogEvent) -> Result<()> {
211 let entry = LogEntry {
212 timestamp: now_iso8601(),
213 event,
214 };
215
216 let json = serde_json::to_string(&entry).context("failed to serialize log entry")?;
217
218 debug!(event = %json, "execution log");
219
220 let mut writer = self.writer.lock().unwrap();
221 writeln!(writer, "{json}").context("failed to write log entry")?;
222 writer.flush().context("failed to flush log")?;
223
224 Ok(())
225 }
226
227 #[allow(dead_code)]
229 pub fn path(&self) -> &Path {
230 &self.path
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn log_entry_serializes_to_json() {
240 let entry = LogEntry {
241 timestamp: "1234567890".to_string(),
242 event: LogEvent::TaskRead {
243 task_id: 1,
244 title: "scaffolding".to_string(),
245 status: "backlog".to_string(),
246 },
247 };
248
249 let json = serde_json::to_string(&entry).unwrap();
250 assert!(json.contains("\"event\":\"task_read\""));
251 assert!(json.contains("\"task_id\":1"));
252 assert!(json.contains("\"timestamp\":\"1234567890\""));
253 }
254
255 #[test]
256 fn all_event_types_serialize() {
257 let events = vec![
258 LogEvent::TaskRead {
259 task_id: 1,
260 title: "test".to_string(),
261 status: "backlog".to_string(),
262 },
263 LogEvent::WorktreeCreated {
264 task_id: 1,
265 path: "/tmp/wt".to_string(),
266 branch: "task/1".to_string(),
267 },
268 LogEvent::PhaseWorktreeCreated {
269 phase: "phase-2.5".to_string(),
270 path: "/tmp/wt-phase".to_string(),
271 branch: "phase-2-5-run-001".to_string(),
272 base_branch: "main".to_string(),
273 },
274 LogEvent::PhaseWorktreeRetained {
275 phase: "phase-2.5".to_string(),
276 path: "/tmp/wt-phase".to_string(),
277 branch: "phase-2-5-run-001".to_string(),
278 reason: "run failed".to_string(),
279 },
280 LogEvent::PhaseWorktreeCleaned {
281 phase: "phase-2.5".to_string(),
282 path: "/tmp/wt-phase".to_string(),
283 branch: "phase-2-5-run-001".to_string(),
284 },
285 LogEvent::AgentLaunched {
286 agent: "claude".to_string(),
287 program: "claude".to_string(),
288 args: vec!["--prompt".to_string(), "task".to_string()],
289 work_dir: "/work".to_string(),
290 },
291 LogEvent::LaunchContextSnapshot {
292 phase: "phase-2.5".to_string(),
293 agent: "claude-code".to_string(),
294 instructions_path: "CLAUDE.md".to_string(),
295 phase_doc_path: "kanban/phase-2.5/PHASE.md".to_string(),
296 config_source: ".batty/config.toml".to_string(),
297 snapshot_path: ".batty/logs/phase-2.5-ctx.log".to_string(),
298 snapshot: "context body".to_string(),
299 },
300 LogEvent::PhaseSelectionDecision {
301 phase: "phase-2.5".to_string(),
302 order_key: "2.5".to_string(),
303 selected: true,
304 reason: "phase selected for execution".to_string(),
305 },
306 LogEvent::ReviewPacketGenerated {
307 phase: "phase-2.5".to_string(),
308 packet_path: "/work/review-packet.md".to_string(),
309 diff_command: "git diff main...phase-2-5-run-001".to_string(),
310 summary_path: Some("/work/phase-summary.md".to_string()),
311 statements_count: 4,
312 execution_log_path: "/work/.batty/logs/phase-2-5-run-001/execution.jsonl"
313 .to_string(),
314 },
315 LogEvent::ReviewDecision {
316 phase: "phase-2.5".to_string(),
317 decision: "merge".to_string(),
318 feedback: None,
319 },
320 LogEvent::ReworkCycleStarted {
321 phase: "phase-2.5".to_string(),
322 attempt: 1,
323 max_retries: 3,
324 feedback: "address flaky test".to_string(),
325 },
326 LogEvent::PromptDetected {
327 kind: "Permission".to_string(),
328 matched_text: "Allow tool Read?".to_string(),
329 },
330 LogEvent::AutoResponse {
331 prompt: "Continue?".to_string(),
332 response: "y".to_string(),
333 },
334 LogEvent::UserInput { length: 5 },
335 LogEvent::TestExecuted {
336 command: "cargo test".to_string(),
337 passed: true,
338 exit_code: Some(0),
339 },
340 LogEvent::TestResult {
341 attempt: 1,
342 passed: true,
343 output_lines: 42,
344 },
345 LogEvent::Commit {
346 hash: "abc123".to_string(),
347 message: "fix bug".to_string(),
348 },
349 LogEvent::Merge {
350 source: "task/1".to_string(),
351 target: "main".to_string(),
352 },
353 LogEvent::PolicyDecision {
354 decision: "act".to_string(),
355 prompt: "Allow?".to_string(),
356 },
357 LogEvent::AgentOutput {
358 line: "hello".to_string(),
359 },
360 LogEvent::RunCompleted {
361 summary: "all good".to_string(),
362 },
363 LogEvent::RunFailed {
364 reason: "tests failed".to_string(),
365 },
366 LogEvent::CompletionDecision {
367 phase: "phase-2.5".to_string(),
368 passed: true,
369 board_all_done: true,
370 milestone_done: true,
371 summary_exists: true,
372 dod_passed: true,
373 executor_stable: true,
374 reasons: vec![],
375 summary_path: Some("/work/phase-summary.md".to_string()),
376 dod_command: "cargo test".to_string(),
377 dod_executed: true,
378 dod_exit_code: Some(0),
379 dod_output_lines: 120,
380 },
381 LogEvent::SessionStarted {
382 phase: "phase-1".to_string(),
383 },
384 LogEvent::SessionEnded {
385 result: "Completed".to_string(),
386 },
387 ];
388
389 for event in events {
390 let entry = LogEntry {
391 timestamp: "0".to_string(),
392 event,
393 };
394 let json = serde_json::to_string(&entry);
395 assert!(json.is_ok(), "failed to serialize: {entry:?}");
396
397 let s = json.unwrap();
399 assert!(s.contains("\"event\":"), "missing event tag in: {s}");
400 }
401 }
402
403 #[test]
404 fn write_and_read_log_file() {
405 let tmp = tempfile::tempdir().unwrap();
406 let log_path = tmp.path().join("test.jsonl");
407
408 let log = ExecutionLog::new(&log_path).unwrap();
409
410 log.log(LogEvent::SessionStarted {
411 phase: "phase-1".to_string(),
412 })
413 .unwrap();
414
415 log.log(LogEvent::TaskRead {
416 task_id: 5,
417 title: "adapter".to_string(),
418 status: "in-progress".to_string(),
419 })
420 .unwrap();
421
422 log.log(LogEvent::SessionEnded {
423 result: "Completed".to_string(),
424 })
425 .unwrap();
426
427 let content = std::fs::read_to_string(&log_path).unwrap();
429 let lines: Vec<&str> = content.lines().collect();
430 assert_eq!(lines.len(), 3);
431
432 for line in &lines {
434 let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
435 assert!(parsed.get("event").is_some());
436 assert!(parsed.get("timestamp").is_some());
437 }
438
439 assert!(lines[0].contains("\"event\":\"session_started\""));
441 assert!(lines[1].contains("\"event\":\"task_read\""));
443 assert!(lines[2].contains("\"event\":\"session_ended\""));
445 }
446
447 #[test]
448 fn creates_parent_directories() {
449 let tmp = tempfile::tempdir().unwrap();
450 let log_path = tmp.path().join("deep").join("nested").join("run.jsonl");
451
452 let log = ExecutionLog::new(&log_path).unwrap();
453 log.log(LogEvent::RunCompleted {
454 summary: "ok".to_string(),
455 })
456 .unwrap();
457
458 assert!(log_path.exists());
459 }
460
461 #[test]
462 fn appends_to_existing_file() {
463 let tmp = tempfile::tempdir().unwrap();
464 let log_path = tmp.path().join("append.jsonl");
465
466 {
468 let log = ExecutionLog::new(&log_path).unwrap();
469 log.log(LogEvent::SessionStarted {
470 phase: "p1".to_string(),
471 })
472 .unwrap();
473 }
474
475 {
477 let log = ExecutionLog::new(&log_path).unwrap();
478 log.log(LogEvent::SessionEnded {
479 result: "ok".to_string(),
480 })
481 .unwrap();
482 }
483
484 let content = std::fs::read_to_string(&log_path).unwrap();
485 assert_eq!(content.lines().count(), 2);
486 }
487
488 #[test]
489 fn log_path_accessor() {
490 let tmp = tempfile::tempdir().unwrap();
491 let log_path = tmp.path().join("test.jsonl");
492
493 let log = ExecutionLog::new(&log_path).unwrap();
494 assert_eq!(log.path(), log_path);
495 }
496
497 #[test]
498 fn timestamp_is_numeric() {
499 let ts = now_iso8601();
500 assert!(
501 ts.parse::<u64>().is_ok(),
502 "timestamp should be numeric: {ts}"
503 );
504 }
505}