use std::fs::{self, File, OpenOptions};
use std::io::{BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::rhei_tui::event::{EventSink, RunEvent, TaskOutcome};
pub struct JournalSink {
inner: Mutex<Inner>,
workspace_root: PathBuf,
}
struct Inner {
writer: Option<BufWriter<File>>,
path: PathBuf,
}
impl JournalSink {
pub fn open(workspace_root: impl AsRef<Path>) -> std::io::Result<Self> {
let workspace_root = workspace_root.as_ref().to_path_buf();
let runtime_dir = workspace_root.join("runtime");
fs::create_dir_all(&runtime_dir)?;
let path = runtime_dir.join("transitions.log");
let file = OpenOptions::new().create(true).append(true).open(&path)?;
Ok(Self {
workspace_root,
inner: Mutex::new(Inner { writer: Some(BufWriter::new(file)), path }),
})
}
fn write_line(&self, line: &str) {
let mut guard = match self.inner.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
if let Some(writer) = guard.writer.as_mut() {
if let Err(err) = writer.write_all(line.as_bytes()) {
eprintln!("warning: journal write failed: {}", err);
guard.writer = None;
return;
}
if let Err(err) = writer.flush() {
eprintln!("warning: journal flush failed: {}", err);
guard.writer = None;
}
}
}
fn format_path(&self, path: &Path) -> String {
match path.strip_prefix(&self.workspace_root) {
Ok(rel) => rel.display().to_string(),
Err(_) => path.display().to_string(),
}
}
pub fn path(&self) -> PathBuf {
let guard = match self.inner.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
};
guard.path.clone()
}
}
impl EventSink for JournalSink {
fn emit(&self, event: RunEvent) {
match event {
RunEvent::SlotAssigned { task, from, to, log_path, wall_clock, .. } => {
let ts = format_rfc3339(wall_clock);
let log = self.format_path(&log_path);
let move_str =
if from == to { format!("start@{to}") } else { format!("{from}\u{2192}{to}") };
let line = format!("{ts} {task} {move_str} {log}\n");
self.write_line(&line);
}
RunEvent::SlotReleased {
task,
from,
to,
log_path,
outcome,
wall_clock,
exit_code,
duration_ms,
..
} => {
let ts = format_rfc3339(wall_clock);
let log = self.format_path(&log_path);
let outcome_str = match &outcome {
TaskOutcome::Completed => "completed",
TaskOutcome::Failed(_) => "failed",
TaskOutcome::Cancelled => "cancelled",
TaskOutcome::TimedOut => "timeout",
};
let mut meta_parts: Vec<String> = Vec::new();
if let Some(code) = exit_code {
meta_parts.push(format!("exit={code}"));
}
meta_parts.push(format!("duration={}", format_duration(duration_ms)));
meta_parts.push(format!("outcome={outcome_str}"));
let meta = meta_parts.join(",");
let move_str =
if from == to { format!("end@{to}") } else { format!("{from}\u{2192}{to}") };
let line = format!("{ts} {task} {move_str} {log} {meta}\n");
self.write_line(&line);
}
RunEvent::UsageReported { task, invocation_id, usage, .. } => {
let cost = usage
.cost_micro
.or(usage.priced_cost_micro)
.map(format_cost_micro)
.unwrap_or_else(|| "unpriced".to_string());
let line = format!(
"{} {} usage invocation={} agent={} cost={} coverage={:?}\n",
format_rfc3339(SystemTime::now()),
task,
invocation_id,
usage.agent,
cost,
usage.coverage
);
self.write_line(&line);
}
_ => {}
}
}
}
fn format_cost_micro(value: u64) -> String {
format!("{}.{:06}", value / 1_000_000, value % 1_000_000)
}
fn format_rfc3339(t: SystemTime) -> String {
let secs = t.duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
let (year, month, day, hour, minute, second) = civil_from_epoch_secs(secs);
format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", year, month, day, hour, minute, second)
}
fn civil_from_epoch_secs(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
let days = secs.div_euclid(86_400);
let time_of_day = secs.rem_euclid(86_400) as u32;
let hour = time_of_day / 3600;
let minute = (time_of_day % 3600) / 60;
let second = time_of_day % 60;
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u32;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = y + if m <= 2 { 1 } else { 0 };
(y as i32, m, d, hour, minute, second)
}
fn format_duration(millis: u64) -> String {
let total_secs = millis / 1000;
if total_secs >= 60 {
let minutes = total_secs / 60;
let seconds = total_secs % 60;
format!("{minutes}m{seconds}s")
} else if total_secs > 0 {
let frac = (millis % 1000) / 100;
if frac == 0 {
format!("{total_secs}s")
} else {
format!("{total_secs}.{frac}s")
}
} else {
format!("{millis}ms")
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::rhei_tui::event::{EventSink, RunEvent, TaskOutcome};
use std::time::{Duration, Instant, SystemTime};
fn fixed_time() -> SystemTime {
SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000)
}
#[test]
fn writes_assigned_and_released_lines() {
let tmp = tempfile::tempdir().unwrap();
let sink = JournalSink::open(tmp.path()).unwrap();
let log_rel = tmp.path().join("runtime/logs/task-1-pending.log");
sink.emit(RunEvent::SlotAssigned {
slot: 0,
task: "task-1".to_string(),
from: "draft".to_string(),
to: "pending".to_string(),
agent: None,
template_context: None,
log_path: log_rel.clone(),
started_at: Instant::now(),
wall_clock: fixed_time(),
});
sink.emit(RunEvent::SlotReleased {
slot: 0,
task: "task-1".to_string(),
from: "draft".to_string(),
to: "pending".to_string(),
log_path: log_rel,
outcome: TaskOutcome::Completed,
finished_at: Instant::now(),
wall_clock: fixed_time() + Duration::from_millis(3_490),
exit_code: Some(0),
duration_ms: 3_490,
});
let contents = std::fs::read_to_string(sink.path()).unwrap();
let lines: Vec<&str> = contents.lines().collect();
assert_eq!(lines.len(), 2);
assert!(lines[0].contains("task-1 draft\u{2192}pending"));
assert!(lines[0].ends_with("runtime/logs/task-1-pending.log"));
assert!(lines[1].contains("exit=0"));
assert!(lines[1].contains("duration=3.4s"));
assert!(lines[1].contains("outcome=completed"));
}
#[test]
fn appends_on_second_open() {
let tmp = tempfile::tempdir().unwrap();
{
let sink = JournalSink::open(tmp.path()).unwrap();
sink.emit(RunEvent::SlotAssigned {
slot: 0,
task: "t1".to_string(),
from: "a".to_string(),
to: "b".to_string(),
agent: None,
template_context: None,
log_path: tmp.path().join("runtime/logs/t1.log"),
started_at: Instant::now(),
wall_clock: fixed_time(),
});
}
{
let sink = JournalSink::open(tmp.path()).unwrap();
sink.emit(RunEvent::SlotAssigned {
slot: 0,
task: "t2".to_string(),
from: "a".to_string(),
to: "b".to_string(),
agent: None,
template_context: None,
log_path: tmp.path().join("runtime/logs/t2.log"),
started_at: Instant::now(),
wall_clock: fixed_time(),
});
}
let contents = std::fs::read_to_string(tmp.path().join("runtime/transitions.log")).unwrap();
assert_eq!(contents.lines().count(), 2);
}
}