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