Skip to main content

batty_cli/log/
mod.rs

1//! Structured execution log — JSON lines per run.
2//!
3//! Every batty session writes a `.jsonl` log file capturing all events:
4//! task reads, agent launches, prompt detections, auto-responses,
5//! test executions, and completion status. Each line is a self-contained
6//! JSON object with a timestamp, making logs easy to grep, stream, and
7//! post-process.
8#![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
19/// Timestamp as ISO 8601 string.
20fn now_iso8601() -> String {
21    // Use chrono if available, otherwise fall back to a simple approach.
22    // For now, use the system time formatted manually.
23    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 as seconds-since-epoch (task #12 can upgrade to chrono if needed)
30    format!("{secs}")
31}
32
33/// A structured event in the execution log.
34#[derive(Debug, Clone, Serialize)]
35pub struct LogEntry {
36    /// Unix timestamp (seconds since epoch).
37    pub timestamp: String,
38    /// The event type and its data.
39    #[serde(flatten)]
40    pub event: LogEvent,
41}
42
43/// All event types that can appear in the execution log.
44#[derive(Debug, Clone, Serialize)]
45#[serde(tag = "event", content = "data")]
46#[serde(rename_all = "snake_case")]
47pub enum LogEvent {
48    /// A task was read from the kanban board.
49    TaskRead {
50        task_id: u32,
51        title: String,
52        status: String,
53    },
54    /// A git worktree was created for the task.
55    WorktreeCreated {
56        task_id: u32,
57        path: String,
58        branch: String,
59    },
60    /// A phase run worktree was created.
61    PhaseWorktreeCreated {
62        phase: String,
63        path: String,
64        branch: String,
65        base_branch: String,
66    },
67    /// A run worktree was retained for later inspection.
68    PhaseWorktreeRetained {
69        phase: String,
70        path: String,
71        branch: String,
72        reason: String,
73    },
74    /// A run worktree was cleaned up after merge.
75    PhaseWorktreeCleaned {
76        phase: String,
77        path: String,
78        branch: String,
79    },
80    /// An agent process was launched.
81    AgentLaunched {
82        agent: String,
83        program: String,
84        args: Vec<String>,
85        work_dir: String,
86    },
87    /// Launch context was composed and snapshotted before execution.
88    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    /// A phase sequencing decision was recorded.
98    PhaseSelectionDecision {
99        phase: String,
100        order_key: String,
101        selected: bool,
102        reason: String,
103    },
104    /// A standardized review packet was generated for human review.
105    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    /// A human review decision was recorded.
114    ReviewDecision {
115        phase: String,
116        decision: String,
117        feedback: Option<String>,
118    },
119    /// A rework cycle was launched after human review feedback.
120    ReworkCycleStarted {
121        phase: String,
122        attempt: u32,
123        max_retries: u32,
124        feedback: String,
125    },
126    /// A prompt was detected in agent output.
127    PromptDetected { kind: String, matched_text: String },
128    /// An auto-response was sent to the agent.
129    AutoResponse { prompt: String, response: String },
130    /// User input was forwarded to the agent.
131    UserInput { length: usize },
132    /// A DoD test command was executed.
133    TestExecuted {
134        command: String,
135        passed: bool,
136        exit_code: Option<i32>,
137    },
138    /// Result of a DoD test (with output summary).
139    TestResult {
140        attempt: u32,
141        passed: bool,
142        output_lines: usize,
143    },
144    /// A git commit was created.
145    Commit { hash: String, message: String },
146    /// A branch was merged.
147    Merge { source: String, target: String },
148    /// A policy decision was made.
149    PolicyDecision { decision: String, prompt: String },
150    /// Agent output line (for verbose logging).
151    AgentOutput { line: String },
152    /// The run completed successfully.
153    RunCompleted { summary: String },
154    /// The run failed.
155    RunFailed { reason: String },
156    /// Completion contract decision (accept/reject + check results).
157    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    /// Session started.
173    SessionStarted { phase: String },
174    /// Session ended.
175    SessionEnded { result: String },
176}
177
178/// Writer for JSON lines execution logs.
179pub struct ExecutionLog {
180    writer: Mutex<BufWriter<File>>,
181    path: PathBuf,
182}
183
184impl ExecutionLog {
185    /// Create a new execution log, writing to the given path.
186    ///
187    /// Creates the file (and parent directories) if they don't exist.
188    /// Appends to an existing file.
189    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    /// Log an event.
208    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    /// Get the path to the log file.
226    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            // Verify it contains the event tag
395            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        // Read back and verify
425        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        // Each line should be valid JSON
430        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        // First line should be session_started
437        assert!(lines[0].contains("\"event\":\"session_started\""));
438        // Second should be task_read
439        assert!(lines[1].contains("\"event\":\"task_read\""));
440        // Third should be session_ended
441        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        // Write first entry
464        {
465            let log = ExecutionLog::new(&log_path).unwrap();
466            log.log(LogEvent::SessionStarted {
467                phase: "p1".to_string(),
468            })
469            .unwrap();
470        }
471
472        // Open again and write second entry
473        {
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}