1use serde::{Deserialize, Serialize};
11use std::{
12 env, fs,
13 io::Write,
14 os::unix::fs::{DirBuilderExt, OpenOptionsExt},
15 path::{Path, PathBuf},
16 time::{SystemTime, UNIX_EPOCH},
17};
18
19#[derive(Clone, Debug, Serialize, Deserialize)]
20pub struct Record {
21 pub unix_timestamp: u64,
22 pub entries: Vec<String>,
24 pub names: Vec<String>,
25 pub success: bool,
26 pub result: String,
28 #[serde(default)]
29 pub log: Option<PathBuf>,
30}
31
32pub fn state_dir() -> Option<PathBuf> {
34 if let Some(directory) = env::var_os("XDG_STATE_HOME") {
35 let path = PathBuf::from(directory);
36 if path.is_absolute() {
37 return Some(path.join("helixforge"));
38 }
39 }
40 env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/state/helixforge"))
41}
42
43fn default_dir() -> std::io::Result<PathBuf> {
44 state_dir().ok_or_else(|| std::io::Error::other("no HOME or XDG_STATE_HOME"))
45}
46
47fn ensure_private(path: &Path) -> std::io::Result<()> {
48 fs::DirBuilder::new()
49 .recursive(true)
50 .mode(0o700)
51 .create(path)
52}
53
54pub fn now() -> u64 {
55 SystemTime::now()
56 .duration_since(UNIX_EPOCH)
57 .unwrap_or_default()
58 .as_secs()
59}
60
61pub const UNFINISHED: &str = "did not finish: HelixForge stopped during the run";
63
64#[derive(Debug)]
66pub struct Pending {
67 directory: PathBuf,
68 path: PathBuf,
69 marker: Marker,
70}
71
72#[derive(Debug, Serialize, Deserialize)]
73struct Marker {
74 record: Record,
77 pid: u32,
78 process_start: Option<u64>,
81 #[serde(default)]
83 outcome: Option<(bool, String)>,
84}
85
86fn process_start(pid: u32) -> Option<u64> {
88 let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
89 let (_, rest) = stat.rsplit_once(')')?;
91 rest.split_whitespace().nth(19)?.parse().ok()
92}
93
94fn alive(marker: &Marker) -> bool {
95 match (marker.process_start, process_start(marker.pid)) {
96 (Some(expected), Some(actual)) => expected == actual,
97 (None, actual) => actual.is_some(),
98 (Some(_), None) => false,
99 }
100}
101
102fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
103 let mut file = fs::OpenOptions::new()
104 .write(true)
105 .create_new(true)
106 .mode(0o600)
107 .open(path)?;
108 file.write_all(bytes)
109}
110
111pub fn begin(entries: &[(String, String)]) -> std::io::Result<Pending> {
113 begin_in(&default_dir()?, entries)
114}
115
116pub fn begin_in(directory: &Path, entries: &[(String, String)]) -> std::io::Result<Pending> {
118 let running = directory.join("running");
119 ensure_private(&running)?;
120 reconcile_in(directory);
121 let pid = std::process::id();
122 let marker = Marker {
123 record: Record {
124 unix_timestamp: now(),
125 entries: entries.iter().map(|(id, _)| id.clone()).collect(),
126 names: entries.iter().map(|(_, name)| name.clone()).collect(),
127 success: false,
128 result: UNFINISHED.to_string(),
129 log: None,
130 },
131 pid,
132 process_start: process_start(pid),
133 outcome: None,
134 };
135 let stamp = SystemTime::now()
136 .duration_since(UNIX_EPOCH)
137 .unwrap_or_default()
138 .as_nanos();
139 let path = running.join(format!("{stamp}-{pid}.json"));
140 write_private(&path, &serde_json::to_vec(&marker)?)?;
141 Ok(Pending {
142 directory: directory.to_path_buf(),
143 path,
144 marker,
145 })
146}
147
148impl Pending {
149 pub fn ended(&mut self, success: bool, result: &str) -> std::io::Result<()> {
152 self.marker.outcome = Some((success, result.to_string()));
153 let staging = self.path.with_extension("new");
154 let _ = fs::remove_file(&staging);
155 write_private(&staging, &serde_json::to_vec(&self.marker)?)?;
156 fs::rename(&staging, &self.path)
157 }
158
159 pub fn finish(
161 mut self,
162 success: bool,
163 result: &str,
164 log: Option<PathBuf>,
165 ) -> std::io::Result<Record> {
166 let _ = self.ended(success, result);
168 let entries: Vec<(String, String)> = self
169 .marker
170 .record
171 .entries
172 .iter()
173 .cloned()
174 .zip(self.marker.record.names.iter().cloned())
175 .collect();
176 let record = record_in(&self.directory, &entries, success, result, log)?;
177 let _ = fs::remove_file(&self.path);
178 Ok(record)
179 }
180}
181
182pub fn active() -> Vec<String> {
185 default_dir()
186 .map(|directory| active_in(&directory))
187 .unwrap_or_default()
188}
189
190pub fn active_in(directory: &Path) -> Vec<String> {
192 let Ok(listing) = fs::read_dir(directory.join("running")) else {
193 return Vec::new();
194 };
195 listing
196 .flatten()
197 .filter(|item| {
198 item.path()
199 .extension()
200 .is_some_and(|extension| extension == "json")
201 })
202 .filter_map(|item| {
203 let bytes = fs::read(item.path()).ok()?;
204 serde_json::from_slice::<Marker>(&bytes).ok()
205 })
206 .filter(|marker| {
207 marker.pid != std::process::id() && marker.outcome.is_none() && alive(marker)
208 })
209 .map(|marker| marker.record.names.join(", "))
210 .collect()
211}
212
213pub fn reconcile_in(directory: &Path) -> usize {
217 let Ok(listing) = fs::read_dir(directory.join("running")) else {
218 return 0;
219 };
220 let mut recorded = 0;
221 for item in listing.flatten() {
222 let path = item.path();
223 if path.extension().is_none_or(|extension| extension != "json") {
224 continue;
225 }
226 let Ok(marker) = fs::read(&path)
227 .map_err(|_| ())
228 .and_then(|bytes| serde_json::from_slice::<Marker>(&bytes).map_err(|_| ()))
229 else {
230 continue;
231 };
232 if alive(&marker) {
233 continue;
234 }
235 let claimed = path.with_extension(format!("claimed-{}", std::process::id()));
236 if fs::rename(&path, &claimed).is_err() {
237 continue;
238 }
239 let mut record = marker.record;
240 if let Some((success, result)) = marker.outcome {
241 record.success = success;
242 record.result = result;
243 }
244 if append(directory, &record).is_ok() {
245 let _ = fs::remove_file(&claimed);
246 recorded += 1;
247 } else {
248 let _ = fs::rename(&claimed, &path);
249 }
250 }
251 recorded
252}
253
254fn append(directory: &Path, record: &Record) -> std::io::Result<()> {
255 ensure_private(directory)?;
256 let mut file = fs::OpenOptions::new()
257 .append(true)
258 .create(true)
259 .mode(0o600)
260 .open(directory.join("history.jsonl"))?;
261 let mut line = serde_json::to_vec(record)?;
262 line.push(b'\n');
263 file.write_all(&line)
265}
266
267pub fn record(
269 entries: &[(String, String)],
270 success: bool,
271 result: &str,
272 log: Option<PathBuf>,
273) -> std::io::Result<Record> {
274 record_in(&default_dir()?, entries, success, result, log)
275}
276
277pub fn record_in(
279 directory: &Path,
280 entries: &[(String, String)],
281 success: bool,
282 result: &str,
283 log: Option<PathBuf>,
284) -> std::io::Result<Record> {
285 let record = Record {
286 unix_timestamp: now(),
287 entries: entries.iter().map(|(id, _)| id.clone()).collect(),
288 names: entries.iter().map(|(_, name)| name.clone()).collect(),
289 success,
290 result: result.to_string(),
291 log,
292 };
293 append(directory, &record)?;
294 Ok(record)
295}
296
297pub fn read(limit: usize) -> std::io::Result<Vec<Record>> {
299 match state_dir() {
300 Some(directory) => read_in(&directory, limit),
301 None => Ok(Vec::new()),
302 }
303}
304
305pub fn read_in(directory: &Path, limit: usize) -> std::io::Result<Vec<Record>> {
307 reconcile_in(directory);
308 let path = directory.join("history.jsonl");
309 if !path.exists() {
310 return Ok(Vec::new());
311 }
312 let text = fs::read_to_string(path)?;
313 let mut records: Vec<Record> = text
314 .lines()
315 .filter_map(|line| serde_json::from_str(line).ok())
316 .collect();
317 records.sort_by_key(|record| record.unix_timestamp);
319 records.reverse();
320 records.truncate(limit);
321 Ok(records)
322}
323
324pub fn save_log(output: &[u8]) -> std::io::Result<PathBuf> {
326 save_log_in(&default_dir()?, output)
327}
328
329pub fn save_log_in(directory: &Path, output: &[u8]) -> std::io::Result<PathBuf> {
331 let logs = directory.join("logs");
332 ensure_private(&logs)?;
333 let path = logs.join(format!("{}-{}.log", now(), std::process::id()));
334 let mut file = fs::OpenOptions::new()
335 .write(true)
336 .create_new(true)
337 .mode(0o600)
338 .open(&path)?;
339 file.write_all(output)?;
340 Ok(path)
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use std::os::unix::fs::PermissionsExt;
347
348 #[test]
349 fn records_round_trip_newest_first() {
350 let directory = tempfile::tempdir().unwrap();
351 record_in(
352 directory.path(),
353 &[("one".into(), "One".into())],
354 true,
355 "exit 0",
356 None,
357 )
358 .unwrap();
359 let log = save_log_in(directory.path(), b"output").unwrap();
360 record_in(
361 directory.path(),
362 &[("two".into(), "Two".into())],
363 false,
364 "exit 1",
365 Some(log.clone()),
366 )
367 .unwrap();
368 let records = read_in(directory.path(), 10).unwrap();
369 assert_eq!(records.len(), 2);
370 assert_eq!(records[0].entries, ["two"]);
371 assert_eq!(records[0].log.as_deref(), Some(log.as_path()));
372 assert!(!records[0].success);
373 assert_eq!(fs::read(&log).unwrap(), b"output");
374 assert_eq!(
375 fs::metadata(&log).unwrap().permissions().mode() & 0o777,
376 0o600
377 );
378 assert_eq!(
379 fs::metadata(directory.path().join("logs"))
380 .unwrap()
381 .permissions()
382 .mode()
383 & 0o777,
384 0o700
385 );
386 assert!(
387 read_in(&directory.path().join("nothing"), 5)
388 .unwrap()
389 .is_empty()
390 );
391 }
392
393 fn entries() -> Vec<(String, String)> {
394 vec![("one".into(), "One".into())]
395 }
396
397 fn markers(directory: &Path) -> usize {
398 fs::read_dir(directory.join("running"))
399 .map(|listing| listing.count())
400 .unwrap_or(0)
401 }
402
403 fn orphan(pending: &Pending) {
406 let mut child = std::process::Command::new("true").spawn().unwrap();
407 let pid = child.id();
408 child.wait().unwrap();
409 let mut marker: Marker = serde_json::from_slice(&fs::read(&pending.path).unwrap()).unwrap();
410 marker.pid = pid;
411 marker.process_start = Some(1);
412 fs::write(&pending.path, serde_json::to_vec(&marker).unwrap()).unwrap();
413 }
414
415 #[test]
416 fn a_finished_run_replaces_its_marker_with_the_record() {
417 let directory = tempfile::tempdir().unwrap();
418 let pending = begin_in(directory.path(), &entries()).unwrap();
419 assert_eq!(markers(directory.path()), 1);
420 assert_eq!(
421 fs::metadata(&pending.path).unwrap().permissions().mode() & 0o777,
422 0o600
423 );
424 pending.finish(true, "exit 0", None).unwrap();
425 assert_eq!(markers(directory.path()), 0);
426 let records = read_in(directory.path(), 5).unwrap();
427 assert_eq!(records.len(), 1);
428 assert_eq!(records[0].result, "exit 0");
429 }
430
431 #[test]
432 fn other_runs_still_going_are_found() {
433 let directory = tempfile::tempdir().unwrap();
434 let pending = begin_in(directory.path(), &entries()).unwrap();
435 assert!(active_in(directory.path()).is_empty());
437 let mut child = std::process::Command::new("sleep")
439 .arg("30")
440 .spawn()
441 .unwrap();
442 let mut marker: Marker = serde_json::from_slice(&fs::read(&pending.path).unwrap()).unwrap();
443 marker.pid = child.id();
444 marker.process_start = process_start(child.id());
445 fs::write(&pending.path, serde_json::to_vec(&marker).unwrap()).unwrap();
446 assert_eq!(active_in(directory.path()).len(), 1);
447 marker.outcome = Some((true, "exit 0".into()));
449 fs::write(&pending.path, serde_json::to_vec(&marker).unwrap()).unwrap();
450 assert!(active_in(directory.path()).is_empty());
451 child.kill().unwrap();
452 child.wait().unwrap();
453 orphan(&pending);
455 let mut marker: Marker = serde_json::from_slice(&fs::read(&pending.path).unwrap()).unwrap();
456 marker.outcome = None;
457 fs::write(&pending.path, serde_json::to_vec(&marker).unwrap()).unwrap();
458 assert!(active_in(directory.path()).is_empty());
459 }
460
461 #[test]
462 fn a_run_in_progress_is_not_recorded_while_its_process_lives() {
463 let directory = tempfile::tempdir().unwrap();
464 let _pending = begin_in(directory.path(), &entries()).unwrap();
465 assert!(read_in(directory.path(), 5).unwrap().is_empty());
466 assert_eq!(markers(directory.path()), 1);
467 }
468
469 #[test]
470 fn a_run_whose_helixforge_died_is_recorded_once_as_unfinished() {
471 let directory = tempfile::tempdir().unwrap();
472 record_in(directory.path(), &entries(), true, "exit 0", None).unwrap();
473 let pending = begin_in(directory.path(), &[("two".into(), "Two".into())]).unwrap();
474 let started = pending.marker.record.unix_timestamp;
475 orphan(&pending);
476 let records = read_in(directory.path(), 5).unwrap();
477 assert_eq!(records.len(), 2);
478 let unfinished = records
479 .iter()
480 .find(|record| record.entries == ["two"])
481 .unwrap();
482 assert_eq!(unfinished.result, UNFINISHED);
483 assert!(!unfinished.success);
484 assert_eq!(unfinished.unix_timestamp, started);
485 assert_eq!(markers(directory.path()), 0);
486 assert_eq!(read_in(directory.path(), 5).unwrap().len(), 2);
487 }
488
489 #[test]
490 fn an_outcome_kept_before_helixforge_died_is_what_gets_recorded() {
491 let directory = tempfile::tempdir().unwrap();
492 let mut pending = begin_in(directory.path(), &entries()).unwrap();
493 pending.ended(true, "exit 0").unwrap();
494 orphan(&pending);
495 assert_eq!(reconcile_in(directory.path()), 1);
496 let records = read_in(directory.path(), 5).unwrap();
497 assert_eq!(records[0].result, "exit 0");
498 assert!(records[0].success);
499 }
500
501 #[test]
502 fn a_marker_claimed_by_another_process_is_not_recorded_twice() {
503 let directory = tempfile::tempdir().unwrap();
504 let pending = begin_in(directory.path(), &entries()).unwrap();
505 orphan(&pending);
506 fs::rename(&pending.path, pending.path.with_extension("claimed-1")).unwrap();
508 assert_eq!(reconcile_in(directory.path()), 0);
509 assert!(read_in(directory.path(), 5).unwrap().is_empty());
510 }
511}