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