nxd_core/execution/
journal.rs1use 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, pub plan_digest: Option<String>,
15 pub action_status: BTreeMap<String, String>, 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 #[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}