use std::fs;
use std::io::Write as _;
use std::path::PathBuf;
use serde::Serialize;
use crate::applog;
use crate::atomic;
pub const META_SCHEMA: &str = "rk.run-meta/1";
pub const RUNS_KEPT: usize = 20;
#[derive(Debug, Serialize)]
pub struct ScriptRecord {
pub path: String,
pub sha256: String,
}
#[derive(Debug, Serialize)]
pub struct SecretHandling {
pub secret: String,
pub present: bool,
pub source: &'static str,
pub transport: &'static str,
pub redacted: bool,
}
#[derive(Debug, Serialize)]
pub struct Meta {
pub schema: &'static str,
pub run_id: String,
pub rk_version: &'static str,
pub command: String,
pub argv: Vec<String>,
pub pid: u32,
pub target: String,
pub forge: String,
pub repo: String,
pub started: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ended: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub scripts: Vec<ScriptRecord>,
pub secrets: Vec<SecretHandling>,
}
#[derive(Debug)]
pub struct Journal {
pub dir: PathBuf,
meta: Meta,
events: Option<fs::File>,
transcript: Option<fs::File>,
}
impl Journal {
pub fn create(command: &str, target: &str, forge: &str, repo: &str) -> std::io::Result<Self> {
let root = runs_root().ok_or_else(|| {
std::io::Error::other("neither XDG_STATE_HOME nor HOME is set; no journal root")
})?;
fs::create_dir_all(&root)?;
let _ = prune_to(RUNS_KEPT.saturating_sub(1));
let run_id = new_run_id();
let dir = root.join(&run_id);
fs::create_dir(&dir)?;
restrict_dir(&dir);
let events = fs::File::create(dir.join("events.jsonl"))?;
let transcript = fs::File::create(dir.join("transcript.txt"))?;
restrict_file(&dir.join("events.jsonl"));
restrict_file(&dir.join("transcript.txt"));
let meta = Meta {
schema: META_SCHEMA,
run_id,
rk_version: env!("CARGO_PKG_VERSION"),
command: command.to_owned(),
argv: std::env::args().skip(1).collect(),
pid: std::process::id(),
target: target.to_owned(),
forge: forge.to_owned(),
repo: repo.to_owned(),
started: applog::now_utc(),
ended: None,
exit_code: None,
reason: None,
scripts: Vec::new(),
secrets: Vec::new(),
};
let journal = Self {
dir,
meta,
events: Some(events),
transcript: Some(transcript),
};
journal.write_meta();
Ok(journal)
}
#[must_use]
pub fn run_id(&self) -> &str {
&self.meta.run_id
}
#[must_use]
pub fn scripts_dir(&self) -> PathBuf {
self.dir.join("scripts")
}
pub fn event_line(&mut self, line: &str) {
if let Some(file) = &mut self.events {
let _ = writeln!(file, "{line}");
}
}
pub fn transcript(&mut self, bytes: &[u8]) {
if let Some(file) = &mut self.transcript {
let _ = file.write_all(bytes);
}
}
pub fn record_script(&mut self, path: String, sha256: String) {
self.meta.scripts.push(ScriptRecord { path, sha256 });
self.write_meta();
}
pub fn record_secret(&mut self, secret: &str, present: bool, source: &'static str) {
self.meta.secrets.push(SecretHandling {
secret: secret.to_owned(),
present,
source,
transport: "stdin",
redacted: true,
});
self.write_meta();
}
pub fn finish(&mut self, exit_code: i32, reason: Option<&str>) {
self.meta.ended = Some(applog::now_utc());
self.meta.exit_code = Some(exit_code);
self.meta.reason = reason.map(str::to_owned);
self.write_meta();
self.events = None;
self.transcript = None;
if exit_code == 0 {
let _ = fs::remove_dir_all(self.scripts_dir());
}
}
fn write_meta(&self) {
if let Ok(text) = serde_json::to_string_pretty(&self.meta) {
let _ = atomic::write(&self.dir.join("meta.json"), text.as_bytes());
}
restrict_file(&self.dir.join("meta.json"));
}
}
#[must_use]
pub fn runs_root() -> Option<PathBuf> {
applog::state_root().map(|root| root.join("runs"))
}
#[must_use]
pub fn list_run_ids() -> Vec<String> {
let Some(root) = runs_root() else {
return Vec::new();
};
let Ok(entries) = fs::read_dir(root) else {
return Vec::new();
};
let mut ids: Vec<String> = entries
.filter_map(Result::ok)
.filter(|entry| entry.path().is_dir())
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
ids.sort();
ids
}
#[must_use]
pub fn prune_to(keep: usize) -> usize {
let Some(root) = runs_root() else { return 0 };
let ids = list_run_ids();
let excess = ids.len().saturating_sub(keep);
let mut removed = 0;
for id in ids.into_iter().take(excess) {
let dir = root.join(&id);
if !prunable(&dir) {
continue;
}
if fs::remove_dir_all(&dir).is_ok() {
removed += 1;
}
}
removed
}
const UNFINISHED_GRACE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
fn prunable(dir: &std::path::Path) -> bool {
let meta = fs::read(dir.join("meta.json"))
.ok()
.and_then(|bytes| serde_json::from_slice::<serde_json::Value>(&bytes).ok());
if meta
.as_ref()
.is_some_and(|meta| !meta["exit_code"].is_null())
{
return true;
}
if let Some(pid) = meta.as_ref().and_then(|meta| meta["pid"].as_u64()) {
if std::path::Path::new("/proc/self").is_dir() {
match std::path::Path::new(&format!("/proc/{pid}")).try_exists() {
Ok(true) => return false,
Ok(false) => return true,
Err(_) => {}
}
}
}
fs::metadata(dir)
.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| modified.elapsed().ok())
.is_some_and(|age| age > UNFINISHED_GRACE)
}
fn new_run_id() -> String {
let stamp = applog::now_utc().replace(':', "-");
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.subsec_nanos());
format!(
"{stamp}-{:08x}",
u64::from(nanos) ^ (u64::from(std::process::id()) << 20)
)
}
fn restrict_dir(dir: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let _ = fs::set_permissions(dir, fs::Permissions::from_mode(0o700));
}
#[cfg(not(unix))]
let _ = dir;
}
fn restrict_file(path: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
let _ = path;
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::{META_SCHEMA, Meta, ScriptRecord, SecretHandling};
#[test]
fn the_meta_schema_snapshot_holds() {
let meta = Meta {
schema: META_SCHEMA,
run_id: "2026-08-29T14-02-11Z-0000abcd".into(),
rk_version: "0.1.0",
command: "setup".into(),
argv: vec!["setup".into(), "--target".into(), ".".into()],
pid: 4242,
target: ".".into(),
forge: "github".into(),
repo: "acme/widget".into(),
started: "2026-08-29T14:02:11Z".into(),
ended: Some("2026-08-29T14:02:12Z".into()),
exit_code: Some(0),
reason: None,
scripts: vec![ScriptRecord {
path: "scripts/github/default-branch".into(),
sha256: "ab".into(),
}],
secrets: vec![SecretHandling {
secret: "RK_BOT_PRIVATE_KEY_FILE".into(),
present: true,
source: "file",
transport: "stdin",
redacted: true,
}],
};
assert_eq!(
serde_json::to_string(&meta).expect("meta serializes"),
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}]}"#
);
}
}