Skip to main content

solti_model/resource/
run.rs

1//! # Task run
2//!
3//! [`TaskRun`] records one execution attempt.
4
5use std::time::SystemTime;
6
7use serde::{Deserialize, Serialize};
8
9use crate::{ModelError, ModelResult, TaskPhase, WorkloadTypeMeta};
10
11/// Record of one execution attempt.
12///
13/// An active run has phase `Running` and no finish fields.
14/// A finished run has a terminal phase and `finishedAt`.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase", try_from = "raw::TaskRunRaw")]
17pub struct TaskRun {
18    workload: WorkloadTypeMeta,
19    generation: u64,
20    attempt: u32,
21    phase: TaskPhase,
22    #[serde(with = "super::metadata::rfc3339_time_serde")]
23    started_at: SystemTime,
24    #[serde(
25        skip_serializing_if = "Option::is_none",
26        with = "super::metadata::rfc3339_time_serde::option",
27        default
28    )]
29    finished_at: Option<SystemTime>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    error: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    exit_code: Option<i32>,
34}
35
36#[cfg(feature = "schema")]
37impl schemars::JsonSchema for TaskRun {
38    fn schema_name() -> std::borrow::Cow<'static, str> {
39        "TaskRun".into()
40    }
41
42    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
43        crate::schema::task_run(generator)
44    }
45}
46
47impl TaskRun {
48    /// Creates an active run.
49    ///
50    /// # Errors
51    ///
52    /// Returns [`ModelError::Invalid`] when generation or attempt is zero.
53    pub fn starting(
54        generation: u64,
55        attempt: u32,
56        workload: WorkloadTypeMeta,
57    ) -> ModelResult<Self> {
58        Self::from_parts(
59            workload,
60            generation,
61            attempt,
62            TaskPhase::Running,
63            super::metadata::time_serde::now(),
64            None,
65            None,
66            None,
67        )
68    }
69
70    /// Reconstructs a run from serialized fields.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`ModelError::Invalid`] when identity fields, phase, timestamps, or terminal diagnostics are inconsistent.
75    #[allow(clippy::too_many_arguments)]
76    pub fn from_parts(
77        workload: WorkloadTypeMeta,
78        generation: u64,
79        attempt: u32,
80        phase: TaskPhase,
81        started_at: SystemTime,
82        finished_at: Option<SystemTime>,
83        error: Option<String>,
84        exit_code: Option<i32>,
85    ) -> ModelResult<Self> {
86        let run = Self {
87            workload,
88            generation,
89            attempt,
90            phase,
91            started_at,
92            finished_at,
93            error,
94            exit_code,
95        };
96        run.validate()?;
97        Ok(run)
98    }
99
100    /// Finishes an active run.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`ModelError::Invalid`] when the run is already finished or `phase` is not terminal.
105    pub fn finish(
106        &mut self,
107        phase: TaskPhase,
108        error: Option<String>,
109        exit_code: Option<i32>,
110    ) -> ModelResult<()> {
111        if !self.is_active() {
112            return Err(ModelError::Invalid("task run is already finished".into()));
113        }
114        if !phase.is_terminal() {
115            return Err(ModelError::Invalid(
116                format!("task run requires a terminal phase, got {phase}").into(),
117            ));
118        }
119        self.finished_at = Some(super::metadata::time_serde::now());
120        self.phase = phase;
121        self.error = error;
122        self.exit_code = exit_code;
123        Ok(())
124    }
125
126    /// Workload GVK executed by this run.
127    pub fn workload(&self) -> &WorkloadTypeMeta {
128        &self.workload
129    }
130
131    /// Desired-state generation executed by this run.
132    pub fn generation(&self) -> u64 {
133        self.generation
134    }
135
136    /// One-based attempt number.
137    pub fn attempt(&self) -> u32 {
138        self.attempt
139    }
140
141    /// Current or terminal run phase.
142    pub fn phase(&self) -> TaskPhase {
143        self.phase
144    }
145
146    /// Time when the run started.
147    pub fn started_at(&self) -> SystemTime {
148        self.started_at
149    }
150
151    /// Time when the run finished.
152    pub fn finished_at(&self) -> Option<SystemTime> {
153        self.finished_at
154    }
155
156    /// Terminal diagnostic, when available.
157    pub fn error(&self) -> Option<&str> {
158        self.error.as_deref()
159    }
160
161    /// Process exit code, when available.
162    pub fn exit_code(&self) -> Option<i32> {
163        self.exit_code
164    }
165
166    /// Returns the serialized run fields.
167    #[allow(clippy::type_complexity)]
168    pub fn into_parts(
169        self,
170    ) -> (
171        WorkloadTypeMeta,
172        u64,
173        u32,
174        TaskPhase,
175        SystemTime,
176        Option<SystemTime>,
177        Option<String>,
178        Option<i32>,
179    ) {
180        (
181            self.workload,
182            self.generation,
183            self.attempt,
184            self.phase,
185            self.started_at,
186            self.finished_at,
187            self.error,
188            self.exit_code,
189        )
190    }
191
192    /// Returns whether the run is active.
193    pub fn is_active(&self) -> bool {
194        self.phase == TaskPhase::Running
195    }
196
197    fn validate(&self) -> ModelResult<()> {
198        if self.generation == 0 {
199            return Err(ModelError::Invalid(
200                "task run generation must be greater than zero".into(),
201            ));
202        }
203        if self.attempt == 0 {
204            return Err(ModelError::Invalid(
205                "task run attempt must be greater than zero".into(),
206            ));
207        }
208        match self.phase {
209            TaskPhase::Running if self.finished_at.is_none() => {
210                if self.error.is_some() || self.exit_code.is_some() {
211                    return Err(ModelError::Invalid(
212                        "active task run cannot contain terminal diagnostics".into(),
213                    ));
214                }
215            }
216            phase if phase.is_terminal() && self.finished_at.is_some() => {}
217            TaskPhase::Running => {
218                return Err(ModelError::Invalid(
219                    "active task run cannot have finishedAt".into(),
220                ));
221            }
222            phase if phase.is_terminal() => {
223                return Err(ModelError::Invalid(
224                    "terminal task run requires finishedAt".into(),
225                ));
226            }
227            _ => {
228                return Err(ModelError::Invalid(
229                    "task run phase must be Running or terminal".into(),
230                ));
231            }
232        }
233        Ok(())
234    }
235}
236
237mod raw {
238    use super::*;
239
240    #[derive(Deserialize)]
241    #[serde(rename_all = "camelCase", deny_unknown_fields)]
242    pub(super) struct TaskRunRaw {
243        workload: WorkloadTypeMeta,
244        generation: u64,
245        attempt: u32,
246        phase: TaskPhase,
247        #[serde(with = "super::super::metadata::rfc3339_time_serde")]
248        started_at: SystemTime,
249        #[serde(default, with = "super::super::metadata::rfc3339_time_serde::option")]
250        finished_at: Option<SystemTime>,
251        #[serde(default)]
252        error: Option<String>,
253        #[serde(default)]
254        exit_code: Option<i32>,
255    }
256
257    impl TryFrom<TaskRunRaw> for TaskRun {
258        type Error = ModelError;
259
260        fn try_from(raw: TaskRunRaw) -> Result<Self, Self::Error> {
261            TaskRun::from_parts(
262                raw.workload,
263                raw.generation,
264                raw.attempt,
265                raw.phase,
266                raw.started_at,
267                raw.finished_at,
268                raw.error,
269                raw.exit_code,
270            )
271        }
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    fn workload() -> WorkloadTypeMeta {
280        WorkloadTypeMeta::new("example.io/v1", "Example").unwrap()
281    }
282
283    #[test]
284    fn finish_requires_terminal_phase_and_active_run() {
285        let mut run = TaskRun::starting(1, 1, workload()).unwrap();
286        assert!(run.finish(TaskPhase::Running, None, None).is_err());
287        run.finish(TaskPhase::Succeeded, None, Some(0)).unwrap();
288        assert!(run.finish(TaskPhase::Failed, None, Some(1)).is_err());
289    }
290
291    #[test]
292    fn serde_rejects_inconsistent_and_unknown_run_fields() {
293        let run = TaskRun::starting(1, 1, workload()).unwrap();
294        let mut json = serde_json::to_value(run).unwrap();
295        json["attempt"] = serde_json::json!(0);
296        assert!(serde_json::from_value::<TaskRun>(json).is_err());
297
298        let run = TaskRun::starting(1, 1, workload()).unwrap();
299        let mut json = serde_json::to_value(run).unwrap();
300        json["unexpected"] = serde_json::json!(true);
301        assert!(serde_json::from_value::<TaskRun>(json).is_err());
302
303        let run = TaskRun::starting(1, 1, workload()).unwrap();
304        let mut finished_active = serde_json::to_value(&run).unwrap();
305        finished_active["finishedAt"] = serde_json::json!("2026-01-01T00:00:00Z");
306        assert!(serde_json::from_value::<TaskRun>(finished_active).is_err());
307
308        let mut unfinished_terminal = serde_json::to_value(run).unwrap();
309        unfinished_terminal["phase"] = serde_json::json!("succeeded");
310        assert!(serde_json::from_value::<TaskRun>(unfinished_terminal).is_err());
311    }
312}