1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16#[non_exhaustive]
17pub enum WorkflowRunStatus {
18 Running,
20 Completed,
22 Paused,
24 BudgetExceeded,
26 Cancelled,
28 Failed,
30}
31
32impl WorkflowRunStatus {
33 #[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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
48pub struct WorkflowRunRecord {
49 pub run_id: String,
51 pub name: String,
53 #[serde(default)]
55 pub description: String,
56 pub status: WorkflowRunStatus,
58 pub journal_path: PathBuf,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub script_ref: Option<String>,
63 pub created_at_ms: u64,
65 pub updated_at_ms: u64,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub pause_kind: Option<PauseKind>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub message: Option<String>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub result: Option<serde_json::Value>,
76}
77
78impl WorkflowRunRecord {
79 #[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 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
132pub trait WorkflowRunStore: Send + Sync {
134 fn put(&self, record: WorkflowRunRecord) -> Result<(), StoreError>;
140
141 fn get(&self, run_id: &str) -> Result<Option<WorkflowRunRecord>, StoreError>;
147
148 fn list(&self) -> Result<Vec<WorkflowRunRecord>, StoreError>;
154
155 fn delete(&self, run_id: &str) -> Result<bool, StoreError>;
161}
162
163#[derive(Debug, thiserror::Error)]
165pub enum StoreError {
166 #[error("workflow store io: {0}")]
168 Io(#[from] std::io::Error),
169 #[error("workflow store parse: {0}")]
171 Parse(String),
172}
173
174#[derive(Debug, Default)]
176pub struct MemoryWorkflowRunStore {
177 map: Mutex<BTreeMap<String, WorkflowRunRecord>>,
178}
179
180impl MemoryWorkflowRunStore {
181 #[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#[derive(Debug, Clone)]
238pub struct FileWorkflowRunStore {
239 root: PathBuf,
240}
241
242impl FileWorkflowRunStore {
243 #[must_use]
245 pub fn new(root: impl Into<PathBuf>) -> Self {
246 Self { root: root.into() }
247 }
248
249 #[must_use]
251 pub fn root(&self) -> &Path {
252 &self.root
253 }
254
255 #[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
328pub 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}