Skip to main content

artifactize_core/store/
lifecycle.rs

1//! Payload-carrying lifecycle states; the legacy flat JSON is parsed at the edge.
2
3use super::ExecutionResult;
4use crate::types::{ExecutionStatus, FailureCode, RequestStatus, RunStatus, Timestamp};
5
6/// Non-active, non-verdict request states historically allow an optional completion time.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum PendingRequest {
9    Queued,
10    Blocked,
11    BudgetExhausted,
12    Stale,
13    Unreviewed,
14    WaitDependency,
15}
16
17#[derive(Debug, Clone)]
18pub enum ExecutionState {
19    Running,
20    WaitingHuman,
21    Completed {
22        result: ExecutionResult,
23        at: Timestamp,
24    },
25    Failed {
26        error: String,
27        code: Option<FailureCode>,
28        at: Timestamp,
29    },
30}
31
32/// Evidence that has passed its criteria, while a dependency sign-off is still pending.
33/// The private payload keeps a RED result out of the public provisional state.
34#[derive(Debug, Clone, PartialEq)]
35pub struct GreenResult(ExecutionResult);
36
37impl TryFrom<ExecutionResult> for GreenResult {
38    type Error = String;
39    fn try_from(result: ExecutionResult) -> Result<Self, Self::Error> {
40        if result.verdict() != crate::runtime::Verdict::Green {
41            return Err("A provisional request requires GREEN evidence.".into());
42        }
43        Ok(Self(result))
44    }
45}
46impl AsRef<ExecutionResult> for GreenResult {
47    fn as_ref(&self) -> &ExecutionResult {
48        &self.0
49    }
50}
51impl From<GreenResult> for ExecutionResult {
52    fn from(result: GreenResult) -> Self {
53        result.0
54    }
55}
56
57#[derive(Debug, Clone)]
58pub enum RequestState {
59    Running,
60    WaitingHuman,
61    Deferred,
62    Provisional {
63        result: GreenResult,
64        at: Timestamp,
65    },
66    Pending {
67        kind: PendingRequest,
68        at: Option<Timestamp>,
69    },
70    Completed {
71        result: ExecutionResult,
72        at: Timestamp,
73    },
74    Failed {
75        error: String,
76        code: Option<FailureCode>,
77        at: Timestamp,
78    },
79}
80
81impl RequestState {
82    /// Construct a pending state without inventing result or error data.
83    pub fn pending(status: RequestStatus, at: Option<Timestamp>) -> Result<Self, String> {
84        Self::from_parts(status, None, None, None, at)
85    }
86
87    pub fn completed(result: ExecutionResult, at: Timestamp) -> Self {
88        Self::Completed { result, at }
89    }
90
91    pub fn failed(error: String, code: Option<FailureCode>, at: Timestamp) -> Self {
92        Self::Failed { error, code, at }
93    }
94
95    pub(super) fn from_parts(
96        status: RequestStatus,
97        result: Option<ExecutionResult>,
98        error: Option<String>,
99        code: Option<FailureCode>,
100        at: Option<Timestamp>,
101    ) -> Result<Self, String> {
102        super::validation::result(
103            status,
104            result.as_ref(),
105            error.as_deref(),
106            code.map(FailureCode::as_str),
107        )?;
108        match status {
109            RequestStatus::Provisional => Ok(Self::Provisional {
110                result: result.expect("validated provisional GREEN").try_into()?,
111                at: at.ok_or("A provisional request needs its completion time.")?,
112            }),
113            RequestStatus::Green | RequestStatus::Red => Ok(Self::Completed {
114                result: result.expect("validated verdict"),
115                at: at.ok_or("A finished request needs its completion time.")?,
116            }),
117            RequestStatus::Error => Ok(Self::Failed {
118                error: error.expect("validated error"),
119                code,
120                at: at.ok_or("A finished request needs its completion time.")?,
121            }),
122            RequestStatus::Running | RequestStatus::WaitingHuman | RequestStatus::Deferred
123                if at.is_some() =>
124            {
125                Err("An active request cannot have a completion time.".into())
126            }
127            RequestStatus::Running => Ok(Self::Running),
128            RequestStatus::WaitingHuman => Ok(Self::WaitingHuman),
129            RequestStatus::Deferred => Ok(Self::Deferred),
130            _ => Ok(Self::Pending {
131                kind: match status {
132                    RequestStatus::Queued => PendingRequest::Queued,
133                    RequestStatus::Blocked => PendingRequest::Blocked,
134                    RequestStatus::BudgetExhausted => PendingRequest::BudgetExhausted,
135                    RequestStatus::Stale => PendingRequest::Stale,
136                    RequestStatus::Unreviewed => PendingRequest::Unreviewed,
137                    RequestStatus::WaitDependency => PendingRequest::WaitDependency,
138                    _ => unreachable!("terminal and active states handled above"),
139                },
140                at,
141            }),
142        }
143    }
144
145    pub fn status(&self) -> RequestStatus {
146        match self {
147            Self::Running => RequestStatus::Running,
148            Self::WaitingHuman => RequestStatus::WaitingHuman,
149            Self::Deferred => RequestStatus::Deferred,
150            Self::Provisional { .. } => RequestStatus::Provisional,
151            Self::Completed { result, .. } => match result.verdict() {
152                crate::runtime::Verdict::Green => RequestStatus::Green,
153                crate::runtime::Verdict::Red => RequestStatus::Red,
154            },
155            Self::Failed { .. } => RequestStatus::Error,
156            Self::Pending { kind, .. } => match kind {
157                PendingRequest::Queued => RequestStatus::Queued,
158                PendingRequest::Blocked => RequestStatus::Blocked,
159                PendingRequest::BudgetExhausted => RequestStatus::BudgetExhausted,
160                PendingRequest::Stale => RequestStatus::Stale,
161                PendingRequest::Unreviewed => RequestStatus::Unreviewed,
162                PendingRequest::WaitDependency => RequestStatus::WaitDependency,
163            },
164        }
165    }
166    pub fn result(&self) -> Option<&ExecutionResult> {
167        match self {
168            Self::Completed { result, .. } => Some(result),
169            Self::Provisional { result, .. } => Some(result.as_ref()),
170            _ => None,
171        }
172    }
173    pub fn error(&self) -> Option<&str> {
174        match self {
175            Self::Failed { error, .. } => Some(error),
176            _ => None,
177        }
178    }
179    pub fn error_code(&self) -> Option<FailureCode> {
180        match self {
181            Self::Failed { code, .. } => *code,
182            _ => None,
183        }
184    }
185    pub fn completed_at(&self) -> Option<Timestamp> {
186        match self {
187            Self::Completed { at, .. } | Self::Provisional { at, .. } | Self::Failed { at, .. } => {
188                Some(*at)
189            }
190            Self::Pending { at, .. } => *at,
191            _ => None,
192        }
193    }
194}
195
196impl ExecutionState {
197    pub(super) fn from_parts(
198        status: ExecutionStatus,
199        result: Option<ExecutionResult>,
200        error: Option<String>,
201        code: Option<FailureCode>,
202        at: Option<Timestamp>,
203    ) -> Result<Self, String> {
204        let state = RequestState::from_parts(status.into(), result, error, code, at)?;
205        Self::try_from(state)
206    }
207    pub fn status(&self) -> ExecutionStatus {
208        match self {
209            Self::Running => ExecutionStatus::Running,
210            Self::WaitingHuman => ExecutionStatus::WaitingHuman,
211            Self::Completed { result, .. } => match result.verdict() {
212                crate::runtime::Verdict::Green => ExecutionStatus::Green,
213                crate::runtime::Verdict::Red => ExecutionStatus::Red,
214            },
215            Self::Failed { .. } => ExecutionStatus::Error,
216        }
217    }
218    pub fn result(&self) -> Option<&ExecutionResult> {
219        match self {
220            Self::Completed { result, .. } => Some(result),
221            _ => None,
222        }
223    }
224    pub fn error(&self) -> Option<&str> {
225        match self {
226            Self::Failed { error, .. } => Some(error),
227            _ => None,
228        }
229    }
230    pub fn error_code(&self) -> Option<FailureCode> {
231        match self {
232            Self::Failed { code, .. } => *code,
233            _ => None,
234        }
235    }
236    pub fn completed_at(&self) -> Option<Timestamp> {
237        match self {
238            Self::Completed { at, .. } | Self::Failed { at, .. } => Some(*at),
239            _ => None,
240        }
241    }
242}
243impl TryFrom<RequestState> for ExecutionState {
244    type Error = String;
245    fn try_from(state: RequestState) -> Result<Self, String> {
246        match state {
247            RequestState::Running => Ok(Self::Running),
248            RequestState::WaitingHuman | RequestState::Deferred => Ok(Self::WaitingHuman),
249            RequestState::Provisional { result, at } => Ok(Self::Completed {
250                result: result.into(),
251                at,
252            }),
253            RequestState::Completed { result, at } => Ok(Self::Completed { result, at }),
254            RequestState::Failed { error, code, at } => Ok(Self::Failed { error, code, at }),
255            RequestState::Pending { .. } => {
256                Err("A pending request has no execution lifecycle state.".into())
257            }
258        }
259    }
260}
261impl From<ExecutionState> for RequestState {
262    fn from(state: ExecutionState) -> Self {
263        match state {
264            ExecutionState::Running => Self::Running,
265            ExecutionState::WaitingHuman => Self::WaitingHuman,
266            ExecutionState::Completed { result, at } => Self::Completed { result, at },
267            ExecutionState::Failed { error, code, at } => Self::Failed { error, code, at },
268        }
269    }
270}
271
272/// Run completion data exists only in a finished lifecycle state.
273#[derive(Debug, Clone)]
274pub enum RunState {
275    Running,
276    SignoffsPending {
277        at: Timestamp,
278    },
279    Green {
280        at: Timestamp,
281    },
282    Red {
283        at: Timestamp,
284    },
285    Error {
286        at: Timestamp,
287        error: Option<String>,
288    },
289    Incomplete {
290        at: Timestamp,
291        error: Option<String>,
292    },
293}
294
295impl RunState {
296    pub(super) fn from_parts(
297        status: RunStatus,
298        at: Option<Timestamp>,
299        error: Option<String>,
300    ) -> Result<Self, String> {
301        match status {
302            RunStatus::Running if at.is_none() && error.is_none() => Ok(Self::Running),
303            RunStatus::Running => {
304                Err("A running Run cannot be completed or carry an error.".into())
305            }
306            RunStatus::Green | RunStatus::Red | RunStatus::SignoffsPending
307                if error.is_some() || at.is_none() =>
308            {
309                Err(
310                    "A completed verdict Run needs a completion time and cannot carry an error."
311                        .into(),
312                )
313            }
314            RunStatus::SignoffsPending => Ok(Self::SignoffsPending {
315                at: at.expect("validated completion time"),
316            }),
317            RunStatus::Green => Ok(Self::Green {
318                at: at.expect("validated completion time"),
319            }),
320            RunStatus::Red => Ok(Self::Red {
321                at: at.expect("validated completion time"),
322            }),
323            RunStatus::Error => Ok(Self::Error {
324                at: at.ok_or("A finished Run needs its completion time.")?,
325                error,
326            }),
327            RunStatus::Incomplete => Ok(Self::Incomplete {
328                at: at.ok_or("A finished Run needs its completion time.")?,
329                error,
330            }),
331        }
332    }
333    pub fn status(&self) -> RunStatus {
334        match self {
335            Self::Running => RunStatus::Running,
336            Self::SignoffsPending { .. } => RunStatus::SignoffsPending,
337            Self::Green { .. } => RunStatus::Green,
338            Self::Red { .. } => RunStatus::Red,
339            Self::Error { .. } => RunStatus::Error,
340            Self::Incomplete { .. } => RunStatus::Incomplete,
341        }
342    }
343    pub fn completed_at(&self) -> Option<Timestamp> {
344        match self {
345            Self::Running => None,
346            Self::SignoffsPending { at }
347            | Self::Green { at }
348            | Self::Red { at }
349            | Self::Error { at, .. }
350            | Self::Incomplete { at, .. } => Some(*at),
351        }
352    }
353    pub fn error(&self) -> Option<&str> {
354        match self {
355            Self::Error { error, .. } | Self::Incomplete { error, .. } => error.as_deref(),
356            _ => None,
357        }
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use serde_json::json;
365
366    #[test]
367    fn provisional_constructor_rejects_red_and_preserves_green_evidence_when_promoted() {
368        let red = ExecutionResult::try_from(json!({"verdict":"RED","reason":"failed"})).unwrap();
369        assert!(GreenResult::try_from(red).is_err());
370        let expected = json!({"verdict":"GREEN","summary":"criteria met"});
371        let green = ExecutionResult::try_from(expected.clone()).unwrap();
372        let provisional = RequestState::Provisional {
373            result: green.try_into().unwrap(),
374            at: "2026-01-01T00:00:00Z".parse().unwrap(),
375        };
376        assert_eq!(provisional.status(), RequestStatus::Provisional);
377        assert_eq!(provisional.result().unwrap().to_json(), expected);
378        let execution = ExecutionState::try_from(provisional).unwrap();
379        assert_eq!(execution.status(), ExecutionStatus::Green);
380        assert_eq!(execution.result().unwrap().to_json(), expected);
381    }
382}