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}