Skip to main content

minion_engine/engine/
state.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::steps::StepOutput;
7
8/// Persisted workflow execution state for resume support
9#[derive(Debug, Serialize, Deserialize)]
10pub struct WorkflowState {
11    pub workflow: String,
12    pub session_id: Option<String>,
13    pub timestamp: String,
14    pub steps: HashMap<String, StepOutput>,
15}
16
17impl WorkflowState {
18    pub fn new(workflow: &str) -> Self {
19        Self {
20            workflow: workflow.to_string(),
21            session_id: None,
22            timestamp: chrono::Utc::now().to_rfc3339(),
23            steps: HashMap::new(),
24        }
25    }
26
27    /// Build a timestamped state file path: /tmp/minion-<workflow>-<timestamp>.state.json
28    pub fn state_file_path(workflow: &str) -> PathBuf {
29        let ts = chrono::Utc::now().format("%Y%m%d%H%M%S");
30        let slug = workflow.replace(' ', "_");
31        PathBuf::from(format!("/tmp/minion-{slug}-{ts}.state.json"))
32    }
33
34    /// Find the most recently modified state file for a workflow in /tmp
35    pub fn find_latest(workflow: &str) -> Option<PathBuf> {
36        let prefix = format!("minion-{}-", workflow.replace(' ', "_"));
37        let suffix = ".state.json";
38
39        std::fs::read_dir("/tmp")
40            .ok()?
41            .filter_map(|e| e.ok())
42            .map(|e| e.path())
43            .filter(|p| {
44                p.file_name()
45                    .and_then(|n| n.to_str())
46                    .map(|n| n.starts_with(&prefix) && n.ends_with(suffix))
47                    .unwrap_or(false)
48            })
49            .max_by_key(|p| p.metadata().and_then(|m| m.modified()).ok())
50    }
51
52    /// Persist state to disk
53    pub fn save(&self, path: &PathBuf) -> anyhow::Result<()> {
54        let json = serde_json::to_string_pretty(self)?;
55        std::fs::write(path, json)?;
56        Ok(())
57    }
58
59    /// Load state from disk
60    pub fn load(path: &PathBuf) -> anyhow::Result<Self> {
61        let json = std::fs::read_to_string(path)?;
62        Ok(serde_json::from_str(&json)?)
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::steps::{CmdOutput, StepOutput};
70    use std::time::Duration;
71    use tempfile::NamedTempFile;
72
73    fn cmd_output(stdout: &str) -> StepOutput {
74        StepOutput::Cmd(CmdOutput {
75            stdout: stdout.to_string(),
76            stderr: String::new(),
77            exit_code: 0,
78            duration: Duration::ZERO,
79        })
80    }
81
82    #[test]
83    fn save_and_load_roundtrip() {
84        let mut state = WorkflowState::new("test-workflow");
85        state.steps.insert("step1".to_string(), cmd_output("hello"));
86        state.steps.insert("step2".to_string(), cmd_output("world"));
87        state.session_id = Some("abc123".to_string());
88
89        let tmp = NamedTempFile::new().unwrap();
90        let path = tmp.path().to_path_buf();
91
92        state.save(&path).unwrap();
93        let loaded = WorkflowState::load(&path).unwrap();
94
95        assert_eq!(loaded.workflow, "test-workflow");
96        assert_eq!(loaded.session_id, Some("abc123".to_string()));
97        assert_eq!(loaded.steps["step1"].text(), "hello");
98        assert_eq!(loaded.steps["step2"].text(), "world");
99    }
100
101    #[test]
102    fn state_file_path_contains_workflow_name() {
103        let path = WorkflowState::state_file_path("fix-issue");
104        let name = path.file_name().unwrap().to_string_lossy();
105        assert!(name.starts_with("minion-fix-issue-"));
106        assert!(name.ends_with(".state.json"));
107    }
108
109    #[test]
110    fn resume_skips_previous_steps() {
111        // Simulate a resume: steps before the resume point get outputs from state
112        let mut state = WorkflowState::new("my-workflow");
113        state.steps.insert("fetch".to_string(), cmd_output("issue data"));
114        state.steps.insert("plan".to_string(), cmd_output("the plan"));
115
116        let resume_from = "implement";
117        let step_names = ["fetch", "plan", "implement", "test"];
118
119        let mut skipped = vec![];
120        let mut to_execute = vec![];
121        let mut found = false;
122
123        for name in &step_names {
124            if *name == resume_from {
125                found = true;
126            }
127            if found {
128                to_execute.push(*name);
129            } else {
130                skipped.push(*name);
131            }
132        }
133
134        assert!(found, "Resume step must be found");
135        assert_eq!(skipped, ["fetch", "plan"]);
136        assert_eq!(to_execute, ["implement", "test"]);
137
138        // Skipped steps get outputs from state
139        for name in &skipped {
140            assert!(state.steps.contains_key(*name), "State must have output for {name}");
141        }
142    }
143}