Skip to main content

meerkat_runtime/
state_machine.rs

1//! Concrete RuntimeStateMachine — wraps RuntimeState with RunId tracking.
2//!
3//! Enforces §22 transitions and tracks which run is active.
4
5use meerkat_core::lifecycle::RunId;
6
7use crate::runtime_state::{RuntimeState, RuntimeStateTransitionError};
8
9/// Concrete runtime state machine with run tracking.
10#[derive(Debug, Clone)]
11pub struct RuntimeStateMachine {
12    state: RuntimeState,
13    current_run_id: Option<RunId>,
14    /// State before entering Running — used to return to Retired after drain.
15    pre_run_state: Option<RuntimeState>,
16}
17
18impl RuntimeStateMachine {
19    /// Create a new state machine in the Initializing state.
20    pub fn new() -> Self {
21        Self {
22            state: RuntimeState::Initializing,
23            current_run_id: None,
24            pre_run_state: None,
25        }
26    }
27
28    /// Create from an existing state (for recovery).
29    pub fn from_state(state: RuntimeState) -> Self {
30        Self {
31            state,
32            current_run_id: None,
33            pre_run_state: None,
34        }
35    }
36
37    /// Get the current state.
38    pub fn state(&self) -> RuntimeState {
39        self.state
40    }
41
42    /// Get the current run ID (if running).
43    pub fn current_run_id(&self) -> Option<&RunId> {
44        self.current_run_id.as_ref()
45    }
46
47    /// Check if the runtime is idle.
48    pub fn is_idle(&self) -> bool {
49        self.state == RuntimeState::Idle
50    }
51
52    /// Check if the runtime is running.
53    pub fn is_running(&self) -> bool {
54        self.state == RuntimeState::Running
55    }
56
57    /// Check if the runtime can process queued inputs.
58    ///
59    /// True for Idle and Retired (Retired drains existing queue).
60    pub fn can_process_queue(&self) -> bool {
61        self.state.can_process_queue()
62    }
63
64    /// Transition to a new state.
65    pub fn transition(
66        &mut self,
67        next: RuntimeState,
68    ) -> Result<RuntimeState, RuntimeStateTransitionError> {
69        let from = self.state;
70        self.state.transition(next)?;
71
72        // Clear run ID when leaving Running
73        if from == RuntimeState::Running && next != RuntimeState::Running {
74            self.current_run_id = None;
75            self.pre_run_state = None;
76        }
77
78        Ok(from)
79    }
80
81    /// Transition to Running with a specific run ID.
82    ///
83    /// Records the pre-run state so `complete_run()` can return to it
84    /// (e.g. Retired → Running → Retired for queue drain).
85    pub fn start_run(&mut self, run_id: RunId) -> Result<(), RuntimeStateTransitionError> {
86        let from = self.state;
87        self.state.transition(RuntimeState::Running)?;
88        self.pre_run_state = Some(from);
89        self.current_run_id = Some(run_id);
90        Ok(())
91    }
92
93    /// Transition from Running back to the pre-run state (run completed).
94    ///
95    /// Returns to Retired if the run was started from Retired (drain mode),
96    /// otherwise returns to Idle.
97    pub fn complete_run(&mut self) -> Result<RunId, RuntimeStateTransitionError> {
98        let return_to = match self.pre_run_state.take() {
99            Some(RuntimeState::Retired) => RuntimeState::Retired,
100            _ => RuntimeState::Idle,
101        };
102        self.state.transition(return_to)?;
103        self.current_run_id
104            .take()
105            .ok_or(RuntimeStateTransitionError {
106                from: RuntimeState::Running,
107                to: return_to,
108            })
109    }
110
111    /// Mark as initialized (Initializing → Idle).
112    pub fn initialize(&mut self) -> Result<(), RuntimeStateTransitionError> {
113        self.state.transition(RuntimeState::Idle)
114    }
115
116    /// Reset the runtime back to Idle after lifecycle cleanup.
117    ///
118    /// Allowed only when not Running. Revives a retired runtime so callers
119    /// can keep using the same logical runtime instance after abandoning
120    /// queued work.
121    pub fn reset_to_idle(&mut self) -> Result<Option<RuntimeState>, RuntimeStateTransitionError> {
122        let from = self.state;
123        match from {
124            RuntimeState::Idle => Ok(None),
125            RuntimeState::Running => Err(RuntimeStateTransitionError {
126                from: RuntimeState::Running,
127                to: RuntimeState::Idle,
128            }),
129            RuntimeState::Retired => {
130                self.state = RuntimeState::Idle;
131                self.current_run_id = None;
132                self.pre_run_state = None;
133                Ok(Some(from))
134            }
135            _ => {
136                self.state.transition(RuntimeState::Idle)?;
137                self.current_run_id = None;
138                self.pre_run_state = None;
139                Ok(Some(from))
140            }
141        }
142    }
143}
144
145impl Default for RuntimeStateMachine {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151#[cfg(test)]
152#[allow(clippy::unwrap_used)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn new_starts_initializing() {
158        let sm = RuntimeStateMachine::new();
159        assert_eq!(sm.state(), RuntimeState::Initializing);
160        assert!(sm.current_run_id().is_none());
161    }
162
163    #[test]
164    fn initialize_transitions_to_idle() {
165        let mut sm = RuntimeStateMachine::new();
166        sm.initialize().unwrap();
167        assert!(sm.is_idle());
168    }
169
170    #[test]
171    fn start_run_transitions_to_running() {
172        let mut sm = RuntimeStateMachine::new();
173        sm.initialize().unwrap();
174        let run_id = RunId::new();
175        sm.start_run(run_id.clone()).unwrap();
176        assert!(sm.is_running());
177        assert_eq!(sm.current_run_id(), Some(&run_id));
178    }
179
180    #[test]
181    fn complete_run_returns_to_idle() {
182        let mut sm = RuntimeStateMachine::new();
183        sm.initialize().unwrap();
184        let run_id = RunId::new();
185        sm.start_run(run_id.clone()).unwrap();
186        let completed_id = sm.complete_run().unwrap();
187        assert_eq!(completed_id, run_id);
188        assert!(sm.is_idle());
189        assert!(sm.current_run_id().is_none());
190    }
191
192    #[test]
193    fn transition_clears_run_id() {
194        let mut sm = RuntimeStateMachine::new();
195        sm.initialize().unwrap();
196        sm.start_run(RunId::new()).unwrap();
197        sm.transition(RuntimeState::Recovering).unwrap();
198        assert!(sm.current_run_id().is_none());
199    }
200
201    #[test]
202    fn from_state_recovery() {
203        let sm = RuntimeStateMachine::from_state(RuntimeState::Recovering);
204        assert_eq!(sm.state(), RuntimeState::Recovering);
205    }
206
207    #[test]
208    fn idle_running_idle_cycle() {
209        let mut sm = RuntimeStateMachine::new();
210        sm.initialize().unwrap();
211
212        for _ in 0..3 {
213            sm.start_run(RunId::new()).unwrap();
214            assert!(sm.is_running());
215            sm.complete_run().unwrap();
216            assert!(sm.is_idle());
217        }
218    }
219
220    #[test]
221    fn invalid_transition_rejected() {
222        let mut sm = RuntimeStateMachine::new();
223        // Can't go straight to Running from Initializing
224        assert!(sm.transition(RuntimeState::Running).is_err());
225    }
226
227    #[test]
228    fn retire_from_idle() {
229        let mut sm = RuntimeStateMachine::new();
230        sm.initialize().unwrap();
231        sm.transition(RuntimeState::Retired).unwrap();
232        assert_eq!(sm.state(), RuntimeState::Retired);
233    }
234
235    #[test]
236    fn stop_from_retired() {
237        let mut sm = RuntimeStateMachine::new();
238        sm.initialize().unwrap();
239        sm.transition(RuntimeState::Retired).unwrap();
240        sm.transition(RuntimeState::Stopped).unwrap();
241        assert!(sm.state().is_terminal());
242    }
243
244    #[test]
245    fn reset_from_retired_returns_to_idle() {
246        let mut sm = RuntimeStateMachine::new();
247        sm.initialize().unwrap();
248        sm.transition(RuntimeState::Retired).unwrap();
249        let from = sm.reset_to_idle().unwrap();
250        assert_eq!(from, Some(RuntimeState::Retired));
251        assert_eq!(sm.state(), RuntimeState::Idle);
252        assert!(sm.current_run_id().is_none());
253    }
254
255    #[test]
256    fn reset_rejected_while_running() {
257        let mut sm = RuntimeStateMachine::new();
258        sm.initialize().unwrap();
259        sm.start_run(RunId::new()).unwrap();
260        assert!(sm.reset_to_idle().is_err());
261        assert!(sm.is_running()); // unchanged
262    }
263
264    #[test]
265    fn destroy_from_idle() {
266        let mut sm = RuntimeStateMachine::new();
267        sm.initialize().unwrap();
268        sm.transition(RuntimeState::Destroyed).unwrap();
269        assert!(sm.state().is_terminal());
270    }
271
272    #[test]
273    fn recovering_to_running() {
274        let mut sm = RuntimeStateMachine::from_state(RuntimeState::Recovering);
275        sm.start_run(RunId::new()).unwrap();
276        assert!(sm.is_running());
277    }
278
279    #[test]
280    fn retired_drain_cycle() {
281        let mut sm = RuntimeStateMachine::new();
282        sm.initialize().unwrap();
283        sm.transition(RuntimeState::Retired).unwrap();
284        assert!(sm.can_process_queue());
285
286        // Start a drain run from Retired
287        let run_id = RunId::new();
288        sm.start_run(run_id.clone()).unwrap();
289        assert!(sm.is_running());
290
291        // Complete returns to Retired (not Idle)
292        let completed = sm.complete_run().unwrap();
293        assert_eq!(completed, run_id);
294        assert_eq!(sm.state(), RuntimeState::Retired);
295    }
296
297    #[test]
298    fn idle_run_returns_to_idle() {
299        let mut sm = RuntimeStateMachine::new();
300        sm.initialize().unwrap();
301
302        let run_id = RunId::new();
303        sm.start_run(run_id.clone()).unwrap();
304        let completed = sm.complete_run().unwrap();
305        assert_eq!(completed, run_id);
306        assert_eq!(sm.state(), RuntimeState::Idle);
307    }
308
309    #[test]
310    fn can_process_queue_states() {
311        let sm_idle = RuntimeStateMachine::from_state(RuntimeState::Idle);
312        assert!(sm_idle.can_process_queue());
313
314        let sm_retired = RuntimeStateMachine::from_state(RuntimeState::Retired);
315        assert!(sm_retired.can_process_queue());
316
317        let sm_running = RuntimeStateMachine::from_state(RuntimeState::Running);
318        assert!(!sm_running.can_process_queue());
319
320        let sm_stopped = RuntimeStateMachine::from_state(RuntimeState::Stopped);
321        assert!(!sm_stopped.can_process_queue());
322    }
323}