Skip to main content

ostraka_runtime/
index.rs

1//! What runs exist, and how they ended.
2//!
3//! `replay` reads one run back by id. Nothing enumerated them, so a run id had
4//! to be copied off the terminal that produced it — and an id nobody kept was a
5//! run nobody could find. An action that leaves no trace did not happen; a trace
6//! nobody can list is barely better.
7
8use crate::{Error, Result};
9use ostraka_core::identity::ActorId;
10use ostraka_core::record::{Outcome, RunRecord, TokenUsage};
11use std::path::Path;
12
13/// One run, as much of it as its record can say.
14#[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    /// Which repository the change was made in.
22    pub repository: String,
23    pub reviewer: Option<ActorId>,
24    pub outcome: Option<Outcome>,
25    pub checks_passed: usize,
26    pub checks_total: usize,
27    /// What each adapter in this run reported spending.
28    pub usage: Vec<TokenUsage>,
29}
30
31/// What one backend has cost across the runs on record.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct BackendUsage {
34    pub adapter: String,
35    pub input: u64,
36    pub output: u64,
37    /// Counts from vendors that report one combined figure instead of a split.
38    ///
39    /// Kept apart from `input` and `output` on purpose. Folding a combined
40    /// total into the input side renders as "14.7k in / 0 out", and that zero
41    /// is a claim the vendor never made.
42    pub total: u64,
43    pub runs: usize,
44    /// Any part of this total came from a vendor that rounds.
45    pub approximate: bool,
46}
47
48/// Per-backend totals across every run given, in adapter-id order.
49///
50/// Sums only what vendors reported. A backend that reports nothing does not
51/// appear, which is the honest difference between "spent nothing" and "does not
52/// say" — the caller can show the second as a dash rather than as a zero.
53pub 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    /// A run whose record is missing or unreadable.
78    ///
79    /// Listed rather than skipped. A run interrupted before it wrote its record
80    /// still happened, and hiding it would make the listing agree with itself
81    /// by leaving out the runs someone most needs to find.
82    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
119/// Every run under `<records_root>/runs/`, newest first.
120///
121/// Ordered by the timestamp a run id ends with rather than by the id itself:
122/// an id is `<task>-<timestamp>`, and the task part sorts first, which put a
123/// listing in process-id order and called it chronological. Reading the time
124/// off the directory name rather than out of the record means a run that never
125/// wrote one still lands in the right place.
126pub 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
154/// The change a finished run produced, read from git.
155///
156/// The run record does not keep the diff, and should not: it would be a second
157/// copy of something git already stores exactly. A run's commit lives on its own
158/// branch, or on the branch a promotion named.
159///
160/// The branch existing is not enough. A refused run has a branch — it was
161/// created before the agent started — whose head is simply the base commit it
162/// branched from, and showing that would present somebody else's change as this
163/// run's output. So the head commit has to say it is this run's, by the trailer
164/// the runtime wrote into it. `None` means the run produced no commit, which is
165/// a true answer rather than a missing one.
166pub 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
186/// The branch whose head is this run's commit, if it left one here.
187///
188/// The same two places [`diff`] reads from, and the same reason for checking
189/// the head rather than trusting the name: a refused run has a branch too — it
190/// was created before the agent started — whose head is the commit it branched
191/// from. Building on that would silently start from somewhere else's work.
192///
193/// `None` is a true answer: the run produced no commit, or produced it in a
194/// different repository.
195pub 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
204/// Where a run's commit can be, in the order it is looked for. Named once
205/// because two lists of branch names drift, and the drift is silent.
206fn 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        // It happened. A listing that leaves out the interrupted runs agrees
309        // with itself by omitting exactly the ones someone is looking for.
310        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        // A run's branch is created before the agent starts, so a refused run
339        // leaves a branch whose head is the commit it branched from. Showing
340        // that would present an unrelated change as this run's output — which
341        // is worse than showing nothing, because it looks right.
342        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        // Refused: a branch, no commit of its own.
352        git(&repo, &["branch", "ostraka/t-refused"]);
353        assert_eq!(diff(&repo, "t-refused").expect("reads"), None);
354
355        // Approved: a branch whose head carries the run trailer.
356        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}