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)]
23pub struct JobId(String);
24
25impl JobId {
26    /// Creates a new job ID from 16 random bytes (32 hex chars).
27    pub fn new() -> Self {
28        Self(crate::utils::generate_id())
29    }
30
31    /// Returns the job ID as a string slice.
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35}
36
37impl Default for JobId {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43/// States in the job lifecycle.
44///
45/// Valid transitions:
46/// ```text
47/// Pending → Validating → Validated → Executing → Completed → RolledBack
48///                     ↘ Failed      ↘ Failed
49/// ```
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum JobState {
53    /// Job has been created but not yet processed.
54    Pending,
55    /// Job arguments are being validated.
56    Validating,
57    /// Arguments passed validation.
58    Validated,
59    /// Capability is currently executing.
60    Executing,
61    /// Capability completed successfully.
62    Completed,
63    /// Job failed during validation or execution.
64    Failed,
65    /// A completed job was rolled back (undo).
66    RolledBack,
67}
68
69/// A tracked unit of work in the Runtimo runtime.
70///
71/// Jobs carry a capability name, serialized arguments, current state,
72/// timestamps, and optional output or error information.
73///
74/// # Example
75///
76/// ```rust
77/// use runtimo_core::{Job, JobState};
78/// use serde_json::json;
79///
80/// let mut job = Job::new("FileRead".into(), json!({"path": "/tmp/test.txt"}), false);
81/// assert_eq!(job.state, JobState::Pending);
82///
83/// job.transition_to(JobState::Validating).unwrap();
84/// assert_eq!(job.state, JobState::Validating);
85/// ```
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct Job {
88    /// Unique job identifier.
89    pub id: JobId,
90    /// Name of the capability to execute.
91    pub capability: String,
92    /// Serialized capability arguments.
93    pub args: serde_json::Value,
94    /// Current state in the job lifecycle.
95    pub state: JobState,
96    /// Unix timestamp (seconds) when the job was created.
97    pub created_at: u64,
98    /// Unix timestamp (seconds) of the last state change.
99    pub updated_at: u64,
100    /// Output data from successful execution (JSON).
101    pub output: Option<serde_json::Value>,
102    /// Error message if the job failed.
103    pub error: Option<String>,
104    /// Whether this job is a dry run.
105    pub dry_run: bool,
106}
107
108impl Job {
109    /// Creates a new job in the `Pending` state.
110    ///
111    /// # Arguments
112    ///
113    /// * `capability` — Name of the capability to execute
114    /// * `args` — Serialized capability arguments
115    /// * `dry_run` — If true, skip side effects
116    pub fn new(capability: String, args: serde_json::Value, dry_run: bool) -> Self {
117        let now = SystemTime::now()
118            .duration_since(UNIX_EPOCH)
119            .unwrap_or_default()
120            .as_secs();
121        Self {
122            id: JobId::new(),
123            capability,
124            args,
125            state: JobState::Pending,
126            created_at: now,
127            updated_at: now,
128            output: None,
129            error: None,
130            dry_run,
131        }
132    }
133
134    /// Attempts to transition the job to a new state.
135    ///
136    /// Only valid transitions are allowed (see [`JobState`] for the state machine).
137    /// On success, updates `updated_at` to the current time.
138    ///
139    /// # Design Note
140    ///
141    /// The state machine is expressed as a `matches!` macro for performance on this
142    /// hot path. A `const fn valid_transitions()` or a lookup table would be more
143    /// extensible but adds indirection. The explicit tuple match is O(1) and
144    /// optimizes to a jump table. If clippy suggests `match_like_matches_macro`,
145    /// this is intentional — the macro is already the most compact form.
146    ///
147    /// # Errors
148    ///
149    /// Returns an error string describing the invalid transition.
150    #[allow(clippy::match_like_matches_macro)]
151    pub fn transition_to(&mut self, new_state: JobState) -> Result<(), String> {
152        let valid = matches!(
153            (self.state, new_state),
154            (JobState::Pending, JobState::Validating)
155                | (JobState::Validating, JobState::Validated)
156                | (JobState::Validating, JobState::Failed)
157                | (JobState::Validated, JobState::Executing)
158                | (JobState::Executing, JobState::Completed)
159                | (JobState::Executing, JobState::Failed)
160                | (JobState::Completed, JobState::RolledBack)
161        );
162
163        if valid {
164            self.state = new_state;
165            self.updated_at = SystemTime::now()
166                .duration_since(UNIX_EPOCH)
167                .unwrap_or_default()
168                .as_secs();
169            Ok(())
170        } else {
171            Err(format!(
172                "Invalid state transition: {:?} -> {:?}",
173                self.state, new_state
174            ))
175        }
176    }
177}