minion_engine/engine/
state.rs1use std::collections::HashMap;
2use std::path::PathBuf;
3
4use serde::{Deserialize, Serialize};
5
6use crate::steps::StepOutput;
7
8#[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 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 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 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 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 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 for name in &skipped {
140 assert!(state.steps.contains_key(*name), "State must have output for {name}");
141 }
142 }
143}