Skip to main content

nxd_core/execution/
journal.rs

1use serde::{Deserialize, Serialize};
2use std::collections::BTreeMap;
3use std::fs;
4use std::path::PathBuf;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
8#[serde(rename_all = "camelCase")]
9pub struct RunJournal {
10	pub run_id: String,
11	pub timestamp_unix: u64,
12	pub operation: String,
13	pub status: String, // "running", "succeeded", "failed", "cancelled"
14	pub plan_digest: Option<String>,
15	pub action_status: BTreeMap<String, String>, // action_id -> status ("pending", "started", "completed", "failed")
16	pub errors: Vec<String>,
17	#[serde(default, skip_serializing_if = "Option::is_none")]
18	pub completed_at_unix: Option<u64>,
19	#[serde(default, skip_serializing_if = "Option::is_none")]
20	pub profile: Option<serde_json::Value>,
21}
22
23pub fn runs_dir() -> PathBuf {
24	#[cfg(test)]
25	{
26		std::env::temp_dir().join("nxd-test-runs")
27	}
28	#[cfg(not(test))]
29	{
30		let state_home = std::env::var("XDG_STATE_HOME").map(PathBuf::from).unwrap_or_else(|_| {
31			let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
32			PathBuf::from(home).join(".local").join("state")
33		});
34		state_home.join(crate::config::APP_NAME).join("runs")
35	}
36}
37
38impl RunJournal {
39	pub fn new(run_id: String, operation: String, plan_digest: Option<String>) -> Self {
40		let timestamp_unix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
41		Self {
42			run_id,
43			timestamp_unix,
44			operation,
45			status: "running".to_string(),
46			plan_digest,
47			action_status: BTreeMap::new(),
48			errors: Vec::new(),
49			completed_at_unix: None,
50			profile: None,
51		}
52	}
53
54	pub fn complete(&mut self) {
55		self.completed_at_unix =
56			Some(SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs());
57		self.profile = crate::profiling::structured_snapshot();
58	}
59
60	pub fn write(&self) -> Result<(), std::io::Error> {
61		let dir = runs_dir();
62		fs::create_dir_all(&dir)?;
63		let path = dir.join(format!("{}.json", self.run_id));
64		let content = serde_json::to_string_pretty(self)?;
65
66		// Write run journals with mode 0600 on unix
67		#[cfg(unix)]
68		{
69			use std::os::unix::fs::OpenOptionsExt;
70			let mut file =
71				fs::OpenOptions::new().create(true).write(true).truncate(true).mode(0o600).open(&path)?;
72			use std::io::Write;
73			file.write_all(content.as_bytes())?;
74		}
75		#[cfg(not(unix))]
76		{
77			fs::write(&path, content)?;
78		}
79		Ok(())
80	}
81
82	pub fn read(run_id: &str) -> Result<Self, std::io::Error> {
83		let path = runs_dir().join(format!("{run_id}.json"));
84		let content = fs::read_to_string(path)?;
85		let journal = serde_json::from_str(&content)?;
86		Ok(journal)
87	}
88
89	pub fn is_cancelled(&self) -> bool {
90		runs_dir().join(format!("{}.cancel", self.run_id)).exists()
91	}
92}
93
94pub fn request_cancellation(run_id: &str) -> Result<(), std::io::Error> {
95	let dir = runs_dir();
96	fs::create_dir_all(&dir)?;
97	let path = dir.join(format!("{run_id}.cancel"));
98	fs::write(path, "")?;
99	Ok(())
100}
101
102pub fn cancellation_requested(run_id: &str) -> bool {
103	runs_dir().join(format!("{run_id}.cancel")).exists()
104}
105
106#[cfg(test)]
107mod tests {
108	use super::*;
109
110	#[test]
111	fn older_journal_remains_deserializable() {
112		let journal: RunJournal = serde_json::from_str(
113			r#"{"runId":"run-old","timestampUnix":1,"operation":"apply","status":"succeeded","planDigest":null,"actionStatus":{},"errors":[]}"#,
114		)
115		.unwrap();
116		assert_eq!(journal.completed_at_unix, None);
117		assert_eq!(journal.profile, None);
118	}
119}