1use std::time::SystemTime;
6
7use serde::{Deserialize, Serialize};
8
9use crate::{ModelError, ModelResult, TaskPhase, WorkloadTypeMeta};
10
11#[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 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 #[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 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 pub fn workload(&self) -> &WorkloadTypeMeta {
128 &self.workload
129 }
130
131 pub fn generation(&self) -> u64 {
133 self.generation
134 }
135
136 pub fn attempt(&self) -> u32 {
138 self.attempt
139 }
140
141 pub fn phase(&self) -> TaskPhase {
143 self.phase
144 }
145
146 pub fn started_at(&self) -> SystemTime {
148 self.started_at
149 }
150
151 pub fn finished_at(&self) -> Option<SystemTime> {
153 self.finished_at
154 }
155
156 pub fn error(&self) -> Option<&str> {
158 self.error.as_deref()
159 }
160
161 pub fn exit_code(&self) -> Option<i32> {
163 self.exit_code
164 }
165
166 #[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 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}