use crate::cook::workflow::checkpoint_errors::CheckpointError;
use crate::cook::workflow::checkpoint_path::CheckpointStorage;
use crate::cook::workflow::executor::WorkflowContext;
use crate::cook::workflow::normalized::NormalizedWorkflow;
use crate::cook::workflow::variable_checkpoint::VariableCheckpointState;
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::Duration;
use tokio::fs;
use tracing::{debug, info, warn};
const DEFAULT_CHECKPOINT_INTERVAL: Duration = Duration::from_secs(60);
pub const CHECKPOINT_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowCheckpoint {
pub workflow_id: String,
pub execution_state: ExecutionState,
pub completed_steps: Vec<CompletedStep>,
pub variable_state: HashMap<String, Value>,
pub mapreduce_state: Option<MapReduceCheckpoint>,
pub timestamp: DateTime<Utc>,
pub version: u32,
pub workflow_hash: String,
pub total_steps: usize,
pub workflow_name: Option<String>,
pub workflow_path: Option<PathBuf>,
#[serde(skip)]
pub error_recovery_state: Option<crate::cook::workflow::error_recovery::ErrorRecoveryState>,
pub retry_checkpoint_state: Option<crate::cook::retry_state::RetryCheckpointState>,
pub variable_checkpoint_state: Option<VariableCheckpointState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionState {
pub current_step_index: usize,
pub total_steps: usize,
pub status: WorkflowStatus,
pub start_time: DateTime<Utc>,
pub last_checkpoint: DateTime<Utc>,
pub current_iteration: Option<usize>,
pub total_iterations: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum WorkflowStatus {
Running,
Paused,
Completed,
Failed,
Interrupted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompletedStep {
pub step_index: usize,
pub command: String,
pub success: bool,
pub output: Option<String>,
pub captured_variables: HashMap<String, String>,
pub duration: Duration,
pub completed_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub retry_state: Option<RetryState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetryState {
pub current_attempt: usize,
pub max_attempts: usize,
pub failure_history: Vec<String>,
pub in_retry_loop: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MapReduceCheckpoint {
pub completed_items: HashSet<String>,
pub failed_items: Vec<String>,
pub in_progress_items: HashMap<String, AgentState>,
pub reduce_completed: bool,
pub agent_results: HashMap<String, Value>,
pub total_items: usize,
pub aggregate_variables: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentState {
pub agent_id: String,
pub item_id: String,
pub started_at: DateTime<Utc>,
pub last_update: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub struct ResumeContext {
pub skip_steps: Vec<CompletedStep>,
pub variable_state: HashMap<String, Value>,
pub mapreduce_state: Option<MapReduceCheckpoint>,
pub start_from_step: usize,
pub resume_iteration: Option<usize>,
pub checkpoint: Option<Box<WorkflowCheckpoint>>,
}
#[derive(Debug, Clone, Default)]
pub struct ResumeOptions {
pub force: bool,
pub from_step: Option<usize>,
pub reset_failures: bool,
pub skip_validation: bool,
}
pub struct CheckpointManager {
storage: CheckpointStorage,
checkpoint_interval: Duration,
enabled: bool,
}
impl CheckpointManager {
pub fn with_storage(storage: CheckpointStorage) -> Self {
Self {
storage,
checkpoint_interval: DEFAULT_CHECKPOINT_INTERVAL,
enabled: true,
}
}
pub fn with_interval(mut self, interval: Duration) -> Self {
self.checkpoint_interval = interval;
self
}
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
#[deprecated(
since = "0.10.0",
note = "Use CheckpointManager::with_storage() with explicit storage strategy instead"
)]
pub fn new(storage_path: PathBuf) -> Self {
Self {
storage: CheckpointStorage::Local(storage_path),
checkpoint_interval: DEFAULT_CHECKPOINT_INTERVAL,
enabled: true,
}
}
#[deprecated(
since = "0.10.0",
note = "Use .with_interval().with_enabled() builder pattern instead"
)]
pub fn configure(&mut self, interval: Duration, enabled: bool) {
self.checkpoint_interval = interval;
self.enabled = enabled;
}
pub async fn save_checkpoint(&self, checkpoint: &WorkflowCheckpoint) -> Result<()> {
if !self.enabled {
return Ok(());
}
let checkpoint_path = self
.storage
.checkpoint_file_path(&checkpoint.workflow_id)
.context("Failed to resolve checkpoint path")?;
let temp_path = checkpoint_path.with_extension("tmp");
ensure_checkpoint_dir_exists(&checkpoint_path).await?;
write_checkpoint_atomically(&checkpoint_path, &temp_path, checkpoint).await?;
info!(
"Saved checkpoint for workflow {} at step {}",
checkpoint.workflow_id, checkpoint.execution_state.current_step_index
);
Ok(())
}
pub async fn save_intervention_request(&self, workflow_id: &str, message: &str) -> Result<()> {
let mut checkpoint = self.load_checkpoint(workflow_id).await?;
checkpoint.variable_state.insert(
"__intervention_required".to_string(),
serde_json::Value::String(message.to_string()),
);
checkpoint.variable_state.insert(
"__intervention_timestamp".to_string(),
serde_json::Value::String(chrono::Utc::now().to_rfc3339()),
);
self.save_checkpoint(&checkpoint).await?;
info!(
"Saved intervention request for workflow {}: {}",
workflow_id, message
);
Ok(())
}
pub async fn load_checkpoint(&self, workflow_id: &str) -> Result<WorkflowCheckpoint> {
let checkpoint_path = self
.storage
.checkpoint_file_path(workflow_id)
.context("Failed to resolve checkpoint path")?;
if !checkpoint_path.exists() {
let checkpoint_dir = self
.storage
.resolve_base_dir()
.context("Failed to resolve checkpoint directory")?;
return Err(CheckpointError::not_found(workflow_id.to_string(), checkpoint_dir).into());
}
let content =
fs::read_to_string(&checkpoint_path)
.await
.map_err(|e| CheckpointError::IoError {
operation: "read checkpoint file".to_string(),
path: Some(checkpoint_path.clone()),
source: e,
})?;
let checkpoint: WorkflowCheckpoint =
serde_json::from_str(&content).map_err(|e| -> anyhow::Error {
CheckpointError::InvalidCheckpoint {
reason: format!("Failed to parse checkpoint: {}", e),
session_id: workflow_id.to_string(),
}
.into()
})?;
if checkpoint.version > CHECKPOINT_VERSION {
return Err(CheckpointError::version_mismatch(
checkpoint.version,
CHECKPOINT_VERSION,
checkpoint_path,
Some(checkpoint.timestamp),
)
.into());
}
Ok(checkpoint)
}
pub async fn should_checkpoint(&self, last_checkpoint: DateTime<Utc>) -> bool {
if !self.enabled {
return false;
}
let elapsed = Utc::now().signed_duration_since(last_checkpoint);
elapsed.num_seconds() as u64 >= self.checkpoint_interval.as_secs()
}
pub async fn delete_checkpoint(&self, workflow_id: &str) -> Result<()> {
let checkpoint_path = self
.storage
.checkpoint_file_path(workflow_id)
.context("Failed to resolve checkpoint path")?;
if checkpoint_path.exists() {
fs::remove_file(checkpoint_path)
.await
.context("Failed to delete checkpoint")?;
debug!("Deleted checkpoint for completed workflow {}", workflow_id);
}
Ok(())
}
pub async fn list_checkpoints(&self) -> Result<Vec<String>> {
let mut checkpoints = Vec::new();
let base_dir = self
.storage
.resolve_base_dir()
.context("Failed to resolve checkpoint base directory")?;
if !base_dir.exists() {
return Ok(checkpoints);
}
let mut entries = fs::read_dir(&base_dir).await?;
while let Some(entry) = entries.next_entry().await? {
if let Some(name) = entry.file_name().to_str() {
if name.ends_with(".checkpoint.json") {
if let Some(workflow_id) = name.strip_suffix(".checkpoint.json") {
checkpoints.push(workflow_id.to_string());
}
}
}
}
Ok(checkpoints)
}
pub fn validate_checkpoint(
checkpoint: &WorkflowCheckpoint,
workflow_hash: &str,
workflow_path: Option<&PathBuf>,
current_steps: usize,
) -> Result<()> {
if checkpoint.workflow_hash != workflow_hash {
warn!("Workflow has changed since checkpoint was created");
if let Some(path) = workflow_path {
return Err(CheckpointError::workflow_hash_mismatch(
checkpoint.workflow_hash.clone(),
workflow_hash.to_string(),
checkpoint.total_steps,
current_steps,
checkpoint.workflow_id.clone(),
path.clone(),
Some(checkpoint.timestamp),
)
.into());
}
}
if checkpoint.execution_state.current_step_index > checkpoint.execution_state.total_steps {
return Err(CheckpointError::InvalidCheckpoint {
reason: format!(
"Step index {} exceeds total steps {}",
checkpoint.execution_state.current_step_index,
checkpoint.execution_state.total_steps
),
session_id: checkpoint.workflow_id.clone(),
}
.into());
}
Ok(())
}
}
pub fn create_checkpoint(
workflow_id: String,
workflow: &NormalizedWorkflow,
context: &WorkflowContext,
completed_steps: Vec<CompletedStep>,
current_step: usize,
workflow_hash: String,
) -> WorkflowCheckpoint {
create_checkpoint_with_total_steps(
workflow_id,
workflow,
context,
completed_steps,
current_step,
workflow_hash,
workflow.steps.len(),
)
}
pub fn create_checkpoint_with_total_steps(
workflow_id: String,
workflow: &NormalizedWorkflow,
context: &WorkflowContext,
completed_steps: Vec<CompletedStep>,
current_step: usize,
workflow_hash: String,
total_steps: usize,
) -> WorkflowCheckpoint {
let mut variable_state = HashMap::new();
for (key, value) in &context.variables {
variable_state.insert(key.clone(), Value::String(value.clone()));
}
for (key, value) in &context.captured_outputs {
variable_state.insert(key.clone(), Value::String(value.clone()));
}
let variable_checkpoint_state = {
use crate::cook::workflow::variable_checkpoint::VariableResumeManager;
let manager = VariableResumeManager::new();
manager
.create_checkpoint(
&context.variables,
&context.captured_outputs,
&context.iteration_vars,
&context.variable_store,
)
.ok()
};
WorkflowCheckpoint {
workflow_id,
execution_state: ExecutionState {
current_step_index: current_step,
total_steps,
status: WorkflowStatus::Running,
start_time: Utc::now(),
last_checkpoint: Utc::now(),
current_iteration: None,
total_iterations: None,
},
completed_steps,
variable_state,
mapreduce_state: None,
timestamp: Utc::now(),
version: CHECKPOINT_VERSION,
workflow_hash,
total_steps,
workflow_name: Some(workflow.name.to_string()),
workflow_path: None, error_recovery_state: None, retry_checkpoint_state: None, variable_checkpoint_state,
}
}
pub fn create_completion_checkpoint(
workflow_id: String,
workflow: &NormalizedWorkflow,
context: &WorkflowContext,
completed_steps: Vec<CompletedStep>,
current_step_index: usize,
workflow_hash: String,
) -> Result<WorkflowCheckpoint> {
let mut checkpoint = create_checkpoint(
workflow_id,
workflow,
context,
completed_steps,
current_step_index,
workflow_hash,
);
checkpoint.execution_state.status = WorkflowStatus::Completed;
Ok(checkpoint)
}
pub fn create_error_checkpoint(
workflow_id: String,
workflow: &NormalizedWorkflow,
context: &WorkflowContext,
completed_steps: Vec<CompletedStep>,
workflow_hash: String,
error: &anyhow::Error,
failed_step_index: usize,
) -> Result<WorkflowCheckpoint> {
let mut checkpoint = create_checkpoint(
workflow_id,
workflow,
context,
completed_steps,
failed_step_index,
workflow_hash,
);
checkpoint.execution_state.status = WorkflowStatus::Failed;
checkpoint.variable_state.insert(
"__error_message".to_string(),
Value::String(error.to_string()),
);
checkpoint.variable_state.insert(
"__failed_step_index".to_string(),
Value::Number(failed_step_index.into()),
);
checkpoint.variable_state.insert(
"__error_timestamp".to_string(),
Value::String(Utc::now().to_rfc3339()),
);
Ok(checkpoint)
}
pub fn build_resume_context(checkpoint: WorkflowCheckpoint) -> ResumeContext {
let completed_steps = checkpoint.completed_steps.clone();
let variable_state = checkpoint.variable_state.clone();
let mapreduce_state = checkpoint.mapreduce_state.clone();
let start_from_step = checkpoint.execution_state.current_step_index;
let resume_iteration = checkpoint.execution_state.current_iteration;
ResumeContext {
skip_steps: completed_steps,
variable_state,
mapreduce_state,
start_from_step,
resume_iteration,
checkpoint: Some(Box::new(checkpoint)),
}
}
fn serialize_checkpoint(checkpoint: &WorkflowCheckpoint) -> Result<String> {
serde_json::to_string_pretty(checkpoint).context("Failed to serialize checkpoint to JSON")
}
async fn ensure_checkpoint_dir_exists(checkpoint_path: &std::path::Path) -> Result<()> {
if let Some(parent) = checkpoint_path.parent() {
fs::create_dir_all(parent)
.await
.context("Failed to create checkpoint directory")?;
}
Ok(())
}
async fn write_checkpoint_atomically(
final_path: &std::path::Path,
temp_path: &std::path::Path,
checkpoint: &WorkflowCheckpoint,
) -> Result<()> {
let json = serialize_checkpoint(checkpoint)?;
fs::write(temp_path, json)
.await
.context("Failed to write checkpoint to temp file")?;
fs::rename(temp_path, final_path)
.await
.context("Failed to move checkpoint to final location")
}