#![cfg_attr(not(test), allow(dead_code))]
use std::fs::{File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use anyhow::{Context, Result};
use serde::Serialize;
use tracing::debug;
fn now_iso8601() -> String {
let now = std::time::SystemTime::now();
let duration = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
format!("{secs}")
}
#[derive(Debug, Clone, Serialize)]
pub struct LogEntry {
pub timestamp: String,
#[serde(flatten)]
pub event: LogEvent,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "event", content = "data")]
#[serde(rename_all = "snake_case")]
pub enum LogEvent {
TaskRead {
task_id: u32,
title: String,
status: String,
},
WorktreeCreated {
task_id: u32,
path: String,
branch: String,
},
PhaseWorktreeCreated {
phase: String,
path: String,
branch: String,
base_branch: String,
},
PhaseWorktreeRetained {
phase: String,
path: String,
branch: String,
reason: String,
},
PhaseWorktreeCleaned {
phase: String,
path: String,
branch: String,
},
AgentLaunched {
agent: String,
program: String,
args: Vec<String>,
work_dir: String,
},
LaunchContextSnapshot {
phase: String,
agent: String,
instructions_path: String,
phase_doc_path: String,
config_source: String,
snapshot_path: String,
snapshot: String,
},
PhaseSelectionDecision {
phase: String,
order_key: String,
selected: bool,
reason: String,
},
ReviewPacketGenerated {
phase: String,
packet_path: String,
diff_command: String,
summary_path: Option<String>,
statements_count: usize,
execution_log_path: String,
},
ReviewDecision {
phase: String,
decision: String,
feedback: Option<String>,
},
ReworkCycleStarted {
phase: String,
attempt: u32,
max_retries: u32,
feedback: String,
},
PromptDetected { kind: String, matched_text: String },
AutoResponse { prompt: String, response: String },
UserInput { length: usize },
TestExecuted {
command: String,
passed: bool,
exit_code: Option<i32>,
},
TestResult {
attempt: u32,
passed: bool,
output_lines: usize,
},
Commit { hash: String, message: String },
Merge { source: String, target: String },
PolicyDecision { decision: String, prompt: String },
AgentOutput { line: String },
RunCompleted { summary: String },
RunFailed { reason: String },
CompletionDecision {
phase: String,
passed: bool,
board_all_done: bool,
milestone_done: bool,
summary_exists: bool,
dod_passed: bool,
executor_stable: bool,
reasons: Vec<String>,
summary_path: Option<String>,
dod_command: String,
dod_executed: bool,
dod_exit_code: Option<i32>,
dod_output_lines: usize,
},
SessionStarted { phase: String },
SessionEnded { result: String },
}
pub struct ExecutionLog {
writer: Mutex<BufWriter<File>>,
path: PathBuf,
}
impl ExecutionLog {
pub fn new(path: &Path) -> Result<Self> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create log directory: {}", parent.display()))?;
}
let file = OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("failed to open log file: {}", path.display()))?;
Ok(Self {
writer: Mutex::new(BufWriter::new(file)),
path: path.to_path_buf(),
})
}
pub fn log(&self, event: LogEvent) -> Result<()> {
let entry = LogEntry {
timestamp: now_iso8601(),
event,
};
let json = serde_json::to_string(&entry).context("failed to serialize log entry")?;
debug!(event = %json, "execution log");
let mut writer = self.writer.lock().unwrap();
writeln!(writer, "{json}").context("failed to write log entry")?;
writer.flush().context("failed to flush log")?;
Ok(())
}
pub fn path(&self) -> &Path {
&self.path
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn log_entry_serializes_to_json() {
let entry = LogEntry {
timestamp: "1234567890".to_string(),
event: LogEvent::TaskRead {
task_id: 1,
title: "scaffolding".to_string(),
status: "backlog".to_string(),
},
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"event\":\"task_read\""));
assert!(json.contains("\"task_id\":1"));
assert!(json.contains("\"timestamp\":\"1234567890\""));
}
#[test]
fn all_event_types_serialize() {
let events = vec![
LogEvent::TaskRead {
task_id: 1,
title: "test".to_string(),
status: "backlog".to_string(),
},
LogEvent::WorktreeCreated {
task_id: 1,
path: "/tmp/wt".to_string(),
branch: "task/1".to_string(),
},
LogEvent::PhaseWorktreeCreated {
phase: "phase-2.5".to_string(),
path: "/tmp/wt-phase".to_string(),
branch: "phase-2-5-run-001".to_string(),
base_branch: "main".to_string(),
},
LogEvent::PhaseWorktreeRetained {
phase: "phase-2.5".to_string(),
path: "/tmp/wt-phase".to_string(),
branch: "phase-2-5-run-001".to_string(),
reason: "run failed".to_string(),
},
LogEvent::PhaseWorktreeCleaned {
phase: "phase-2.5".to_string(),
path: "/tmp/wt-phase".to_string(),
branch: "phase-2-5-run-001".to_string(),
},
LogEvent::AgentLaunched {
agent: "claude".to_string(),
program: "claude".to_string(),
args: vec!["--prompt".to_string(), "task".to_string()],
work_dir: "/work".to_string(),
},
LogEvent::LaunchContextSnapshot {
phase: "phase-2.5".to_string(),
agent: "claude-code".to_string(),
instructions_path: "CLAUDE.md".to_string(),
phase_doc_path: "kanban/phase-2.5/PHASE.md".to_string(),
config_source: ".batty/config.toml".to_string(),
snapshot_path: ".batty/logs/phase-2.5-ctx.log".to_string(),
snapshot: "context body".to_string(),
},
LogEvent::PhaseSelectionDecision {
phase: "phase-2.5".to_string(),
order_key: "2.5".to_string(),
selected: true,
reason: "phase selected for execution".to_string(),
},
LogEvent::ReviewPacketGenerated {
phase: "phase-2.5".to_string(),
packet_path: "/work/review-packet.md".to_string(),
diff_command: "git diff main...phase-2-5-run-001".to_string(),
summary_path: Some("/work/phase-summary.md".to_string()),
statements_count: 4,
execution_log_path: "/work/.batty/logs/phase-2-5-run-001/execution.jsonl"
.to_string(),
},
LogEvent::ReviewDecision {
phase: "phase-2.5".to_string(),
decision: "merge".to_string(),
feedback: None,
},
LogEvent::ReworkCycleStarted {
phase: "phase-2.5".to_string(),
attempt: 1,
max_retries: 3,
feedback: "address flaky test".to_string(),
},
LogEvent::PromptDetected {
kind: "Permission".to_string(),
matched_text: "Allow tool Read?".to_string(),
},
LogEvent::AutoResponse {
prompt: "Continue?".to_string(),
response: "y".to_string(),
},
LogEvent::UserInput { length: 5 },
LogEvent::TestExecuted {
command: "cargo test".to_string(),
passed: true,
exit_code: Some(0),
},
LogEvent::TestResult {
attempt: 1,
passed: true,
output_lines: 42,
},
LogEvent::Commit {
hash: "abc123".to_string(),
message: "fix bug".to_string(),
},
LogEvent::Merge {
source: "task/1".to_string(),
target: "main".to_string(),
},
LogEvent::PolicyDecision {
decision: "act".to_string(),
prompt: "Allow?".to_string(),
},
LogEvent::AgentOutput {
line: "hello".to_string(),
},
LogEvent::RunCompleted {
summary: "all good".to_string(),
},
LogEvent::RunFailed {
reason: "tests failed".to_string(),
},
LogEvent::CompletionDecision {
phase: "phase-2.5".to_string(),
passed: true,
board_all_done: true,
milestone_done: true,
summary_exists: true,
dod_passed: true,
executor_stable: true,
reasons: vec![],
summary_path: Some("/work/phase-summary.md".to_string()),
dod_command: "cargo test".to_string(),
dod_executed: true,
dod_exit_code: Some(0),
dod_output_lines: 120,
},
LogEvent::SessionStarted {
phase: "phase-1".to_string(),
},
LogEvent::SessionEnded {
result: "Completed".to_string(),
},
];
for event in events {
let entry = LogEntry {
timestamp: "0".to_string(),
event,
};
let json = serde_json::to_string(&entry);
assert!(json.is_ok(), "failed to serialize: {entry:?}");
let s = json.unwrap();
assert!(s.contains("\"event\":"), "missing event tag in: {s}");
}
}
#[test]
fn write_and_read_log_file() {
let tmp = tempfile::tempdir().unwrap();
let log_path = tmp.path().join("test.jsonl");
let log = ExecutionLog::new(&log_path).unwrap();
log.log(LogEvent::SessionStarted {
phase: "phase-1".to_string(),
})
.unwrap();
log.log(LogEvent::TaskRead {
task_id: 5,
title: "adapter".to_string(),
status: "in-progress".to_string(),
})
.unwrap();
log.log(LogEvent::SessionEnded {
result: "Completed".to_string(),
})
.unwrap();
let content = std::fs::read_to_string(&log_path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert_eq!(lines.len(), 3);
for line in &lines {
let parsed: serde_json::Value = serde_json::from_str(line).unwrap();
assert!(parsed.get("event").is_some());
assert!(parsed.get("timestamp").is_some());
}
assert!(lines[0].contains("\"event\":\"session_started\""));
assert!(lines[1].contains("\"event\":\"task_read\""));
assert!(lines[2].contains("\"event\":\"session_ended\""));
}
#[test]
fn creates_parent_directories() {
let tmp = tempfile::tempdir().unwrap();
let log_path = tmp.path().join("deep").join("nested").join("run.jsonl");
let log = ExecutionLog::new(&log_path).unwrap();
log.log(LogEvent::RunCompleted {
summary: "ok".to_string(),
})
.unwrap();
assert!(log_path.exists());
}
#[test]
fn appends_to_existing_file() {
let tmp = tempfile::tempdir().unwrap();
let log_path = tmp.path().join("append.jsonl");
{
let log = ExecutionLog::new(&log_path).unwrap();
log.log(LogEvent::SessionStarted {
phase: "p1".to_string(),
})
.unwrap();
}
{
let log = ExecutionLog::new(&log_path).unwrap();
log.log(LogEvent::SessionEnded {
result: "ok".to_string(),
})
.unwrap();
}
let content = std::fs::read_to_string(&log_path).unwrap();
assert_eq!(content.lines().count(), 2);
}
#[test]
fn log_path_accessor() {
let tmp = tempfile::tempdir().unwrap();
let log_path = tmp.path().join("test.jsonl");
let log = ExecutionLog::new(&log_path).unwrap();
assert_eq!(log.path(), log_path);
}
#[test]
fn timestamp_is_numeric() {
let ts = now_iso8601();
assert!(
ts.parse::<u64>().is_ok(),
"timestamp should be numeric: {ts}"
);
}
}