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}