paramodel_elements/lifecycle.rs
1// Copyright (c) Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Lifecycle metadata: health-check timing, operational state, and
5//! shutdown semantics.
6//!
7//! Per SRD-0007 D9–D11: `HealthCheckSpec` controls readiness polling;
8//! `OperationalState` is the 11-variant runtime lifecycle enum;
9//! `ShutdownSemantics` decides whether reducto emits a `Teardown` or
10//! an `Await` step.
11
12use std::time::Duration;
13
14use jiff::Timestamp;
15use serde::{Deserialize, Serialize};
16
17// ---------------------------------------------------------------------------
18// HealthCheckSpec.
19// ---------------------------------------------------------------------------
20
21/// Readiness-polling timing.
22///
23/// The host system owns the health-check mechanism (protocol, endpoint,
24/// acceptance); paramodel only needs the timing parameters so the
25/// runtime can coordinate.
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub struct HealthCheckSpec {
28 /// Maximum total duration the runtime waits for readiness.
29 #[serde(with = "duration_secs")]
30 pub timeout: Duration,
31 /// Number of retry attempts before giving up.
32 pub max_retries: u32,
33 /// Delay between retry attempts.
34 #[serde(with = "duration_secs")]
35 pub retry_interval: Duration,
36}
37
38impl HealthCheckSpec {
39 /// Construct a health-check spec.
40 #[must_use]
41 pub const fn new(timeout: Duration, max_retries: u32, retry_interval: Duration) -> Self {
42 Self {
43 timeout,
44 max_retries,
45 retry_interval,
46 }
47 }
48}
49
50// Manual Duration serialisation as floating-point seconds keeps the
51// wire format compact and portable across languages.
52mod duration_secs {
53 use serde::{Deserializer, Serializer};
54 use std::time::Duration;
55
56 pub fn serialize<S: Serializer>(
57 d: &Duration,
58 s: S,
59 ) -> std::result::Result<S::Ok, S::Error> {
60 s.serialize_f64(d.as_secs_f64())
61 }
62
63 pub fn deserialize<'de, D: Deserializer<'de>>(
64 d: D,
65 ) -> std::result::Result<Duration, D::Error> {
66 use serde::Deserialize;
67 let secs = f64::deserialize(d)?;
68 if !secs.is_finite() || secs < 0.0 {
69 return Err(serde::de::Error::custom(
70 "duration seconds must be a finite non-negative f64",
71 ));
72 }
73 Ok(Duration::from_secs_f64(secs))
74 }
75}
76
77// ---------------------------------------------------------------------------
78// OperationalState.
79// ---------------------------------------------------------------------------
80
81/// Element runtime lifecycle state.
82///
83/// Normal progression: `Inactive → Provisioning → Starting →
84/// HealthCheck → Ready → Running → Stopping → Stopped → Terminated`.
85/// `Failed` and `Unknown` are non-sequential and reachable from any
86/// state.
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum OperationalState {
90 /// Not yet started.
91 Inactive,
92 /// Infrastructure is being allocated.
93 Provisioning,
94 /// Process is starting up.
95 Starting,
96 /// Verifying readiness.
97 HealthCheck,
98 /// Available for use.
99 Ready,
100 /// Actively serving a trial.
101 Running,
102 /// Graceful shutdown in progress.
103 Stopping,
104 /// Stopped normally; resources still allocated.
105 Stopped,
106 /// Error state; cannot operate.
107 Failed,
108 /// Fully torn down; all resources released.
109 Terminated,
110 /// Status cannot be determined.
111 Unknown,
112}
113
114// ---------------------------------------------------------------------------
115// LiveStatusSummary.
116// ---------------------------------------------------------------------------
117
118/// One-shot state snapshot returned by
119/// [`ElementRuntime::status_check`](crate::ElementRuntime::status_check).
120#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
121pub struct LiveStatusSummary {
122 /// The observed state.
123 pub state: OperationalState,
124 /// One-line human-readable evidence / explanation.
125 pub summary: String,
126}
127
128impl LiveStatusSummary {
129 /// Construct an `Unknown` summary with the given explanation.
130 #[must_use]
131 pub fn unknown(summary: impl Into<String>) -> Self {
132 Self {
133 state: OperationalState::Unknown,
134 summary: summary.into(),
135 }
136 }
137
138 /// Construct an `Inactive` summary with a default message.
139 #[must_use]
140 pub fn inactive() -> Self {
141 Self {
142 state: OperationalState::Inactive,
143 summary: "element not yet started".to_owned(),
144 }
145 }
146}
147
148// ---------------------------------------------------------------------------
149// ShutdownSemantics.
150// ---------------------------------------------------------------------------
151
152/// Whether an element terminates by explicit signal or by self-completion.
153///
154/// Reducto Rule 1 uses this to pick between `Teardown` and `Await`
155/// steps. Default is `Service`.
156#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
157#[serde(rename_all = "snake_case")]
158pub enum ShutdownSemantics {
159 /// Long-running; requires an explicit stop signal.
160 #[default]
161 Service,
162 /// Self-terminating; awaited rather than stopped.
163 Command,
164}
165
166// ---------------------------------------------------------------------------
167// StateTransition.
168// ---------------------------------------------------------------------------
169
170/// One observed transition between two [`OperationalState`]s.
171///
172/// Emitted by [`ElementRuntime::observe_state`](crate::ElementRuntime::observe_state)
173/// listeners. Implementations must deliver a synthetic initial
174/// transition from `Unknown` to the current state so registration acts
175/// as catch-up.
176#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
177pub struct StateTransition {
178 /// Previous state.
179 pub from: OperationalState,
180 /// New state.
181 pub to: OperationalState,
182 /// One-line evidence for the transition.
183 pub summary: String,
184 /// Observation timestamp.
185 pub timestamp: Timestamp,
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191
192 #[test]
193 fn shutdown_default_is_service() {
194 assert_eq!(ShutdownSemantics::default(), ShutdownSemantics::Service);
195 }
196
197 #[test]
198 fn live_status_helpers() {
199 let u = LiveStatusSummary::unknown("probe failed");
200 assert_eq!(u.state, OperationalState::Unknown);
201 assert_eq!(u.summary, "probe failed");
202 let i = LiveStatusSummary::inactive();
203 assert_eq!(i.state, OperationalState::Inactive);
204 }
205
206 #[test]
207 fn operational_state_serde_is_snake_case() {
208 let s = serde_json::to_string(&OperationalState::HealthCheck).unwrap();
209 assert_eq!(s, "\"health_check\"");
210 let back: OperationalState = serde_json::from_str(&s).unwrap();
211 assert_eq!(back, OperationalState::HealthCheck);
212 }
213
214 #[test]
215 fn health_check_spec_serde_roundtrip() {
216 let spec = HealthCheckSpec::new(
217 Duration::from_secs(30),
218 5,
219 Duration::from_millis(500),
220 );
221 let json = serde_json::to_string(&spec).unwrap();
222 let back: HealthCheckSpec = serde_json::from_str(&json).unwrap();
223 assert_eq!(spec, back);
224 }
225
226 #[test]
227 fn state_transition_serde_roundtrip() {
228 let t = StateTransition {
229 from: OperationalState::Unknown,
230 to: OperationalState::Ready,
231 summary: "probe passed".to_owned(),
232 timestamp: Timestamp::from_second(0).unwrap(),
233 };
234 let json = serde_json::to_string(&t).unwrap();
235 let back: StateTransition = serde_json::from_str(&json).unwrap();
236 assert_eq!(t, back);
237 }
238}