Skip to main content

machi_workflow/
store.rs

1//! Workflow run metadata store (journal path + status — not UI).
2
3use std::collections::BTreeMap;
4use std::fs::{self, File};
5use std::io::{BufRead, BufReader, Write};
6use std::path::{Path, PathBuf};
7use std::sync::Mutex;
8
9use serde::{Deserialize, Serialize};
10
11use crate::run::{PauseKind, WorkflowOutcome};
12
13/// Coarse status for listing / resume UX.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16#[non_exhaustive]
17pub enum WorkflowRunStatus {
18    /// Still running (host-owned; optional).
19    Running,
20    /// Completed successfully.
21    Completed,
22    /// Paused (resumable).
23    Paused,
24    /// Agent budget exceeded (resumable with higher budget).
25    BudgetExceeded,
26    /// Cancelled.
27    Cancelled,
28    /// Hard failure.
29    Failed,
30}
31
32impl WorkflowRunStatus {
33    /// Derive status from a terminal outcome.
34    #[must_use]
35    pub const fn from_outcome(outcome: &WorkflowOutcome) -> Self {
36        match outcome {
37            WorkflowOutcome::Completed { .. } => Self::Completed,
38            WorkflowOutcome::Paused { .. } => Self::Paused,
39            WorkflowOutcome::BudgetExceeded { .. } => Self::BudgetExceeded,
40            WorkflowOutcome::Cancelled => Self::Cancelled,
41            WorkflowOutcome::Failed { .. } => Self::Failed,
42        }
43    }
44}
45
46/// Durable metadata for one workflow run.
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct WorkflowRunRecord {
49    /// Stable run id (host-assigned, often [`machi_types::WorkflowRunId`]).
50    pub run_id: String,
51    /// Workflow meta name when known.
52    pub name: String,
53    /// Optional description.
54    #[serde(default)]
55    pub description: String,
56    /// Current status.
57    pub status: WorkflowRunStatus,
58    /// Absolute or relative path to the journal jsonl.
59    pub journal_path: PathBuf,
60    /// Optional script fingerprint (hash or path).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub script_ref: Option<String>,
63    /// Unix ms created.
64    pub created_at_ms: u64,
65    /// Unix ms last updated.
66    pub updated_at_ms: u64,
67    /// Last pause kind when paused.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub pause_kind: Option<PauseKind>,
70    /// Last error or pause message.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub message: Option<String>,
73    /// Completed workflow result payload (for host `resume_from` replay).
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub result: Option<serde_json::Value>,
76}
77
78impl WorkflowRunRecord {
79    /// Start a running record.
80    #[must_use]
81    pub fn new_running(
82        run_id: impl Into<String>,
83        name: impl Into<String>,
84        journal_path: PathBuf,
85    ) -> Self {
86        let now = unix_now_ms();
87        Self {
88            run_id: run_id.into(),
89            name: name.into(),
90            description: String::new(),
91            status: WorkflowRunStatus::Running,
92            journal_path,
93            script_ref: None,
94            created_at_ms: now,
95            updated_at_ms: now,
96            pause_kind: None,
97            message: None,
98            result: None,
99        }
100    }
101
102    /// Apply a terminal outcome.
103    pub fn apply_outcome(&mut self, outcome: &WorkflowOutcome) {
104        self.status = WorkflowRunStatus::from_outcome(outcome);
105        self.updated_at_ms = unix_now_ms();
106        match outcome {
107            WorkflowOutcome::Paused { kind, message } => {
108                self.pause_kind = Some(*kind);
109                self.message = Some(message.clone());
110                self.result = None;
111            }
112            WorkflowOutcome::BudgetExceeded { message }
113            | WorkflowOutcome::Failed { error: message } => {
114                self.pause_kind = None;
115                self.message = Some(message.clone());
116                self.result = None;
117            }
118            WorkflowOutcome::Completed { result } => {
119                self.pause_kind = None;
120                self.message = None;
121                self.result = Some(result.clone());
122            }
123            WorkflowOutcome::Cancelled => {
124                self.pause_kind = None;
125                self.message = None;
126                self.result = None;
127            }
128        }
129    }
130}
131
132/// Host port for listing and resuming workflow runs.
133pub trait WorkflowRunStore: Send + Sync {
134    /// Insert or replace a record.
135    ///
136    /// # Errors
137    ///
138    /// Backend I/O failures.
139    fn put(&self, record: WorkflowRunRecord) -> Result<(), StoreError>;
140
141    /// Fetch by run id.
142    ///
143    /// # Errors
144    ///
145    /// Backend I/O failures.
146    fn get(&self, run_id: &str) -> Result<Option<WorkflowRunRecord>, StoreError>;
147
148    /// List all runs (newest-updated first when possible).
149    ///
150    /// # Errors
151    ///
152    /// Backend I/O failures.
153    fn list(&self) -> Result<Vec<WorkflowRunRecord>, StoreError>;
154
155    /// Remove a run metadata entry (does not delete the journal file).
156    ///
157    /// # Errors
158    ///
159    /// Backend I/O failures.
160    fn delete(&self, run_id: &str) -> Result<bool, StoreError>;
161}
162
163/// Store failures.
164#[derive(Debug, thiserror::Error)]
165pub enum StoreError {
166    /// I/O.
167    #[error("workflow store io: {0}")]
168    Io(#[from] std::io::Error),
169    /// Parse.
170    #[error("workflow store parse: {0}")]
171    Parse(String),
172}
173
174/// In-memory store for tests.
175#[derive(Debug, Default)]
176pub struct MemoryWorkflowRunStore {
177    map: Mutex<BTreeMap<String, WorkflowRunRecord>>,
178}
179
180impl MemoryWorkflowRunStore {
181    /// Empty store.
182    #[must_use]
183    pub fn new() -> Self {
184        Self::default()
185    }
186}
187
188impl WorkflowRunStore for MemoryWorkflowRunStore {
189    fn put(&self, record: WorkflowRunRecord) -> Result<(), StoreError> {
190        self.map
191            .lock()
192            .unwrap_or_else(std::sync::PoisonError::into_inner)
193            .insert(record.run_id.clone(), record);
194        Ok(())
195    }
196
197    fn get(&self, run_id: &str) -> Result<Option<WorkflowRunRecord>, StoreError> {
198        Ok(self
199            .map
200            .lock()
201            .unwrap_or_else(std::sync::PoisonError::into_inner)
202            .get(run_id)
203            .cloned())
204    }
205
206    fn list(&self) -> Result<Vec<WorkflowRunRecord>, StoreError> {
207        let mut rows: Vec<_> = self
208            .map
209            .lock()
210            .unwrap_or_else(std::sync::PoisonError::into_inner)
211            .values()
212            .cloned()
213            .collect();
214        rows.sort_by_key(|r| std::cmp::Reverse(r.updated_at_ms));
215        Ok(rows)
216    }
217
218    fn delete(&self, run_id: &str) -> Result<bool, StoreError> {
219        Ok(self
220            .map
221            .lock()
222            .unwrap_or_else(std::sync::PoisonError::into_inner)
223            .remove(run_id)
224            .is_some())
225    }
226}
227
228/// Directory of `*.json` records + optional shared journal root.
229///
230/// Layout:
231/// ```text
232/// {root}/
233///   index.jsonl          # optional append-only index (rewritten on put for simplicity: one file per run)
234///   runs/{run_id}.json
235///   journals/{run_id}.jsonl   # conventional journal path helper
236/// ```
237#[derive(Debug, Clone)]
238pub struct FileWorkflowRunStore {
239    root: PathBuf,
240}
241
242impl FileWorkflowRunStore {
243    /// Root directory (created on demand).
244    #[must_use]
245    pub fn new(root: impl Into<PathBuf>) -> Self {
246        Self { root: root.into() }
247    }
248
249    /// Root accessor.
250    #[must_use]
251    pub fn root(&self) -> &Path {
252        &self.root
253    }
254
255    /// Conventional journal path for a run id.
256    #[must_use]
257    pub fn journal_path_for(&self, run_id: &str) -> PathBuf {
258        self.root.join("journals").join(format!("{run_id}.jsonl"))
259    }
260
261    fn runs_dir(&self) -> PathBuf {
262        self.root.join("runs")
263    }
264
265    fn record_path(&self, run_id: &str) -> PathBuf {
266        self.runs_dir().join(format!("{run_id}.json"))
267    }
268}
269
270impl WorkflowRunStore for FileWorkflowRunStore {
271    fn put(&self, record: WorkflowRunRecord) -> Result<(), StoreError> {
272        let dir = self.runs_dir();
273        fs::create_dir_all(&dir)?;
274        let path = self.record_path(&record.run_id);
275        let tmp = path.with_extension("json.tmp");
276        let body =
277            serde_json::to_vec_pretty(&record).map_err(|e| StoreError::Parse(e.to_string()))?;
278        {
279            let mut f = File::create(&tmp)?;
280            f.write_all(&body)?;
281            f.sync_data()?;
282        }
283        fs::rename(&tmp, &path)?;
284        Ok(())
285    }
286
287    fn get(&self, run_id: &str) -> Result<Option<WorkflowRunRecord>, StoreError> {
288        let path = self.record_path(run_id);
289        if !path.is_file() {
290            return Ok(None);
291        }
292        let bytes = fs::read(&path)?;
293        let rec = serde_json::from_slice(&bytes).map_err(|e| StoreError::Parse(e.to_string()))?;
294        Ok(Some(rec))
295    }
296
297    fn list(&self) -> Result<Vec<WorkflowRunRecord>, StoreError> {
298        let dir = self.runs_dir();
299        if !dir.is_dir() {
300            return Ok(Vec::new());
301        }
302        let mut rows = Vec::new();
303        for entry in fs::read_dir(dir)? {
304            let entry = entry?;
305            let path = entry.path();
306            if path.extension().and_then(|e| e.to_str()) != Some("json") {
307                continue;
308            }
309            let bytes = fs::read(&path)?;
310            let rec: WorkflowRunRecord =
311                serde_json::from_slice(&bytes).map_err(|e| StoreError::Parse(e.to_string()))?;
312            rows.push(rec);
313        }
314        rows.sort_by_key(|r| std::cmp::Reverse(r.updated_at_ms));
315        Ok(rows)
316    }
317
318    fn delete(&self, run_id: &str) -> Result<bool, StoreError> {
319        let path = self.record_path(run_id);
320        if !path.is_file() {
321            return Ok(false);
322        }
323        fs::remove_file(path)?;
324        Ok(true)
325    }
326}
327
328/// Read first line of a jsonl index if present (diagnostic helper).
329///
330/// # Errors
331///
332/// Returns I/O errors when the path exists but cannot be read.
333pub fn peek_jsonl_line(path: &Path) -> Result<Option<String>, StoreError> {
334    if !path.is_file() {
335        return Ok(None);
336    }
337    let f = File::open(path)?;
338    let mut lines = BufReader::new(f).lines();
339    match lines.next() {
340        Some(Ok(line)) => Ok(Some(line)),
341        Some(Err(e)) => Err(e.into()),
342        None => Ok(None),
343    }
344}
345
346fn unix_now_ms() -> u64 {
347    std::time::SystemTime::now()
348        .duration_since(std::time::UNIX_EPOCH)
349        .map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
350}
351
352#[cfg(test)]
353mod tests {
354    use tempfile::tempdir;
355
356    use super::*;
357
358    #[test]
359    fn memory_put_list_delete() {
360        let store = MemoryWorkflowRunStore::new();
361        let mut rec = WorkflowRunRecord::new_running("run_1", "demo", PathBuf::from("j.jsonl"));
362        store.put(rec.clone()).expect("put");
363        rec.apply_outcome(&WorkflowOutcome::Completed {
364            result: serde_json::json!({"ok": true}),
365        });
366        store.put(rec).expect("put2");
367        let listed = store.list().expect("list");
368        assert_eq!(listed.len(), 1);
369        assert_eq!(
370            listed.first().map(|r| r.status),
371            Some(WorkflowRunStatus::Completed)
372        );
373        assert!(store.delete("run_1").expect("del"));
374        assert!(store.get("run_1").expect("get").is_none());
375    }
376
377    #[test]
378    fn file_store_round_trip() {
379        let dir = tempdir().expect("tmp");
380        let store = FileWorkflowRunStore::new(dir.path());
381        let journal = store.journal_path_for("wf_abc");
382        let mut rec = WorkflowRunRecord::new_running("wf_abc", "fanout", journal);
383        rec.description = "test".into();
384        store.put(rec.clone()).expect("put");
385        let got = store.get("wf_abc").expect("get").expect("some");
386        assert_eq!(got.name, "fanout");
387        assert_eq!(store.list().expect("list").len(), 1);
388        rec.apply_outcome(&WorkflowOutcome::Failed {
389            error: "boom".into(),
390        });
391        store.put(rec).expect("put fail");
392        let got = store.get("wf_abc").expect("g2").expect("s");
393        assert_eq!(got.status, WorkflowRunStatus::Failed);
394        assert_eq!(got.message.as_deref(), Some("boom"));
395    }
396}