Skip to main content

ironflow_engine/
handler.rs

1//! [`WorkflowHandler`] trait — dynamic workflows with context chaining.
2//!
3//! Implement this trait to define workflows where steps can reference
4//! outputs from previous steps. The handler receives a [`WorkflowContext`]
5//! that provides step execution methods with automatic persistence.
6//!
7//! # Examples
8//!
9//! ```no_run
10//! use ironflow_engine::handler::WorkflowHandler;
11//! use ironflow_engine::context::WorkflowContext;
12//! use ironflow_engine::config::{ShellConfig, AgentStepConfig};
13//! use ironflow_engine::error::EngineError;
14//! use std::future::Future;
15//! use std::pin::Pin;
16//!
17//! struct DeployWorkflow;
18//!
19//! impl WorkflowHandler for DeployWorkflow {
20//!     fn name(&self) -> &str {
21//!         "deploy"
22//!     }
23//!
24//!     fn execute<'a>(
25//!         &'a self,
26//!         ctx: &'a mut WorkflowContext,
27//!     ) -> Pin<Box<dyn Future<Output = Result<(), EngineError>> + Send + 'a>> {
28//!         Box::pin(async move {
29//!             let build = ctx.shell("build", ShellConfig::new("cargo build --release")).await?;
30//!             let tests = ctx.shell("test", ShellConfig::new("cargo test")).await?;
31//!
32//!             let review = ctx.agent("review", AgentStepConfig::new(
33//!                 &format!("Build:\n{}\nTests:\n{}\nReview.",
34//!                     build.output["stdout"], tests.output["stdout"])
35//!             )).await?;
36//!
37//!             if review.output.as_str().unwrap_or("").contains("LGTM") {
38//!                 ctx.shell("deploy", ShellConfig::new("./deploy.sh")).await?;
39//!             }
40//!
41//!             Ok(())
42//!         })
43//!     }
44//! }
45//! ```
46
47use std::collections::HashMap;
48use std::future::Future;
49use std::pin::Pin;
50
51use rust_decimal::Decimal;
52use schemars::JsonSchema;
53use serde::Serialize;
54use serde_json::Value;
55
56use crate::context::WorkflowContext;
57use crate::error::EngineError;
58use crate::run_creator::{CreateRunOpts, RunCreator, RunCreatorFuture};
59use crate::schedule::CronSchedule;
60
61/// Generate a JSON Schema [`Value`] from a type that derives [`JsonSchema`].
62///
63/// Use this in [`WorkflowHandler::input_schema`] to automatically derive the
64/// schema from your input struct instead of writing JSON by hand.
65///
66/// # Examples
67///
68/// ```
69/// use schemars::JsonSchema;
70/// use serde::Deserialize;
71/// use ironflow_engine::handler::input_schema_for;
72///
73/// #[derive(Deserialize, JsonSchema)]
74/// struct DeployInput {
75///     environment: String,
76///     dry_run: Option<bool>,
77/// }
78///
79/// let schema = input_schema_for::<DeployInput>();
80/// assert_eq!(schema["type"], "object");
81/// assert!(schema["properties"]["environment"].is_object());
82/// ```
83pub fn input_schema_for<T: JsonSchema>() -> Value {
84    let schema = schemars::schema_for!(T);
85    serde_json::to_value(schema).expect("schema serialization cannot fail")
86}
87
88/// Boxed future returned by [`WorkflowHandler::execute`].
89pub type HandlerFuture<'a> = Pin<Box<dyn Future<Output = Result<(), EngineError>> + Send + 'a>>;
90
91/// Metadata about a workflow, returned by [`WorkflowHandler::describe`].
92///
93/// Contains a human-readable description and optional Rust source code
94/// for display in the dashboard.
95#[derive(Debug, Clone, Serialize)]
96pub struct WorkflowInfo {
97    /// Human-readable description of what the workflow does.
98    pub description: String,
99    /// Optional Rust source code of the handler (for UI display).
100    pub source_code: Option<String>,
101    /// Names of sub-workflows invoked by this handler.
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub sub_workflows: Vec<String>,
104    /// Optional `/`-separated category path used to group workflows in the UI tree.
105    ///
106    /// A value like `"data/etl"` places the workflow under `data` → `etl`.
107    /// `None` means the workflow is uncategorized.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub category: Option<String>,
110    /// Handler version string, used to trace which code produced a given run.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub version: Option<String>,
113    /// Versions accepted for replay without `force`.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub compatible_versions: Vec<String>,
116    /// JSON Schema describing the expected input payload.
117    ///
118    /// When present, the dashboard renders a dynamic form from this schema
119    /// and the engine validates the payload before creating a run.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub input_schema: Option<Value>,
122    /// Labels automatically applied to every run of this workflow.
123    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
124    pub default_labels: HashMap<String, String>,
125    /// Optional cron schedule for automatic execution.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub schedule: Option<CronSchedule>,
128    /// Default cumulative cost cap applied to runs of this workflow, in USD.
129    ///
130    /// Overridden by a cap supplied at run creation, and takes precedence over
131    /// the server-wide default. `None` means the handler declares no default.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub default_max_cost_usd: Option<Decimal>,
134}
135
136/// A dynamic workflow handler with context-aware step chaining.
137///
138/// Implement this trait to define workflows where each step can use
139/// the output of previous steps. Register handlers with
140/// [`Engine::register`](crate::engine::Engine::register) and execute
141/// them by name.
142///
143/// # Why `Pin<Box<dyn Future>>` instead of `async fn`?
144///
145/// The handler must be object-safe (`dyn WorkflowHandler`) to allow
146/// registering different handler types in the engine's registry.
147pub trait WorkflowHandler: Send + Sync {
148    /// The workflow name used for registration and lookup.
149    fn name(&self) -> &str;
150
151    /// Handler version string, used to trace which code version produced a run.
152    ///
153    /// Override this to return a meaningful version (semver, git SHA, build
154    /// hash, etc.). The default is `"1"`.
155    ///
156    /// The engine records this value on every run it creates so that retries
157    /// can detect when the handler has changed since the original execution.
158    fn version(&self) -> Option<&str> {
159        Some("1")
160    }
161
162    /// Versions of this handler that can replay payloads produced by an
163    /// older run without requiring `force`.
164    ///
165    /// When a retry targets a run whose `handler_version` differs from
166    /// [`version`](Self::version), the engine checks this list. If the
167    /// run's version appears here, the retry proceeds normally; otherwise
168    /// it is refused with `409 HANDLER_VERSION_MISMATCH` unless the caller
169    /// passes `force=true`.
170    ///
171    /// The default is an empty slice (only the current version is accepted).
172    ///
173    /// # Examples
174    ///
175    /// ```
176    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
177    /// # use ironflow_engine::context::WorkflowContext;
178    /// struct MigratedHandler;
179    ///
180    /// impl WorkflowHandler for MigratedHandler {
181    ///     fn name(&self) -> &str { "migrated" }
182    ///     fn version(&self) -> Option<&str> { Some("2.0.0") }
183    ///     fn compatible_versions(&self) -> &[&str] { &["1.0.0", "1.5.0"] }
184    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
185    ///         Box::pin(async move { Ok(()) })
186    ///     }
187    /// }
188    ///
189    /// assert_eq!(MigratedHandler.compatible_versions(), &["1.0.0", "1.5.0"]);
190    /// ```
191    fn compatible_versions(&self) -> &[&str] {
192        &[]
193    }
194
195    /// Optional `/`-separated category path used to group workflows in the UI tree.
196    ///
197    /// Return a value like `"data/etl"` to place the workflow under `data` → `etl`.
198    /// The default is `None` (uncategorized).
199    ///
200    /// Validation (empty segments, leading or trailing `/`, `//`, whitespace
201    /// segments) is enforced at registration time by
202    /// [`Engine::register`](crate::engine::Engine::register).
203    fn category(&self) -> Option<&str> {
204        None
205    }
206
207    /// Return a JSON Schema describing the expected input payload.
208    ///
209    /// When present, the dashboard renders a dynamic form from this schema
210    /// and the engine validates the payload before creating a run.
211    /// The default is `None` (no schema, free-form payload).
212    fn input_schema(&self) -> Option<Value> {
213        None
214    }
215
216    /// Labels automatically applied to every run of this workflow.
217    ///
218    /// These are merged with any labels provided at run creation time.
219    /// User-provided labels take precedence over defaults.
220    fn default_labels(&self) -> HashMap<String, String> {
221        HashMap::new()
222    }
223
224    /// Optional cron schedule for automatic execution.
225    ///
226    /// Return a [`CronSchedule`] built from a cron expression
227    /// (5 or 6 fields, as supported by [`croner`]).
228    ///
229    /// When set, the engine exposes this handler via
230    /// [`Engine::scheduled_handlers`](crate::engine::Engine::scheduled_handlers)
231    /// so the runtime can wire it into a cron scheduler automatically.
232    ///
233    /// The default is `None` (no automatic scheduling).
234    ///
235    /// # Examples
236    ///
237    /// ```
238    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
239    /// # use ironflow_engine::context::WorkflowContext;
240    /// # use ironflow_engine::schedule::CronSchedule;
241    /// struct HourlySync;
242    ///
243    /// impl WorkflowHandler for HourlySync {
244    ///     fn name(&self) -> &str { "hourly-sync" }
245    ///     fn schedule(&self) -> Option<&CronSchedule> {
246    ///         // In practice, store as a field or use `std::sync::LazyLock`.
247    ///         None
248    ///     }
249    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
250    ///         Box::pin(async move { Ok(()) })
251    ///     }
252    /// }
253    /// ```
254    fn schedule(&self) -> Option<&CronSchedule> {
255        None
256    }
257
258    /// Default cumulative cost cap for runs of this workflow, in USD.
259    ///
260    /// Applied when the run creation request does not supply one. Takes
261    /// precedence over the server-wide
262    /// [`IRONFLOW_DEFAULT_RUN_MAX_COST_USD`](crate::budget::DEFAULT_RUN_MAX_COST_ENV).
263    /// The default is `None` (fall back to the server default, or no cap).
264    ///
265    /// # Examples
266    ///
267    /// ```
268    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
269    /// # use ironflow_engine::context::WorkflowContext;
270    /// use rust_decimal::Decimal;
271    ///
272    /// struct ExpensiveAnalysis;
273    ///
274    /// impl WorkflowHandler for ExpensiveAnalysis {
275    ///     fn name(&self) -> &str { "expensive-analysis" }
276    ///     fn default_max_cost_usd(&self) -> Option<Decimal> {
277    ///         Some(Decimal::new(500, 2)) // $5.00
278    ///     }
279    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
280    ///         Box::pin(async move { Ok(()) })
281    ///     }
282    /// }
283    ///
284    /// assert_eq!(ExpensiveAnalysis.default_max_cost_usd(), Some(Decimal::new(500, 2)));
285    /// ```
286    fn default_max_cost_usd(&self) -> Option<Decimal> {
287        None
288    }
289
290    /// Check whether a run carrying `run_version` can be replayed by this
291    /// handler without `force`.
292    ///
293    /// Compatibility rules:
294    /// - `run_version` is `None` (old run predating version tracking): always
295    ///   compatible.
296    /// - `run_version` equals [`version`](Self::version): compatible.
297    /// - `run_version` appears in [`compatible_versions`](Self::compatible_versions):
298    ///   compatible.
299    /// - Otherwise: incompatible.
300    ///
301    /// # Examples
302    ///
303    /// ```
304    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
305    /// # use ironflow_engine::context::WorkflowContext;
306    /// struct MyHandler;
307    ///
308    /// impl WorkflowHandler for MyHandler {
309    ///     fn name(&self) -> &str { "my-handler" }
310    ///     fn version(&self) -> Option<&str> { Some("2.0.0") }
311    ///     fn compatible_versions(&self) -> &[&str] { &["1.0.0"] }
312    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
313    ///         Box::pin(async move { Ok(()) })
314    ///     }
315    /// }
316    ///
317    /// assert!(MyHandler.is_version_compatible(None));
318    /// assert!(MyHandler.is_version_compatible(Some("2.0.0")));
319    /// assert!(MyHandler.is_version_compatible(Some("1.0.0")));
320    /// assert!(!MyHandler.is_version_compatible(Some("0.5.0")));
321    /// ```
322    fn is_version_compatible(&self, run_version: Option<&str>) -> bool {
323        let Some(rv) = run_version else {
324            return true;
325        };
326        if self.version() == Some(rv) {
327            return true;
328        }
329        self.compatible_versions().contains(&rv)
330    }
331
332    /// Return metadata about this workflow (description, source code).
333    ///
334    /// Override this to provide a description and source code for the
335    /// dashboard UI. The default returns an empty description with no source
336    /// but propagates [`WorkflowHandler::category`],
337    /// [`WorkflowHandler::version`], [`WorkflowHandler::input_schema`],
338    /// [`WorkflowHandler::default_labels`],
339    /// [`WorkflowHandler::compatible_versions`],
340    /// and [`WorkflowHandler::schedule`].
341    fn describe(&self) -> WorkflowInfo {
342        WorkflowInfo {
343            description: String::new(),
344            source_code: None,
345            sub_workflows: Vec::new(),
346            category: self.category().map(str::to_string),
347            version: self.version().map(str::to_string),
348            compatible_versions: self
349                .compatible_versions()
350                .iter()
351                .map(|s| s.to_string())
352                .collect(),
353            input_schema: self.input_schema(),
354            default_labels: self.default_labels(),
355            schedule: self.schedule().cloned(),
356            default_max_cost_usd: self.default_max_cost_usd(),
357        }
358    }
359
360    /// Create a run for this workflow, using handler metadata automatically.
361    ///
362    /// Assembles a [`NewRun`](ironflow_store::entities::NewRun) from [`name`](Self::name),
363    /// [`version`](Self::version), and [`default_max_cost_usd`](Self::default_max_cost_usd),
364    /// then delegates to the given [`RunCreator`].
365    ///
366    /// # Errors
367    ///
368    /// Returns [`EngineError`] if the underlying store rejects the run.
369    ///
370    /// # Examples
371    ///
372    /// ```no_run
373    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
374    /// # use ironflow_engine::context::WorkflowContext;
375    /// # use ironflow_engine::run_creator::{CreateRunOpts, RunCreator};
376    /// # use ironflow_store::entities::TriggerKind;
377    /// struct DeployWorkflow;
378    ///
379    /// impl WorkflowHandler for DeployWorkflow {
380    ///     fn name(&self) -> &str { "deploy" }
381    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
382    ///         Box::pin(async move { Ok(()) })
383    ///     }
384    /// }
385    ///
386    /// # async fn example(store: &dyn RunCreator) -> Result<(), ironflow_engine::error::EngineError> {
387    /// let opts = CreateRunOpts::new().trigger(TriggerKind::Api);
388    /// let run = DeployWorkflow.create_run(store, opts).await?.into_run();
389    /// assert_eq!(run.workflow_name, "deploy");
390    /// # Ok(())
391    /// # }
392    /// ```
393    fn create_run<'a>(
394        &self,
395        creator: &'a dyn RunCreator,
396        opts: CreateRunOpts,
397    ) -> RunCreatorFuture<'a> {
398        use tracing::{Instrument, info_span};
399
400        let new_run = opts.build(self.name(), self.version(), self.default_max_cost_usd());
401        let span = info_span!("handler.create_run", workflow = %self.name());
402        Box::pin(creator.create_run(new_run).instrument(span))
403    }
404
405    /// Execute the workflow with the given context.
406    ///
407    /// The context provides [`shell`](WorkflowContext::shell),
408    /// [`http`](WorkflowContext::http), and [`agent`](WorkflowContext::agent)
409    /// methods that automatically persist each step.
410    ///
411    /// # Errors
412    ///
413    /// Return [`EngineError`] if any step fails. The engine will mark
414    /// the run as `Failed` and record the error.
415    fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a>;
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use serde::{Deserialize, Serialize};
422
423    #[derive(Debug, Serialize, Deserialize, JsonSchema)]
424    struct TestInput {
425        environment: String,
426        #[serde(default)]
427        dry_run: bool,
428    }
429
430    struct MinimalHandler;
431
432    impl WorkflowHandler for MinimalHandler {
433        fn name(&self) -> &str {
434            "minimal"
435        }
436
437        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
438            Box::pin(async { Ok(()) })
439        }
440    }
441
442    struct FullFeaturedHandler;
443
444    impl WorkflowHandler for FullFeaturedHandler {
445        fn name(&self) -> &str {
446            "full"
447        }
448
449        fn version(&self) -> Option<&str> {
450            Some("1.2.0")
451        }
452
453        fn category(&self) -> Option<&str> {
454            Some("data/etl")
455        }
456
457        fn input_schema(&self) -> Option<Value> {
458            Some(input_schema_for::<TestInput>())
459        }
460
461        fn default_labels(&self) -> HashMap<String, String> {
462            HashMap::from([
463                ("team".to_string(), "platform".to_string()),
464                ("env".to_string(), "prod".to_string()),
465            ])
466        }
467
468        fn default_max_cost_usd(&self) -> Option<Decimal> {
469            Some(Decimal::new(750, 2))
470        }
471
472        fn describe(&self) -> WorkflowInfo {
473            WorkflowInfo {
474                description: "Full-featured test handler".to_string(),
475                source_code: Some("fn test() {}".to_string()),
476                sub_workflows: vec!["helper".to_string()],
477                category: self.category().map(str::to_string),
478                version: self.version().map(str::to_string),
479                compatible_versions: self
480                    .compatible_versions()
481                    .iter()
482                    .map(|s| s.to_string())
483                    .collect(),
484                input_schema: self.input_schema(),
485                default_labels: self.default_labels(),
486                schedule: self.schedule().cloned(),
487                default_max_cost_usd: self.default_max_cost_usd(),
488            }
489        }
490
491        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
492            Box::pin(async { Ok(()) })
493        }
494    }
495
496    #[test]
497    fn minimal_handler_has_required_name() {
498        let handler = MinimalHandler;
499        assert_eq!(handler.name(), "minimal");
500    }
501
502    #[test]
503    fn minimal_handler_defaults_to_version_1() {
504        let handler = MinimalHandler;
505        assert_eq!(handler.version(), Some("1"));
506    }
507
508    #[test]
509    fn minimal_handler_defaults_to_no_compatible_versions() {
510        let handler = MinimalHandler;
511        assert!(handler.compatible_versions().is_empty());
512    }
513
514    #[test]
515    fn minimal_handler_defaults_to_no_category() {
516        let handler = MinimalHandler;
517        assert_eq!(handler.category(), None);
518    }
519
520    #[test]
521    fn minimal_handler_defaults_to_no_schema() {
522        let handler = MinimalHandler;
523        assert_eq!(handler.input_schema(), None);
524    }
525
526    #[test]
527    fn minimal_handler_defaults_to_empty_labels() {
528        let handler = MinimalHandler;
529        let labels = handler.default_labels();
530        assert!(labels.is_empty());
531    }
532
533    #[test]
534    fn minimal_handler_defaults_to_no_schedule() {
535        let handler = MinimalHandler;
536        assert_eq!(handler.schedule(), None);
537    }
538
539    #[test]
540    fn minimal_handler_describe_reflects_defaults() {
541        let handler = MinimalHandler;
542        let info = handler.describe();
543        assert_eq!(info.description, "");
544        assert_eq!(info.source_code, None);
545        assert_eq!(info.sub_workflows, Vec::<String>::new());
546        assert_eq!(info.category, None);
547        assert_eq!(info.version, Some("1".to_string()));
548        assert!(info.compatible_versions.is_empty());
549        assert_eq!(info.input_schema, None);
550        assert!(info.default_labels.is_empty());
551        assert_eq!(info.schedule, None);
552    }
553
554    #[test]
555    fn full_handler_returns_all_metadata() {
556        let handler = FullFeaturedHandler;
557        assert_eq!(handler.name(), "full");
558        assert_eq!(handler.version(), Some("1.2.0"));
559        assert_eq!(handler.category(), Some("data/etl"));
560        assert!(handler.input_schema().is_some());
561    }
562
563    #[test]
564    fn full_handler_default_labels_are_set() {
565        let handler = FullFeaturedHandler;
566        let labels = handler.default_labels();
567        assert_eq!(labels.get("team"), Some(&"platform".to_string()));
568        assert_eq!(labels.get("env"), Some(&"prod".to_string()));
569    }
570
571    #[test]
572    fn full_handler_describe_includes_all_fields() {
573        let handler = FullFeaturedHandler;
574        let info = handler.describe();
575        assert_eq!(info.description, "Full-featured test handler");
576        assert_eq!(info.source_code, Some("fn test() {}".to_string()));
577        assert_eq!(info.sub_workflows, vec!["helper".to_string()]);
578        assert_eq!(info.category, Some("data/etl".to_string()));
579        assert_eq!(info.version, Some("1.2.0".to_string()));
580        assert!(info.input_schema.is_some());
581        assert_eq!(info.default_labels.len(), 2);
582    }
583
584    #[test]
585    fn input_schema_for_generates_json_schema() {
586        let schema = input_schema_for::<TestInput>();
587        assert_eq!(schema["type"], "object");
588        assert!(schema["properties"]["environment"].is_object());
589        assert!(schema["properties"]["dry_run"].is_object());
590    }
591
592    #[test]
593    fn input_schema_for_preserves_serde_attributes() {
594        let schema = input_schema_for::<TestInput>();
595        let properties = &schema["properties"];
596        assert!(properties.is_object());
597        assert!(properties.get("environment").is_some());
598        assert!(properties.get("dry_run").is_some());
599    }
600
601    #[test]
602    fn minimal_handler_defaults_to_no_max_cost() {
603        assert!(MinimalHandler.default_max_cost_usd().is_none());
604        assert!(MinimalHandler.describe().default_max_cost_usd.is_none());
605    }
606
607    #[test]
608    fn describe_propagates_handler_max_cost() {
609        assert_eq!(
610            FullFeaturedHandler.describe().default_max_cost_usd,
611            Some(Decimal::new(750, 2))
612        );
613    }
614
615    #[test]
616    fn workflow_info_omits_absent_max_cost_from_json() {
617        let json = serde_json::to_value(MinimalHandler.describe()).expect("serialize");
618        assert!(json.get("default_max_cost_usd").is_none());
619    }
620
621    #[test]
622    fn workflow_info_serializes_with_skip_empty() {
623        let info = WorkflowInfo {
624            description: "test".to_string(),
625            source_code: None,
626            sub_workflows: Vec::new(),
627            category: None,
628            version: None,
629            compatible_versions: Vec::new(),
630            input_schema: None,
631            default_labels: HashMap::new(),
632            schedule: None,
633            default_max_cost_usd: None,
634        };
635
636        let json = serde_json::to_value(&info).expect("serialize");
637        assert_eq!(json["description"], "test");
638        // Optional fields with skip_serializing_if may still be present or absent
639        // depending on the serde configuration. Just verify the description is there.
640        assert!(json.is_object());
641    }
642
643    #[test]
644    fn workflow_info_serializes_with_values() {
645        let info = WorkflowInfo {
646            description: "test".to_string(),
647            source_code: Some("code".to_string()),
648            sub_workflows: vec!["sub".to_string()],
649            category: Some("cat".to_string()),
650            version: Some("1.0.0".to_string()),
651            compatible_versions: vec!["0.9.0".to_string()],
652            input_schema: Some(serde_json::json!({"type": "object"})),
653            default_labels: HashMap::from([("key".to_string(), "value".to_string())]),
654            schedule: Some(CronSchedule::new("0 0 * * * *").unwrap()),
655            default_max_cost_usd: Some(Decimal::new(750, 2)),
656        };
657
658        let json = serde_json::to_value(&info).expect("serialize");
659        assert_eq!(json["description"], "test");
660        assert_eq!(json["source_code"], "code");
661        assert_eq!(json["sub_workflows"][0], "sub");
662        assert_eq!(json["category"], "cat");
663        assert_eq!(json["version"], "1.0.0");
664        assert_eq!(json["default_labels"]["key"], "value");
665        assert_eq!(json["schedule"], "0 0 * * * *");
666        assert_eq!(json["compatible_versions"][0], "0.9.0");
667    }
668
669    // ---- is_version_compatible ----
670
671    struct VersionedHandler;
672
673    impl WorkflowHandler for VersionedHandler {
674        fn name(&self) -> &str {
675            "versioned"
676        }
677        fn version(&self) -> Option<&str> {
678            Some("2.0.0")
679        }
680        fn compatible_versions(&self) -> &[&str] {
681            &["1.5.0", "1.9.0"]
682        }
683        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
684            Box::pin(async { Ok(()) })
685        }
686    }
687
688    #[test]
689    fn version_compatible_with_same_version() {
690        assert!(VersionedHandler.is_version_compatible(Some("2.0.0")));
691    }
692
693    #[test]
694    fn version_compatible_with_none_run_version() {
695        assert!(VersionedHandler.is_version_compatible(None));
696    }
697
698    #[test]
699    fn version_compatible_with_listed_version() {
700        assert!(VersionedHandler.is_version_compatible(Some("1.5.0")));
701        assert!(VersionedHandler.is_version_compatible(Some("1.9.0")));
702    }
703
704    #[test]
705    fn version_incompatible_with_unlisted_version() {
706        assert!(!VersionedHandler.is_version_compatible(Some("1.0.0")));
707        assert!(!VersionedHandler.is_version_compatible(Some("3.0.0")));
708    }
709
710    #[test]
711    fn minimal_handler_compatible_with_same_default() {
712        assert!(MinimalHandler.is_version_compatible(Some("1")));
713    }
714
715    #[test]
716    fn minimal_handler_incompatible_with_different_version() {
717        assert!(!MinimalHandler.is_version_compatible(Some("2")));
718    }
719
720    // ---- WorkflowHandler::create_run ----
721
722    #[tokio::test]
723    async fn handler_create_run_uses_handler_metadata() {
724        use ironflow_store::entities::TriggerKind;
725        use ironflow_store::memory::InMemoryStore;
726
727        let store = InMemoryStore::new();
728
729        let opts = CreateRunOpts::new().trigger(TriggerKind::Api);
730        let creation = FullFeaturedHandler
731            .create_run(&store, opts)
732            .await
733            .expect("create_run");
734        let run = creation.into_run();
735
736        assert_eq!(run.workflow_name, "full");
737        assert_eq!(run.handler_version, Some("1.2.0".to_string()));
738        assert_eq!(run.max_cost_usd, Some(Decimal::new(750, 2)));
739    }
740
741    #[tokio::test]
742    async fn handler_create_run_opts_override_handler_defaults() {
743        use ironflow_store::memory::InMemoryStore;
744
745        let store = InMemoryStore::new();
746
747        let opts = CreateRunOpts::new().max_cost_usd(Decimal::new(100, 2));
748        let creation = FullFeaturedHandler
749            .create_run(&store, opts)
750            .await
751            .expect("create_run");
752        let run = creation.into_run();
753
754        assert_eq!(run.max_cost_usd, Some(Decimal::new(100, 2)));
755    }
756
757    #[tokio::test]
758    async fn handler_create_run_minimal_handler_defaults() {
759        use ironflow_store::memory::InMemoryStore;
760
761        let store = InMemoryStore::new();
762
763        let opts = CreateRunOpts::new();
764        let creation = MinimalHandler
765            .create_run(&store, opts)
766            .await
767            .expect("create_run");
768        let run = creation.into_run();
769
770        assert_eq!(run.workflow_name, "minimal");
771        assert_eq!(run.handler_version, Some("1".to_string()));
772        assert_eq!(run.max_cost_usd, None);
773    }
774}