Skip to main content

helixforge_engine/
history.rs

1//! An append-only record of runs in `$XDG_STATE_HOME/helixforge/history.jsonl`,
2//! with saved terminal output beside it under `logs/`.
3//!
4//! A run in progress also has a marker under `running/` naming the process
5//! that owns it. The finished record replaces the marker. When HelixForge dies
6//! during a run, the marker stays; the next time the history is read, a marker
7//! whose process is gone becomes a record that says the run did not finish.
8//! `history.jsonl` keeps one format throughout, so older versions read it.
9
10use 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    /// Entry ids, in the order they ran.
23    pub entries: Vec<String>,
24    pub names: Vec<String>,
25    pub success: bool,
26    /// The exit status, "interrupted", or why the run could not start.
27    pub result: String,
28    #[serde(default)]
29    pub log: Option<PathBuf>,
30}
31
32/// The state directory: `$XDG_STATE_HOME/helixforge` or `~/.local/state/helixforge`.
33pub 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
61/// The result recorded for a run whose HelixForge stopped before it ended.
62pub const UNFINISHED: &str = "did not finish: HelixForge stopped during the run";
63
64/// A run in progress, with its marker under `running/`.
65#[derive(Debug)]
66pub struct Pending {
67    directory: PathBuf,
68    path: PathBuf,
69    marker: Marker,
70}
71
72#[derive(Debug, Serialize, Deserialize)]
73struct Marker {
74    /// The run as it would be recorded if it never finished, stamped with
75    /// its start.
76    record: Record,
77    pid: u32,
78    /// The owner's start time from `/proc/<pid>/stat`, so a reused process id
79    /// is not mistaken for the owner.
80    process_start: Option<u64>,
81    /// How the run ended, once it has, if the record is not written yet.
82    #[serde(default)]
83    outcome: Option<(bool, String)>,
84}
85
86/// When a process started, in clock ticks after boot; `None` if it is gone.
87fn process_start(pid: u32) -> Option<u64> {
88    let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
89    // After the command name: state is field 3 and the start time field 22.
90    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
111/// Note that a run started, in the default history.
112pub fn begin(entries: &[(String, String)]) -> std::io::Result<Pending> {
113    begin_in(&default_dir()?, entries)
114}
115
116/// Note that a run started, in the history in `directory`.
117pub 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    /// Keep how the run ended in its marker, for when the record is written
150    /// later (the interface writes it when its window closes).
151    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    /// Write the finished record and remove the marker.
160    pub fn finish(
161        mut self,
162        success: bool,
163        result: &str,
164        log: Option<PathBuf>,
165    ) -> std::io::Result<Record> {
166        // If writing the record fails, the marker still knows the outcome.
167        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
182/// The runs of other HelixForge processes that are still going, in the
183/// default history: each as its entries' names.
184pub fn active() -> Vec<String> {
185    default_dir()
186        .map(|directory| active_in(&directory))
187        .unwrap_or_default()
188}
189
190/// `active` in the history in `directory`.
191pub 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
213/// Turn the markers of runs whose HelixForge has gone into records. Each
214/// marker is claimed by renaming it first, so two HelixForge processes never
215/// record the same run. Returns how many runs were recorded.
216pub 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    // One write per record, so concurrent appends do not interleave.
264    file.write_all(&line)
265}
266
267/// Append one record to the default history.
268pub 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
277/// Append one record to the history in `directory`.
278pub 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
297/// The most recent records first, from the default history.
298pub 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
305/// The most recent records first, from the history in `directory`.
306pub 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    // A run recorded late, after its HelixForge died, carries its start time.
318    records.sort_by_key(|record| record.unix_timestamp);
319    records.reverse();
320    records.truncate(limit);
321    Ok(records)
322}
323
324/// Save terminal output under `logs/` in the default state directory.
325pub fn save_log(output: &[u8]) -> std::io::Result<PathBuf> {
326    save_log_in(&default_dir()?, output)
327}
328
329/// Save terminal output under `logs/` in `directory` and return its path.
330pub 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    /// Make a marker look as if its HelixForge had died: point it at a
404    /// process that has exited.
405    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        // Its own run is not "another".
436        assert!(active_in(directory.path()).is_empty());
437        // Another process that is alive and has not ended its run.
438        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        // Once its program has ended, it no longer counts.
448        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        // Nor does a process that has gone.
454        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        // Another HelixForge got there first and renamed it.
507        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}