use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use rust_decimal::Decimal;
use schemars::JsonSchema;
use serde::Serialize;
use serde_json::Value;
use crate::context::WorkflowContext;
use crate::error::EngineError;
use crate::guard::WorkflowGuardConfig;
use crate::run_creator::{CreateRunOpts, RunCreator, RunCreatorFuture};
use crate::schedule::CronSchedule;
pub fn input_schema_for<T: JsonSchema>() -> Value {
let schema = schemars::schema_for!(T);
serde_json::to_value(schema).expect("schema serialization cannot fail")
}
pub type HandlerFuture<'a> = Pin<Box<dyn Future<Output = Result<(), EngineError>> + Send + 'a>>;
#[derive(Debug, Clone, Default, Serialize)]
pub struct WorkflowInfo {
pub description: String,
pub source_code: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sub_workflows: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub compatible_versions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_schema: Option<Value>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub default_labels: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schedule: Option<CronSchedule>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_max_cost_usd: Option<Decimal>,
}
impl WorkflowInfo {
pub fn new(description: impl Into<String>) -> Self {
Self {
description: description.into(),
..Self::default()
}
}
pub fn with_source_code(mut self, source: impl Into<String>) -> Self {
self.source_code = Some(source.into());
self
}
pub fn with_sub_workflows<I, S>(mut self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.sub_workflows = names.into_iter().map(Into::into).collect();
self
}
pub fn with_category(mut self, category: impl Into<String>) -> Self {
self.category = Some(category.into());
self
}
pub fn with_version(mut self, version: impl Into<String>) -> Self {
self.version = Some(version.into());
self
}
pub fn with_compatible_versions<I, S>(mut self, versions: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.compatible_versions = versions.into_iter().map(Into::into).collect();
self
}
pub fn with_input_schema(mut self, schema: Value) -> Self {
self.input_schema = Some(schema);
self
}
pub fn with_default_labels(mut self, labels: HashMap<String, String>) -> Self {
self.default_labels = labels;
self
}
pub fn with_schedule(mut self, schedule: CronSchedule) -> Self {
self.schedule = Some(schedule);
self
}
pub fn with_default_max_cost_usd(mut self, cap: Decimal) -> Self {
self.default_max_cost_usd = Some(cap);
self
}
}
pub trait WorkflowHandler: Send + Sync {
fn name(&self) -> &str;
fn version(&self) -> Option<&str> {
Some("1")
}
fn compatible_versions(&self) -> &[&str] {
&[]
}
fn description(&self) -> &str {
""
}
fn source_code(&self) -> Option<&str> {
None
}
fn sub_workflows(&self) -> Vec<String> {
Vec::new()
}
fn category(&self) -> Option<&str> {
None
}
fn input_schema(&self) -> Option<Value> {
None
}
fn default_labels(&self) -> HashMap<String, String> {
HashMap::new()
}
fn schedule(&self) -> Option<&CronSchedule> {
None
}
fn default_max_cost_usd(&self) -> Option<Decimal> {
None
}
fn guard_config(&self) -> Option<WorkflowGuardConfig> {
None
}
fn is_version_compatible(&self, run_version: Option<&str>) -> bool {
let Some(rv) = run_version else {
return true;
};
if self.version() == Some(rv) {
return true;
}
self.compatible_versions().contains(&rv)
}
fn describe(&self) -> WorkflowInfo {
WorkflowInfo {
description: self.description().to_string(),
source_code: self.source_code().map(str::to_string),
sub_workflows: self.sub_workflows(),
category: self.category().map(str::to_string),
version: self.version().map(str::to_string),
compatible_versions: self
.compatible_versions()
.iter()
.map(|s| s.to_string())
.collect(),
input_schema: self.input_schema(),
default_labels: self.default_labels(),
schedule: self.schedule().cloned(),
default_max_cost_usd: self.default_max_cost_usd(),
}
}
fn create_run<'a>(
&self,
creator: &'a dyn RunCreator,
opts: CreateRunOpts,
) -> RunCreatorFuture<'a> {
use tracing::{Instrument, info_span};
let new_run = opts.build(self.name(), self.version(), self.default_max_cost_usd());
let span = info_span!("handler.create_run", workflow = %self.name());
Box::pin(creator.create_run(new_run).instrument(span))
}
fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a>;
}
impl<T: WorkflowHandler + ?Sized> WorkflowHandler for Box<T> {
fn name(&self) -> &str {
(**self).name()
}
fn version(&self) -> Option<&str> {
(**self).version()
}
fn compatible_versions(&self) -> &[&str] {
(**self).compatible_versions()
}
fn description(&self) -> &str {
(**self).description()
}
fn source_code(&self) -> Option<&str> {
(**self).source_code()
}
fn sub_workflows(&self) -> Vec<String> {
(**self).sub_workflows()
}
fn category(&self) -> Option<&str> {
(**self).category()
}
fn input_schema(&self) -> Option<Value> {
(**self).input_schema()
}
fn default_labels(&self) -> HashMap<String, String> {
(**self).default_labels()
}
fn schedule(&self) -> Option<&CronSchedule> {
(**self).schedule()
}
fn default_max_cost_usd(&self) -> Option<Decimal> {
(**self).default_max_cost_usd()
}
fn guard_config(&self) -> Option<WorkflowGuardConfig> {
(**self).guard_config()
}
fn is_version_compatible(&self, run_version: Option<&str>) -> bool {
(**self).is_version_compatible(run_version)
}
fn describe(&self) -> WorkflowInfo {
(**self).describe()
}
fn create_run<'a>(
&self,
creator: &'a dyn RunCreator,
opts: CreateRunOpts,
) -> RunCreatorFuture<'a> {
(**self).create_run(creator, opts)
}
fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
(**self).execute(ctx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
struct TestInput {
environment: String,
#[serde(default)]
dry_run: bool,
}
struct MinimalHandler;
impl WorkflowHandler for MinimalHandler {
fn name(&self) -> &str {
"minimal"
}
fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
Box::pin(async { Ok(()) })
}
}
struct FullFeaturedHandler;
impl WorkflowHandler for FullFeaturedHandler {
fn name(&self) -> &str {
"full"
}
fn version(&self) -> Option<&str> {
Some("1.2.0")
}
fn category(&self) -> Option<&str> {
Some("data/etl")
}
fn input_schema(&self) -> Option<Value> {
Some(input_schema_for::<TestInput>())
}
fn default_labels(&self) -> HashMap<String, String> {
HashMap::from([
("team".to_string(), "platform".to_string()),
("env".to_string(), "prod".to_string()),
])
}
fn default_max_cost_usd(&self) -> Option<Decimal> {
Some(Decimal::new(750, 2))
}
fn describe(&self) -> WorkflowInfo {
WorkflowInfo {
description: "Full-featured test handler".to_string(),
source_code: Some("fn test() {}".to_string()),
sub_workflows: vec!["helper".to_string()],
category: self.category().map(str::to_string),
version: self.version().map(str::to_string),
compatible_versions: self
.compatible_versions()
.iter()
.map(|s| s.to_string())
.collect(),
input_schema: self.input_schema(),
default_labels: self.default_labels(),
schedule: self.schedule().cloned(),
default_max_cost_usd: self.default_max_cost_usd(),
}
}
fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
Box::pin(async { Ok(()) })
}
}
#[test]
fn minimal_handler_has_required_name() {
let handler = MinimalHandler;
assert_eq!(handler.name(), "minimal");
}
#[test]
fn minimal_handler_defaults_to_version_1() {
let handler = MinimalHandler;
assert_eq!(handler.version(), Some("1"));
}
#[test]
fn minimal_handler_defaults_to_no_compatible_versions() {
let handler = MinimalHandler;
assert!(handler.compatible_versions().is_empty());
}
#[test]
fn minimal_handler_defaults_to_no_category() {
let handler = MinimalHandler;
assert_eq!(handler.category(), None);
}
#[test]
fn minimal_handler_defaults_to_no_schema() {
let handler = MinimalHandler;
assert_eq!(handler.input_schema(), None);
}
#[test]
fn minimal_handler_defaults_to_empty_labels() {
let handler = MinimalHandler;
let labels = handler.default_labels();
assert!(labels.is_empty());
}
#[test]
fn minimal_handler_defaults_to_no_schedule() {
let handler = MinimalHandler;
assert_eq!(handler.schedule(), None);
}
#[test]
fn minimal_handler_describe_reflects_defaults() {
let handler = MinimalHandler;
let info = handler.describe();
assert_eq!(info.description, "");
assert_eq!(info.source_code, None);
assert_eq!(info.sub_workflows, Vec::<String>::new());
assert_eq!(info.category, None);
assert_eq!(info.version, Some("1".to_string()));
assert!(info.compatible_versions.is_empty());
assert_eq!(info.input_schema, None);
assert!(info.default_labels.is_empty());
assert_eq!(info.schedule, None);
}
#[test]
fn full_handler_returns_all_metadata() {
let handler = FullFeaturedHandler;
assert_eq!(handler.name(), "full");
assert_eq!(handler.version(), Some("1.2.0"));
assert_eq!(handler.category(), Some("data/etl"));
assert!(handler.input_schema().is_some());
}
#[test]
fn full_handler_default_labels_are_set() {
let handler = FullFeaturedHandler;
let labels = handler.default_labels();
assert_eq!(labels.get("team"), Some(&"platform".to_string()));
assert_eq!(labels.get("env"), Some(&"prod".to_string()));
}
#[test]
fn full_handler_describe_includes_all_fields() {
let handler = FullFeaturedHandler;
let info = handler.describe();
assert_eq!(info.description, "Full-featured test handler");
assert_eq!(info.source_code, Some("fn test() {}".to_string()));
assert_eq!(info.sub_workflows, vec!["helper".to_string()]);
assert_eq!(info.category, Some("data/etl".to_string()));
assert_eq!(info.version, Some("1.2.0".to_string()));
assert!(info.input_schema.is_some());
assert_eq!(info.default_labels.len(), 2);
}
#[test]
fn input_schema_for_generates_json_schema() {
let schema = input_schema_for::<TestInput>();
assert_eq!(schema["type"], "object");
assert!(schema["properties"]["environment"].is_object());
assert!(schema["properties"]["dry_run"].is_object());
}
#[test]
fn input_schema_for_preserves_serde_attributes() {
let schema = input_schema_for::<TestInput>();
let properties = &schema["properties"];
assert!(properties.is_object());
assert!(properties.get("environment").is_some());
assert!(properties.get("dry_run").is_some());
}
#[test]
fn minimal_handler_defaults_to_no_max_cost() {
assert!(MinimalHandler.default_max_cost_usd().is_none());
assert!(MinimalHandler.describe().default_max_cost_usd.is_none());
}
#[test]
fn describe_propagates_handler_max_cost() {
assert_eq!(
FullFeaturedHandler.describe().default_max_cost_usd,
Some(Decimal::new(750, 2))
);
}
#[test]
fn workflow_info_omits_absent_max_cost_from_json() {
let json = serde_json::to_value(MinimalHandler.describe()).expect("serialize");
assert!(json.get("default_max_cost_usd").is_none());
}
#[test]
fn workflow_info_serializes_with_skip_empty() {
let info = WorkflowInfo {
description: "test".to_string(),
source_code: None,
sub_workflows: Vec::new(),
category: None,
version: None,
compatible_versions: Vec::new(),
input_schema: None,
default_labels: HashMap::new(),
schedule: None,
default_max_cost_usd: None,
};
let json = serde_json::to_value(&info).expect("serialize");
assert_eq!(json["description"], "test");
assert!(json.is_object());
}
#[test]
fn workflow_info_serializes_with_values() {
let info = WorkflowInfo {
description: "test".to_string(),
source_code: Some("code".to_string()),
sub_workflows: vec!["sub".to_string()],
category: Some("cat".to_string()),
version: Some("1.0.0".to_string()),
compatible_versions: vec!["0.9.0".to_string()],
input_schema: Some(serde_json::json!({"type": "object"})),
default_labels: HashMap::from([("key".to_string(), "value".to_string())]),
schedule: Some(CronSchedule::new("0 0 * * * *").unwrap()),
default_max_cost_usd: Some(Decimal::new(750, 2)),
};
let json = serde_json::to_value(&info).expect("serialize");
assert_eq!(json["description"], "test");
assert_eq!(json["source_code"], "code");
assert_eq!(json["sub_workflows"][0], "sub");
assert_eq!(json["category"], "cat");
assert_eq!(json["version"], "1.0.0");
assert_eq!(json["default_labels"]["key"], "value");
assert_eq!(json["schedule"], "0 0 * * * *");
assert_eq!(json["compatible_versions"][0], "0.9.0");
}
struct VersionedHandler;
impl WorkflowHandler for VersionedHandler {
fn name(&self) -> &str {
"versioned"
}
fn version(&self) -> Option<&str> {
Some("2.0.0")
}
fn compatible_versions(&self) -> &[&str] {
&["1.5.0", "1.9.0"]
}
fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
Box::pin(async { Ok(()) })
}
}
#[test]
fn version_compatible_with_same_version() {
assert!(VersionedHandler.is_version_compatible(Some("2.0.0")));
}
#[test]
fn version_compatible_with_none_run_version() {
assert!(VersionedHandler.is_version_compatible(None));
}
#[test]
fn version_compatible_with_listed_version() {
assert!(VersionedHandler.is_version_compatible(Some("1.5.0")));
assert!(VersionedHandler.is_version_compatible(Some("1.9.0")));
}
#[test]
fn version_incompatible_with_unlisted_version() {
assert!(!VersionedHandler.is_version_compatible(Some("1.0.0")));
assert!(!VersionedHandler.is_version_compatible(Some("3.0.0")));
}
#[test]
fn minimal_handler_compatible_with_same_default() {
assert!(MinimalHandler.is_version_compatible(Some("1")));
}
#[test]
fn minimal_handler_incompatible_with_different_version() {
assert!(!MinimalHandler.is_version_compatible(Some("2")));
}
#[tokio::test]
async fn handler_create_run_uses_handler_metadata() {
use ironflow_store::entities::TriggerKind;
use ironflow_store::memory::InMemoryStore;
let store = InMemoryStore::new();
let opts = CreateRunOpts::new().trigger(TriggerKind::Api);
let creation = FullFeaturedHandler
.create_run(&store, opts)
.await
.expect("create_run");
let run = creation.into_run();
assert_eq!(run.workflow_name, "full");
assert_eq!(run.handler_version, Some("1.2.0".to_string()));
assert_eq!(run.max_cost_usd, Some(Decimal::new(750, 2)));
}
#[tokio::test]
async fn handler_create_run_opts_override_handler_defaults() {
use ironflow_store::memory::InMemoryStore;
let store = InMemoryStore::new();
let opts = CreateRunOpts::new().max_cost_usd(Decimal::new(100, 2));
let creation = FullFeaturedHandler
.create_run(&store, opts)
.await
.expect("create_run");
let run = creation.into_run();
assert_eq!(run.max_cost_usd, Some(Decimal::new(100, 2)));
}
#[tokio::test]
async fn handler_create_run_minimal_handler_defaults() {
use ironflow_store::memory::InMemoryStore;
let store = InMemoryStore::new();
let opts = CreateRunOpts::new();
let creation = MinimalHandler
.create_run(&store, opts)
.await
.expect("create_run");
let run = creation.into_run();
assert_eq!(run.workflow_name, "minimal");
assert_eq!(run.handler_version, Some("1".to_string()));
assert_eq!(run.max_cost_usd, None);
}
struct Documented;
impl WorkflowHandler for Documented {
fn name(&self) -> &str {
"documented"
}
fn description(&self) -> &str {
"A documented handler"
}
fn source_code(&self) -> Option<&str> {
Some("struct Documented;")
}
fn sub_workflows(&self) -> Vec<String> {
vec!["child".to_string()]
}
fn category(&self) -> Option<&str> {
Some("tests/handlers")
}
fn version(&self) -> Option<&str> {
Some("3.1.0")
}
fn compatible_versions(&self) -> &[&str] {
&["3.0.0"]
}
fn input_schema(&self) -> Option<Value> {
Some(input_schema_for::<TestInput>())
}
fn default_labels(&self) -> HashMap<String, String> {
HashMap::from([("team".to_string(), "core".to_string())])
}
fn default_max_cost_usd(&self) -> Option<Decimal> {
Some(Decimal::new(250, 2))
}
fn guard_config(&self) -> Option<WorkflowGuardConfig> {
Some(WorkflowGuardConfig::new().with_max_depth(4))
}
fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
Box::pin(async { Ok(()) })
}
}
#[test]
fn default_describe_propagates_every_trait_method() {
let info = Documented.describe();
assert_eq!(info.description, "A documented handler");
assert_eq!(info.source_code.as_deref(), Some("struct Documented;"));
assert_eq!(info.sub_workflows, vec!["child".to_string()]);
assert_eq!(info.category.as_deref(), Some("tests/handlers"));
assert_eq!(info.version.as_deref(), Some("3.1.0"));
assert_eq!(info.compatible_versions, vec!["3.0.0".to_string()]);
assert!(info.input_schema.is_some());
assert_eq!(info.default_labels["team"], "core");
assert_eq!(info.default_max_cost_usd, Some(Decimal::new(250, 2)));
}
#[test]
fn minimal_handler_describe_uses_defaults() {
let info = MinimalHandler.describe();
assert_eq!(info.description, "");
assert!(info.source_code.is_none());
assert!(info.sub_workflows.is_empty());
assert!(info.category.is_none());
assert_eq!(info.version.as_deref(), Some("1"));
}
#[test]
fn workflow_info_builder_sets_every_field() {
let schedule = CronSchedule::new("0 0 * * *").expect("valid cron");
let info = WorkflowInfo::new("desc")
.with_source_code("code")
.with_sub_workflows(["a", "b"])
.with_category("cat/sub")
.with_version("2")
.with_compatible_versions(["1"])
.with_input_schema(serde_json::json!({"type": "object"}))
.with_default_labels(HashMap::from([("k".to_string(), "v".to_string())]))
.with_schedule(schedule)
.with_default_max_cost_usd(Decimal::ONE);
assert_eq!(info.description, "desc");
assert_eq!(info.source_code.as_deref(), Some("code"));
assert_eq!(info.sub_workflows, vec!["a".to_string(), "b".to_string()]);
assert_eq!(info.category.as_deref(), Some("cat/sub"));
assert_eq!(info.version.as_deref(), Some("2"));
assert_eq!(info.compatible_versions, vec!["1".to_string()]);
assert_eq!(info.input_schema.unwrap()["type"], "object");
assert_eq!(info.default_labels["k"], "v");
assert!(info.schedule.is_some());
assert_eq!(info.default_max_cost_usd, Some(Decimal::ONE));
}
#[test]
fn workflow_info_new_matches_default_for_other_fields() {
let info = WorkflowInfo::new("only description");
let default = WorkflowInfo::default();
assert_eq!(info.description, "only description");
assert_eq!(default.description, "");
assert_eq!(info.source_code, default.source_code);
assert_eq!(info.sub_workflows, default.sub_workflows);
assert_eq!(info.category, default.category);
assert_eq!(info.version, default.version);
assert_eq!(info.default_max_cost_usd, default.default_max_cost_usd);
}
#[test]
fn boxed_handler_delegates_every_method() {
let boxed: Box<dyn WorkflowHandler> = Box::new(Documented);
assert_eq!(boxed.name(), "documented");
assert_eq!(boxed.version(), Some("3.1.0"));
assert_eq!(boxed.compatible_versions(), &["3.0.0"]);
assert_eq!(boxed.description(), "A documented handler");
assert_eq!(boxed.source_code(), Some("struct Documented;"));
assert_eq!(boxed.sub_workflows(), vec!["child".to_string()]);
assert_eq!(boxed.category(), Some("tests/handlers"));
assert!(boxed.input_schema().is_some());
assert_eq!(boxed.default_labels()["team"], "core");
assert!(boxed.schedule().is_none());
assert_eq!(boxed.default_max_cost_usd(), Some(Decimal::new(250, 2)));
assert_eq!(boxed.guard_config().map(|g| g.max_depth), Some(4));
assert!(boxed.is_version_compatible(Some("3.0.0")));
assert!(!boxed.is_version_compatible(Some("0.1.0")));
assert_eq!(boxed.describe().description, "A documented handler");
}
#[test]
fn boxed_handler_is_accepted_by_generic_register() {
fn takes_handler(handler: impl WorkflowHandler + 'static) -> String {
handler.name().to_string()
}
let boxed: Box<dyn WorkflowHandler> = Box::new(MinimalHandler);
assert_eq!(takes_handler(boxed), "minimal");
}
#[tokio::test]
async fn boxed_handler_create_run_delegates_metadata() {
use ironflow_store::memory::InMemoryStore;
use ironflow_store::models::TriggerKind;
let store = InMemoryStore::new();
let boxed: Box<dyn WorkflowHandler> = Box::new(Documented);
let run = boxed
.create_run(&store, CreateRunOpts::new().trigger(TriggerKind::Manual))
.await
.expect("run created")
.into_run();
assert_eq!(run.workflow_name, "documented");
assert_eq!(run.handler_version.as_deref(), Some("3.1.0"));
assert_eq!(run.max_cost_usd, Some(Decimal::new(250, 2)));
}
}