oximedia_workflow/
checkpoint.rs1use crate::error::{Result, WorkflowError};
36use serde::{Deserialize, Serialize};
37use std::collections::HashMap;
38use std::path::PathBuf;
39use std::time::{SystemTime, UNIX_EPOCH};
40
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct WorkflowCheckpoint {
48 pub workflow_id: String,
50 pub completed_steps: Vec<String>,
52 pub step_outputs: HashMap<String, serde_json::Value>,
54 pub created_at: u64,
56 pub workflow_version: u32,
58}
59
60impl WorkflowCheckpoint {
61 #[must_use]
63 pub fn new(workflow_id: impl Into<String>, workflow_version: u32) -> Self {
64 let created_at = SystemTime::now()
65 .duration_since(UNIX_EPOCH)
66 .map(|d| d.as_secs())
67 .unwrap_or(0);
68 Self {
69 workflow_id: workflow_id.into(),
70 completed_steps: Vec::new(),
71 step_outputs: HashMap::new(),
72 created_at,
73 workflow_version,
74 }
75 }
76
77 pub fn mark_step_completed(
79 &mut self,
80 step_name: impl Into<String>,
81 output: Option<serde_json::Value>,
82 ) {
83 let name = step_name.into();
84 if !self.completed_steps.contains(&name) {
85 self.completed_steps.push(name.clone());
86 }
87 if let Some(v) = output {
88 self.step_outputs.insert(name, v);
89 }
90 }
91
92 #[must_use]
94 pub fn is_step_completed(&self, step_name: &str) -> bool {
95 self.completed_steps.iter().any(|s| s == step_name)
96 }
97
98 #[must_use]
100 pub fn step_output(&self, step_name: &str) -> Option<&serde_json::Value> {
101 self.step_outputs.get(step_name)
102 }
103
104 #[must_use]
106 pub fn completed_count(&self) -> usize {
107 self.completed_steps.len()
108 }
109}
110
111#[derive(Debug, Clone)]
120pub struct CheckpointManager {
121 storage_dir: PathBuf,
122}
123
124impl CheckpointManager {
125 #[must_use]
130 pub fn new(storage_dir: PathBuf) -> Self {
131 Self { storage_dir }
132 }
133
134 #[must_use]
136 pub fn storage_dir(&self) -> &PathBuf {
137 &self.storage_dir
138 }
139
140 fn checkpoint_path(&self, workflow_id: &str) -> PathBuf {
146 self.storage_dir
147 .join(format!("{}.checkpoint.json", workflow_id))
148 }
149
150 fn ensure_dir(&self) -> Result<()> {
152 std::fs::create_dir_all(&self.storage_dir).map_err(WorkflowError::Io)
153 }
154
155 pub fn save(&self, checkpoint: &WorkflowCheckpoint) -> Result<()> {
168 self.ensure_dir()?;
169 let path = self.checkpoint_path(&checkpoint.workflow_id);
170 let json = serde_json::to_vec_pretty(checkpoint)?;
171 std::fs::write(&path, &json).map_err(WorkflowError::Io)
172 }
173
174 pub fn load(&self, workflow_id: &str) -> Result<WorkflowCheckpoint> {
182 let path = self.checkpoint_path(workflow_id);
183 if !path.exists() {
184 return Err(WorkflowError::FileNotFound(path));
185 }
186 let bytes = std::fs::read(&path).map_err(WorkflowError::Io)?;
187 let cp: WorkflowCheckpoint = serde_json::from_slice(&bytes)?;
188 Ok(cp)
189 }
190
191 pub fn delete(&self, workflow_id: &str) -> Result<()> {
199 let path = self.checkpoint_path(workflow_id);
200 if path.exists() {
201 std::fs::remove_file(&path).map_err(WorkflowError::Io)?;
202 }
203 Ok(())
204 }
205
206 #[must_use]
208 pub fn exists(&self, workflow_id: &str) -> bool {
209 self.checkpoint_path(workflow_id).exists()
210 }
211
212 pub fn list_checkpoints(&self) -> Result<Vec<String>> {
222 if !self.storage_dir.exists() {
223 return Ok(Vec::new());
224 }
225
226 let mut ids = Vec::new();
227 let entries = std::fs::read_dir(&self.storage_dir).map_err(WorkflowError::Io)?;
228
229 for entry in entries {
230 let entry = entry.map_err(WorkflowError::Io)?;
231 let file_name = entry.file_name();
232 let name = file_name.to_string_lossy();
233 if let Some(id) = name.strip_suffix(".checkpoint.json") {
234 if self.exists(id) {
236 ids.push(id.to_string());
237 }
238 }
239 }
240
241 ids.sort();
242 Ok(ids)
243 }
244}
245
246#[must_use]
268pub fn should_skip_step(checkpoint: &Option<WorkflowCheckpoint>, step_name: &str) -> bool {
269 checkpoint
270 .as_ref()
271 .map(|cp| cp.is_step_completed(step_name))
272 .unwrap_or(false)
273}
274
275#[cfg(test)]
280mod tests {
281 use super::*;
282
283 fn temp_dir(suffix: &str) -> PathBuf {
285 let base = std::env::temp_dir().join(format!("oximedia_cp_test_{}", suffix));
286 let _ = std::fs::remove_dir_all(&base);
288 base
289 }
290
291 #[test]
296 fn test_checkpoint_new_fields() {
297 let cp = WorkflowCheckpoint::new("wf-999", 2);
298 assert_eq!(cp.workflow_id, "wf-999");
299 assert_eq!(cp.workflow_version, 2);
300 assert!(cp.completed_steps.is_empty());
301 assert!(cp.step_outputs.is_empty());
302 assert!(cp.created_at > 0);
304 }
305
306 #[test]
307 fn test_mark_step_completed_no_output() {
308 let mut cp = WorkflowCheckpoint::new("wf-001", 1);
309 cp.mark_step_completed("ingest", None);
310 assert!(cp.is_step_completed("ingest"));
311 assert!(cp.step_output("ingest").is_none());
312 assert_eq!(cp.completed_count(), 1);
313 }
314
315 #[test]
316 fn test_mark_step_completed_with_output() {
317 let mut cp = WorkflowCheckpoint::new("wf-001", 1);
318 let val = serde_json::json!({"frames": 1000});
319 cp.mark_step_completed("analyse", Some(val.clone()));
320 assert!(cp.is_step_completed("analyse"));
321 assert_eq!(cp.step_output("analyse"), Some(&val));
322 }
323
324 #[test]
325 fn test_mark_step_completed_dedup() {
326 let mut cp = WorkflowCheckpoint::new("wf-001", 1);
327 cp.mark_step_completed("transcode", None);
328 cp.mark_step_completed("transcode", None);
329 assert_eq!(cp.completed_count(), 1);
330 }
331
332 #[test]
333 fn test_is_step_completed_false() {
334 let cp = WorkflowCheckpoint::new("wf-001", 1);
335 assert!(!cp.is_step_completed("missing_step"));
336 }
337
338 #[test]
343 fn test_save_and_load() {
344 let dir = temp_dir("save_load");
345 let mgr = CheckpointManager::new(dir.clone());
346
347 let mut cp = WorkflowCheckpoint::new("wf-save", 1);
348 cp.mark_step_completed("step-a", Some(serde_json::json!({"ok": true})));
349 mgr.save(&cp).expect("save should succeed");
350
351 let loaded = mgr.load("wf-save").expect("load should succeed");
352 assert_eq!(loaded.workflow_id, "wf-save");
353 assert_eq!(loaded.completed_steps, vec!["step-a"]);
354 assert_eq!(
355 loaded.step_output("step-a"),
356 Some(&serde_json::json!({"ok": true}))
357 );
358 }
359
360 #[test]
361 fn test_load_nonexistent_returns_error() {
362 let dir = temp_dir("load_missing");
363 let mgr = CheckpointManager::new(dir);
364 let result = mgr.load("no-such-workflow");
365 assert!(
366 result.is_err(),
367 "loading a missing checkpoint must return Err"
368 );
369 }
370
371 #[test]
372 fn test_save_overwrites_previous() {
373 let dir = temp_dir("overwrite");
374 let mgr = CheckpointManager::new(dir);
375
376 let mut cp1 = WorkflowCheckpoint::new("wf-ow", 1);
377 cp1.mark_step_completed("step-a", None);
378 mgr.save(&cp1).expect("first save");
379
380 let mut cp2 = WorkflowCheckpoint::new("wf-ow", 1);
381 cp2.mark_step_completed("step-a", None);
382 cp2.mark_step_completed("step-b", None);
383 mgr.save(&cp2).expect("second save");
384
385 let loaded = mgr.load("wf-ow").expect("load after overwrite");
386 assert_eq!(loaded.completed_count(), 2);
387 }
388
389 #[test]
394 fn test_exists_true_after_save() {
395 let dir = temp_dir("exists_true");
396 let mgr = CheckpointManager::new(dir);
397 let cp = WorkflowCheckpoint::new("wf-ex", 1);
398 mgr.save(&cp).expect("save");
399 assert!(mgr.exists("wf-ex"));
400 }
401
402 #[test]
403 fn test_exists_false_before_save() {
404 let dir = temp_dir("exists_false");
405 let mgr = CheckpointManager::new(dir);
406 assert!(!mgr.exists("wf-not-saved"));
407 }
408
409 #[test]
410 fn test_exists_false_after_delete() {
411 let dir = temp_dir("exists_after_delete");
412 let mgr = CheckpointManager::new(dir);
413 let cp = WorkflowCheckpoint::new("wf-del-ex", 1);
414 mgr.save(&cp).expect("save");
415 mgr.delete("wf-del-ex").expect("delete");
416 assert!(!mgr.exists("wf-del-ex"));
417 }
418
419 #[test]
424 fn test_delete_existing() {
425 let dir = temp_dir("delete_existing");
426 let mgr = CheckpointManager::new(dir);
427 let cp = WorkflowCheckpoint::new("wf-del", 1);
428 mgr.save(&cp).expect("save");
429 assert!(mgr.exists("wf-del"));
430 mgr.delete("wf-del").expect("delete should succeed");
431 assert!(!mgr.exists("wf-del"));
432 }
433
434 #[test]
435 fn test_delete_nonexistent_ok() {
436 let dir = temp_dir("delete_nonexistent");
437 let mgr = CheckpointManager::new(dir);
438 assert!(mgr.delete("ghost-workflow").is_ok());
440 }
441
442 #[test]
447 fn test_list_empty_when_no_dir() {
448 let dir = temp_dir("list_nodir");
449 let mgr = CheckpointManager::new(dir);
451 let ids = mgr.list_checkpoints().expect("list should succeed");
452 assert!(ids.is_empty());
453 }
454
455 #[test]
456 fn test_list_checkpoints_after_multiple_saves() {
457 let dir = temp_dir("list_multiple");
458 let mgr = CheckpointManager::new(dir);
459
460 for id in &["wf-alpha", "wf-beta", "wf-gamma"] {
461 let cp = WorkflowCheckpoint::new(*id, 1);
462 mgr.save(&cp).expect("save");
463 }
464
465 let mut ids = mgr.list_checkpoints().expect("list");
466 ids.sort();
467 assert_eq!(ids, vec!["wf-alpha", "wf-beta", "wf-gamma"]);
468 }
469
470 #[test]
471 fn test_list_excludes_deleted() {
472 let dir = temp_dir("list_after_delete");
473 let mgr = CheckpointManager::new(dir);
474
475 mgr.save(&WorkflowCheckpoint::new("wf-keep", 1))
476 .expect("save keep");
477 mgr.save(&WorkflowCheckpoint::new("wf-gone", 1))
478 .expect("save gone");
479 mgr.delete("wf-gone").expect("delete gone");
480
481 let ids = mgr.list_checkpoints().expect("list");
482 assert!(ids.contains(&"wf-keep".to_string()));
483 assert!(!ids.contains(&"wf-gone".to_string()));
484 }
485
486 #[test]
491 fn test_should_skip_step_with_completed_step() {
492 let mut cp = WorkflowCheckpoint::new("wf-skip", 1);
493 cp.mark_step_completed("encode", None);
494 assert!(should_skip_step(&Some(cp), "encode"));
495 }
496
497 #[test]
498 fn test_should_skip_step_with_missing_step() {
499 let cp = WorkflowCheckpoint::new("wf-skip", 1);
500 assert!(!should_skip_step(&Some(cp), "not-yet-done"));
501 }
502
503 #[test]
504 fn test_should_skip_step_with_no_checkpoint() {
505 assert!(!should_skip_step(&None, "any-step"));
506 }
507
508 #[test]
509 fn test_should_skip_step_multiple_steps() {
510 let mut cp = WorkflowCheckpoint::new("wf-multi", 1);
511 cp.mark_step_completed("ingest", None);
512 cp.mark_step_completed("transcode", None);
513 assert!(should_skip_step(&Some(cp.clone()), "ingest"));
514 assert!(should_skip_step(&Some(cp.clone()), "transcode"));
515 assert!(!should_skip_step(&Some(cp), "upload"));
516 }
517}