release_kit/commands/
runs.rs1use 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#[derive(Debug, Serialize)]
15struct RunRow {
16 id: String,
18 #[serde(skip_serializing_if = "Option::is_none")]
20 command: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
23 forge: Option<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
26 exit_code: Option<i64>,
27 #[serde(skip_serializing_if = "Option::is_none")]
29 reason: Option<String>,
30}
31
32#[derive(Debug, Serialize)]
34struct ListReport {
35 schema: &'static str,
37 runs: Vec<RunRow>,
39}
40
41pub 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 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}