use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use chrono::{DateTime, Utc};
use rust_decimal::Decimal;
use serde_json::Value;
use ironflow_store::entities::{NewRun, RunActor, RunCreation, TriggerKind};
use ironflow_store::store::RunStore;
use crate::error::EngineError;
pub type RunCreatorFuture<'a> =
Pin<Box<dyn Future<Output = Result<RunCreation, EngineError>> + Send + 'a>>;
pub trait RunCreator: Send + Sync {
fn create_run(&self, req: NewRun) -> RunCreatorFuture<'_>;
}
impl<T: RunStore + ?Sized> RunCreator for T {
fn create_run(&self, req: NewRun) -> RunCreatorFuture<'_> {
Box::pin(async move {
RunStore::create_run(self, req)
.await
.map_err(EngineError::from)
})
}
}
#[derive(Debug, Clone, Default)]
pub struct CreateRunOpts {
trigger: Option<TriggerKind>,
payload: Option<Value>,
max_retries: Option<u32>,
scheduled_at: Option<DateTime<Utc>>,
created_by: Option<RunActor>,
idempotency_key: Option<String>,
labels: Option<HashMap<String, String>>,
max_cost_usd: Option<Decimal>,
}
impl CreateRunOpts {
pub fn new() -> Self {
Self::default()
}
pub fn trigger(mut self, trigger: TriggerKind) -> Self {
self.trigger = Some(trigger);
self
}
pub fn payload(mut self, payload: Value) -> Self {
self.payload = Some(payload);
self
}
pub fn max_retries(mut self, max_retries: u32) -> Self {
self.max_retries = Some(max_retries);
self
}
pub fn scheduled_at(mut self, at: DateTime<Utc>) -> Self {
self.scheduled_at = Some(at);
self
}
pub fn created_by(mut self, actor: RunActor) -> Self {
self.created_by = Some(actor);
self
}
pub fn idempotency_key(mut self, key: impl Into<String>) -> Self {
self.idempotency_key = Some(key.into());
self
}
pub fn labels(mut self, labels: HashMap<String, String>) -> Self {
self.labels = Some(labels);
self
}
pub fn max_cost_usd(mut self, cap: Decimal) -> Self {
self.max_cost_usd = Some(cap);
self
}
pub fn build(
self,
workflow_name: &str,
handler_version: Option<&str>,
default_max_cost_usd: Option<Decimal>,
) -> NewRun {
NewRun {
workflow_name: workflow_name.to_string(),
trigger: self.trigger.unwrap_or(TriggerKind::Manual),
payload: self.payload.unwrap_or_else(|| serde_json::json!({})),
max_retries: self.max_retries.unwrap_or(0),
handler_version: handler_version.map(str::to_string),
labels: self.labels.unwrap_or_default(),
scheduled_at: self.scheduled_at,
created_by: self.created_by,
idempotency_key: self.idempotency_key,
max_cost_usd: self.max_cost_usd.or(default_max_cost_usd),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn create_run_opts_default_produces_correct_defaults() {
let opts = CreateRunOpts::new();
let new_run = opts.build("test-workflow", None, None);
assert_eq!(new_run.workflow_name, "test-workflow");
assert_eq!(new_run.trigger, TriggerKind::Manual);
assert_eq!(new_run.payload, json!({}));
assert_eq!(new_run.max_retries, 0);
assert_eq!(new_run.handler_version, None);
assert!(new_run.labels.is_empty());
assert_eq!(new_run.scheduled_at, None);
assert_eq!(new_run.created_by, None);
assert_eq!(new_run.idempotency_key, None);
assert_eq!(new_run.max_cost_usd, None);
}
#[test]
fn create_run_opts_builder_sets_all_fields() {
let labels = HashMap::from([("env".to_string(), "prod".to_string())]);
let scheduled = Utc::now();
let new_run = CreateRunOpts::new()
.trigger(TriggerKind::Webhook {
path: "/hooks/gh".to_string(),
})
.payload(json!({"ref": "main"}))
.max_retries(3)
.scheduled_at(scheduled)
.idempotency_key("key-123")
.labels(labels.clone())
.max_cost_usd(Decimal::new(500, 2))
.build("deploy", Some("2.0.0"), None);
assert_eq!(new_run.workflow_name, "deploy");
assert_eq!(
new_run.trigger,
TriggerKind::Webhook {
path: "/hooks/gh".to_string()
}
);
assert_eq!(new_run.payload, json!({"ref": "main"}));
assert_eq!(new_run.max_retries, 3);
assert_eq!(new_run.handler_version, Some("2.0.0".to_string()));
assert_eq!(new_run.scheduled_at, Some(scheduled));
assert_eq!(new_run.idempotency_key, Some("key-123".to_string()));
assert_eq!(new_run.labels, labels);
assert_eq!(new_run.max_cost_usd, Some(Decimal::new(500, 2)));
}
#[test]
fn create_run_opts_build_uses_handler_metadata() {
let new_run =
CreateRunOpts::new().build("my-handler", Some("3.0.0"), Some(Decimal::new(1000, 2)));
assert_eq!(new_run.workflow_name, "my-handler");
assert_eq!(new_run.handler_version, Some("3.0.0".to_string()));
assert_eq!(new_run.max_cost_usd, Some(Decimal::new(1000, 2)));
}
#[test]
fn create_run_opts_explicit_max_cost_overrides_handler_default() {
let new_run = CreateRunOpts::new()
.max_cost_usd(Decimal::new(200, 2))
.build("handler", Some("1"), Some(Decimal::new(1000, 2)));
assert_eq!(new_run.max_cost_usd, Some(Decimal::new(200, 2)));
}
#[tokio::test]
async fn run_creator_blanket_impl_with_in_memory_store() {
use ironflow_store::memory::InMemoryStore;
let store = InMemoryStore::new();
let creator: &dyn RunCreator = &store;
let new_run =
CreateRunOpts::new()
.trigger(TriggerKind::Api)
.build("blanket-test", None, None);
let creation = creator.create_run(new_run).await.expect("create_run");
let run = creation.into_run();
assert_eq!(run.workflow_name, "blanket-test");
}
#[tokio::test]
async fn create_run_with_reused_idempotency_key_returns_existing() {
use ironflow_store::memory::InMemoryStore;
let store = InMemoryStore::new();
let creator: &dyn RunCreator = &store;
let first = creator
.create_run(CreateRunOpts::new().idempotency_key("dedup-1").build(
"idem-test",
None,
None,
))
.await
.expect("first create_run");
assert!(first.is_created());
let second = creator
.create_run(CreateRunOpts::new().idempotency_key("dedup-1").build(
"idem-test",
None,
None,
))
.await
.expect("second create_run");
assert!(!second.is_created());
assert_eq!(first.into_run().id, second.into_run().id);
}
}