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#[allow(dead_code)]
45#[derive(Debug, Clone, Serialize)]
46#[serde(tag = "event", content = "data")]
47#[serde(rename_all = "snake_case")]
48pub enum LogEvent {
49    /// A task was read from the kanban board.
50    TaskRead {
51        task_id: u32,
52        title: String,
53        status: String,
54    },
55    /// A git worktree was created for the task.
56    WorktreeCreated {
57        task_id: u32,
58        path: String,
59        branch: String,
60    },
61    /// A phase run worktree was created.
62    PhaseWorktreeCreated {
63        phase: String,
64        path: String,
65        branch: String,
66        base_branch: String,
67    },
68    /// A run worktree was retained for later inspection.
69    PhaseWorktreeRetained {
70        phase: String,
71        path: String,
72        branch: String,
73        reason: String,
74    },
75    /// A run worktree was cleaned up after merge.
76    PhaseWorktreeCleaned {
77        phase: String,
78        path: String,
79        branch: String,
80    },
81    /// An agent process was launched.
82    AgentLaunched {
83        agent: String,
84        program: String,
85        args: Vec<String>,
86        work_dir: String,
87    },
88    /// Launch context was composed and snapshotted before execution.
89    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    /// A phase sequencing decision was recorded.
99    PhaseSelectionDecision {
100        phase: String,
101        order_key: String,
102        selected: bool,
103        reason: String,
104    },
105    /// A standardized review packet was generated for human review.
106    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    /// A human review decision was recorded.
115    ReviewDecision {
116        phase: String,
117        decision: String,
118        feedback: Option<String>,
119    },
120    /// A rework cycle was launched after human review feedback.
121    ReworkCycleStarted {
122        phase: String,
123        attempt: u32,
124        max_retries: u32,
125        feedback: String,
126    },
127    /// A prompt was detected in agent output.
128    PromptDetected { kind: String, matched_text: String },
129    /// An auto-response was sent to the agent.
130    AutoResponse { prompt: String, response: String },
131    /// User input was forwarded to the agent.
132    UserInput { length: usize },
133    /// A DoD test command was executed.
134    TestExecuted {
135        command: String,
136        passed: bool,
137        exit_code: Option<i32>,
138    },
139    /// Result of a DoD test (with output summary).
140    TestResult {
141        attempt: u32,
142        passed: bool,
143        output_lines: usize,
144    },
145    /// A git commit was created.
146    Commit { hash: String, message: String },
147    /// A branch was merged.
148    Merge { source: String, target: String },
149    /// A policy decision was made.
150    PolicyDecision { decision: String, prompt: String },
151    /// Agent output line (for verbose logging).
152    AgentOutput { line: String },
153    /// The run completed successfully.
154    RunCompleted { summary: String },
155    /// The run failed.
156    RunFailed { reason: String },
157    /// Completion contract decision (accept/reject + check results).
158    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    /// Session started.
174    SessionStarted { phase: String },
175    /// Session ended.
176    SessionEnded { result: String },
177}
178
179/// Writer for JSON lines execution logs.
180pub struct ExecutionLog {
181    writer: Mutex<BufWriter<File>>,
182    #[allow(dead_code)]
183    path: PathBuf,
184}
185
186impl ExecutionLog {
187    /// Create a new execution log, writing to the given path.
188    ///
189    /// Creates the file (and parent directories) if they don't exist.
190    /// Appends to an existing file.
191    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    /// Log an event.
210    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    /// Get the path to the log file.
228    #[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            // Verify it contains the event tag
398            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        // Read back and verify
428        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        // Each line should be valid JSON
433        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        // First line should be session_started
440        assert!(lines[0].contains("\"event\":\"session_started\""));
441        // Second should be task_read
442        assert!(lines[1].contains("\"event\":\"task_read\""));
443        // Third should be session_ended
444        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        // Write first entry
467        {
468            let log = ExecutionLog::new(&log_path).unwrap();
469            log.log(LogEvent::SessionStarted {
470                phase: "p1".to_string(),
471            })
472            .unwrap();
473        }
474
475        // Open again and write second entry
476        {
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}