1use std::fs;
11use std::io::Write as _;
12use std::path::PathBuf;
13
14use serde::Serialize;
15
16use crate::applog;
17use crate::atomic;
18
19pub const META_SCHEMA: &str = "rk.run-meta/1";
21
22pub const RUNS_KEPT: usize = 20;
24
25#[derive(Debug, Serialize)]
27pub struct ScriptRecord {
28 pub path: String,
30 pub sha256: String,
32}
33
34#[derive(Debug, Serialize)]
37pub struct SecretHandling {
38 pub secret: String,
40 pub present: bool,
42 pub source: &'static str,
45 pub transport: &'static str,
47 pub redacted: bool,
49}
50
51#[derive(Debug, Serialize)]
53pub struct Meta {
54 pub schema: &'static str,
56 pub run_id: String,
58 pub rk_version: &'static str,
60 pub command: String,
62 pub argv: Vec<String>,
64 pub pid: u32,
66 pub target: String,
68 pub forge: String,
70 pub repo: String,
72 pub started: String,
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub ended: Option<String>,
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub exit_code: Option<i32>,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub reason: Option<String>,
83 pub scripts: Vec<ScriptRecord>,
85 pub secrets: Vec<SecretHandling>,
87}
88
89#[derive(Debug)]
91pub struct Journal {
92 pub dir: PathBuf,
94 meta: Meta,
95 events: Option<fs::File>,
96 transcript: Option<fs::File>,
97}
98
99impl Journal {
100 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 #[must_use]
150 pub fn run_id(&self) -> &str {
151 &self.meta.run_id
152 }
153
154 #[must_use]
156 pub fn scripts_dir(&self) -> PathBuf {
157 self.dir.join("scripts")
158 }
159
160 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 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 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 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 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#[must_use]
219pub fn runs_root() -> Option<PathBuf> {
220 applog::state_root().map(|root| root.join("runs"))
221}
222
223#[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#[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
266const UNFINISHED_GRACE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
269
270fn 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 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
305fn 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
319fn 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
330fn 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 #[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}