Skip to main content

meerkat_runtime/
runtime_state.rs

1//! §22 RuntimeState — the runtime's public state projection.
2//!
3//! Canonical live transition legality lives in the checked-in `MeerkatMachine`
4//! plus the runtime driver that realizes its coarse control transitions.
5
6use serde::{Deserialize, Serialize};
7
8#[cfg(test)]
9fn can_transition(from: &RuntimeState, next: &RuntimeState) -> bool {
10    use RuntimeState::{Attached, Destroyed, Idle, Initializing, Retired, Running, Stopped};
11
12    matches!(
13        (from, next),
14        (Initializing, Idle | Stopped | Destroyed)
15            | (Idle, Attached | Running | Retired | Stopped | Destroyed)
16            | (Attached, Running | Idle | Retired | Stopped | Destroyed)
17            | (Running, Idle | Attached | Retired | Stopped | Destroyed)
18            | (Retired, Running | Stopped | Destroyed)
19            | (Stopped, Destroyed)
20    )
21}
22
23/// The state of a runtime instance.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26#[non_exhaustive]
27pub enum RuntimeState {
28    /// Initializing (first state after creation).
29    Initializing,
30    /// Idle — no executor attached, no run in progress, ready to accept input.
31    Idle,
32    /// Attached — executor attached, runtime loop alive, waiting for input.
33    Attached,
34    /// A run is in progress.
35    Running,
36    /// Retired — no longer accepting new input, draining existing.
37    Retired,
38    /// Permanently stopped (terminal).
39    Stopped,
40    /// Destroyed (terminal).
41    Destroyed,
42}
43
44impl RuntimeState {
45    /// Check if this is a terminal state.
46    ///
47    /// Only `Destroyed` is terminal. `Stopped` allows transitions like
48    /// `RegisterSession`, `UnregisterSession`, `PrepareBindings`, and `Destroy`.
49    pub fn is_terminal(&self) -> bool {
50        matches!(self, Self::Destroyed)
51    }
52
53    /// Check if the runtime can accept new input in this state.
54    pub fn can_accept_input(&self) -> bool {
55        matches!(self, Self::Idle | Self::Attached | Self::Running)
56    }
57
58    /// Check if the runtime can process queued inputs in this state.
59    pub fn can_process_queue(&self) -> bool {
60        matches!(self, Self::Idle | Self::Attached | Self::Retired)
61    }
62
63    /// Check if the runtime is in the Attached state.
64    pub fn is_attached(&self) -> bool {
65        matches!(self, Self::Attached)
66    }
67
68    /// Check if the runtime is Idle or Attached.
69    pub fn is_idle_or_attached(&self) -> bool {
70        matches!(self, Self::Idle | Self::Attached)
71    }
72}
73
74/// Classify the machine-owned coarse phase a run should return to after a
75/// terminal outcome.
76///
77/// The checked-in `MeerkatMachine` owns the return-phase semantics through its
78/// `current_run_id` / `pre_run_phase` state. Runtime helpers may realize that
79/// projection, but they should not invent the mapping themselves.
80pub fn run_return_phase_from_pre_run_phase(pre_run_phase: Option<RuntimeState>) -> RuntimeState {
81    match pre_run_phase {
82        Some(RuntimeState::Attached) => RuntimeState::Attached,
83        Some(RuntimeState::Retired) => RuntimeState::Retired,
84        Some(
85            RuntimeState::Idle
86            | RuntimeState::Initializing
87            | RuntimeState::Running
88            | RuntimeState::Stopped
89            | RuntimeState::Destroyed,
90        )
91        | None => RuntimeState::Idle,
92    }
93}
94
95/// Classify the machine-owned pre-run phase that should be remembered when a
96/// new run starts from the current coarse runtime phase.
97pub fn run_start_pre_phase_from_phase(
98    phase: RuntimeState,
99) -> Result<RuntimeState, RuntimeStateTransitionError> {
100    match phase {
101        RuntimeState::Idle | RuntimeState::Attached | RuntimeState::Retired => Ok(phase),
102        from => Err(RuntimeStateTransitionError {
103            from,
104            to: RuntimeState::Running,
105        }),
106    }
107}
108
109impl std::fmt::Display for RuntimeState {
110    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111        match self {
112            Self::Initializing => write!(f, "initializing"),
113            Self::Idle => write!(f, "idle"),
114            Self::Attached => write!(f, "attached"),
115            Self::Running => write!(f, "running"),
116            Self::Retired => write!(f, "retired"),
117            Self::Stopped => write!(f, "stopped"),
118            Self::Destroyed => write!(f, "destroyed"),
119        }
120    }
121}
122
123/// Error when an invalid runtime state transition is attempted.
124#[derive(Debug, Clone, thiserror::Error)]
125#[error("Invalid runtime state transition: {from} -> {to}")]
126pub struct RuntimeStateTransitionError {
127    pub from: RuntimeState,
128    pub to: RuntimeState,
129}
130
131#[cfg(test)]
132#[allow(clippy::unwrap_used)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn terminal_states() {
138        assert!(!RuntimeState::Stopped.is_terminal());
139        assert!(RuntimeState::Destroyed.is_terminal());
140        assert!(!RuntimeState::Initializing.is_terminal());
141        assert!(!RuntimeState::Idle.is_terminal());
142        assert!(!RuntimeState::Attached.is_terminal());
143        assert!(!RuntimeState::Running.is_terminal());
144        assert!(!RuntimeState::Retired.is_terminal());
145    }
146
147    #[test]
148    fn input_and_queue_capabilities() {
149        assert!(RuntimeState::Idle.can_accept_input());
150        assert!(RuntimeState::Attached.can_accept_input());
151        assert!(RuntimeState::Running.can_accept_input());
152        assert!(!RuntimeState::Retired.can_accept_input());
153
154        assert!(RuntimeState::Idle.can_process_queue());
155        assert!(RuntimeState::Attached.can_process_queue());
156        assert!(RuntimeState::Retired.can_process_queue());
157        assert!(!RuntimeState::Running.can_process_queue());
158    }
159
160    #[test]
161    fn attachment_predicates() {
162        assert!(RuntimeState::Attached.is_attached());
163        assert!(RuntimeState::Idle.is_idle_or_attached());
164        assert!(RuntimeState::Attached.is_idle_or_attached());
165        assert!(!RuntimeState::Running.is_idle_or_attached());
166    }
167
168    #[test]
169    fn transition_table_matches_spec_examples() {
170        assert!(can_transition(
171            &RuntimeState::Initializing,
172            &RuntimeState::Idle
173        ));
174        assert!(can_transition(&RuntimeState::Idle, &RuntimeState::Attached));
175        assert!(can_transition(
176            &RuntimeState::Attached,
177            &RuntimeState::Running
178        ));
179        assert!(can_transition(
180            &RuntimeState::Running,
181            &RuntimeState::Retired
182        ));
183        assert!(can_transition(
184            &RuntimeState::Retired,
185            &RuntimeState::Stopped
186        ));
187
188        assert!(!can_transition(&RuntimeState::Stopped, &RuntimeState::Idle));
189        assert!(!can_transition(
190            &RuntimeState::Destroyed,
191            &RuntimeState::Running
192        ));
193        assert!(!can_transition(&RuntimeState::Retired, &RuntimeState::Idle));
194    }
195
196    #[test]
197    fn run_return_phase_classifier_matches_machine_projection() {
198        assert_eq!(
199            run_return_phase_from_pre_run_phase(Some(RuntimeState::Idle)),
200            RuntimeState::Idle
201        );
202        assert_eq!(
203            run_return_phase_from_pre_run_phase(Some(RuntimeState::Attached)),
204            RuntimeState::Attached
205        );
206        assert_eq!(
207            run_return_phase_from_pre_run_phase(Some(RuntimeState::Retired)),
208            RuntimeState::Retired
209        );
210        assert_eq!(
211            run_return_phase_from_pre_run_phase(None),
212            RuntimeState::Idle
213        );
214    }
215
216    #[test]
217    fn run_start_pre_phase_classifier_matches_machine_projection() {
218        assert!(
219            matches!(
220                run_start_pre_phase_from_phase(RuntimeState::Idle),
221                Ok(RuntimeState::Idle)
222            ),
223            "idle should be a legal run start phase"
224        );
225        assert!(
226            matches!(
227                run_start_pre_phase_from_phase(RuntimeState::Attached),
228                Ok(RuntimeState::Attached)
229            ),
230            "attached should be a legal run start phase"
231        );
232        assert!(
233            matches!(
234                run_start_pre_phase_from_phase(RuntimeState::Retired),
235                Ok(RuntimeState::Retired)
236            ),
237            "retired should be a legal drain start phase"
238        );
239        assert!(
240            run_start_pre_phase_from_phase(RuntimeState::Stopped).is_err(),
241            "stopped should not be a legal run start phase"
242        );
243    }
244
245    #[test]
246    fn transition_failure_shape_matches_runtime_error() {
247        let result = if can_transition(&RuntimeState::Stopped, &RuntimeState::Idle) {
248            Ok(())
249        } else {
250            Err(RuntimeStateTransitionError {
251                from: RuntimeState::Stopped,
252                to: RuntimeState::Idle,
253            })
254        };
255
256        assert!(result.is_err());
257        assert!(matches!(
258            result.unwrap_err(),
259            RuntimeStateTransitionError {
260                from: RuntimeState::Stopped,
261                to: RuntimeState::Idle
262            }
263        ));
264    }
265
266    #[test]
267    fn serde_roundtrip_all_states() {
268        for state in [
269            RuntimeState::Initializing,
270            RuntimeState::Idle,
271            RuntimeState::Attached,
272            RuntimeState::Running,
273            RuntimeState::Retired,
274            RuntimeState::Stopped,
275            RuntimeState::Destroyed,
276        ] {
277            let json = serde_json::to_value(state).unwrap();
278            let parsed: RuntimeState = serde_json::from_value(json).unwrap();
279            assert_eq!(state, parsed);
280        }
281    }
282
283    #[test]
284    fn display() {
285        assert_eq!(RuntimeState::Idle.to_string(), "idle");
286        assert_eq!(RuntimeState::Attached.to_string(), "attached");
287        assert_eq!(RuntimeState::Running.to_string(), "running");
288        assert_eq!(RuntimeState::Destroyed.to_string(), "destroyed");
289    }
290}