Skip to main content

release_kit/commands/
runs.rs

1//! `rk runs`: inspect and prune the run journals.
2//!
3//! The journal is audit evidence: what ran, against what, and what came
4//! back. These verbs read and bound it; nothing here resumes anything.
5
6use serde::Serialize;
7
8use crate::cli::runs::{RunsAction, RunsArgs};
9use crate::error::RkError;
10use crate::output::Output;
11use crate::setup::journal::{self, RUNS_KEPT};
12
13/// One run's listing row, read from its `meta.json`.
14#[derive(Debug, Serialize)]
15struct RunRow {
16    /// The run id, equal to its directory name.
17    id: String,
18    /// The subcommand that ran, where the record is readable.
19    #[serde(skip_serializing_if = "Option::is_none")]
20    command: Option<String>,
21    /// The forge acted on.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    forge: Option<String>,
24    /// The process exit code, absent for a run still open or killed.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    exit_code: Option<i64>,
27    /// The failure reason, where one was recorded.
28    #[serde(skip_serializing_if = "Option::is_none")]
29    reason: Option<String>,
30}
31
32/// The machine form of `rk runs list`.
33#[derive(Debug, Serialize)]
34struct ListReport {
35    /// The shape version of this document.
36    schema: &'static str,
37    /// Every kept run, oldest first.
38    runs: Vec<RunRow>,
39}
40
41/// Dispatch the runs surface.
42///
43/// # Errors
44///
45/// Returns [`RkError::NotFound`] for an unknown run id and I/O failures
46/// from the state root.
47pub fn run(args: &RunsArgs) -> Result<(), RkError> {
48    match &args.action {
49        RunsAction::List { json } => list(Output::new(*json)),
50        RunsAction::Show { id, json } => show(Output::new(*json), id),
51        RunsAction::Prune { keep } => {
52            prune(keep.unwrap_or(RUNS_KEPT));
53            Ok(())
54        }
55    }
56}
57
58fn read_row(id: &str) -> RunRow {
59    let meta = journal::runs_root()
60        .map(|root| root.join(id).join("meta.json"))
61        .and_then(|path| std::fs::read(path).ok())
62        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
63    let field = |name: &str| {
64        meta.as_ref()
65            .and_then(|value| value[name].as_str().map(str::to_owned))
66    };
67    RunRow {
68        id: id.to_owned(),
69        command: field("command"),
70        forge: field("forge"),
71        exit_code: meta.as_ref().and_then(|value| value["exit_code"].as_i64()),
72        reason: field("reason"),
73    }
74}
75
76fn list(out: Output) -> Result<(), RkError> {
77    let rows: Vec<RunRow> = journal::list_run_ids()
78        .iter()
79        .map(|id| read_row(id))
80        .collect();
81    for row in &rows {
82        use std::fmt::Write as _;
83        let mut line = row.id.clone();
84        if let Some(command) = &row.command {
85            let _ = write!(line, "  {command}");
86        }
87        match (row.exit_code, &row.reason) {
88            (Some(0), _) => line.push_str("  ok"),
89            (Some(code), Some(reason)) => {
90                let _ = write!(line, "  exit {code} ({reason})");
91            }
92            (Some(code), None) => {
93                let _ = write!(line, "  exit {code}");
94            }
95            (None, _) => line.push_str("  unfinished"),
96        }
97        out.result_line(line);
98    }
99    if rows.is_empty() {
100        out.result_line("no runs are kept");
101    }
102    out.emit(&ListReport {
103        schema: "rk.runs/1",
104        runs: rows,
105    })?;
106    Ok(())
107}
108
109fn show(out: Output, id: &str) -> Result<(), RkError> {
110    // An id is one directory name under the runs root, never a path: a
111    // separator or a parent component would let a stray argument read a
112    // meta.json from anywhere on the filesystem.
113    if id.contains(['/', '\\']) || id == ".." || id == "." || id.is_empty() {
114        return Err(RkError::NotFound {
115            kind: "run",
116            name: id.to_owned(),
117        });
118    }
119    let root = journal::runs_root()
120        .ok_or_else(|| RkError::Other(anyhow::anyhow!("neither XDG_STATE_HOME nor HOME is set")))?;
121    let dir = root.join(id);
122    let meta_path = dir.join("meta.json");
123    let bytes = std::fs::read(&meta_path).map_err(|_| RkError::NotFound {
124        kind: "run",
125        name: id.to_owned(),
126    })?;
127    if out.is_json() {
128        let meta: serde_json::Value =
129            serde_json::from_slice(&bytes).map_err(anyhow::Error::from)?;
130        out.emit(&meta)?;
131        return Ok(());
132    }
133    out.result_raw(&String::from_utf8_lossy(&bytes));
134    out.result_line("");
135    out.result_line(format!(
136        "events:     {}",
137        dir.join("events.jsonl").display()
138    ));
139    out.result_line(format!(
140        "transcript: {}",
141        dir.join("transcript.txt").display()
142    ));
143    if dir.join("scripts").is_dir() {
144        out.result_line(format!("scripts:    {}", dir.join("scripts").display()));
145    }
146    Ok(())
147}
148
149fn prune(keep: usize) {
150    let removed = journal::prune_to(keep);
151    let out = Output::human();
152    out.result_line(format!("pruned {removed} runs; keeping the newest {keep}"));
153}