use crate::{Error, Result};
use ostraka_core::identity::ActorId;
use ostraka_core::record::{Outcome, RunRecord, TokenUsage};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct RunSummary {
pub run_id: String,
pub started_at: String,
pub prompt: String,
pub author: ActorId,
pub adapter: String,
pub repository: String,
pub reviewer: Option<ActorId>,
pub outcome: Option<Outcome>,
pub checks_passed: usize,
pub checks_total: usize,
pub usage: Vec<TokenUsage>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendUsage {
pub adapter: String,
pub input: u64,
pub output: u64,
pub total: u64,
pub runs: usize,
pub approximate: bool,
}
pub fn by_backend(runs: &[RunSummary]) -> Vec<BackendUsage> {
let mut totals: std::collections::BTreeMap<String, BackendUsage> =
std::collections::BTreeMap::new();
for usage in runs.iter().flat_map(|run| run.usage.iter()) {
let entry = totals
.entry(usage.adapter.clone())
.or_insert_with(|| BackendUsage {
adapter: usage.adapter.clone(),
input: 0,
output: 0,
total: 0,
runs: 0,
approximate: false,
});
entry.input += usage.input.unwrap_or(0);
entry.output += usage.output.unwrap_or(0);
entry.total += usage.total.unwrap_or(0);
entry.runs += 1;
entry.approximate |= usage.approximate;
}
totals.into_values().collect()
}
impl RunSummary {
fn unfinished(run_id: &str) -> Self {
Self {
run_id: run_id.to_string(),
started_at: String::new(),
prompt: "(no record — the run did not finish)".to_string(),
author: ActorId::new(""),
adapter: String::new(),
repository: String::new(),
reviewer: None,
outcome: None,
checks_passed: 0,
checks_total: 0,
usage: Vec::new(),
}
}
fn from_record(record: &RunRecord) -> Self {
Self {
run_id: record.run_id.clone(),
started_at: record.started_at.clone(),
prompt: record.prompt.clone(),
author: record.author.clone(),
adapter: record.adapter.clone(),
repository: record.repository.clone(),
reviewer: record.approval.as_ref().map(|a| a.reviewer.clone()),
outcome: record.outcome,
checks_passed: record.checks.iter().filter(|c| c.passed()).count(),
checks_total: record.checks.len(),
usage: record.usage.clone(),
}
}
pub fn approved(&self) -> bool {
self.outcome == Some(Outcome::Approved)
}
}
pub fn list(records_root: &Path) -> Result<Vec<RunSummary>> {
let dir = records_root.join("runs");
if !dir.is_dir() {
return Ok(Vec::new());
}
let mut ids: Vec<String> = std::fs::read_dir(&dir)
.map_err(|e| Error::Other(format!("{}: {e}", dir.display())))?
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_dir())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
let when = |id: &str| {
id.rsplit_once('-')
.map(|(_, t)| t.to_string())
.unwrap_or_default()
};
ids.sort_by(|a, b| when(b).cmp(&when(a)).then_with(|| b.cmp(a)));
Ok(ids
.iter()
.map(|id| match read(&dir.join(id)) {
Some(record) => RunSummary::from_record(&record),
None => RunSummary::unfinished(id),
})
.collect())
}
pub fn diff(repo: &Path, run_id: &str) -> Result<Option<String>> {
for branch in candidates(run_id) {
if !head_is_this_run(repo, &branch, run_id)? {
continue;
}
let out = std::process::Command::new("git")
.args(["show", "--format=", "--patch", &branch])
.current_dir(repo)
.output()
.map_err(|e| Error::Other(format!("git show: {e}")))?;
if out.status.success() {
let text = String::from_utf8_lossy(&out.stdout).into_owned();
if !text.trim().is_empty() {
return Ok(Some(text));
}
}
}
Ok(None)
}
pub fn commit_branch(repo: &Path, run_id: &str) -> Result<Option<String>> {
for branch in candidates(run_id) {
if head_is_this_run(repo, &branch, run_id)? {
return Ok(Some(branch));
}
}
Ok(None)
}
fn candidates(run_id: &str) -> [String; 2] {
[format!("ostraka/{run_id}"), format!("promoted/{run_id}")]
}
fn head_is_this_run(repo: &Path, branch: &str, run_id: &str) -> Result<bool> {
let out = std::process::Command::new("git")
.args(["log", "-1", "--format=%B", branch])
.current_dir(repo)
.output()
.map_err(|e| Error::Other(format!("git log: {e}")))?;
if !out.status.success() {
return Ok(false);
}
let message = String::from_utf8_lossy(&out.stdout);
Ok(message
.lines()
.any(|line| line.trim() == format!("Run: {run_id}")))
}
fn read(dir: &Path) -> Option<RunRecord> {
let text = std::fs::read_to_string(dir.join("record.json")).ok()?;
serde_json::from_str(&text).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use ostraka_core::gate::{Approval, CheckRecord, Verdict};
use std::path::PathBuf;
fn root() -> PathBuf {
static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let path = std::env::temp_dir().join(format!(
"ostraka-index-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
let _ = std::fs::remove_dir_all(&path);
path
}
fn write_run(records_root: &Path, run_id: &str, outcome: Outcome, passed: bool) {
let dir = records_root.join("runs").join(run_id);
std::fs::create_dir_all(&dir).expect("run dir");
let record = RunRecord {
run_id: run_id.to_string(),
task_id: "t".into(),
prompt: format!("do {run_id}"),
author: ActorId::new("archon"),
adapter: "a".into(),
repository: "only".into(),
started_at: "2026-09-07T00:00:00Z".into(),
finished_at: None,
checks: vec![CheckRecord {
name: "test".into(),
cmd: "true".into(),
exit_code: Some(if passed { 0 } else { 1 }),
stdout: String::new(),
stderr: String::new(),
duration_ms: 1,
}],
approval: Some(Approval {
reviewer: ActorId::new("ephor"),
verdict: Verdict::Approve,
}),
usage: Vec::new(),
outcome: Some(outcome),
};
std::fs::write(
dir.join("record.json"),
serde_json::to_string(&record).expect("serializes"),
)
.expect("write");
}
#[test]
fn a_project_that_has_never_run_lists_nothing_rather_than_failing() {
assert!(list(&root()).expect("lists").is_empty());
}
#[test]
fn runs_are_listed_newest_first() {
let root = root();
write_run(&root, "t1-20260907T000100Z", Outcome::Approved, true);
write_run(&root, "t2-20260907T000300Z", Outcome::Rejected, false);
write_run(&root, "t3-20260907T000200Z", Outcome::Approved, true);
let runs = list(&root).expect("lists");
let ids: Vec<&str> = runs.iter().map(|r| r.run_id.as_str()).collect();
assert_eq!(
ids,
[
"t2-20260907T000300Z",
"t3-20260907T000200Z",
"t1-20260907T000100Z"
]
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_run_that_never_wrote_a_record_is_still_listed() {
let root = root();
write_run(&root, "t1-20260907T000100Z", Outcome::Approved, true);
std::fs::create_dir_all(root.join("runs").join("t2-20260907T000200Z")).expect("dir");
let runs = list(&root).expect("lists");
assert_eq!(runs.len(), 2);
assert_eq!(runs[0].run_id, "t2-20260907T000200Z");
assert!(runs[0].outcome.is_none());
assert!(runs[0].prompt.contains("did not finish"));
assert!(runs[1].approved());
let _ = std::fs::remove_dir_all(&root);
}
fn git(repo: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.args(args)
.current_dir(repo)
.output()
.expect("git runs");
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_refused_runs_branch_is_not_mistaken_for_its_change() {
let repo = root();
std::fs::create_dir_all(&repo).expect("repo dir");
git(&repo, &["init", "-q", "-b", "main"]);
git(&repo, &["config", "user.email", "t@example.invalid"]);
git(&repo, &["config", "user.name", "t"]);
std::fs::write(repo.join("seed.txt"), "seed\n").expect("write");
git(&repo, &["add", "-A"]);
git(&repo, &["commit", "-q", "-m", "someone else's change"]);
git(&repo, &["branch", "ostraka/t-refused"]);
assert_eq!(diff(&repo, "t-refused").expect("reads"), None);
git(&repo, &["checkout", "-q", "-b", "ostraka/t-approved"]);
std::fs::write(repo.join("added.txt"), "new\n").expect("write");
git(&repo, &["add", "-A"]);
git(
&repo,
&["commit", "-q", "-m", "do a thing\n\nRun: t-approved"],
);
let change = diff(&repo, "t-approved")
.expect("reads")
.expect("has a diff");
assert!(change.contains("added.txt"), "{change}");
assert!(
!change.contains("seed.txt"),
"showed the base commit:\n{change}"
);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn a_run_with_no_branch_at_all_reads_as_no_change_rather_than_an_error() {
let repo = root();
std::fs::create_dir_all(&repo).expect("repo dir");
git(&repo, &["init", "-q", "-b", "main"]);
assert_eq!(diff(&repo, "t-never-existed").expect("reads"), None);
let _ = std::fs::remove_dir_all(&repo);
}
#[test]
fn a_summary_carries_what_the_run_was_for() {
let root = root();
write_run(&root, "t1-20260907T000100Z", Outcome::Rejected, false);
let runs = list(&root).expect("lists");
let run = &runs[0];
assert_eq!(run.prompt, "do t1-20260907T000100Z");
assert_eq!(run.author.as_str(), "archon");
assert_eq!(run.reviewer.as_ref().map(ActorId::as_str), Some("ephor"));
assert_eq!((run.checks_passed, run.checks_total), (0, 1));
assert!(!run.approved());
let _ = std::fs::remove_dir_all(&root);
}
}