ironflow_engine/run_creator.rs
1//! [`RunCreator`] trait and [`CreateRunOpts`] builder -- centralised run creation.
2//!
3//! [`RunCreator`] is a minimal trait with a single method: create a run from
4//! a [`NewRun`]. It is intentionally thinner than [`RunStore`] so that any
5//! store implementation can be used as a run creator through the blanket impl.
6//!
7//! [`CreateRunOpts`] is a builder for the optional fields of a run creation
8//! request. Combined with [`WorkflowHandler::create_run`](crate::handler::WorkflowHandler::create_run), it assembles a
9//! [`NewRun`] from the handler's own metadata, removing duplication across
10//! call sites.
11//!
12//! # Examples
13//!
14//! ```no_run
15//! use ironflow_engine::run_creator::{CreateRunOpts, RunCreator};
16//! use ironflow_store::entities::TriggerKind;
17//! use ironflow_store::memory::InMemoryStore;
18//!
19//! # async fn example() -> Result<(), ironflow_engine::error::EngineError> {
20//! let store = InMemoryStore::new();
21//! let creator: &dyn RunCreator = &store;
22//!
23//! let new_run = CreateRunOpts::new()
24//! .trigger(TriggerKind::Api)
25//! .build("deploy", Some("1.0.0"), None);
26//!
27//! let creation = creator.create_run(new_run).await?;
28//! # Ok(())
29//! # }
30//! ```
31
32use std::collections::HashMap;
33use std::future::Future;
34use std::pin::Pin;
35
36use chrono::{DateTime, Utc};
37use rust_decimal::Decimal;
38use serde_json::Value;
39
40use ironflow_store::entities::{NewRun, RunActor, RunCreation, TriggerKind};
41use ironflow_store::store::RunStore;
42
43use crate::error::EngineError;
44
45/// Future returned by [`RunCreator::create_run`].
46pub type RunCreatorFuture<'a> =
47 Pin<Box<dyn Future<Output = Result<RunCreation, EngineError>> + Send + 'a>>;
48
49/// Minimal trait for creating workflow runs.
50///
51/// Automatically implemented for every [`RunStore`] via a blanket impl,
52/// so any store (InMemory, Postgres, ApiRunStore) is a valid [`RunCreator`].
53///
54/// # Examples
55///
56/// ```no_run
57/// use ironflow_engine::run_creator::RunCreator;
58/// use ironflow_store::entities::{NewRun, TriggerKind};
59///
60/// # async fn example(creator: &dyn RunCreator) -> Result<(), ironflow_engine::error::EngineError> {
61/// let new_run = NewRun {
62/// workflow_name: "deploy".to_string(),
63/// trigger: TriggerKind::Manual,
64/// payload: serde_json::json!({}),
65/// max_retries: 0,
66/// handler_version: None,
67/// labels: Default::default(),
68/// scheduled_at: None,
69/// created_by: None,
70/// idempotency_key: None,
71/// max_cost_usd: None,
72/// };
73/// let creation = creator.create_run(new_run).await?;
74/// # Ok(())
75/// # }
76/// ```
77pub trait RunCreator: Send + Sync {
78 /// Create a new workflow run.
79 ///
80 /// # Errors
81 ///
82 /// Returns [`EngineError`] if the run could not be created.
83 fn create_run(&self, req: NewRun) -> RunCreatorFuture<'_>;
84}
85
86impl<T: RunStore + ?Sized> RunCreator for T {
87 fn create_run(&self, req: NewRun) -> RunCreatorFuture<'_> {
88 Box::pin(async move {
89 RunStore::create_run(self, req)
90 .await
91 .map_err(EngineError::from)
92 })
93 }
94}
95
96/// Builder for optional run creation fields.
97///
98/// Builds a [`NewRun`] from handler metadata plus user-supplied overrides.
99/// Use with [`WorkflowHandler::create_run`] to avoid duplicating workflow
100/// name, version, and cost cap at every call site.
101///
102/// [`WorkflowHandler::create_run`]: crate::handler::WorkflowHandler::create_run
103///
104/// # Examples
105///
106/// ```
107/// use ironflow_engine::run_creator::CreateRunOpts;
108/// use ironflow_store::entities::TriggerKind;
109/// use serde_json::json;
110///
111/// let new_run = CreateRunOpts::new()
112/// .trigger(TriggerKind::Webhook { path: "/hooks/gh".to_string() })
113/// .payload(json!({"ref": "main"}))
114/// .max_retries(3)
115/// .build("deploy", Some("2.0.0"), None);
116///
117/// assert_eq!(new_run.workflow_name, "deploy");
118/// assert_eq!(new_run.max_retries, 3);
119/// assert_eq!(new_run.handler_version, Some("2.0.0".to_string()));
120/// ```
121#[derive(Debug, Clone, Default)]
122pub struct CreateRunOpts {
123 trigger: Option<TriggerKind>,
124 payload: Option<Value>,
125 max_retries: Option<u32>,
126 scheduled_at: Option<DateTime<Utc>>,
127 created_by: Option<RunActor>,
128 idempotency_key: Option<String>,
129 labels: Option<HashMap<String, String>>,
130 max_cost_usd: Option<Decimal>,
131}
132
133impl CreateRunOpts {
134 /// Create a new builder with all fields unset.
135 ///
136 /// # Examples
137 ///
138 /// ```
139 /// use ironflow_engine::run_creator::CreateRunOpts;
140 ///
141 /// let opts = CreateRunOpts::new();
142 /// let new_run = opts.build("my-workflow", None, None);
143 /// assert_eq!(new_run.workflow_name, "my-workflow");
144 /// ```
145 pub fn new() -> Self {
146 Self::default()
147 }
148
149 /// Set how the run was triggered.
150 ///
151 /// Defaults to [`TriggerKind::Manual`] if not set.
152 pub fn trigger(mut self, trigger: TriggerKind) -> Self {
153 self.trigger = Some(trigger);
154 self
155 }
156
157 /// Set the trigger-specific payload.
158 ///
159 /// Defaults to `json!({})` if not set.
160 pub fn payload(mut self, payload: Value) -> Self {
161 self.payload = Some(payload);
162 self
163 }
164
165 /// Set the maximum retry attempts.
166 ///
167 /// Defaults to `0` if not set.
168 pub fn max_retries(mut self, max_retries: u32) -> Self {
169 self.max_retries = Some(max_retries);
170 self
171 }
172
173 /// Schedule the run for later execution.
174 pub fn scheduled_at(mut self, at: DateTime<Utc>) -> Self {
175 self.scheduled_at = Some(at);
176 self
177 }
178
179 /// Set the authenticated principal creating this run.
180 pub fn created_by(mut self, actor: RunActor) -> Self {
181 self.created_by = Some(actor);
182 self
183 }
184
185 /// Set an idempotency key to prevent duplicate runs.
186 pub fn idempotency_key(mut self, key: impl Into<String>) -> Self {
187 self.idempotency_key = Some(key.into());
188 self
189 }
190
191 /// Set user-defined labels for categorization.
192 pub fn labels(mut self, labels: HashMap<String, String>) -> Self {
193 self.labels = Some(labels);
194 self
195 }
196
197 /// Set the maximum cumulative cost allowed for this run.
198 pub fn max_cost_usd(mut self, cap: Decimal) -> Self {
199 self.max_cost_usd = Some(cap);
200 self
201 }
202
203 /// Assemble a [`NewRun`] from these options and handler metadata.
204 ///
205 /// * `workflow_name` -- typically from [`WorkflowHandler::name`].
206 /// * `handler_version` -- typically from [`WorkflowHandler::version`].
207 /// * `default_max_cost_usd` -- typically from [`WorkflowHandler::default_max_cost_usd`].
208 /// Applied only when [`max_cost_usd`](Self::max_cost_usd) was not set.
209 ///
210 /// [`WorkflowHandler::name`]: crate::handler::WorkflowHandler::name
211 /// [`WorkflowHandler::version`]: crate::handler::WorkflowHandler::version
212 /// [`WorkflowHandler::default_max_cost_usd`]: crate::handler::WorkflowHandler::default_max_cost_usd
213 ///
214 /// # Examples
215 ///
216 /// ```
217 /// use ironflow_engine::run_creator::CreateRunOpts;
218 /// use rust_decimal::Decimal;
219 ///
220 /// let new_run = CreateRunOpts::new()
221 /// .build("my-handler", Some("3.0.0"), Some(Decimal::new(1000, 2)));
222 ///
223 /// assert_eq!(new_run.workflow_name, "my-handler");
224 /// assert_eq!(new_run.handler_version, Some("3.0.0".to_string()));
225 /// assert_eq!(new_run.max_cost_usd, Some(Decimal::new(1000, 2)));
226 /// ```
227 pub fn build(
228 self,
229 workflow_name: &str,
230 handler_version: Option<&str>,
231 default_max_cost_usd: Option<Decimal>,
232 ) -> NewRun {
233 NewRun {
234 workflow_name: workflow_name.to_string(),
235 trigger: self.trigger.unwrap_or(TriggerKind::Manual),
236 payload: self.payload.unwrap_or_else(|| serde_json::json!({})),
237 max_retries: self.max_retries.unwrap_or(0),
238 handler_version: handler_version.map(str::to_string),
239 labels: self.labels.unwrap_or_default(),
240 scheduled_at: self.scheduled_at,
241 created_by: self.created_by,
242 idempotency_key: self.idempotency_key,
243 max_cost_usd: self.max_cost_usd.or(default_max_cost_usd),
244 }
245 }
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use serde_json::json;
252
253 #[test]
254 fn create_run_opts_default_produces_correct_defaults() {
255 let opts = CreateRunOpts::new();
256 let new_run = opts.build("test-workflow", None, None);
257
258 assert_eq!(new_run.workflow_name, "test-workflow");
259 assert_eq!(new_run.trigger, TriggerKind::Manual);
260 assert_eq!(new_run.payload, json!({}));
261 assert_eq!(new_run.max_retries, 0);
262 assert_eq!(new_run.handler_version, None);
263 assert!(new_run.labels.is_empty());
264 assert_eq!(new_run.scheduled_at, None);
265 assert_eq!(new_run.created_by, None);
266 assert_eq!(new_run.idempotency_key, None);
267 assert_eq!(new_run.max_cost_usd, None);
268 }
269
270 #[test]
271 fn create_run_opts_builder_sets_all_fields() {
272 let labels = HashMap::from([("env".to_string(), "prod".to_string())]);
273 let scheduled = Utc::now();
274
275 let new_run = CreateRunOpts::new()
276 .trigger(TriggerKind::Webhook {
277 path: "/hooks/gh".to_string(),
278 })
279 .payload(json!({"ref": "main"}))
280 .max_retries(3)
281 .scheduled_at(scheduled)
282 .idempotency_key("key-123")
283 .labels(labels.clone())
284 .max_cost_usd(Decimal::new(500, 2))
285 .build("deploy", Some("2.0.0"), None);
286
287 assert_eq!(new_run.workflow_name, "deploy");
288 assert_eq!(
289 new_run.trigger,
290 TriggerKind::Webhook {
291 path: "/hooks/gh".to_string()
292 }
293 );
294 assert_eq!(new_run.payload, json!({"ref": "main"}));
295 assert_eq!(new_run.max_retries, 3);
296 assert_eq!(new_run.handler_version, Some("2.0.0".to_string()));
297 assert_eq!(new_run.scheduled_at, Some(scheduled));
298 assert_eq!(new_run.idempotency_key, Some("key-123".to_string()));
299 assert_eq!(new_run.labels, labels);
300 assert_eq!(new_run.max_cost_usd, Some(Decimal::new(500, 2)));
301 }
302
303 #[test]
304 fn create_run_opts_build_uses_handler_metadata() {
305 let new_run =
306 CreateRunOpts::new().build("my-handler", Some("3.0.0"), Some(Decimal::new(1000, 2)));
307
308 assert_eq!(new_run.workflow_name, "my-handler");
309 assert_eq!(new_run.handler_version, Some("3.0.0".to_string()));
310 assert_eq!(new_run.max_cost_usd, Some(Decimal::new(1000, 2)));
311 }
312
313 #[test]
314 fn create_run_opts_explicit_max_cost_overrides_handler_default() {
315 let new_run = CreateRunOpts::new()
316 .max_cost_usd(Decimal::new(200, 2))
317 .build("handler", Some("1"), Some(Decimal::new(1000, 2)));
318
319 assert_eq!(new_run.max_cost_usd, Some(Decimal::new(200, 2)));
320 }
321
322 #[tokio::test]
323 async fn run_creator_blanket_impl_with_in_memory_store() {
324 use ironflow_store::memory::InMemoryStore;
325
326 let store = InMemoryStore::new();
327 let creator: &dyn RunCreator = &store;
328
329 let new_run =
330 CreateRunOpts::new()
331 .trigger(TriggerKind::Api)
332 .build("blanket-test", None, None);
333
334 let creation = creator.create_run(new_run).await.expect("create_run");
335 let run = creation.into_run();
336 assert_eq!(run.workflow_name, "blanket-test");
337 }
338
339 #[tokio::test]
340 async fn create_run_with_reused_idempotency_key_returns_existing() {
341 use ironflow_store::memory::InMemoryStore;
342
343 let store = InMemoryStore::new();
344 let creator: &dyn RunCreator = &store;
345
346 let first = creator
347 .create_run(CreateRunOpts::new().idempotency_key("dedup-1").build(
348 "idem-test",
349 None,
350 None,
351 ))
352 .await
353 .expect("first create_run");
354 assert!(first.is_created());
355
356 let second = creator
357 .create_run(CreateRunOpts::new().idempotency_key("dedup-1").build(
358 "idem-test",
359 None,
360 None,
361 ))
362 .await
363 .expect("second create_run");
364 assert!(!second.is_created());
365 assert_eq!(first.into_run().id, second.into_run().id);
366 }
367}