1use crate::{Error, Result};
9use ostraka_core::identity::ActorId;
10use ostraka_core::record::{Outcome, RunRecord, TokenUsage};
11use std::path::Path;
12
13#[derive(Debug, Clone)]
15pub struct RunSummary {
16 pub run_id: String,
17 pub started_at: String,
18 pub prompt: String,
19 pub author: ActorId,
20 pub adapter: String,
21 pub repository: String,
23 pub reviewer: Option<ActorId>,
24 pub outcome: Option<Outcome>,
25 pub checks_passed: usize,
26 pub checks_total: usize,
27 pub usage: Vec<TokenUsage>,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct BackendUsage {
34 pub adapter: String,
35 pub input: u64,
36 pub output: u64,
37 pub total: u64,
43 pub runs: usize,
44 pub approximate: bool,
46}
47
48pub fn by_backend(runs: &[RunSummary]) -> Vec<BackendUsage> {
54 let mut totals: std::collections::BTreeMap<String, BackendUsage> =
55 std::collections::BTreeMap::new();
56 for usage in runs.iter().flat_map(|run| run.usage.iter()) {
57 let entry = totals
58 .entry(usage.adapter.clone())
59 .or_insert_with(|| BackendUsage {
60 adapter: usage.adapter.clone(),
61 input: 0,
62 output: 0,
63 total: 0,
64 runs: 0,
65 approximate: false,
66 });
67 entry.input += usage.input.unwrap_or(0);
68 entry.output += usage.output.unwrap_or(0);
69 entry.total += usage.total.unwrap_or(0);
70 entry.runs += 1;
71 entry.approximate |= usage.approximate;
72 }
73 totals.into_values().collect()
74}
75
76impl RunSummary {
77 fn unfinished(run_id: &str) -> Self {
83 Self {
84 run_id: run_id.to_string(),
85 started_at: String::new(),
86 prompt: "(no record — the run did not finish)".to_string(),
87 author: ActorId::new(""),
88 adapter: String::new(),
89 repository: String::new(),
90 reviewer: None,
91 outcome: None,
92 checks_passed: 0,
93 checks_total: 0,
94 usage: Vec::new(),
95 }
96 }
97
98 fn from_record(record: &RunRecord) -> Self {
99 Self {
100 run_id: record.run_id.clone(),
101 started_at: record.started_at.clone(),
102 prompt: record.prompt.clone(),
103 author: record.author.clone(),
104 adapter: record.adapter.clone(),
105 repository: record.repository.clone(),
106 reviewer: record.approval.as_ref().map(|a| a.reviewer.clone()),
107 outcome: record.outcome,
108 checks_passed: record.checks.iter().filter(|c| c.passed()).count(),
109 checks_total: record.checks.len(),
110 usage: record.usage.clone(),
111 }
112 }
113
114 pub fn approved(&self) -> bool {
115 self.outcome == Some(Outcome::Approved)
116 }
117}
118
119pub fn list(records_root: &Path) -> Result<Vec<RunSummary>> {
127 let dir = records_root.join("runs");
128 if !dir.is_dir() {
129 return Ok(Vec::new());
130 }
131
132 let mut ids: Vec<String> = std::fs::read_dir(&dir)
133 .map_err(|e| Error::Other(format!("{}: {e}", dir.display())))?
134 .filter_map(|entry| entry.ok())
135 .filter(|entry| entry.path().is_dir())
136 .map(|entry| entry.file_name().to_string_lossy().into_owned())
137 .collect();
138 let when = |id: &str| {
139 id.rsplit_once('-')
140 .map(|(_, t)| t.to_string())
141 .unwrap_or_default()
142 };
143 ids.sort_by(|a, b| when(b).cmp(&when(a)).then_with(|| b.cmp(a)));
144
145 Ok(ids
146 .iter()
147 .map(|id| match read(&dir.join(id)) {
148 Some(record) => RunSummary::from_record(&record),
149 None => RunSummary::unfinished(id),
150 })
151 .collect())
152}
153
154pub fn diff(repo: &Path, run_id: &str) -> Result<Option<String>> {
167 for branch in candidates(run_id) {
168 if !head_is_this_run(repo, &branch, run_id)? {
169 continue;
170 }
171 let out = std::process::Command::new("git")
172 .args(["show", "--format=", "--patch", &branch])
173 .current_dir(repo)
174 .output()
175 .map_err(|e| Error::Other(format!("git show: {e}")))?;
176 if out.status.success() {
177 let text = String::from_utf8_lossy(&out.stdout).into_owned();
178 if !text.trim().is_empty() {
179 return Ok(Some(text));
180 }
181 }
182 }
183 Ok(None)
184}
185
186pub fn commit_branch(repo: &Path, run_id: &str) -> Result<Option<String>> {
196 for branch in candidates(run_id) {
197 if head_is_this_run(repo, &branch, run_id)? {
198 return Ok(Some(branch));
199 }
200 }
201 Ok(None)
202}
203
204fn candidates(run_id: &str) -> [String; 2] {
207 [format!("ostraka/{run_id}"), format!("promoted/{run_id}")]
208}
209
210fn head_is_this_run(repo: &Path, branch: &str, run_id: &str) -> Result<bool> {
211 let out = std::process::Command::new("git")
212 .args(["log", "-1", "--format=%B", branch])
213 .current_dir(repo)
214 .output()
215 .map_err(|e| Error::Other(format!("git log: {e}")))?;
216 if !out.status.success() {
217 return Ok(false);
218 }
219 let message = String::from_utf8_lossy(&out.stdout);
220 Ok(message
221 .lines()
222 .any(|line| line.trim() == format!("Run: {run_id}")))
223}
224
225fn read(dir: &Path) -> Option<RunRecord> {
226 let text = std::fs::read_to_string(dir.join("record.json")).ok()?;
227 serde_json::from_str(&text).ok()
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use ostraka_core::gate::{Approval, CheckRecord, Verdict};
234 use std::path::PathBuf;
235
236 fn root() -> PathBuf {
237 static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
238 let path = std::env::temp_dir().join(format!(
239 "ostraka-index-{}-{}",
240 std::process::id(),
241 NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
242 ));
243 let _ = std::fs::remove_dir_all(&path);
244 path
245 }
246
247 fn write_run(records_root: &Path, run_id: &str, outcome: Outcome, passed: bool) {
248 let dir = records_root.join("runs").join(run_id);
249 std::fs::create_dir_all(&dir).expect("run dir");
250 let record = RunRecord {
251 run_id: run_id.to_string(),
252 task_id: "t".into(),
253 prompt: format!("do {run_id}"),
254 author: ActorId::new("archon"),
255 adapter: "a".into(),
256 repository: "only".into(),
257 started_at: "2026-09-07T00:00:00Z".into(),
258 finished_at: None,
259 checks: vec![CheckRecord {
260 name: "test".into(),
261 cmd: "true".into(),
262 exit_code: Some(if passed { 0 } else { 1 }),
263 stdout: String::new(),
264 stderr: String::new(),
265 duration_ms: 1,
266 }],
267 approval: Some(Approval {
268 reviewer: ActorId::new("ephor"),
269 verdict: Verdict::Approve,
270 }),
271 usage: Vec::new(),
272 outcome: Some(outcome),
273 };
274 std::fs::write(
275 dir.join("record.json"),
276 serde_json::to_string(&record).expect("serializes"),
277 )
278 .expect("write");
279 }
280
281 #[test]
282 fn a_project_that_has_never_run_lists_nothing_rather_than_failing() {
283 assert!(list(&root()).expect("lists").is_empty());
284 }
285
286 #[test]
287 fn runs_are_listed_newest_first() {
288 let root = root();
289 write_run(&root, "t1-20260907T000100Z", Outcome::Approved, true);
290 write_run(&root, "t2-20260907T000300Z", Outcome::Rejected, false);
291 write_run(&root, "t3-20260907T000200Z", Outcome::Approved, true);
292
293 let runs = list(&root).expect("lists");
294 let ids: Vec<&str> = runs.iter().map(|r| r.run_id.as_str()).collect();
295 assert_eq!(
296 ids,
297 [
298 "t2-20260907T000300Z",
299 "t3-20260907T000200Z",
300 "t1-20260907T000100Z"
301 ]
302 );
303 let _ = std::fs::remove_dir_all(&root);
304 }
305
306 #[test]
307 fn a_run_that_never_wrote_a_record_is_still_listed() {
308 let root = root();
311 write_run(&root, "t1-20260907T000100Z", Outcome::Approved, true);
312 std::fs::create_dir_all(root.join("runs").join("t2-20260907T000200Z")).expect("dir");
313
314 let runs = list(&root).expect("lists");
315 assert_eq!(runs.len(), 2);
316 assert_eq!(runs[0].run_id, "t2-20260907T000200Z");
317 assert!(runs[0].outcome.is_none());
318 assert!(runs[0].prompt.contains("did not finish"));
319 assert!(runs[1].approved());
320 let _ = std::fs::remove_dir_all(&root);
321 }
322
323 fn git(repo: &Path, args: &[&str]) {
324 let out = std::process::Command::new("git")
325 .args(args)
326 .current_dir(repo)
327 .output()
328 .expect("git runs");
329 assert!(
330 out.status.success(),
331 "git {args:?}: {}",
332 String::from_utf8_lossy(&out.stderr)
333 );
334 }
335
336 #[test]
337 fn a_refused_runs_branch_is_not_mistaken_for_its_change() {
338 let repo = root();
343 std::fs::create_dir_all(&repo).expect("repo dir");
344 git(&repo, &["init", "-q", "-b", "main"]);
345 git(&repo, &["config", "user.email", "t@example.invalid"]);
346 git(&repo, &["config", "user.name", "t"]);
347 std::fs::write(repo.join("seed.txt"), "seed\n").expect("write");
348 git(&repo, &["add", "-A"]);
349 git(&repo, &["commit", "-q", "-m", "someone else's change"]);
350
351 git(&repo, &["branch", "ostraka/t-refused"]);
353 assert_eq!(diff(&repo, "t-refused").expect("reads"), None);
354
355 git(&repo, &["checkout", "-q", "-b", "ostraka/t-approved"]);
357 std::fs::write(repo.join("added.txt"), "new\n").expect("write");
358 git(&repo, &["add", "-A"]);
359 git(
360 &repo,
361 &["commit", "-q", "-m", "do a thing\n\nRun: t-approved"],
362 );
363
364 let change = diff(&repo, "t-approved")
365 .expect("reads")
366 .expect("has a diff");
367 assert!(change.contains("added.txt"), "{change}");
368 assert!(
369 !change.contains("seed.txt"),
370 "showed the base commit:\n{change}"
371 );
372
373 let _ = std::fs::remove_dir_all(&repo);
374 }
375
376 #[test]
377 fn a_run_with_no_branch_at_all_reads_as_no_change_rather_than_an_error() {
378 let repo = root();
379 std::fs::create_dir_all(&repo).expect("repo dir");
380 git(&repo, &["init", "-q", "-b", "main"]);
381 assert_eq!(diff(&repo, "t-never-existed").expect("reads"), None);
382 let _ = std::fs::remove_dir_all(&repo);
383 }
384
385 #[test]
386 fn a_summary_carries_what_the_run_was_for() {
387 let root = root();
388 write_run(&root, "t1-20260907T000100Z", Outcome::Rejected, false);
389 let runs = list(&root).expect("lists");
390 let run = &runs[0];
391 assert_eq!(run.prompt, "do t1-20260907T000100Z");
392 assert_eq!(run.author.as_str(), "archon");
393 assert_eq!(run.reviewer.as_ref().map(ActorId::as_str), Some("ephor"));
394 assert_eq!((run.checks_passed, run.checks_total), (0, 1));
395 assert!(!run.approved());
396 let _ = std::fs::remove_dir_all(&root);
397 }
398}