Skip to main content

runtimo_core/
job.rs

1//! Job lifecycle management.
2//!
3//! Provides [`Job`], [`JobId`], and [`JobState`] for tracking capability
4//! executions through their lifecycle: `Pending → Validating → Validated →
5//! Executing → Completed` (or `Failed`, with optional `RolledBack`).
6
7use serde::{Deserialize, Serialize};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10/// Unique identifier for a job.
11///
12/// Generated from the current timestamp in nanoseconds, formatted as hex.
13///
14/// # Example
15///
16/// ```rust
17/// use runtimo_core::JobId;
18///
19/// let id = JobId::new();
20/// assert!(!id.as_str().is_empty());
21/// ```
22#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
23#[allow(clippy::exhaustive_structs)]
24pub struct JobId(String);
25
26impl JobId {
27    /// Creates a new job ID from 16 random bytes (32 hex chars).
28    #[must_use]
29    pub fn new() -> Self {
30        Self(crate::utils::generate_id())
31    }
32
33    /// Returns the job ID as a string slice.
34    #[must_use]
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl Default for JobId {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46/// States in the job lifecycle.
47///
48/// Valid transitions:
49/// ```text
50/// Pending → Validating → Validated → Executing → Completed → RolledBack
51///                     ↘ Failed      ↘ Failed
52/// ```
53#[allow(clippy::exhaustive_enums)] // new states are breaking changes regardless
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56pub enum JobState {
57    /// Job has been created but not yet processed.
58    Pending,
59    /// Job arguments are being validated.
60    Validating,
61    /// Arguments passed validation.
62    Validated,
63    /// Capability is currently executing.
64    Executing,
65    /// Capability completed successfully.
66    Completed,
67    /// Job failed during validation or execution.
68    Failed,
69    /// A completed job was rolled back (undo).
70    RolledBack,
71}
72
73/// A tracked unit of work in the Runtimo runtime.
74///
75/// Jobs carry a capability name, serialized arguments, current state,
76/// timestamps, and optional output or error information.
77///
78/// # Example
79///
80/// ```rust,ignore
81/// use runtimo_core::{Job, JobState};
82/// use serde_json::json;
83///
84/// let mut job = Job::new("FileRead".into(), json!({"path": "/tmp/test.txt"}), false);
85/// assert_eq!(job.state, JobState::Pending);
86///
87/// job.transition_to(JobState::Validating).unwrap();
88/// assert_eq!(job.state, JobState::Validating);
89/// ```
90#[derive(Debug, Clone, Serialize, Deserialize)]
91#[allow(clippy::exhaustive_structs)]
92pub struct Job {
93    /// Unique job identifier.
94    pub id: JobId,
95    /// Name of the capability to execute.
96    pub capability: String,
97    /// Serialized capability arguments.
98    pub args: serde_json::Value,
99    /// Current state in the job lifecycle.
100    pub state: JobState,
101    /// Unix timestamp (seconds) when the job was created.
102    pub created_at: u64,
103    /// Unix timestamp (seconds) of the last state change.
104    pub updated_at: u64,
105    /// Output data from successful execution (JSON).
106    pub output: Option<serde_json::Value>,
107    /// Error message if the job failed.
108    pub error: Option<String>,
109    /// Whether this job is a dry run.
110    pub dry_run: bool,
111}
112
113impl Job {
114    /// Creates a new job in the `Pending` state.
115    ///
116    /// # Arguments
117    ///
118    /// * `capability` — Name of the capability to execute
119    /// * `args` — Serialized capability arguments
120    /// * `dry_run` — If true, skip side effects
121    #[must_use]
122    pub fn new(capability: String, args: serde_json::Value, dry_run: bool) -> Self {
123        let now = SystemTime::now()
124            .duration_since(UNIX_EPOCH)
125            .unwrap_or_default()
126            .as_secs();
127        Self {
128            id: JobId::new(),
129            capability,
130            args,
131            state: JobState::Pending,
132            created_at: now,
133            updated_at: now,
134            output: None,
135            error: None,
136            dry_run,
137        }
138    }
139
140    /// Attempts to transition the job to a new state.
141    ///
142    /// Only valid transitions are allowed (see [`JobState`] for the state machine).
143    /// On success, updates `updated_at` to the current time.
144    ///
145    /// # Design Note
146    ///
147    /// The state machine is expressed as a `matches!` macro for performance on this
148    /// hot path. A `const fn valid_transitions()` or a lookup table would be more
149    /// extensible but adds indirection. The explicit tuple match is O(1) and
150    /// optimizes to a jump table. If clippy suggests `match_like_matches_macro`,
151    /// this is intentional — the macro is already the most compact form.
152    ///
153    /// # Errors
154    ///
155    /// Returns an error string describing the invalid transition.
156    #[allow(clippy::match_like_matches_macro, clippy::unnested_or_patterns)]
157    pub fn transition_to(&mut self, new_state: JobState) -> Result<(), String> {
158        let valid = matches!(
159            (self.state, new_state),
160            (JobState::Pending, JobState::Validating)
161                | (JobState::Validating, JobState::Validated)
162                | (JobState::Validating, JobState::Failed)
163                | (JobState::Validated, JobState::Executing)
164                | (JobState::Executing, JobState::Completed)
165                | (JobState::Executing, JobState::Failed)
166                | (JobState::Completed, JobState::RolledBack)
167        );
168
169        if valid {
170            self.state = new_state;
171            self.updated_at = SystemTime::now()
172                .duration_since(UNIX_EPOCH)
173                .unwrap_or_default()
174                .as_secs();
175            Ok(())
176        } else {
177            Err(format!(
178                "Invalid state transition: {:?} -> {:?}",
179                self.state, new_state
180            ))
181        }
182    }
183}
184
185#[cfg(test)]
186#[allow(clippy::unwrap_used)]
187mod tests {
188    use super::*;
189    use serde_json::json;
190
191    #[test]
192    fn test_job_state_valid_transitions() {
193        let mut job = Job::new("FileRead".into(), json!({"path": "/tmp/x"}), false);
194        assert_eq!(job.state, JobState::Pending);
195
196        // Valid transitions: Pending → Validating → Validated → Executing → Completed
197        job.transition_to(JobState::Validating).unwrap();
198        assert_eq!(job.state, JobState::Validating);
199
200        job.transition_to(JobState::Validated).unwrap();
201        assert_eq!(job.state, JobState::Validated);
202
203        job.transition_to(JobState::Executing).unwrap();
204        assert_eq!(job.state, JobState::Executing);
205
206        job.transition_to(JobState::Completed).unwrap();
207        assert_eq!(job.state, JobState::Completed);
208
209        // Completed → RolledBack is valid
210        job.transition_to(JobState::RolledBack).unwrap();
211        assert_eq!(job.state, JobState::RolledBack);
212    }
213
214    #[test]
215    fn test_job_state_invalid_transitions() {
216        let mut job = Job::new("FileRead".into(), json!({"path": "/tmp/x"}), false);
217
218        // Pending → Executing is invalid (skip Validating, Validated)
219        let result = job.transition_to(JobState::Executing);
220        assert!(result.is_err(), "Pending → Executing should be invalid");
221        assert_eq!(job.state, JobState::Pending); // state unchanged
222
223        // Pending → Completed is invalid
224        let result = job.transition_to(JobState::Completed);
225        assert!(result.is_err(), "Pending → Completed should be invalid");
226
227        // Transition normally then test reverse
228        job.transition_to(JobState::Validating).unwrap();
229        job.transition_to(JobState::Validated).unwrap();
230        job.transition_to(JobState::Executing).unwrap();
231        job.transition_to(JobState::Completed).unwrap();
232
233        // Completed → Executing is invalid (can't go backwards)
234        let result = job.transition_to(JobState::Executing);
235        assert!(result.is_err(), "Completed → Executing should be invalid");
236        assert_eq!(job.state, JobState::Completed);
237
238        // Completed → Validated is invalid
239        let result = job.transition_to(JobState::Validated);
240        assert!(result.is_err(), "Completed → Validated should be invalid");
241    }
242
243    #[test]
244    fn test_job_id_uniqueness() {
245        // Generate many IDs and verify they are all unique
246        let mut seen = std::collections::HashSet::new();
247        for _ in 0..100 {
248            let id = JobId::new();
249            let s = id.as_str().to_string();
250            assert!(!s.is_empty(), "JobId should not be empty");
251            assert_eq!(s.len(), 32, "JobId should be 32 hex chars for urandom mode");
252            assert!(
253                seen.insert(s),
254                "JobId collision detected after {} IDs",
255                seen.len()
256            );
257        }
258        assert_eq!(seen.len(), 100);
259    }
260
261    #[test]
262    fn test_job_id_format() {
263        let id = JobId::new();
264        let s = id.as_str();
265        // Should be hex characters only
266        assert!(
267            s.chars().all(|c| c.is_ascii_hexdigit()),
268            "JobId must be hex: {}",
269            s
270        );
271    }
272
273    #[test]
274    fn test_job_state_failed_paths() {
275        // Validating → Failed
276        let mut job = Job::new("ShellExec".into(), json!({"cmd": "bad"}), false);
277        job.transition_to(JobState::Validating).unwrap();
278        job.transition_to(JobState::Failed).unwrap();
279        assert_eq!(job.state, JobState::Failed);
280
281        // Executing → Failed
282        let mut job2 = Job::new("ShellExec".into(), json!({"cmd": "bad"}), false);
283        job2.transition_to(JobState::Validating).unwrap();
284        job2.transition_to(JobState::Validated).unwrap();
285        job2.transition_to(JobState::Executing).unwrap();
286        job2.transition_to(JobState::Failed).unwrap();
287        assert_eq!(job2.state, JobState::Failed);
288    }
289
290    #[test]
291    fn test_job_timestamps() {
292        let before = SystemTime::now()
293            .duration_since(UNIX_EPOCH)
294            .unwrap()
295            .as_secs();
296        let job = Job::new("FileRead".into(), json!({}), false);
297        let after = SystemTime::now()
298            .duration_since(UNIX_EPOCH)
299            .unwrap()
300            .as_secs();
301
302        assert!(job.created_at >= before);
303        assert!(job.created_at <= after);
304        assert_eq!(job.created_at, job.updated_at); // same at creation
305    }
306
307    #[test]
308    fn test_job_transition_updates_timestamp() {
309        let mut job = Job::new("FileRead".into(), json!({}), false);
310        let created = job.updated_at;
311
312        // Sleep a tiny bit to ensure timestamp changes
313        std::thread::sleep(std::time::Duration::from_millis(10));
314        job.transition_to(JobState::Validating).unwrap();
315        assert!(job.updated_at >= created);
316    }
317}