Skip to main content

prodigy/cook/workflow/
checkpoint.rs

1//! Workflow checkpoint management for resume capability
2//!
3//! Provides checkpoint creation, persistence, and restoration for workflow execution.
4
5use crate::cook::workflow::checkpoint_errors::CheckpointError;
6use crate::cook::workflow::checkpoint_path::CheckpointStorage;
7use crate::cook::workflow::executor::WorkflowContext;
8use crate::cook::workflow::normalized::NormalizedWorkflow;
9use crate::cook::workflow::variable_checkpoint::VariableCheckpointState;
10use anyhow::{Context, Result};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::{HashMap, HashSet};
15use std::path::PathBuf;
16use std::time::Duration;
17use tokio::fs;
18use tracing::{debug, info, warn};
19
20/// Checkpoint interval default (60 seconds)
21const DEFAULT_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
22
23/// Version for checkpoint format compatibility
24pub const CHECKPOINT_VERSION: u32 = 1;
25
26/// Complete workflow checkpoint for resumption
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct WorkflowCheckpoint {
29    /// Unique workflow execution ID
30    pub workflow_id: String,
31    /// Current execution state
32    pub execution_state: ExecutionState,
33    /// Completed steps with results
34    pub completed_steps: Vec<CompletedStep>,
35    /// Variable state for interpolation
36    pub variable_state: HashMap<String, Value>,
37    /// MapReduce state if applicable
38    pub mapreduce_state: Option<MapReduceCheckpoint>,
39    /// Timestamp of checkpoint
40    pub timestamp: DateTime<Utc>,
41    /// Checkpoint format version
42    pub version: u32,
43    /// Hash of original workflow for validation
44    pub workflow_hash: String,
45    /// Total number of steps in workflow
46    pub total_steps: usize,
47    /// Workflow name for reference
48    pub workflow_name: Option<String>,
49    /// Path to workflow file for resume
50    pub workflow_path: Option<PathBuf>,
51    /// Error recovery state (stored in variable_state as __error_recovery_state)
52    #[serde(skip)]
53    pub error_recovery_state: Option<crate::cook::workflow::error_recovery::ErrorRecoveryState>,
54    /// Enhanced retry state for comprehensive persistence
55    pub retry_checkpoint_state: Option<crate::cook::retry_state::RetryCheckpointState>,
56    /// Enhanced variable checkpoint state for comprehensive variable persistence
57    pub variable_checkpoint_state: Option<VariableCheckpointState>,
58}
59
60/// Current state of workflow execution
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ExecutionState {
63    /// Index of current step being executed
64    pub current_step_index: usize,
65    /// Total number of steps
66    pub total_steps: usize,
67    /// Current workflow status
68    pub status: WorkflowStatus,
69    /// When execution started
70    pub start_time: DateTime<Utc>,
71    /// Last checkpoint timestamp
72    pub last_checkpoint: DateTime<Utc>,
73    /// Current iteration for iterative workflows
74    pub current_iteration: Option<usize>,
75    /// Total iterations for iterative workflows
76    pub total_iterations: Option<usize>,
77}
78
79/// Workflow execution status
80#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
81pub enum WorkflowStatus {
82    /// Workflow is running
83    Running,
84    /// Workflow is paused
85    Paused,
86    /// Workflow completed successfully
87    Completed,
88    /// Workflow failed
89    Failed,
90    /// Workflow was interrupted
91    Interrupted,
92}
93
94/// Record of a completed workflow step
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct CompletedStep {
97    /// Step index in workflow
98    pub step_index: usize,
99    /// Command that was executed
100    pub command: String,
101    /// Whether step succeeded
102    pub success: bool,
103    /// Captured output if any
104    pub output: Option<String>,
105    /// Variables captured from this step
106    pub captured_variables: HashMap<String, String>,
107    /// Duration of execution
108    pub duration: Duration,
109    /// Timestamp when completed
110    pub completed_at: DateTime<Utc>,
111    /// Retry state if this step is being retried
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub retry_state: Option<RetryState>,
114}
115
116/// State of a step being retried
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct RetryState {
119    /// Current attempt number (1-based)
120    pub current_attempt: usize,
121    /// Maximum attempts allowed
122    pub max_attempts: usize,
123    /// Failure reasons from each attempt
124    pub failure_history: Vec<String>,
125    /// Whether currently in retry loop
126    pub in_retry_loop: bool,
127}
128
129/// MapReduce job checkpoint state
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct MapReduceCheckpoint {
132    /// Items that have been completed
133    pub completed_items: HashSet<String>,
134    /// Items that failed
135    pub failed_items: Vec<String>,
136    /// Items currently being processed
137    pub in_progress_items: HashMap<String, AgentState>,
138    /// Whether reduce phase completed
139    pub reduce_completed: bool,
140    /// Results from completed agents
141    pub agent_results: HashMap<String, Value>,
142    /// Total number of original items
143    pub total_items: usize,
144    /// MapReduce aggregate variables (map.successful, map.failed, etc.)
145    pub aggregate_variables: HashMap<String, String>,
146}
147
148/// State of an agent processing an item
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct AgentState {
151    /// Agent ID
152    pub agent_id: String,
153    /// Item being processed
154    pub item_id: String,
155    /// When processing started
156    pub started_at: DateTime<Utc>,
157    /// Last update time
158    pub last_update: DateTime<Utc>,
159}
160
161/// Context for resuming workflow execution
162#[derive(Debug, Clone)]
163pub struct ResumeContext {
164    /// Steps to skip (already completed)
165    pub skip_steps: Vec<CompletedStep>,
166    /// Variable state to restore
167    pub variable_state: HashMap<String, Value>,
168    /// MapReduce state if applicable
169    pub mapreduce_state: Option<MapReduceCheckpoint>,
170    /// Starting step index
171    pub start_from_step: usize,
172    /// Iteration to resume from
173    pub resume_iteration: Option<usize>,
174    /// Original checkpoint for reference
175    pub checkpoint: Option<Box<WorkflowCheckpoint>>,
176}
177
178/// Options for resuming workflow
179#[derive(Debug, Clone, Default)]
180pub struct ResumeOptions {
181    /// Force resume even if marked complete
182    pub force: bool,
183    /// Resume from specific step
184    pub from_step: Option<usize>,
185    /// Reset failed items for retry
186    pub reset_failures: bool,
187    /// Skip validation of workflow compatibility
188    pub skip_validation: bool,
189}
190
191/// Manager for workflow checkpoints
192///
193/// `CheckpointManager` is immutable after construction. Use the builder pattern
194/// to configure interval and enabled state.
195///
196/// # Example
197///
198/// ```rust
199/// use prodigy::cook::workflow::{CheckpointManager, CheckpointStorage};
200/// use std::time::Duration;
201///
202/// let storage = CheckpointStorage::Session {
203///     session_id: "session-123".to_string(),
204/// };
205///
206/// let manager = CheckpointManager::with_storage(storage)
207///     .with_interval(Duration::from_secs(60))
208///     .with_enabled(true);
209/// ```
210pub struct CheckpointManager {
211    /// Checkpoint storage strategy (immutable)
212    storage: CheckpointStorage,
213    /// Checkpoint interval (immutable)
214    checkpoint_interval: Duration,
215    /// Whether checkpointing is enabled (immutable)
216    enabled: bool,
217}
218
219impl CheckpointManager {
220    /// Create a new checkpoint manager with explicit storage strategy
221    ///
222    /// Returns a `CheckpointManager` with default configuration:
223    /// - Interval: 60 seconds
224    /// - Enabled: true
225    ///
226    /// Use builder methods to customize configuration.
227    pub fn with_storage(storage: CheckpointStorage) -> Self {
228        Self {
229            storage,
230            checkpoint_interval: DEFAULT_CHECKPOINT_INTERVAL,
231            enabled: true,
232        }
233    }
234
235    /// Configure checkpoint interval (builder pattern)
236    ///
237    /// # Example
238    ///
239    /// ```rust
240    /// # use prodigy::cook::workflow::{CheckpointManager, CheckpointStorage};
241    /// # use std::time::Duration;
242    /// let storage = CheckpointStorage::Session { session_id: "test".to_string() };
243    /// let manager = CheckpointManager::with_storage(storage)
244    ///     .with_interval(Duration::from_secs(30));
245    /// ```
246    pub fn with_interval(mut self, interval: Duration) -> Self {
247        self.checkpoint_interval = interval;
248        self
249    }
250
251    /// Enable or disable checkpointing (builder pattern)
252    ///
253    /// # Example
254    ///
255    /// ```rust
256    /// # use prodigy::cook::workflow::{CheckpointManager, CheckpointStorage};
257    /// let storage = CheckpointStorage::Session { session_id: "test".to_string() };
258    /// let manager = CheckpointManager::with_storage(storage)
259    ///     .with_enabled(false);
260    /// ```
261    pub fn with_enabled(mut self, enabled: bool) -> Self {
262        self.enabled = enabled;
263        self
264    }
265
266    /// Create a new checkpoint manager (deprecated - use with_storage)
267    ///
268    /// This constructor is maintained for backwards compatibility but is deprecated.
269    /// New code should use `with_storage()` with an explicit CheckpointStorage strategy.
270    ///
271    /// # Migration
272    ///
273    /// Old API:
274    /// ```rust,ignore
275    /// let mut manager = CheckpointManager::new(PathBuf::from("/tmp"));
276    /// manager.configure(Duration::from_secs(60), true);
277    /// ```
278    ///
279    /// New API:
280    /// ```rust
281    /// # use prodigy::cook::workflow::{CheckpointManager, CheckpointStorage};
282    /// # use std::time::Duration;
283    /// # use std::path::PathBuf;
284    /// let manager = CheckpointManager::with_storage(
285    ///     CheckpointStorage::Local(PathBuf::from("/tmp"))
286    /// )
287    /// .with_interval(Duration::from_secs(60))
288    /// .with_enabled(true);
289    /// ```
290    #[deprecated(
291        since = "0.10.0",
292        note = "Use CheckpointManager::with_storage() with explicit storage strategy instead"
293    )]
294    pub fn new(storage_path: PathBuf) -> Self {
295        Self {
296            storage: CheckpointStorage::Local(storage_path),
297            checkpoint_interval: DEFAULT_CHECKPOINT_INTERVAL,
298            enabled: true,
299        }
300    }
301
302    /// Configure checkpoint settings (deprecated - use builder pattern)
303    ///
304    /// This method mutates the `CheckpointManager` after construction, which violates
305    /// the immutability principle. Use the builder pattern instead.
306    ///
307    /// # Migration
308    ///
309    /// Old API:
310    /// ```rust,ignore
311    /// let mut manager = CheckpointManager::new(path);
312    /// manager.configure(Duration::from_secs(60), true);
313    /// ```
314    ///
315    /// New API:
316    /// ```rust
317    /// # use prodigy::cook::workflow::{CheckpointManager, CheckpointStorage};
318    /// # use std::time::Duration;
319    /// # use std::path::PathBuf;
320    /// let manager = CheckpointManager::with_storage(
321    ///     CheckpointStorage::Local(PathBuf::from("/tmp"))
322    /// )
323    /// .with_interval(Duration::from_secs(60))
324    /// .with_enabled(true);
325    /// ```
326    #[deprecated(
327        since = "0.10.0",
328        note = "Use .with_interval().with_enabled() builder pattern instead"
329    )]
330    pub fn configure(&mut self, interval: Duration, enabled: bool) {
331        self.checkpoint_interval = interval;
332        self.enabled = enabled;
333    }
334
335    /// Save a checkpoint for the workflow
336    pub async fn save_checkpoint(&self, checkpoint: &WorkflowCheckpoint) -> Result<()> {
337        if !self.enabled {
338            return Ok(());
339        }
340
341        // Pure: resolve paths using storage strategy
342        let checkpoint_path = self
343            .storage
344            .checkpoint_file_path(&checkpoint.workflow_id)
345            .context("Failed to resolve checkpoint path")?;
346        let temp_path = checkpoint_path.with_extension("tmp");
347
348        // I/O: ensure directory exists
349        ensure_checkpoint_dir_exists(&checkpoint_path).await?;
350
351        // I/O: atomic write to filesystem
352        write_checkpoint_atomically(&checkpoint_path, &temp_path, checkpoint).await?;
353
354        info!(
355            "Saved checkpoint for workflow {} at step {}",
356            checkpoint.workflow_id, checkpoint.execution_state.current_step_index
357        );
358
359        Ok(())
360    }
361
362    /// Save an intervention request to checkpoint metadata
363    pub async fn save_intervention_request(&self, workflow_id: &str, message: &str) -> Result<()> {
364        // Load existing checkpoint
365        let mut checkpoint = self.load_checkpoint(workflow_id).await?;
366
367        // Add intervention request to variable_state (used as metadata storage)
368        checkpoint.variable_state.insert(
369            "__intervention_required".to_string(),
370            serde_json::Value::String(message.to_string()),
371        );
372        checkpoint.variable_state.insert(
373            "__intervention_timestamp".to_string(),
374            serde_json::Value::String(chrono::Utc::now().to_rfc3339()),
375        );
376
377        // Save updated checkpoint
378        self.save_checkpoint(&checkpoint).await?;
379
380        info!(
381            "Saved intervention request for workflow {}: {}",
382            workflow_id, message
383        );
384
385        Ok(())
386    }
387
388    /// Load a checkpoint for resuming
389    pub async fn load_checkpoint(&self, workflow_id: &str) -> Result<WorkflowCheckpoint> {
390        let checkpoint_path = self
391            .storage
392            .checkpoint_file_path(workflow_id)
393            .context("Failed to resolve checkpoint path")?;
394
395        // Check if checkpoint exists
396        if !checkpoint_path.exists() {
397            let checkpoint_dir = self
398                .storage
399                .resolve_base_dir()
400                .context("Failed to resolve checkpoint directory")?;
401
402            return Err(CheckpointError::not_found(workflow_id.to_string(), checkpoint_dir).into());
403        }
404
405        let content =
406            fs::read_to_string(&checkpoint_path)
407                .await
408                .map_err(|e| CheckpointError::IoError {
409                    operation: "read checkpoint file".to_string(),
410                    path: Some(checkpoint_path.clone()),
411                    source: e,
412                })?;
413
414        let checkpoint: WorkflowCheckpoint =
415            serde_json::from_str(&content).map_err(|e| -> anyhow::Error {
416                CheckpointError::InvalidCheckpoint {
417                    reason: format!("Failed to parse checkpoint: {}", e),
418                    session_id: workflow_id.to_string(),
419                }
420                .into()
421            })?;
422
423        // Validate version compatibility
424        if checkpoint.version > CHECKPOINT_VERSION {
425            return Err(CheckpointError::version_mismatch(
426                checkpoint.version,
427                CHECKPOINT_VERSION,
428                checkpoint_path,
429                Some(checkpoint.timestamp),
430            )
431            .into());
432        }
433
434        Ok(checkpoint)
435    }
436
437    /// Check if an auto-checkpoint is needed
438    pub async fn should_checkpoint(&self, last_checkpoint: DateTime<Utc>) -> bool {
439        if !self.enabled {
440            return false;
441        }
442
443        let elapsed = Utc::now().signed_duration_since(last_checkpoint);
444        elapsed.num_seconds() as u64 >= self.checkpoint_interval.as_secs()
445    }
446
447    /// Delete a checkpoint after successful completion
448    pub async fn delete_checkpoint(&self, workflow_id: &str) -> Result<()> {
449        let checkpoint_path = self
450            .storage
451            .checkpoint_file_path(workflow_id)
452            .context("Failed to resolve checkpoint path")?;
453        if checkpoint_path.exists() {
454            fs::remove_file(checkpoint_path)
455                .await
456                .context("Failed to delete checkpoint")?;
457            debug!("Deleted checkpoint for completed workflow {}", workflow_id);
458        }
459        Ok(())
460    }
461
462    /// List all available checkpoints
463    pub async fn list_checkpoints(&self) -> Result<Vec<String>> {
464        let mut checkpoints = Vec::new();
465
466        let base_dir = self
467            .storage
468            .resolve_base_dir()
469            .context("Failed to resolve checkpoint base directory")?;
470
471        if !base_dir.exists() {
472            return Ok(checkpoints);
473        }
474
475        let mut entries = fs::read_dir(&base_dir).await?;
476        while let Some(entry) = entries.next_entry().await? {
477            if let Some(name) = entry.file_name().to_str() {
478                if name.ends_with(".checkpoint.json") {
479                    if let Some(workflow_id) = name.strip_suffix(".checkpoint.json") {
480                        checkpoints.push(workflow_id.to_string());
481                    }
482                }
483            }
484        }
485
486        Ok(checkpoints)
487    }
488
489    /// Validate checkpoint compatibility with current workflow
490    pub fn validate_checkpoint(
491        checkpoint: &WorkflowCheckpoint,
492        workflow_hash: &str,
493        workflow_path: Option<&PathBuf>,
494        current_steps: usize,
495    ) -> Result<()> {
496        // Check workflow hasn't changed incompatibly
497        if checkpoint.workflow_hash != workflow_hash {
498            warn!("Workflow has changed since checkpoint was created");
499
500            // If we have workflow path info, return detailed error
501            if let Some(path) = workflow_path {
502                return Err(CheckpointError::workflow_hash_mismatch(
503                    checkpoint.workflow_hash.clone(),
504                    workflow_hash.to_string(),
505                    checkpoint.total_steps,
506                    current_steps,
507                    checkpoint.workflow_id.clone(),
508                    path.clone(),
509                    Some(checkpoint.timestamp),
510                )
511                .into());
512            }
513        }
514
515        // Validate checkpoint integrity
516        if checkpoint.execution_state.current_step_index > checkpoint.execution_state.total_steps {
517            return Err(CheckpointError::InvalidCheckpoint {
518                reason: format!(
519                    "Step index {} exceeds total steps {}",
520                    checkpoint.execution_state.current_step_index,
521                    checkpoint.execution_state.total_steps
522                ),
523                session_id: checkpoint.workflow_id.clone(),
524            }
525            .into());
526        }
527
528        Ok(())
529    }
530}
531
532/// Create a checkpoint from current workflow state
533pub fn create_checkpoint(
534    workflow_id: String,
535    workflow: &NormalizedWorkflow,
536    context: &WorkflowContext,
537    completed_steps: Vec<CompletedStep>,
538    current_step: usize,
539    workflow_hash: String,
540) -> WorkflowCheckpoint {
541    create_checkpoint_with_total_steps(
542        workflow_id,
543        workflow,
544        context,
545        completed_steps,
546        current_step,
547        workflow_hash,
548        workflow.steps.len(),
549    )
550}
551
552/// Create a checkpoint from current workflow state with explicit total steps
553pub fn create_checkpoint_with_total_steps(
554    workflow_id: String,
555    workflow: &NormalizedWorkflow,
556    context: &WorkflowContext,
557    completed_steps: Vec<CompletedStep>,
558    current_step: usize,
559    workflow_hash: String,
560    total_steps: usize,
561) -> WorkflowCheckpoint {
562    // Convert WorkflowContext variables to Value map
563    let mut variable_state = HashMap::new();
564    for (key, value) in &context.variables {
565        variable_state.insert(key.clone(), Value::String(value.clone()));
566    }
567    for (key, value) in &context.captured_outputs {
568        variable_state.insert(key.clone(), Value::String(value.clone()));
569    }
570
571    // Create enhanced variable checkpoint state
572    let variable_checkpoint_state = {
573        use crate::cook::workflow::variable_checkpoint::VariableResumeManager;
574        let manager = VariableResumeManager::new();
575        manager
576            .create_checkpoint(
577                &context.variables,
578                &context.captured_outputs,
579                &context.iteration_vars,
580                &context.variable_store,
581            )
582            .ok()
583    };
584
585    WorkflowCheckpoint {
586        workflow_id,
587        execution_state: ExecutionState {
588            current_step_index: current_step,
589            total_steps,
590            status: WorkflowStatus::Running,
591            start_time: Utc::now(),
592            last_checkpoint: Utc::now(),
593            current_iteration: None,
594            total_iterations: None,
595        },
596        completed_steps,
597        variable_state,
598        mapreduce_state: None,
599        timestamp: Utc::now(),
600        version: CHECKPOINT_VERSION,
601        workflow_hash,
602        total_steps,
603        workflow_name: Some(workflow.name.to_string()),
604        workflow_path: None,          // Will be set by the executor if available
605        error_recovery_state: None,   // Will be set if error handlers are present
606        retry_checkpoint_state: None, // Will be set by the executor if retry state exists
607        variable_checkpoint_state,
608    }
609}
610
611/// Pure function: create checkpoint for successful completion
612///
613/// Creates a checkpoint marked as completed without performing any I/O.
614/// Returns `Result<WorkflowCheckpoint>` for consistency with error path.
615pub fn create_completion_checkpoint(
616    workflow_id: String,
617    workflow: &NormalizedWorkflow,
618    context: &WorkflowContext,
619    completed_steps: Vec<CompletedStep>,
620    current_step_index: usize,
621    workflow_hash: String,
622) -> Result<WorkflowCheckpoint> {
623    let mut checkpoint = create_checkpoint(
624        workflow_id,
625        workflow,
626        context,
627        completed_steps,
628        current_step_index,
629        workflow_hash,
630    );
631
632    checkpoint.execution_state.status = WorkflowStatus::Completed;
633    Ok(checkpoint)
634}
635
636/// Pure function: create checkpoint with error context for failure recovery
637///
638/// Creates a checkpoint marked as failed with error context stored in variable_state.
639/// This enables users to inspect failure details and potentially resume from the point of failure.
640///
641/// Error context is stored in special variables:
642/// - `__error_message`: The error message as a string
643/// - `__failed_step_index`: The index of the step that failed
644/// - `__error_timestamp`: ISO 8601 timestamp of when the error occurred
645pub fn create_error_checkpoint(
646    workflow_id: String,
647    workflow: &NormalizedWorkflow,
648    context: &WorkflowContext,
649    completed_steps: Vec<CompletedStep>,
650    workflow_hash: String,
651    error: &anyhow::Error,
652    failed_step_index: usize,
653) -> Result<WorkflowCheckpoint> {
654    let mut checkpoint = create_checkpoint(
655        workflow_id,
656        workflow,
657        context,
658        completed_steps,
659        failed_step_index,
660        workflow_hash,
661    );
662
663    // Set status to Failed
664    checkpoint.execution_state.status = WorkflowStatus::Failed;
665
666    // Store error context in variable_state for debugging
667    checkpoint.variable_state.insert(
668        "__error_message".to_string(),
669        Value::String(error.to_string()),
670    );
671    checkpoint.variable_state.insert(
672        "__failed_step_index".to_string(),
673        Value::Number(failed_step_index.into()),
674    );
675    checkpoint.variable_state.insert(
676        "__error_timestamp".to_string(),
677        Value::String(Utc::now().to_rfc3339()),
678    );
679
680    Ok(checkpoint)
681}
682
683/// Build resume context from a checkpoint
684pub fn build_resume_context(checkpoint: WorkflowCheckpoint) -> ResumeContext {
685    let completed_steps = checkpoint.completed_steps.clone();
686    let variable_state = checkpoint.variable_state.clone();
687    let mapreduce_state = checkpoint.mapreduce_state.clone();
688    let start_from_step = checkpoint.execution_state.current_step_index;
689    let resume_iteration = checkpoint.execution_state.current_iteration;
690
691    ResumeContext {
692        skip_steps: completed_steps,
693        variable_state,
694        mapreduce_state,
695        start_from_step,
696        resume_iteration,
697        checkpoint: Some(Box::new(checkpoint)),
698    }
699}
700
701// ============================================================================
702// Pure Functions: Separated from I/O for testability
703// ============================================================================
704
705/// Pure function: serialize checkpoint to JSON
706///
707/// This is a pure function with no side effects, making it easily testable.
708/// Takes a checkpoint and returns the JSON string representation.
709fn serialize_checkpoint(checkpoint: &WorkflowCheckpoint) -> Result<String> {
710    serde_json::to_string_pretty(checkpoint).context("Failed to serialize checkpoint to JSON")
711}
712
713// ============================================================================
714// I/O Operations: Separated at module boundaries
715// ============================================================================
716
717/// I/O operation: ensure checkpoint directory exists
718///
719/// Creates parent directories for the checkpoint file if they don't exist.
720async fn ensure_checkpoint_dir_exists(checkpoint_path: &std::path::Path) -> Result<()> {
721    if let Some(parent) = checkpoint_path.parent() {
722        fs::create_dir_all(parent)
723            .await
724            .context("Failed to create checkpoint directory")?;
725    }
726    Ok(())
727}
728
729/// I/O operation: atomic write checkpoint to filesystem
730///
731/// Writes checkpoint to a temporary file first, then atomically renames it
732/// to the final location to prevent corruption from interrupted writes.
733async fn write_checkpoint_atomically(
734    final_path: &std::path::Path,
735    temp_path: &std::path::Path,
736    checkpoint: &WorkflowCheckpoint,
737) -> Result<()> {
738    // Pure: serialize checkpoint
739    let json = serialize_checkpoint(checkpoint)?;
740
741    // I/O: write to temp file
742    fs::write(temp_path, json)
743        .await
744        .context("Failed to write checkpoint to temp file")?;
745
746    // I/O: atomic rename
747    fs::rename(temp_path, final_path)
748        .await
749        .context("Failed to move checkpoint to final location")
750}