use std::fs::File;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use ingot_runtime::{RunEvent, Usage};
use serde::Serialize;
use serde_json::Value;
pub const RUNS_DIR: &str = "runs";
const RECORD_SCHEMA_VERSION: u32 = 1;
const MAX_LISTED: usize = 500;
pub struct RunRecorder {
path: PathBuf,
file: File,
id: String,
started: u64,
closed: bool,
}
impl RunRecorder {
pub fn begin(out_dir: &Path, agent: &str, provider: &str, contained: bool) -> Option<Self> {
let directory = out_dir.join(RUNS_DIR);
std::fs::create_dir_all(&directory).ok()?;
let started = now();
let id = format!("{started:010}-{}", std::process::id());
let path = directory.join(format!("{id}.jsonl"));
let file = File::create(&path).ok()?;
let mut recorder = RunRecorder {
path,
file,
id,
started,
closed: false,
};
let header = serde_json::json!({
"record": "started",
"schemaVersion": RECORD_SCHEMA_VERSION,
"id": recorder.id,
"agent": agent,
"provider": provider,
"contained": contained,
"startedUnix": started,
});
recorder.write(&header.to_string());
Some(recorder)
}
pub fn event(&mut self, event: &RunEvent) {
let line = event.to_json_line();
self.write(&line);
}
pub fn finish(&mut self, outcome: Outcome<'_>) {
if self.closed {
return;
}
self.closed = true;
let mut line = serde_json::json!({
"record": "finished",
"finishedUnix": now(),
"startedUnix": self.started,
});
let object = line.as_object_mut().expect("a literal object");
match outcome {
Outcome::Finished { steps, usage, cost } => {
object.insert("ok".into(), Value::Bool(true));
object.insert("steps".into(), Value::from(steps));
object.insert(
"usage".into(),
serde_json::to_value(usage).unwrap_or(Value::Null),
);
if let Some(cost) = cost {
object.insert("cost".into(), Value::from(cost));
}
}
Outcome::Failed { reason } => {
object.insert("ok".into(), Value::Bool(false));
object.insert("reason".into(), Value::from(reason));
}
}
self.write(&line.to_string());
}
pub fn path(&self) -> &Path {
&self.path
}
fn write(&mut self, line: &str) {
let _ = writeln!(self.file, "{line}");
let _ = self.file.flush();
}
}
pub enum Outcome<'a> {
Finished {
steps: u32,
usage: Usage,
cost: Option<String>,
},
Failed {
reason: &'a str,
},
}
fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|since| since.as_secs())
.unwrap_or_default()
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RunSummary {
pub id: String,
pub agent: String,
pub provider: String,
pub contained: bool,
pub started_unix: u64,
pub finished_unix: Option<u64>,
pub state: &'static str,
pub steps: Option<u32>,
pub usage: Option<Usage>,
pub cost: Option<String>,
pub reason: Option<String>,
pub event_count: usize,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RunDetail {
#[serde(flatten)]
pub summary: RunSummary,
pub events: Vec<Value>,
}
pub fn list(out_dir: &Path) -> Vec<RunSummary> {
let directory = out_dir.join(RUNS_DIR);
let Ok(entries) = std::fs::read_dir(&directory) else {
return Vec::new();
};
let mut ids: Vec<String> = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
name.strip_suffix(".jsonl")
.filter(|id| is_record_id(id))
.map(str::to_string)
})
.collect();
ids.sort();
ids.reverse();
ids.truncate(MAX_LISTED);
ids.iter()
.filter_map(|id| read(out_dir, id).ok().map(|detail| detail.summary))
.collect()
}
pub fn count(out_dir: &Path) -> usize {
std::fs::read_dir(out_dir.join(RUNS_DIR))
.map(|entries| {
entries
.filter_map(Result::ok)
.filter(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
name.strip_suffix(".jsonl")
.map(is_record_id)
.unwrap_or(false)
})
.count()
})
.unwrap_or(0)
}
pub fn read(out_dir: &Path, id: &str) -> Result<RunDetail> {
let path = record_path(out_dir, id)?;
let file = File::open(&path).with_context(|| format!("reading {}", path.display()))?;
let mut header: Option<Value> = None;
let mut trailer: Option<Value> = None;
let mut events = Vec::new();
for line in BufReader::new(file).lines() {
let Ok(line) = line else { break };
let Ok(value) = serde_json::from_str::<Value>(&line) else {
break;
};
match value.get("record").and_then(Value::as_str) {
Some("started") => header = Some(value),
Some(_) => trailer = Some(value),
None if value.get("event").is_some() => events.push(value),
None => {}
}
}
let Some(header) = header else {
bail!("{} has no opening record line", path.display());
};
let string = |value: &Value, key: &str| {
value
.get(key)
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_default()
};
let ok = trailer
.as_ref()
.and_then(|value| value.get("ok"))
.and_then(Value::as_bool);
let summary = RunSummary {
id: id.to_string(),
agent: string(&header, "agent"),
provider: string(&header, "provider"),
contained: header
.get("contained")
.and_then(Value::as_bool)
.unwrap_or(false),
started_unix: header
.get("startedUnix")
.and_then(Value::as_u64)
.unwrap_or_default(),
finished_unix: trailer
.as_ref()
.and_then(|value| value.get("finishedUnix"))
.and_then(Value::as_u64),
state: match ok {
Some(true) => "finished",
Some(false) => "failed",
None => "unfinished",
},
steps: trailer
.as_ref()
.and_then(|value| value.get("steps"))
.and_then(Value::as_u64)
.map(|steps| steps as u32),
usage: trailer
.as_ref()
.and_then(|value| value.get("usage"))
.and_then(|usage| serde_json::from_value(usage.clone()).ok()),
cost: trailer
.as_ref()
.and_then(|value| value.get("cost"))
.and_then(Value::as_str)
.map(str::to_string),
reason: trailer
.as_ref()
.and_then(|value| value.get("reason"))
.and_then(Value::as_str)
.map(str::to_string),
event_count: events.len(),
};
Ok(RunDetail { summary, events })
}
pub fn delete(out_dir: &Path, id: &str) -> Result<()> {
let path = record_path(out_dir, id)?;
std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))
}
fn record_path(out_dir: &Path, id: &str) -> Result<PathBuf> {
if !is_record_id(id) {
bail!("`{id}` is not a run identifier");
}
Ok(out_dir.join(RUNS_DIR).join(format!("{id}.jsonl")))
}
fn is_record_id(id: &str) -> bool {
match id.split_once('-') {
Some((seconds, pid)) => {
!seconds.is_empty()
&& !pid.is_empty()
&& seconds.bytes().all(|byte| byte.is_ascii_digit())
&& pid.bytes().all(|byte| byte.is_ascii_digit())
}
None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Dir(PathBuf);
impl Dir {
fn new(name: &str) -> Dir {
let path =
std::env::temp_dir().join(format!("ingot-runs-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("a temporary directory");
Dir(path)
}
}
impl Drop for Dir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn a_record_holds_the_event_stream_verbatim() {
let dir = Dir::new("verbatim");
let mut recorder =
RunRecorder::begin(&dir.0, "Research", "anthropic", false).expect("a recorder");
let event = RunEvent::NodeStarted {
node: "n1".into(),
kind: "model.call".into(),
};
recorder.event(&event);
recorder.finish(Outcome::Finished {
steps: 1,
usage: Usage::default(),
cost: None,
});
let id = recorder.id.clone();
let path = recorder.path().to_path_buf();
drop(recorder);
let detail = read(&dir.0, &id).expect("the record must read back");
assert_eq!(detail.summary.state, "finished");
assert_eq!(detail.summary.agent, "Research");
assert_eq!(detail.events.len(), 1);
let encoded = serde_json::to_string(&detail).expect("a detail serializes");
assert_eq!(encoded.matches("\"events\":").count(), 1, "{encoded}");
let stored = std::fs::read_to_string(&path).expect("the record must read");
let lines: Vec<&str> = stored.lines().collect();
assert_eq!(lines[1], event.to_json_line());
assert!(lines[0].contains("\"record\":\"started\""), "{}", lines[0]);
assert!(lines[2].contains("\"record\":\"finished\""), "{}", lines[2]);
assert!(!lines[1].contains("\"record\""));
}
#[test]
fn a_run_that_never_reported_a_result_is_unfinished_rather_than_guessed_at() {
let dir = Dir::new("unfinished");
let mut recorder =
RunRecorder::begin(&dir.0, "Research", "replay", false).expect("a recorder");
recorder.event(&RunEvent::RunStarted {
agent: "Research".into(),
provider: "replay".into(),
});
let id = recorder.id.clone();
drop(recorder);
let summary = read(&dir.0, &id)
.expect("the record must read back")
.summary;
assert_eq!(summary.state, "unfinished");
assert_eq!(summary.finished_unix, None);
}
#[test]
fn a_half_written_line_does_not_hide_the_run() {
let dir = Dir::new("truncated");
let directory = dir.0.join(RUNS_DIR);
std::fs::create_dir_all(&directory).expect("a runs directory");
std::fs::write(
directory.join("0000000001-7.jsonl"),
"{\"record\":\"started\",\"id\":\"0000000001-7\",\"agent\":\"A\",\"startedUnix\":1}\n\
{\"event\":\"nodeStarted\",\"node\":\"n1\",\"kind\":\"model.ca",
)
.expect("a truncated record");
let summary = read(&dir.0, "0000000001-7")
.expect("a truncated record must still read")
.summary;
assert_eq!(summary.agent, "A");
assert_eq!(summary.event_count, 0);
assert_eq!(summary.state, "unfinished");
}
#[test]
fn an_identifier_from_a_url_cannot_name_a_file_outside_the_directory() {
let dir = Dir::new("traversal");
for hostile in [
"../../etc/passwd",
"..",
"1-2/../../x",
r"..\..\windows",
"C:1-2",
"1-2.jsonl",
"",
] {
assert!(
record_path(&dir.0, hostile).is_err(),
"`{hostile}` must not name a record"
);
}
assert!(record_path(&dir.0, "0000000001-7").is_ok());
}
#[test]
fn records_are_listed_newest_first() {
let dir = Dir::new("order");
let directory = dir.0.join(RUNS_DIR);
std::fs::create_dir_all(&directory).expect("a runs directory");
for (id, started) in [
("0000000001-1", 1),
("0000000009-1", 9),
("0000000005-1", 5),
] {
std::fs::write(
directory.join(format!("{id}.jsonl")),
format!("{{\"record\":\"started\",\"agent\":\"A\",\"startedUnix\":{started}}}\n"),
)
.expect("a record");
}
let ids: Vec<String> = list(&dir.0).into_iter().map(|run| run.id).collect();
assert_eq!(ids, ["0000000009-1", "0000000005-1", "0000000001-1"]);
assert_eq!(count(&dir.0), 3);
}
}