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        opt.map(|ms| {
111            UNIX_EPOCH
112                .checked_add(std::time::Duration::from_millis(ms))
113                .ok_or_else(|| <D::Error as serde::de::Error>::custom("timestamp out of range"))
114        })
115        .transpose()
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn starting_creates_running_run() {
125        let run = TaskRun::starting(1);
126        assert_eq!(run.attempt, 1);
127        assert_eq!(run.phase, TaskPhase::Running);
128        assert!(run.is_active());
129        assert!(run.finished_at.is_none());
130        assert!(run.error.is_none());
131        assert!(run.exit_code.is_none());
132    }
133
134    #[test]
135    fn finish_closes_run() {
136        let mut run = TaskRun::starting(2);
137        run.finish(TaskPhase::Failed, Some("boom".into()), Some(1));
138
139        assert!(!run.is_active());
140        assert!(run.finished_at.is_some());
141        assert_eq!(run.phase, TaskPhase::Failed);
142        assert_eq!(run.error.as_deref(), Some("boom"));
143        assert_eq!(run.exit_code, Some(1));
144    }
145
146    #[test]
147    fn finish_succeeded_no_error() {
148        let mut run = TaskRun::starting(1);
149        run.finish(TaskPhase::Succeeded, None, None);
150
151        assert!(!run.is_active());
152        assert_eq!(run.phase, TaskPhase::Succeeded);
153        assert!(run.error.is_none());
154        assert!(run.exit_code.is_none());
155    }
156
157    #[test]
158    fn serde_roundtrip_active() {
159        let run = TaskRun::starting(3);
160        let json = serde_json::to_string(&run).unwrap();
161        let back: TaskRun = serde_json::from_str(&json).unwrap();
162
163        assert_eq!(back.attempt, 3);
164        assert_eq!(back.phase, TaskPhase::Running);
165        assert!(back.finished_at.is_none());
166    }
167
168    #[test]
169    fn serde_roundtrip_finished() {
170        let mut run = TaskRun::starting(1);
171        run.finish(TaskPhase::Timeout, Some("timeout".into()), None);
172
173        let json = serde_json::to_string(&run).unwrap();
174        let back: TaskRun = serde_json::from_str(&json).unwrap();
175
176        assert_eq!(back.phase, TaskPhase::Timeout);
177        assert!(back.finished_at.is_some());
178        assert_eq!(back.error.as_deref(), Some("timeout"));
179    }
180
181    #[test]
182    fn serde_skips_none_fields() {
183        let run = TaskRun::starting(1);
184        let json = serde_json::to_string(&run).unwrap();
185        assert!(!json.contains("finishedAt"));
186        assert!(!json.contains("error"));
187        assert!(!json.contains("exitCode"));
188    }
189}