Skip to main content

release_kit/setup/
journal.rs

1//! The run journal: the audit record of one conversation with a forge.
2//!
3//! It answers, after the fact and from another session, what ran, with what
4//! arguments, against what, and what came back. Per run: `meta.json`,
5//! `events.jsonl`, `transcript.txt`, and the materialized scripts — removed
6//! on clean completion, kept on failure. Retention is bounded: a run count
7//! cap enforced at the start of each new run, oldest pruned first. The
8//! journal is audit evidence and never resumable state.
9
10use std::fs;
11use std::io::Write as _;
12use std::path::PathBuf;
13
14use serde::Serialize;
15
16use crate::applog;
17use crate::atomic;
18
19/// The version of the `meta.json` shape.
20pub const META_SCHEMA: &str = "rk.run-meta/1";
21
22/// How many runs the journal root keeps; run N+1 prunes the oldest.
23pub const RUNS_KEPT: usize = 20;
24
25/// One materialized script, proven by digest.
26#[derive(Debug, Serialize)]
27pub struct ScriptRecord {
28    /// The script's path relative to the run directory.
29    pub path: String,
30    /// The digest of the bytes that ran, equal to the embedded bytes.
31    pub sha256: String,
32}
33
34/// How a secret was handled: the fact of the handling, never the value and
35/// never a fingerprint of one.
36#[derive(Debug, Serialize)]
37pub struct SecretHandling {
38    /// The environment variable the secret arrived in.
39    pub secret: String,
40    /// Whether a value was present.
41    pub present: bool,
42    /// Where the value came from: `environment` for a value the operator
43    /// exported, `file` for one rk read from a path they named.
44    pub source: &'static str,
45    /// How it reached the forge CLI.
46    pub transport: &'static str,
47    /// Whether outputs were redacted for it.
48    pub redacted: bool,
49}
50
51/// The `meta.json` document.
52#[derive(Debug, Serialize)]
53pub struct Meta {
54    /// The shape version of this document.
55    pub schema: &'static str,
56    /// The run's identifier, equal to its directory name.
57    pub run_id: String,
58    /// The binary that ran.
59    pub rk_version: &'static str,
60    /// The subcommand.
61    pub command: String,
62    /// The invocation's arguments as typed; a secret never appears in argv.
63    pub argv: Vec<String>,
64    /// The process that owns the run, for liveness while unfinished.
65    pub pid: u32,
66    /// The target repository.
67    pub target: String,
68    /// The forge acted on.
69    pub forge: String,
70    /// The project path.
71    pub repo: String,
72    /// Wall-clock UTC start.
73    pub started: String,
74    /// Wall-clock UTC end, absent while running.
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub ended: Option<String>,
77    /// The process exit code, absent while running.
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub exit_code: Option<i32>,
80    /// The failure reason, absent on success.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub reason: Option<String>,
83    /// Every script this run materialized, with its digest.
84    pub scripts: Vec<ScriptRecord>,
85    /// Every secret this run handled.
86    pub secrets: Vec<SecretHandling>,
87}
88
89/// One open journal.
90#[derive(Debug)]
91pub struct Journal {
92    /// The run's directory.
93    pub dir: PathBuf,
94    meta: Meta,
95    events: Option<fs::File>,
96    transcript: Option<fs::File>,
97}
98
99impl Journal {
100    /// Create the run directory — before any remote mutation — pruning the
101    /// oldest runs past the cap first.
102    ///
103    /// # Errors
104    ///
105    /// Any filesystem failure; the caller decides whether that refuses the
106    /// run (apply) or only costs the record (preview and check).
107    pub fn create(command: &str, target: &str, forge: &str, repo: &str) -> std::io::Result<Self> {
108        let root = runs_root().ok_or_else(|| {
109            std::io::Error::other("neither XDG_STATE_HOME nor HOME is set; no journal root")
110        })?;
111        fs::create_dir_all(&root)?;
112        let _ = prune_to(RUNS_KEPT.saturating_sub(1));
113        let run_id = new_run_id();
114        let dir = root.join(&run_id);
115        fs::create_dir(&dir)?;
116        restrict_dir(&dir);
117        let events = fs::File::create(dir.join("events.jsonl"))?;
118        let transcript = fs::File::create(dir.join("transcript.txt"))?;
119        restrict_file(&dir.join("events.jsonl"));
120        restrict_file(&dir.join("transcript.txt"));
121        let meta = Meta {
122            schema: META_SCHEMA,
123            run_id,
124            rk_version: env!("CARGO_PKG_VERSION"),
125            command: command.to_owned(),
126            argv: std::env::args().skip(1).collect(),
127            pid: std::process::id(),
128            target: target.to_owned(),
129            forge: forge.to_owned(),
130            repo: repo.to_owned(),
131            started: applog::now_utc(),
132            ended: None,
133            exit_code: None,
134            reason: None,
135            scripts: Vec::new(),
136            secrets: Vec::new(),
137        };
138        let journal = Self {
139            dir,
140            meta,
141            events: Some(events),
142            transcript: Some(transcript),
143        };
144        journal.write_meta();
145        Ok(journal)
146    }
147
148    /// The run's identifier.
149    #[must_use]
150    pub fn run_id(&self) -> &str {
151        &self.meta.run_id
152    }
153
154    /// The directory the run's scripts materialize into.
155    #[must_use]
156    pub fn scripts_dir(&self) -> PathBuf {
157        self.dir.join("scripts")
158    }
159
160    /// Append one already-serialized event line.
161    pub fn event_line(&mut self, line: &str) {
162        if let Some(file) = &mut self.events {
163            let _ = writeln!(file, "{line}");
164        }
165    }
166
167    /// Append raw bytes to the transcript, in arrival order.
168    pub fn transcript(&mut self, bytes: &[u8]) {
169        if let Some(file) = &mut self.transcript {
170            let _ = file.write_all(bytes);
171        }
172    }
173
174    /// Record one materialized script's digest.
175    pub fn record_script(&mut self, path: String, sha256: String) {
176        self.meta.scripts.push(ScriptRecord { path, sha256 });
177        self.write_meta();
178    }
179
180    /// Record one secret's handling: where the value came from, and how
181    /// it reached the forge CLI. A key file is `file`, because the
182    /// environment carried only its path.
183    pub fn record_secret(&mut self, secret: &str, present: bool, source: &'static str) {
184        self.meta.secrets.push(SecretHandling {
185            secret: secret.to_owned(),
186            present,
187            source,
188            transport: "stdin",
189            redacted: true,
190        });
191        self.write_meta();
192    }
193
194    /// Close the run: record the terminal status, and remove the
195    /// materialized scripts on clean completion so a failed run keeps
196    /// exactly the script that ran beside the transcript of what it did.
197    pub fn finish(&mut self, exit_code: i32, reason: Option<&str>) {
198        self.meta.ended = Some(applog::now_utc());
199        self.meta.exit_code = Some(exit_code);
200        self.meta.reason = reason.map(str::to_owned);
201        self.write_meta();
202        self.events = None;
203        self.transcript = None;
204        if exit_code == 0 {
205            let _ = fs::remove_dir_all(self.scripts_dir());
206        }
207    }
208
209    fn write_meta(&self) {
210        if let Ok(text) = serde_json::to_string_pretty(&self.meta) {
211            let _ = atomic::write(&self.dir.join("meta.json"), text.as_bytes());
212        }
213        restrict_file(&self.dir.join("meta.json"));
214    }
215}
216
217/// The journal root: `<state root>/runs`.
218#[must_use]
219pub fn runs_root() -> Option<PathBuf> {
220    applog::state_root().map(|root| root.join("runs"))
221}
222
223/// Every run directory name, oldest first. The id opens with the UTC
224/// timestamp, so lexical order is age order.
225#[must_use]
226pub fn list_run_ids() -> Vec<String> {
227    let Some(root) = runs_root() else {
228        return Vec::new();
229    };
230    let Ok(entries) = fs::read_dir(root) else {
231        return Vec::new();
232    };
233    let mut ids: Vec<String> = entries
234        .filter_map(Result::ok)
235        .filter(|entry| entry.path().is_dir())
236        .map(|entry| entry.file_name().to_string_lossy().into_owned())
237        .collect();
238    ids.sort();
239    ids
240}
241
242/// Remove the oldest runs past `keep`; the count removed.
243///
244/// A run whose record carries no terminal status may still be mutating a
245/// forge, and unlinking a live run's scripts and record would leave it
246/// running unobserved — so an unfinished run is spared until it is old
247/// enough that it can only be the debris of a crash.
248#[must_use]
249pub fn prune_to(keep: usize) -> usize {
250    let Some(root) = runs_root() else { return 0 };
251    let ids = list_run_ids();
252    let excess = ids.len().saturating_sub(keep);
253    let mut removed = 0;
254    for id in ids.into_iter().take(excess) {
255        let dir = root.join(&id);
256        if !prunable(&dir) {
257            continue;
258        }
259        if fs::remove_dir_all(&dir).is_ok() {
260            removed += 1;
261        }
262    }
263    removed
264}
265
266/// How long an unfinished run is presumed live before it counts as crash
267/// debris the pruner may take.
268const UNFINISHED_GRACE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
269
270/// Whether a run directory may be pruned: any finished run; an unfinished
271/// one only once its owning process is provably gone, or — where the host
272/// cannot answer that — once it is older than the grace period. A stale pid
273/// reused by another process reads as alive and merely delays the prune,
274/// which errs in the safe direction.
275fn prunable(dir: &std::path::Path) -> bool {
276    let meta = fs::read(dir.join("meta.json"))
277        .ok()
278        .and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
279    if meta
280        .as_ref()
281        .is_some_and(|meta| !meta["exit_code"].is_null())
282    {
283        return true;
284    }
285    // Only a readable procfs answer decides: `Ok(false)` proves the owner
286    // is gone, `Ok(true)` proves it may be alive, and an error — a hardened
287    // mount, a permission failure — falls through to the grace period
288    // rather than reading as an exited owner.
289    if let Some(pid) = meta.as_ref().and_then(|meta| meta["pid"].as_u64()) {
290        if std::path::Path::new("/proc/self").is_dir() {
291            match std::path::Path::new(&format!("/proc/{pid}")).try_exists() {
292                Ok(true) => return false,
293                Ok(false) => return true,
294                Err(_) => {}
295            }
296        }
297    }
298    fs::metadata(dir)
299        .and_then(|meta| meta.modified())
300        .ok()
301        .and_then(|modified| modified.elapsed().ok())
302        .is_some_and(|age| age > UNFINISHED_GRACE)
303}
304
305/// A fresh run id: the UTC timestamp, then a suffix from the clock's
306/// nanoseconds and the process id, so two concurrent runs on one host get
307/// distinct directories.
308fn new_run_id() -> String {
309    let stamp = applog::now_utc().replace(':', "-");
310    let nanos = std::time::SystemTime::now()
311        .duration_since(std::time::UNIX_EPOCH)
312        .map_or(0, |d| d.subsec_nanos());
313    format!(
314        "{stamp}-{:08x}",
315        u64::from(nanos) ^ (u64::from(std::process::id()) << 20)
316    )
317}
318
319/// 0700 on the run directory: the journal can hold command transcripts.
320fn restrict_dir(dir: &std::path::Path) {
321    #[cfg(unix)]
322    {
323        use std::os::unix::fs::PermissionsExt as _;
324        let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
325    }
326    #[cfg(not(unix))]
327    let _ = dir;
328}
329
330/// 0600 on a journal file: data, not an executable.
331fn restrict_file(path: &std::path::Path) {
332    #[cfg(unix)]
333    {
334        use std::os::unix::fs::PermissionsExt as _;
335        let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
336    }
337    #[cfg(not(unix))]
338    let _ = path;
339}
340
341#[cfg(test)]
342mod tests {
343    #![allow(clippy::expect_used)]
344
345    use super::{META_SCHEMA, Meta, ScriptRecord, SecretHandling};
346
347    /// The `rk.run-meta/1` schema, held by snapshot.
348    #[test]
349    fn the_meta_schema_snapshot_holds() {
350        let meta = Meta {
351            schema: META_SCHEMA,
352            run_id: "2026-08-29T14-02-11Z-0000abcd".into(),
353            rk_version: "0.1.0",
354            command: "setup".into(),
355            argv: vec!["setup".into(), "--target".into(), ".".into()],
356            pid: 4242,
357            target: ".".into(),
358            forge: "github".into(),
359            repo: "acme/widget".into(),
360            started: "2026-08-29T14:02:11Z".into(),
361            ended: Some("2026-08-29T14:02:12Z".into()),
362            exit_code: Some(0),
363            reason: None,
364            scripts: vec![ScriptRecord {
365                path: "scripts/github/default-branch".into(),
366                sha256: "ab".into(),
367            }],
368            secrets: vec![SecretHandling {
369                secret: "RK_BOT_PRIVATE_KEY_FILE".into(),
370                present: true,
371                source: "file",
372                transport: "stdin",
373                redacted: true,
374            }],
375        };
376        assert_eq!(
377            serde_json::to_string(&meta).expect("meta serializes"),
378            r#"{"schema":"rk.run-meta/1","run_id":"2026-08-29T14-02-11Z-0000abcd","rk_version":"0.1.0","command":"setup","argv":["setup","--target","."],"pid":4242,"target":".","forge":"github","repo":"acme/widget","started":"2026-08-29T14:02:11Z","ended":"2026-08-29T14:02:12Z","exit_code":0,"scripts":[{"path":"scripts/github/default-branch","sha256":"ab"}],"secrets":[{"secret":"RK_BOT_PRIVATE_KEY_FILE","present":true,"source":"file","transport":"stdin","redacted":true}]}"#
379        );
380    }
381}