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}