use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::verdict::Finding;
pub const STDIN_CAP_HEAD: usize = 64 * 1024;
pub const STDIN_CAP_TAIL: usize = 16 * 1024;
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum StepRecord {
RunStart {
run_id: String,
script_fingerprint: String,
},
Agent {
step: u64,
service: String,
exit: i32,
tokens: u64,
session: Option<String>,
checkpoint: Option<String>,
},
Judge {
step: u64,
service: String,
exit: i32,
tokens: u64,
sufficient: Option<bool>,
soak: u32,
summary: Option<String>,
rendered: String,
},
}
impl StepRecord {
pub fn step(&self) -> Option<u64> {
match self {
StepRecord::RunStart { .. } => None,
StepRecord::Agent { step, .. } | StepRecord::Judge { step, .. } => Some(*step),
}
}
pub fn exit(&self) -> Option<i32> {
match self {
StepRecord::RunStart { .. } => None,
StepRecord::Agent { exit, .. } | StepRecord::Judge { exit, .. } => Some(*exit),
}
}
}
#[derive(Debug)]
pub struct StepsJournal {
path: PathBuf,
}
impl StepsJournal {
pub fn new(run_dir: &Path) -> StepsJournal {
StepsJournal {
path: run_dir.join("steps.jsonl"),
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn append(&self, record: &StepRecord) -> Result<(), String> {
let line = serde_json::to_string(record)
.map_err(|err| format!("failed to serialize step record: {err}"))?;
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.map_err(|err| format!("failed to open {}: {err}", self.path.display()))?;
writeln!(file, "{line}").map_err(|err| format!("failed to append step record: {err}"))?;
Ok(())
}
pub fn load(&self) -> Result<Vec<StepRecord>, String> {
let text = match fs::read_to_string(&self.path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(format!("failed to read {}: {err}", self.path.display())),
};
let mut records = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<StepRecord>(line) {
Ok(record) => records.push(record),
Err(_) => break,
}
}
Ok(records)
}
}
#[derive(Debug)]
pub struct SuggestionsLedger {
path: PathBuf,
}
impl SuggestionsLedger {
pub fn new(run_dir: &Path) -> SuggestionsLedger {
SuggestionsLedger {
path: run_dir.join("suggestions.md"),
}
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn append(&self, step: u64, suggestions: &[&Finding]) -> Result<(), String> {
if suggestions.is_empty() {
return Ok(());
}
let mut entry = String::new();
for finding in suggestions {
entry.push_str(&format!(
"- (step {step}) {} — {} ({})\n",
finding.where_, finding.what, finding.why
));
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
.map_err(|err| format!("failed to open {}: {err}", self.path.display()))?;
file.write_all(entry.as_bytes())
.map_err(|err| format!("failed to append suggestions: {err}"))?;
Ok(())
}
pub fn read(&self) -> String {
fs::read_to_string(&self.path).unwrap_or_default()
}
pub fn entry_count(&self) -> usize {
self.read().lines().filter(|l| !l.trim().is_empty()).count()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct CappedContext {
pub text: String,
pub truncated: bool,
}
pub fn cap_context(input: &str, full_log_path: &str) -> CappedContext {
if input.len() <= STDIN_CAP_HEAD + STDIN_CAP_TAIL {
return CappedContext {
text: input.to_string(),
truncated: false,
};
}
let head_end = floor_char_boundary(input, STDIN_CAP_HEAD);
let tail_start = ceil_char_boundary(input, input.len() - STDIN_CAP_TAIL);
let text = format!(
"{}\n[truncated: full log at {}]\n{}",
&input[..head_end],
full_log_path,
&input[tail_start..]
);
CappedContext {
text,
truncated: true,
}
}
fn floor_char_boundary(s: &str, mut index: usize) -> usize {
while index > 0 && !s.is_char_boundary(index) {
index -= 1;
}
index
}
fn ceil_char_boundary(s: &str, mut index: usize) -> usize {
while index < s.len() && !s.is_char_boundary(index) {
index += 1;
}
index
}
pub fn generate_run_id(runs_root: &Path) -> String {
let now = time::OffsetDateTime::now_utc();
let base = format!(
"{:04}-{:02}-{:02}T{:02}-{:02}-{:02}",
now.year(),
u8::from(now.month()),
now.day(),
now.hour(),
now.minute(),
now.second()
);
let mut candidate = base.clone();
let mut suffix = 1;
while runs_root.join(&candidate).exists() {
candidate = format!("{base}.{suffix}");
suffix += 1;
}
candidate
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RunReport {
pub run_id: String,
pub run_dir: PathBuf,
pub exit: i32,
pub iterations: u64,
pub agent_counts: Vec<(String, u64)>,
pub final_verdict_summary: Option<String>,
pub final_soak: Option<(u32, u32)>,
pub suggestions_entries: usize,
pub interrupted: bool,
}
impl RunReport {
pub fn summary(&self, script: &str) -> String {
let mut out = String::new();
out.push_str(&format!("Ran {script} (run {}).\n", self.run_id));
let counts = if self.agent_counts.is_empty() {
"no agent invocations".to_string()
} else {
self.agent_counts
.iter()
.map(|(service, count)| format!("{count} {service}"))
.collect::<Vec<_>>()
.join(", ")
};
let mut status_line = format!(
"Exit {} after {} iterations ({counts})",
self.exit, self.iterations
);
if let Some((passes, target)) = self.final_soak {
status_line.push_str(&format!(", judge passed soak {passes}/{target}"));
}
if self.interrupted {
status_line.push_str(", interrupted by SIGINT");
}
status_line.push_str(".\n");
out.push_str(&status_line);
if let Some(summary) = self.final_verdict_summary.as_ref() {
out.push_str(&format!("Final verdict: {summary:?}\n"));
}
out.push_str(&format!("Journal: {}/\n", self.run_dir.display()));
out.push_str(&format!(
"Suggestions ledger: {} entries.\n",
self.suggestions_entries
));
out
}
}
#[cfg(test)]
mod tests {
use super::super::verdict::Severity;
use super::*;
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"sid-ralph-journal-{name}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn journal_round_trips_records() {
let dir = temp_dir("roundtrip");
let journal = StepsJournal::new(&dir);
let records = vec![
StepRecord::RunStart {
run_id: "r1".to_string(),
script_fingerprint: "f".to_string(),
},
StepRecord::Agent {
step: 1,
service: "fix".to_string(),
exit: 0,
tokens: 100,
session: Some("s1".to_string()),
checkpoint: Some("refs/sid/ralph/r1/1".to_string()),
},
StepRecord::Judge {
step: 2,
service: "judge".to_string(),
exit: 1,
tokens: 50,
sufficient: Some(false),
soak: 0,
summary: Some("not done".to_string()),
rendered: "# Verdict: insufficient\n".to_string(),
},
];
for record in &records {
journal.append(record).unwrap();
}
assert_eq!(journal.load().unwrap(), records);
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn journal_ignores_trailing_partial_line() {
let dir = temp_dir("partial");
let journal = StepsJournal::new(&dir);
journal
.append(&StepRecord::Agent {
step: 1,
service: "fix".to_string(),
exit: 0,
tokens: 0,
session: None,
checkpoint: None,
})
.unwrap();
let mut file = fs::OpenOptions::new()
.append(true)
.open(journal.path())
.unwrap();
file.write_all(b"{\"kind\":\"judge\",\"step\":2,").unwrap();
drop(file);
let records = journal.load().unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0].step(), Some(1));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn journal_load_missing_file_is_empty() {
let dir = temp_dir("missing");
let journal = StepsJournal::new(&dir);
assert!(journal.load().unwrap().is_empty());
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn ledger_appends_and_counts() {
let dir = temp_dir("ledger");
let ledger = SuggestionsLedger::new(&dir);
assert_eq!(ledger.entry_count(), 0);
let finding = Finding {
severity: Severity::Suggestion,
where_: "README.md".to_string(),
what: "Mention the soak flag".to_string(),
why: "Operators will want it".to_string(),
};
ledger.append(3, &[&finding]).unwrap();
ledger.append(5, &[&finding]).unwrap();
ledger.append(6, &[]).unwrap();
assert_eq!(ledger.entry_count(), 2);
let text = ledger.read();
assert!(text.contains("(step 3) README.md — Mention the soak flag"));
assert!(text.contains("(step 5)"));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn cap_context_passes_small_input_through() {
let capped = cap_context("hello", "/run/ci-001.log");
assert_eq!(capped.text, "hello");
assert!(!capped.truncated);
}
#[test]
fn cap_context_truncates_large_input() {
let big = "a".repeat(10 * 1024 * 1024);
let capped = cap_context(&big, "/run/ci-001.log");
assert!(capped.truncated);
assert!(
capped
.text
.contains("[truncated: full log at /run/ci-001.log]")
);
assert!(capped.text.len() < STDIN_CAP_HEAD + STDIN_CAP_TAIL + 128);
assert!(capped.text.starts_with(&"a".repeat(100)));
assert!(capped.text.ends_with(&"a".repeat(100)));
}
#[test]
fn cap_context_respects_utf8_boundaries() {
let big = "é".repeat((STDIN_CAP_HEAD + STDIN_CAP_TAIL) / 2 + 1024);
let capped = cap_context(&big, "/run/ci-002.log");
assert!(capped.truncated);
assert!(
capped
.text
.contains("[truncated: full log at /run/ci-002.log]")
);
}
#[test]
fn run_id_shape_and_collisions() {
let dir = temp_dir("runid");
let id = generate_run_id(&dir);
assert_eq!(id.len(), "2026-06-10T14-22-07".len());
assert!(id.contains('T'));
fs::create_dir_all(dir.join(&id)).unwrap();
let second = generate_run_id(&dir);
assert_ne!(id, second);
assert!(second.starts_with(&id[..11]));
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn summary_matches_plan_shape() {
let report = RunReport {
run_id: "2026-06-10T14-22-07".to_string(),
run_dir: PathBuf::from("/sessions/abc/runs/2026-06-10T14-22-07"),
exit: 0,
iterations: 4,
agent_counts: vec![("fix".to_string(), 2), ("task".to_string(), 2)],
final_verdict_summary: Some("The plan is complete.".to_string()),
final_soak: Some((5, 5)),
suggestions_entries: 3,
interrupted: false,
};
let text = report.summary("ralph.sid");
assert!(text.starts_with("Ran ralph.sid (run 2026-06-10T14-22-07).\n"));
assert!(
text.contains("Exit 0 after 4 iterations (2 fix, 2 task), judge passed soak 5/5.\n")
);
assert!(text.contains("Final verdict: \"The plan is complete.\"\n"));
assert!(text.contains("Journal: /sessions/abc/runs/2026-06-10T14-22-07/\n"));
assert!(text.contains("Suggestions ledger: 3 entries.\n"));
}
#[test]
fn summary_notes_interrupt() {
let report = RunReport {
run_id: "r".to_string(),
run_dir: PathBuf::from("/r"),
exit: 130,
iterations: 1,
agent_counts: vec![("fix".to_string(), 1)],
final_verdict_summary: None,
final_soak: None,
suggestions_entries: 0,
interrupted: true,
};
let text = report.summary("ralph.sid");
assert!(text.contains("interrupted by SIGINT"));
assert!(!text.contains("Final verdict"));
}
}