Skip to main content

solti_model/resource/
run.rs

1//! # Task run record.
2//!
3//! [`TaskRun`] captures a single execution attempt with start/finish times and outcome.
4
5use std::time::SystemTime;
6
7use serde::{Deserialize, Serialize};
8
9use crate::TaskPhase;
10
11/// Record of a single task execution attempt.
12///
13/// Each time the supervisor starts a task, a new `TaskRun` is created.
14/// When the attempt finishes (success, failure, timeout, etc.), the run is closed with the terminal phase and timestamp.
15///
16/// Runs are associated with a [`Task`](crate::Task) via its [`TaskId`](crate::TaskId) and ordered by attempt number.
17///
18/// ## Also
19///
20/// - [`Task`](crate::Task) parent resource.
21/// - [`TaskPhase`] phase values stored in `phase` field.
22///
23/// # Lifecycle
24///
25/// ```text
26///   TaskStarting  ──►  TaskRun { phase: Running, finished_at: None }
27///        │
28///        ├──► TaskStopped    ──► phase = Succeeded, finished_at = Some(now)
29///        ├──► TaskFailed     ──► phase = Failed,    finished_at = Some(now)
30///        ├──► TimeoutHit     ──► phase = Timeout,   finished_at = Some(now)
31///        └──► ActorExhausted ──► phase = Exhausted, finished_at = Some(now)
32/// ```
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct TaskRun {
36    /// Attempt number (1-based, matches the task's attempt counter after increment).
37    pub attempt: u32,
38    /// Phase this run ended in (or `Running` if still active).
39    pub phase: TaskPhase,
40    /// When the run started.
41    #[serde(with = "super::metadata::time_serde")]
42    pub started_at: SystemTime,
43    /// When the run finished (`None` while still running).
44    #[serde(
45        skip_serializing_if = "Option::is_none",
46        with = "option_time_serde",
47        default
48    )]
49    pub finished_at: Option<SystemTime>,
50    /// Error message (present when phase is Failed/Timeout/Exhausted).
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub error: Option<String>,
53    /// Process exit code (Subprocess/Container only).
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub exit_code: Option<i32>,
56}
57
58impl TaskRun {
59    /// Create a new run record for an attempt that just started.
60    pub fn starting(attempt: u32) -> Self {
61        Self {
62            attempt,
63            phase: TaskPhase::Running,
64            started_at: SystemTime::now(),
65            finished_at: None,
66            error: None,
67            exit_code: None,
68        }
69    }
70
71    /// Close the run with a terminal phase.
72    pub fn finish(&mut self, phase: TaskPhase, error: Option<String>, exit_code: Option<i32>) {
73        self.finished_at = Some(SystemTime::now());
74        self.phase = phase;
75        self.error = error;
76        self.exit_code = exit_code;
77    }
78
79    /// Whether this run is still in progress.
80    pub fn is_active(&self) -> bool {
81        self.finished_at.is_none()
82    }
83}
84
85mod option_time_serde {
86    use serde::{Deserialize, Deserializer, Serialize, Serializer};
87    use std::time::{SystemTime, UNIX_EPOCH};
88
89    pub fn serialize<S>(time: &Option<SystemTime>, serializer: S) -> Result<S::Ok, S::Error>
90    where
91        S: Serializer,
92    {
93        match time {
94            Some(t) => {
95                let since_epoch = t
96                    .duration_since(UNIX_EPOCH)
97                    .map_err(serde::ser::Error::custom)?;
98                let ms = since_epoch.as_secs() * 1_000 + u64::from(since_epoch.subsec_millis());
99                ms.serialize(serializer)
100            }
101            None => serializer.serialize_none(),
102        }
103    }
104
105    pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<SystemTime>, D::Error>
106    where
107        D: Deserializer<'de>,
108    {
109        let opt: Option<u64> = Option::deserialize(deserializer)?;
110        Ok(opt.map(|ms| UNIX_EPOCH + std::time::Duration::from_millis(ms)))
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn starting_creates_running_run() {
120        let run = TaskRun::starting(1);
121        assert_eq!(run.attempt, 1);
122        assert_eq!(run.phase, TaskPhase::Running);
123        assert!(run.is_active());
124        assert!(run.finished_at.is_none());
125        assert!(run.error.is_none());
126        assert!(run.exit_code.is_none());
127    }
128
129    #[test]
130    fn finish_closes_run() {
131        let mut run = TaskRun::starting(2);
132        run.finish(TaskPhase::Failed, Some("boom".into()), Some(1));
133
134        assert!(!run.is_active());
135        assert!(run.finished_at.is_some());
136        assert_eq!(run.phase, TaskPhase::Failed);
137        assert_eq!(run.error.as_deref(), Some("boom"));
138        assert_eq!(run.exit_code, Some(1));
139    }
140
141    #[test]
142    fn finish_succeeded_no_error() {
143        let mut run = TaskRun::starting(1);
144        run.finish(TaskPhase::Succeeded, None, None);
145
146        assert!(!run.is_active());
147        assert_eq!(run.phase, TaskPhase::Succeeded);
148        assert!(run.error.is_none());
149        assert!(run.exit_code.is_none());
150    }
151
152    #[test]
153    fn serde_roundtrip_active() {
154        let run = TaskRun::starting(3);
155        let json = serde_json::to_string(&run).unwrap();
156        let back: TaskRun = serde_json::from_str(&json).unwrap();
157
158        assert_eq!(back.attempt, 3);
159        assert_eq!(back.phase, TaskPhase::Running);
160        assert!(back.finished_at.is_none());
161    }
162
163    #[test]
164    fn serde_roundtrip_finished() {
165        let mut run = TaskRun::starting(1);
166        run.finish(TaskPhase::Timeout, Some("timeout".into()), None);
167
168        let json = serde_json::to_string(&run).unwrap();
169        let back: TaskRun = serde_json::from_str(&json).unwrap();
170
171        assert_eq!(back.phase, TaskPhase::Timeout);
172        assert!(back.finished_at.is_some());
173        assert_eq!(back.error.as_deref(), Some("timeout"));
174    }
175
176    #[test]
177    fn serde_skips_none_fields() {
178        let run = TaskRun::starting(1);
179        let json = serde_json::to_string(&run).unwrap();
180        assert!(!json.contains("finishedAt"));
181        assert!(!json.contains("error"));
182        assert!(!json.contains("exitCode"));
183    }
184}