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::guard::WorkflowGuardConfig;
59use crate::run_creator::{CreateRunOpts, RunCreator, RunCreatorFuture};
60use crate::schedule::CronSchedule;
61
62/// Generate a JSON Schema [`Value`] from a type that derives [`JsonSchema`].
63///
64/// Use this in [`WorkflowHandler::input_schema`] to automatically derive the
65/// schema from your input struct instead of writing JSON by hand.
66///
67/// # Examples
68///
69/// ```
70/// use schemars::JsonSchema;
71/// use serde::Deserialize;
72/// use ironflow_engine::handler::input_schema_for;
73///
74/// #[derive(Deserialize, JsonSchema)]
75/// struct DeployInput {
76///     environment: String,
77///     dry_run: Option<bool>,
78/// }
79///
80/// let schema = input_schema_for::<DeployInput>();
81/// assert_eq!(schema["type"], "object");
82/// assert!(schema["properties"]["environment"].is_object());
83/// ```
84pub fn input_schema_for<T: JsonSchema>() -> Value {
85    let schema = schemars::schema_for!(T);
86    serde_json::to_value(schema).expect("schema serialization cannot fail")
87}
88
89/// Boxed future returned by [`WorkflowHandler::execute`].
90pub type HandlerFuture<'a> = Pin<Box<dyn Future<Output = Result<(), EngineError>> + Send + 'a>>;
91
92/// Metadata about a workflow, returned by [`WorkflowHandler::describe`].
93///
94/// Contains a human-readable description and optional Rust source code
95/// for display in the dashboard.
96///
97/// Most handlers never build this struct by hand: override
98/// [`WorkflowHandler::description`] and [`WorkflowHandler::source_code`] and
99/// the default [`WorkflowHandler::describe`] assembles it from the other
100/// trait methods. The builder below exists for handlers that override
101/// `describe` entirely.
102///
103/// # Examples
104///
105/// ```
106/// use ironflow_engine::handler::WorkflowInfo;
107///
108/// let info = WorkflowInfo::new("Deploy to production")
109///     .with_category("ops")
110///     .with_version("2.0.0")
111///     .with_sub_workflows(["build"]);
112///
113/// assert_eq!(info.description, "Deploy to production");
114/// assert_eq!(info.category.as_deref(), Some("ops"));
115/// assert_eq!(info.sub_workflows, vec!["build".to_string()]);
116/// ```
117#[derive(Debug, Clone, Default, Serialize)]
118pub struct WorkflowInfo {
119    /// Human-readable description of what the workflow does.
120    pub description: String,
121    /// Optional Rust source code of the handler (for UI display).
122    pub source_code: Option<String>,
123    /// Names of sub-workflows invoked by this handler.
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub sub_workflows: Vec<String>,
126    /// Optional `/`-separated category path used to group workflows in the UI tree.
127    ///
128    /// A value like `"data/etl"` places the workflow under `data` → `etl`.
129    /// `None` means the workflow is uncategorized.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub category: Option<String>,
132    /// Handler version string, used to trace which code produced a given run.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub version: Option<String>,
135    /// Versions accepted for replay without `force`.
136    #[serde(default, skip_serializing_if = "Vec::is_empty")]
137    pub compatible_versions: Vec<String>,
138    /// JSON Schema describing the expected input payload.
139    ///
140    /// When present, the dashboard renders a dynamic form from this schema
141    /// and the engine validates the payload before creating a run.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub input_schema: Option<Value>,
144    /// Labels automatically applied to every run of this workflow.
145    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
146    pub default_labels: HashMap<String, String>,
147    /// Optional cron schedule for automatic execution.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub schedule: Option<CronSchedule>,
150    /// Default cumulative cost cap applied to runs of this workflow, in USD.
151    ///
152    /// Overridden by a cap supplied at run creation, and takes precedence over
153    /// the server-wide default. `None` means the handler declares no default.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub default_max_cost_usd: Option<Decimal>,
156}
157
158impl WorkflowInfo {
159    /// Create metadata with a description and every other field at its default.
160    ///
161    /// # Examples
162    ///
163    /// ```
164    /// use ironflow_engine::handler::WorkflowInfo;
165    ///
166    /// let info = WorkflowInfo::new("Nightly backup");
167    /// assert_eq!(info.description, "Nightly backup");
168    /// assert!(info.source_code.is_none());
169    /// assert!(info.sub_workflows.is_empty());
170    /// ```
171    pub fn new(description: impl Into<String>) -> Self {
172        Self {
173            description: description.into(),
174            ..Self::default()
175        }
176    }
177
178    /// Attach the handler source code, typically via `include_str!`.
179    ///
180    /// # Examples
181    ///
182    /// ```
183    /// use ironflow_engine::handler::WorkflowInfo;
184    ///
185    /// let info = WorkflowInfo::new("Demo").with_source_code("struct Demo;");
186    /// assert_eq!(info.source_code.as_deref(), Some("struct Demo;"));
187    /// ```
188    pub fn with_source_code(mut self, source: impl Into<String>) -> Self {
189        self.source_code = Some(source.into());
190        self
191    }
192
193    /// Declare the sub-workflows this handler invokes.
194    ///
195    /// # Examples
196    ///
197    /// ```
198    /// use ironflow_engine::handler::WorkflowInfo;
199    ///
200    /// let info = WorkflowInfo::new("Report").with_sub_workflows(["collect", "enrich"]);
201    /// assert_eq!(info.sub_workflows, vec!["collect".to_string(), "enrich".to_string()]);
202    /// ```
203    pub fn with_sub_workflows<I, S>(mut self, names: I) -> Self
204    where
205        I: IntoIterator<Item = S>,
206        S: Into<String>,
207    {
208        self.sub_workflows = names.into_iter().map(Into::into).collect();
209        self
210    }
211
212    /// Set the `/`-separated category path.
213    ///
214    /// # Examples
215    ///
216    /// ```
217    /// use ironflow_engine::handler::WorkflowInfo;
218    ///
219    /// let info = WorkflowInfo::new("ETL").with_category("data/etl");
220    /// assert_eq!(info.category.as_deref(), Some("data/etl"));
221    /// ```
222    pub fn with_category(mut self, category: impl Into<String>) -> Self {
223        self.category = Some(category.into());
224        self
225    }
226
227    /// Set the handler version.
228    ///
229    /// # Examples
230    ///
231    /// ```
232    /// use ironflow_engine::handler::WorkflowInfo;
233    ///
234    /// let info = WorkflowInfo::new("Deploy").with_version("1.2.0");
235    /// assert_eq!(info.version.as_deref(), Some("1.2.0"));
236    /// ```
237    pub fn with_version(mut self, version: impl Into<String>) -> Self {
238        self.version = Some(version.into());
239        self
240    }
241
242    /// Set the versions accepted for replay without `force`.
243    ///
244    /// # Examples
245    ///
246    /// ```
247    /// use ironflow_engine::handler::WorkflowInfo;
248    ///
249    /// let info = WorkflowInfo::new("Deploy").with_compatible_versions(["1.0.0"]);
250    /// assert_eq!(info.compatible_versions, vec!["1.0.0".to_string()]);
251    /// ```
252    pub fn with_compatible_versions<I, S>(mut self, versions: I) -> Self
253    where
254        I: IntoIterator<Item = S>,
255        S: Into<String>,
256    {
257        self.compatible_versions = versions.into_iter().map(Into::into).collect();
258        self
259    }
260
261    /// Set the JSON Schema of the expected input payload.
262    ///
263    /// # Examples
264    ///
265    /// ```
266    /// use ironflow_engine::handler::WorkflowInfo;
267    /// use serde_json::json;
268    ///
269    /// let info = WorkflowInfo::new("Greet").with_input_schema(json!({"type": "object"}));
270    /// assert_eq!(info.input_schema.unwrap()["type"], "object");
271    /// ```
272    pub fn with_input_schema(mut self, schema: Value) -> Self {
273        self.input_schema = Some(schema);
274        self
275    }
276
277    /// Set the labels applied to every run of this workflow.
278    ///
279    /// # Examples
280    ///
281    /// ```
282    /// use std::collections::HashMap;
283    /// use ironflow_engine::handler::WorkflowInfo;
284    ///
285    /// let labels = HashMap::from([("team".to_string(), "core".to_string())]);
286    /// let info = WorkflowInfo::new("Sync").with_default_labels(labels);
287    /// assert_eq!(info.default_labels["team"], "core");
288    /// ```
289    pub fn with_default_labels(mut self, labels: HashMap<String, String>) -> Self {
290        self.default_labels = labels;
291        self
292    }
293
294    /// Set the cron schedule.
295    ///
296    /// # Examples
297    ///
298    /// ```
299    /// use ironflow_engine::handler::WorkflowInfo;
300    /// use ironflow_engine::schedule::CronSchedule;
301    ///
302    /// let schedule = CronSchedule::new("0 0 * * *")?;
303    /// let info = WorkflowInfo::new("Nightly").with_schedule(schedule);
304    /// assert!(info.schedule.is_some());
305    /// # Ok::<(), String>(())
306    /// ```
307    pub fn with_schedule(mut self, schedule: CronSchedule) -> Self {
308        self.schedule = Some(schedule);
309        self
310    }
311
312    /// Set the default cumulative cost cap in USD.
313    ///
314    /// # Examples
315    ///
316    /// ```
317    /// use ironflow_engine::handler::WorkflowInfo;
318    /// use rust_decimal::Decimal;
319    ///
320    /// let info = WorkflowInfo::new("Analysis").with_default_max_cost_usd(Decimal::new(500, 2));
321    /// assert_eq!(info.default_max_cost_usd, Some(Decimal::new(500, 2)));
322    /// ```
323    pub fn with_default_max_cost_usd(mut self, cap: Decimal) -> Self {
324        self.default_max_cost_usd = Some(cap);
325        self
326    }
327}
328
329/// A dynamic workflow handler with context-aware step chaining.
330///
331/// Implement this trait to define workflows where each step can use
332/// the output of previous steps. Register handlers with
333/// [`Engine::register`](crate::engine::Engine::register) and execute
334/// them by name.
335///
336/// # Why `Pin<Box<dyn Future>>` instead of `async fn`?
337///
338/// The handler must be object-safe (`dyn WorkflowHandler`) to allow
339/// registering different handler types in the engine's registry.
340pub trait WorkflowHandler: Send + Sync {
341    /// The workflow name used for registration and lookup.
342    fn name(&self) -> &str;
343
344    /// Handler version string, used to trace which code version produced a run.
345    ///
346    /// Override this to return a meaningful version (semver, git SHA, build
347    /// hash, etc.). The default is `"1"`.
348    ///
349    /// The engine records this value on every run it creates so that retries
350    /// can detect when the handler has changed since the original execution.
351    fn version(&self) -> Option<&str> {
352        Some("1")
353    }
354
355    /// Versions of this handler that can replay payloads produced by an
356    /// older run without requiring `force`.
357    ///
358    /// When a retry targets a run whose `handler_version` differs from
359    /// [`version`](Self::version), the engine checks this list. If the
360    /// run's version appears here, the retry proceeds normally; otherwise
361    /// it is refused with `409 HANDLER_VERSION_MISMATCH` unless the caller
362    /// passes `force=true`.
363    ///
364    /// The default is an empty slice (only the current version is accepted).
365    ///
366    /// # Examples
367    ///
368    /// ```
369    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
370    /// # use ironflow_engine::context::WorkflowContext;
371    /// struct MigratedHandler;
372    ///
373    /// impl WorkflowHandler for MigratedHandler {
374    ///     fn name(&self) -> &str { "migrated" }
375    ///     fn version(&self) -> Option<&str> { Some("2.0.0") }
376    ///     fn compatible_versions(&self) -> &[&str] { &["1.0.0", "1.5.0"] }
377    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
378    ///         Box::pin(async move { Ok(()) })
379    ///     }
380    /// }
381    ///
382    /// assert_eq!(MigratedHandler.compatible_versions(), &["1.0.0", "1.5.0"]);
383    /// ```
384    fn compatible_versions(&self) -> &[&str] {
385        &[]
386    }
387
388    /// Human-readable description shown in the dashboard and the CLI.
389    ///
390    /// The default is an empty string. Override this rather than
391    /// [`describe`](Self::describe): the default `describe` picks it up.
392    ///
393    /// # Examples
394    ///
395    /// ```
396    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
397    /// # use ironflow_engine::context::WorkflowContext;
398    /// struct Backup;
399    ///
400    /// impl WorkflowHandler for Backup {
401    ///     fn name(&self) -> &str { "backup" }
402    ///     fn description(&self) -> &str { "Nightly database backup" }
403    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
404    ///         Box::pin(async move { Ok(()) })
405    ///     }
406    /// }
407    ///
408    /// assert_eq!(Backup.describe().description, "Nightly database backup");
409    /// ```
410    fn description(&self) -> &str {
411        ""
412    }
413
414    /// Rust source of the handler, displayed in the dashboard.
415    ///
416    /// Return `Some(include_str!("this_file.rs"))` to show the code next to
417    /// the run. The default is `None`.
418    ///
419    /// # Examples
420    ///
421    /// ```
422    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
423    /// # use ironflow_engine::context::WorkflowContext;
424    /// struct Backup;
425    ///
426    /// impl WorkflowHandler for Backup {
427    ///     fn name(&self) -> &str { "backup" }
428    ///     fn source_code(&self) -> Option<&str> { Some("struct Backup;") }
429    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
430    ///         Box::pin(async move { Ok(()) })
431    ///     }
432    /// }
433    ///
434    /// assert_eq!(Backup.describe().source_code.as_deref(), Some("struct Backup;"));
435    /// ```
436    fn source_code(&self) -> Option<&str> {
437        None
438    }
439
440    /// Names of the sub-workflows this handler invokes through
441    /// [`WorkflowContext::workflow`](crate::context::WorkflowContext::workflow).
442    ///
443    /// Purely informational: the dashboard uses it to draw the call graph.
444    /// The default is empty.
445    ///
446    /// # Examples
447    ///
448    /// ```
449    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
450    /// # use ironflow_engine::context::WorkflowContext;
451    /// struct Report;
452    ///
453    /// impl WorkflowHandler for Report {
454    ///     fn name(&self) -> &str { "report" }
455    ///     fn sub_workflows(&self) -> Vec<String> { vec!["collect".to_string()] }
456    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
457    ///         Box::pin(async move { Ok(()) })
458    ///     }
459    /// }
460    ///
461    /// assert_eq!(Report.describe().sub_workflows, vec!["collect".to_string()]);
462    /// ```
463    fn sub_workflows(&self) -> Vec<String> {
464        Vec::new()
465    }
466
467    /// Optional `/`-separated category path used to group workflows in the UI tree.
468    ///
469    /// Return a value like `"data/etl"` to place the workflow under `data` → `etl`.
470    /// The default is `None` (uncategorized).
471    ///
472    /// Validation (empty segments, leading or trailing `/`, `//`, whitespace
473    /// segments) is enforced at registration time by
474    /// [`Engine::register`](crate::engine::Engine::register).
475    fn category(&self) -> Option<&str> {
476        None
477    }
478
479    /// Return a JSON Schema describing the expected input payload.
480    ///
481    /// When present, the dashboard renders a dynamic form from this schema
482    /// and the engine validates the payload before creating a run.
483    /// The default is `None` (no schema, free-form payload).
484    fn input_schema(&self) -> Option<Value> {
485        None
486    }
487
488    /// Labels automatically applied to every run of this workflow.
489    ///
490    /// These are merged with any labels provided at run creation time.
491    /// User-provided labels take precedence over defaults.
492    fn default_labels(&self) -> HashMap<String, String> {
493        HashMap::new()
494    }
495
496    /// Optional cron schedule for automatic execution.
497    ///
498    /// Return a [`CronSchedule`] built from a cron expression
499    /// (5 or 6 fields, as supported by [`croner`]).
500    ///
501    /// When set, the engine exposes this handler via
502    /// [`Engine::scheduled_handlers`](crate::engine::Engine::scheduled_handlers)
503    /// so the runtime can wire it into a cron scheduler automatically.
504    ///
505    /// The default is `None` (no automatic scheduling).
506    ///
507    /// # Examples
508    ///
509    /// ```
510    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
511    /// # use ironflow_engine::context::WorkflowContext;
512    /// # use ironflow_engine::schedule::CronSchedule;
513    /// struct HourlySync;
514    ///
515    /// impl WorkflowHandler for HourlySync {
516    ///     fn name(&self) -> &str { "hourly-sync" }
517    ///     fn schedule(&self) -> Option<&CronSchedule> {
518    ///         // In practice, store as a field or use `std::sync::LazyLock`.
519    ///         None
520    ///     }
521    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
522    ///         Box::pin(async move { Ok(()) })
523    ///     }
524    /// }
525    /// ```
526    fn schedule(&self) -> Option<&CronSchedule> {
527        None
528    }
529
530    /// Default cumulative cost cap for runs of this workflow, in USD.
531    ///
532    /// Applied when the run creation request does not supply one. Takes
533    /// precedence over the server-wide
534    /// [`IRONFLOW_DEFAULT_RUN_MAX_COST_USD`](crate::budget::DEFAULT_RUN_MAX_COST_ENV).
535    /// The default is `None` (fall back to the server default, or no cap).
536    ///
537    /// # Examples
538    ///
539    /// ```
540    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
541    /// # use ironflow_engine::context::WorkflowContext;
542    /// use rust_decimal::Decimal;
543    ///
544    /// struct ExpensiveAnalysis;
545    ///
546    /// impl WorkflowHandler for ExpensiveAnalysis {
547    ///     fn name(&self) -> &str { "expensive-analysis" }
548    ///     fn default_max_cost_usd(&self) -> Option<Decimal> {
549    ///         Some(Decimal::new(500, 2)) // $5.00
550    ///     }
551    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
552    ///         Box::pin(async move { Ok(()) })
553    ///     }
554    /// }
555    ///
556    /// assert_eq!(ExpensiveAnalysis.default_max_cost_usd(), Some(Decimal::new(500, 2)));
557    /// ```
558    fn default_max_cost_usd(&self) -> Option<Decimal> {
559        None
560    }
561
562    /// Optional guard configuration for this workflow.
563    ///
564    /// When present, overrides the engine's global guard configuration
565    /// for runs of this handler. The default is `None` (use the engine's
566    /// global configuration).
567    ///
568    /// # Examples
569    ///
570    /// ```
571    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
572    /// # use ironflow_engine::context::WorkflowContext;
573    /// use ironflow_engine::guard::WorkflowGuardConfig;
574    ///
575    /// struct StrictWorkflow;
576    ///
577    /// impl WorkflowHandler for StrictWorkflow {
578    ///     fn name(&self) -> &str { "strict" }
579    ///     fn guard_config(&self) -> Option<WorkflowGuardConfig> {
580    ///         Some(WorkflowGuardConfig::new().with_max_depth(2))
581    ///     }
582    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
583    ///         Box::pin(async move { Ok(()) })
584    ///     }
585    /// }
586    ///
587    /// assert_eq!(StrictWorkflow.guard_config().unwrap().max_depth, 2);
588    /// ```
589    fn guard_config(&self) -> Option<WorkflowGuardConfig> {
590        None
591    }
592
593    /// Check whether a run carrying `run_version` can be replayed by this
594    /// handler without `force`.
595    ///
596    /// Compatibility rules:
597    /// - `run_version` is `None` (old run predating version tracking): always
598    ///   compatible.
599    /// - `run_version` equals [`version`](Self::version): compatible.
600    /// - `run_version` appears in [`compatible_versions`](Self::compatible_versions):
601    ///   compatible.
602    /// - Otherwise: incompatible.
603    ///
604    /// # Examples
605    ///
606    /// ```
607    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
608    /// # use ironflow_engine::context::WorkflowContext;
609    /// struct MyHandler;
610    ///
611    /// impl WorkflowHandler for MyHandler {
612    ///     fn name(&self) -> &str { "my-handler" }
613    ///     fn version(&self) -> Option<&str> { Some("2.0.0") }
614    ///     fn compatible_versions(&self) -> &[&str] { &["1.0.0"] }
615    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
616    ///         Box::pin(async move { Ok(()) })
617    ///     }
618    /// }
619    ///
620    /// assert!(MyHandler.is_version_compatible(None));
621    /// assert!(MyHandler.is_version_compatible(Some("2.0.0")));
622    /// assert!(MyHandler.is_version_compatible(Some("1.0.0")));
623    /// assert!(!MyHandler.is_version_compatible(Some("0.5.0")));
624    /// ```
625    fn is_version_compatible(&self, run_version: Option<&str>) -> bool {
626        let Some(rv) = run_version else {
627            return true;
628        };
629        if self.version() == Some(rv) {
630            return true;
631        }
632        self.compatible_versions().contains(&rv)
633    }
634
635    /// Return metadata about this workflow.
636    ///
637    /// The default assembles a [`WorkflowInfo`] from every other trait
638    /// method: [`description`](Self::description),
639    /// [`source_code`](Self::source_code),
640    /// [`sub_workflows`](Self::sub_workflows), [`category`](Self::category),
641    /// [`version`](Self::version),
642    /// [`compatible_versions`](Self::compatible_versions),
643    /// [`input_schema`](Self::input_schema),
644    /// [`default_labels`](Self::default_labels), [`schedule`](Self::schedule)
645    /// and [`default_max_cost_usd`](Self::default_max_cost_usd). Override
646    /// those instead of this method; override `describe` only when the
647    /// metadata cannot be expressed through them.
648    fn describe(&self) -> WorkflowInfo {
649        WorkflowInfo {
650            description: self.description().to_string(),
651            source_code: self.source_code().map(str::to_string),
652            sub_workflows: self.sub_workflows(),
653            category: self.category().map(str::to_string),
654            version: self.version().map(str::to_string),
655            compatible_versions: self
656                .compatible_versions()
657                .iter()
658                .map(|s| s.to_string())
659                .collect(),
660            input_schema: self.input_schema(),
661            default_labels: self.default_labels(),
662            schedule: self.schedule().cloned(),
663            default_max_cost_usd: self.default_max_cost_usd(),
664        }
665    }
666
667    /// Create a run for this workflow, using handler metadata automatically.
668    ///
669    /// Assembles a [`NewRun`](ironflow_store::entities::NewRun) from [`name`](Self::name),
670    /// [`version`](Self::version), and [`default_max_cost_usd`](Self::default_max_cost_usd),
671    /// then delegates to the given [`RunCreator`].
672    ///
673    /// # Errors
674    ///
675    /// Returns [`EngineError`] if the underlying store rejects the run.
676    ///
677    /// # Examples
678    ///
679    /// ```no_run
680    /// # use ironflow_engine::handler::{WorkflowHandler, HandlerFuture};
681    /// # use ironflow_engine::context::WorkflowContext;
682    /// # use ironflow_engine::run_creator::{CreateRunOpts, RunCreator};
683    /// # use ironflow_store::entities::TriggerKind;
684    /// struct DeployWorkflow;
685    ///
686    /// impl WorkflowHandler for DeployWorkflow {
687    ///     fn name(&self) -> &str { "deploy" }
688    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
689    ///         Box::pin(async move { Ok(()) })
690    ///     }
691    /// }
692    ///
693    /// # async fn example(store: &dyn RunCreator) -> Result<(), ironflow_engine::error::EngineError> {
694    /// let opts = CreateRunOpts::new().trigger(TriggerKind::Api);
695    /// let run = DeployWorkflow.create_run(store, opts).await?.into_run();
696    /// assert_eq!(run.workflow_name, "deploy");
697    /// # Ok(())
698    /// # }
699    /// ```
700    fn create_run<'a>(
701        &self,
702        creator: &'a dyn RunCreator,
703        opts: CreateRunOpts,
704    ) -> RunCreatorFuture<'a> {
705        use tracing::{Instrument, info_span};
706
707        let new_run = opts.build(self.name(), self.version(), self.default_max_cost_usd());
708        let span = info_span!("handler.create_run", workflow = %self.name());
709        Box::pin(creator.create_run(new_run).instrument(span))
710    }
711
712    /// Execute the workflow with the given context.
713    ///
714    /// The context provides [`shell`](WorkflowContext::shell),
715    /// [`http`](WorkflowContext::http), and [`agent`](WorkflowContext::agent)
716    /// methods that automatically persist each step.
717    ///
718    /// # Errors
719    ///
720    /// Return [`EngineError`] if any step fails. The engine will mark
721    /// the run as `Failed` and record the error.
722    fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a>;
723}
724
725/// A boxed handler is a handler.
726///
727/// Lets a single `Vec<Box<dyn WorkflowHandler>>` feed both
728/// [`Engine::register`](crate::engine::Engine::register) and a worker
729/// builder, so the API server and the workers cannot drift apart in the
730/// list of workflows they know.
731///
732/// # Examples
733///
734/// ```
735/// use ironflow_engine::handler::{HandlerFuture, WorkflowHandler};
736/// use ironflow_engine::context::WorkflowContext;
737///
738/// struct Hello;
739///
740/// impl WorkflowHandler for Hello {
741///     fn name(&self) -> &str { "hello" }
742///     fn description(&self) -> &str { "Says hello" }
743///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
744///         Box::pin(async move { Ok(()) })
745///     }
746/// }
747///
748/// fn handlers() -> Vec<Box<dyn WorkflowHandler>> {
749///     vec![Box::new(Hello)]
750/// }
751///
752/// for handler in handlers() {
753///     assert_eq!(handler.name(), "hello");
754///     assert_eq!(handler.describe().description, "Says hello");
755/// }
756/// ```
757impl<T: WorkflowHandler + ?Sized> WorkflowHandler for Box<T> {
758    fn name(&self) -> &str {
759        (**self).name()
760    }
761
762    fn version(&self) -> Option<&str> {
763        (**self).version()
764    }
765
766    fn compatible_versions(&self) -> &[&str] {
767        (**self).compatible_versions()
768    }
769
770    fn description(&self) -> &str {
771        (**self).description()
772    }
773
774    fn source_code(&self) -> Option<&str> {
775        (**self).source_code()
776    }
777
778    fn sub_workflows(&self) -> Vec<String> {
779        (**self).sub_workflows()
780    }
781
782    fn category(&self) -> Option<&str> {
783        (**self).category()
784    }
785
786    fn input_schema(&self) -> Option<Value> {
787        (**self).input_schema()
788    }
789
790    fn default_labels(&self) -> HashMap<String, String> {
791        (**self).default_labels()
792    }
793
794    fn schedule(&self) -> Option<&CronSchedule> {
795        (**self).schedule()
796    }
797
798    fn default_max_cost_usd(&self) -> Option<Decimal> {
799        (**self).default_max_cost_usd()
800    }
801
802    fn guard_config(&self) -> Option<WorkflowGuardConfig> {
803        (**self).guard_config()
804    }
805
806    fn is_version_compatible(&self, run_version: Option<&str>) -> bool {
807        (**self).is_version_compatible(run_version)
808    }
809
810    fn describe(&self) -> WorkflowInfo {
811        (**self).describe()
812    }
813
814    fn create_run<'a>(
815        &self,
816        creator: &'a dyn RunCreator,
817        opts: CreateRunOpts,
818    ) -> RunCreatorFuture<'a> {
819        (**self).create_run(creator, opts)
820    }
821
822    fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
823        (**self).execute(ctx)
824    }
825}
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use serde::{Deserialize, Serialize};
831
832    #[derive(Debug, Serialize, Deserialize, JsonSchema)]
833    struct TestInput {
834        environment: String,
835        #[serde(default)]
836        dry_run: bool,
837    }
838
839    struct MinimalHandler;
840
841    impl WorkflowHandler for MinimalHandler {
842        fn name(&self) -> &str {
843            "minimal"
844        }
845
846        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
847            Box::pin(async { Ok(()) })
848        }
849    }
850
851    struct FullFeaturedHandler;
852
853    impl WorkflowHandler for FullFeaturedHandler {
854        fn name(&self) -> &str {
855            "full"
856        }
857
858        fn version(&self) -> Option<&str> {
859            Some("1.2.0")
860        }
861
862        fn category(&self) -> Option<&str> {
863            Some("data/etl")
864        }
865
866        fn input_schema(&self) -> Option<Value> {
867            Some(input_schema_for::<TestInput>())
868        }
869
870        fn default_labels(&self) -> HashMap<String, String> {
871            HashMap::from([
872                ("team".to_string(), "platform".to_string()),
873                ("env".to_string(), "prod".to_string()),
874            ])
875        }
876
877        fn default_max_cost_usd(&self) -> Option<Decimal> {
878            Some(Decimal::new(750, 2))
879        }
880
881        fn describe(&self) -> WorkflowInfo {
882            WorkflowInfo {
883                description: "Full-featured test handler".to_string(),
884                source_code: Some("fn test() {}".to_string()),
885                sub_workflows: vec!["helper".to_string()],
886                category: self.category().map(str::to_string),
887                version: self.version().map(str::to_string),
888                compatible_versions: self
889                    .compatible_versions()
890                    .iter()
891                    .map(|s| s.to_string())
892                    .collect(),
893                input_schema: self.input_schema(),
894                default_labels: self.default_labels(),
895                schedule: self.schedule().cloned(),
896                default_max_cost_usd: self.default_max_cost_usd(),
897            }
898        }
899
900        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
901            Box::pin(async { Ok(()) })
902        }
903    }
904
905    #[test]
906    fn minimal_handler_has_required_name() {
907        let handler = MinimalHandler;
908        assert_eq!(handler.name(), "minimal");
909    }
910
911    #[test]
912    fn minimal_handler_defaults_to_version_1() {
913        let handler = MinimalHandler;
914        assert_eq!(handler.version(), Some("1"));
915    }
916
917    #[test]
918    fn minimal_handler_defaults_to_no_compatible_versions() {
919        let handler = MinimalHandler;
920        assert!(handler.compatible_versions().is_empty());
921    }
922
923    #[test]
924    fn minimal_handler_defaults_to_no_category() {
925        let handler = MinimalHandler;
926        assert_eq!(handler.category(), None);
927    }
928
929    #[test]
930    fn minimal_handler_defaults_to_no_schema() {
931        let handler = MinimalHandler;
932        assert_eq!(handler.input_schema(), None);
933    }
934
935    #[test]
936    fn minimal_handler_defaults_to_empty_labels() {
937        let handler = MinimalHandler;
938        let labels = handler.default_labels();
939        assert!(labels.is_empty());
940    }
941
942    #[test]
943    fn minimal_handler_defaults_to_no_schedule() {
944        let handler = MinimalHandler;
945        assert_eq!(handler.schedule(), None);
946    }
947
948    #[test]
949    fn minimal_handler_describe_reflects_defaults() {
950        let handler = MinimalHandler;
951        let info = handler.describe();
952        assert_eq!(info.description, "");
953        assert_eq!(info.source_code, None);
954        assert_eq!(info.sub_workflows, Vec::<String>::new());
955        assert_eq!(info.category, None);
956        assert_eq!(info.version, Some("1".to_string()));
957        assert!(info.compatible_versions.is_empty());
958        assert_eq!(info.input_schema, None);
959        assert!(info.default_labels.is_empty());
960        assert_eq!(info.schedule, None);
961    }
962
963    #[test]
964    fn full_handler_returns_all_metadata() {
965        let handler = FullFeaturedHandler;
966        assert_eq!(handler.name(), "full");
967        assert_eq!(handler.version(), Some("1.2.0"));
968        assert_eq!(handler.category(), Some("data/etl"));
969        assert!(handler.input_schema().is_some());
970    }
971
972    #[test]
973    fn full_handler_default_labels_are_set() {
974        let handler = FullFeaturedHandler;
975        let labels = handler.default_labels();
976        assert_eq!(labels.get("team"), Some(&"platform".to_string()));
977        assert_eq!(labels.get("env"), Some(&"prod".to_string()));
978    }
979
980    #[test]
981    fn full_handler_describe_includes_all_fields() {
982        let handler = FullFeaturedHandler;
983        let info = handler.describe();
984        assert_eq!(info.description, "Full-featured test handler");
985        assert_eq!(info.source_code, Some("fn test() {}".to_string()));
986        assert_eq!(info.sub_workflows, vec!["helper".to_string()]);
987        assert_eq!(info.category, Some("data/etl".to_string()));
988        assert_eq!(info.version, Some("1.2.0".to_string()));
989        assert!(info.input_schema.is_some());
990        assert_eq!(info.default_labels.len(), 2);
991    }
992
993    #[test]
994    fn input_schema_for_generates_json_schema() {
995        let schema = input_schema_for::<TestInput>();
996        assert_eq!(schema["type"], "object");
997        assert!(schema["properties"]["environment"].is_object());
998        assert!(schema["properties"]["dry_run"].is_object());
999    }
1000
1001    #[test]
1002    fn input_schema_for_preserves_serde_attributes() {
1003        let schema = input_schema_for::<TestInput>();
1004        let properties = &schema["properties"];
1005        assert!(properties.is_object());
1006        assert!(properties.get("environment").is_some());
1007        assert!(properties.get("dry_run").is_some());
1008    }
1009
1010    #[test]
1011    fn minimal_handler_defaults_to_no_max_cost() {
1012        assert!(MinimalHandler.default_max_cost_usd().is_none());
1013        assert!(MinimalHandler.describe().default_max_cost_usd.is_none());
1014    }
1015
1016    #[test]
1017    fn describe_propagates_handler_max_cost() {
1018        assert_eq!(
1019            FullFeaturedHandler.describe().default_max_cost_usd,
1020            Some(Decimal::new(750, 2))
1021        );
1022    }
1023
1024    #[test]
1025    fn workflow_info_omits_absent_max_cost_from_json() {
1026        let json = serde_json::to_value(MinimalHandler.describe()).expect("serialize");
1027        assert!(json.get("default_max_cost_usd").is_none());
1028    }
1029
1030    #[test]
1031    fn workflow_info_serializes_with_skip_empty() {
1032        let info = WorkflowInfo {
1033            description: "test".to_string(),
1034            source_code: None,
1035            sub_workflows: Vec::new(),
1036            category: None,
1037            version: None,
1038            compatible_versions: Vec::new(),
1039            input_schema: None,
1040            default_labels: HashMap::new(),
1041            schedule: None,
1042            default_max_cost_usd: None,
1043        };
1044
1045        let json = serde_json::to_value(&info).expect("serialize");
1046        assert_eq!(json["description"], "test");
1047        // Optional fields with skip_serializing_if may still be present or absent
1048        // depending on the serde configuration. Just verify the description is there.
1049        assert!(json.is_object());
1050    }
1051
1052    #[test]
1053    fn workflow_info_serializes_with_values() {
1054        let info = WorkflowInfo {
1055            description: "test".to_string(),
1056            source_code: Some("code".to_string()),
1057            sub_workflows: vec!["sub".to_string()],
1058            category: Some("cat".to_string()),
1059            version: Some("1.0.0".to_string()),
1060            compatible_versions: vec!["0.9.0".to_string()],
1061            input_schema: Some(serde_json::json!({"type": "object"})),
1062            default_labels: HashMap::from([("key".to_string(), "value".to_string())]),
1063            schedule: Some(CronSchedule::new("0 0 * * * *").unwrap()),
1064            default_max_cost_usd: Some(Decimal::new(750, 2)),
1065        };
1066
1067        let json = serde_json::to_value(&info).expect("serialize");
1068        assert_eq!(json["description"], "test");
1069        assert_eq!(json["source_code"], "code");
1070        assert_eq!(json["sub_workflows"][0], "sub");
1071        assert_eq!(json["category"], "cat");
1072        assert_eq!(json["version"], "1.0.0");
1073        assert_eq!(json["default_labels"]["key"], "value");
1074        assert_eq!(json["schedule"], "0 0 * * * *");
1075        assert_eq!(json["compatible_versions"][0], "0.9.0");
1076    }
1077
1078    // ---- is_version_compatible ----
1079
1080    struct VersionedHandler;
1081
1082    impl WorkflowHandler for VersionedHandler {
1083        fn name(&self) -> &str {
1084            "versioned"
1085        }
1086        fn version(&self) -> Option<&str> {
1087            Some("2.0.0")
1088        }
1089        fn compatible_versions(&self) -> &[&str] {
1090            &["1.5.0", "1.9.0"]
1091        }
1092        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1093            Box::pin(async { Ok(()) })
1094        }
1095    }
1096
1097    #[test]
1098    fn version_compatible_with_same_version() {
1099        assert!(VersionedHandler.is_version_compatible(Some("2.0.0")));
1100    }
1101
1102    #[test]
1103    fn version_compatible_with_none_run_version() {
1104        assert!(VersionedHandler.is_version_compatible(None));
1105    }
1106
1107    #[test]
1108    fn version_compatible_with_listed_version() {
1109        assert!(VersionedHandler.is_version_compatible(Some("1.5.0")));
1110        assert!(VersionedHandler.is_version_compatible(Some("1.9.0")));
1111    }
1112
1113    #[test]
1114    fn version_incompatible_with_unlisted_version() {
1115        assert!(!VersionedHandler.is_version_compatible(Some("1.0.0")));
1116        assert!(!VersionedHandler.is_version_compatible(Some("3.0.0")));
1117    }
1118
1119    #[test]
1120    fn minimal_handler_compatible_with_same_default() {
1121        assert!(MinimalHandler.is_version_compatible(Some("1")));
1122    }
1123
1124    #[test]
1125    fn minimal_handler_incompatible_with_different_version() {
1126        assert!(!MinimalHandler.is_version_compatible(Some("2")));
1127    }
1128
1129    // ---- WorkflowHandler::create_run ----
1130
1131    #[tokio::test]
1132    async fn handler_create_run_uses_handler_metadata() {
1133        use ironflow_store::entities::TriggerKind;
1134        use ironflow_store::memory::InMemoryStore;
1135
1136        let store = InMemoryStore::new();
1137
1138        let opts = CreateRunOpts::new().trigger(TriggerKind::Api);
1139        let creation = FullFeaturedHandler
1140            .create_run(&store, opts)
1141            .await
1142            .expect("create_run");
1143        let run = creation.into_run();
1144
1145        assert_eq!(run.workflow_name, "full");
1146        assert_eq!(run.handler_version, Some("1.2.0".to_string()));
1147        assert_eq!(run.max_cost_usd, Some(Decimal::new(750, 2)));
1148    }
1149
1150    #[tokio::test]
1151    async fn handler_create_run_opts_override_handler_defaults() {
1152        use ironflow_store::memory::InMemoryStore;
1153
1154        let store = InMemoryStore::new();
1155
1156        let opts = CreateRunOpts::new().max_cost_usd(Decimal::new(100, 2));
1157        let creation = FullFeaturedHandler
1158            .create_run(&store, opts)
1159            .await
1160            .expect("create_run");
1161        let run = creation.into_run();
1162
1163        assert_eq!(run.max_cost_usd, Some(Decimal::new(100, 2)));
1164    }
1165
1166    #[tokio::test]
1167    async fn handler_create_run_minimal_handler_defaults() {
1168        use ironflow_store::memory::InMemoryStore;
1169
1170        let store = InMemoryStore::new();
1171
1172        let opts = CreateRunOpts::new();
1173        let creation = MinimalHandler
1174            .create_run(&store, opts)
1175            .await
1176            .expect("create_run");
1177        let run = creation.into_run();
1178
1179        assert_eq!(run.workflow_name, "minimal");
1180        assert_eq!(run.handler_version, Some("1".to_string()));
1181        assert_eq!(run.max_cost_usd, None);
1182    }
1183
1184    struct Documented;
1185
1186    impl WorkflowHandler for Documented {
1187        fn name(&self) -> &str {
1188            "documented"
1189        }
1190
1191        fn description(&self) -> &str {
1192            "A documented handler"
1193        }
1194
1195        fn source_code(&self) -> Option<&str> {
1196            Some("struct Documented;")
1197        }
1198
1199        fn sub_workflows(&self) -> Vec<String> {
1200            vec!["child".to_string()]
1201        }
1202
1203        fn category(&self) -> Option<&str> {
1204            Some("tests/handlers")
1205        }
1206
1207        fn version(&self) -> Option<&str> {
1208            Some("3.1.0")
1209        }
1210
1211        fn compatible_versions(&self) -> &[&str] {
1212            &["3.0.0"]
1213        }
1214
1215        fn input_schema(&self) -> Option<Value> {
1216            Some(input_schema_for::<TestInput>())
1217        }
1218
1219        fn default_labels(&self) -> HashMap<String, String> {
1220            HashMap::from([("team".to_string(), "core".to_string())])
1221        }
1222
1223        fn default_max_cost_usd(&self) -> Option<Decimal> {
1224            Some(Decimal::new(250, 2))
1225        }
1226
1227        fn guard_config(&self) -> Option<WorkflowGuardConfig> {
1228            Some(WorkflowGuardConfig::new().with_max_depth(4))
1229        }
1230
1231        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
1232            Box::pin(async { Ok(()) })
1233        }
1234    }
1235
1236    #[test]
1237    fn default_describe_propagates_every_trait_method() {
1238        let info = Documented.describe();
1239        assert_eq!(info.description, "A documented handler");
1240        assert_eq!(info.source_code.as_deref(), Some("struct Documented;"));
1241        assert_eq!(info.sub_workflows, vec!["child".to_string()]);
1242        assert_eq!(info.category.as_deref(), Some("tests/handlers"));
1243        assert_eq!(info.version.as_deref(), Some("3.1.0"));
1244        assert_eq!(info.compatible_versions, vec!["3.0.0".to_string()]);
1245        assert!(info.input_schema.is_some());
1246        assert_eq!(info.default_labels["team"], "core");
1247        assert_eq!(info.default_max_cost_usd, Some(Decimal::new(250, 2)));
1248    }
1249
1250    #[test]
1251    fn minimal_handler_describe_uses_defaults() {
1252        let info = MinimalHandler.describe();
1253        assert_eq!(info.description, "");
1254        assert!(info.source_code.is_none());
1255        assert!(info.sub_workflows.is_empty());
1256        assert!(info.category.is_none());
1257        assert_eq!(info.version.as_deref(), Some("1"));
1258    }
1259
1260    #[test]
1261    fn workflow_info_builder_sets_every_field() {
1262        let schedule = CronSchedule::new("0 0 * * *").expect("valid cron");
1263        let info = WorkflowInfo::new("desc")
1264            .with_source_code("code")
1265            .with_sub_workflows(["a", "b"])
1266            .with_category("cat/sub")
1267            .with_version("2")
1268            .with_compatible_versions(["1"])
1269            .with_input_schema(serde_json::json!({"type": "object"}))
1270            .with_default_labels(HashMap::from([("k".to_string(), "v".to_string())]))
1271            .with_schedule(schedule)
1272            .with_default_max_cost_usd(Decimal::ONE);
1273
1274        assert_eq!(info.description, "desc");
1275        assert_eq!(info.source_code.as_deref(), Some("code"));
1276        assert_eq!(info.sub_workflows, vec!["a".to_string(), "b".to_string()]);
1277        assert_eq!(info.category.as_deref(), Some("cat/sub"));
1278        assert_eq!(info.version.as_deref(), Some("2"));
1279        assert_eq!(info.compatible_versions, vec!["1".to_string()]);
1280        assert_eq!(info.input_schema.unwrap()["type"], "object");
1281        assert_eq!(info.default_labels["k"], "v");
1282        assert!(info.schedule.is_some());
1283        assert_eq!(info.default_max_cost_usd, Some(Decimal::ONE));
1284    }
1285
1286    #[test]
1287    fn workflow_info_new_matches_default_for_other_fields() {
1288        let info = WorkflowInfo::new("only description");
1289        let default = WorkflowInfo::default();
1290        assert_eq!(info.description, "only description");
1291        assert_eq!(default.description, "");
1292        assert_eq!(info.source_code, default.source_code);
1293        assert_eq!(info.sub_workflows, default.sub_workflows);
1294        assert_eq!(info.category, default.category);
1295        assert_eq!(info.version, default.version);
1296        assert_eq!(info.default_max_cost_usd, default.default_max_cost_usd);
1297    }
1298
1299    #[test]
1300    fn boxed_handler_delegates_every_method() {
1301        let boxed: Box<dyn WorkflowHandler> = Box::new(Documented);
1302        assert_eq!(boxed.name(), "documented");
1303        assert_eq!(boxed.version(), Some("3.1.0"));
1304        assert_eq!(boxed.compatible_versions(), &["3.0.0"]);
1305        assert_eq!(boxed.description(), "A documented handler");
1306        assert_eq!(boxed.source_code(), Some("struct Documented;"));
1307        assert_eq!(boxed.sub_workflows(), vec!["child".to_string()]);
1308        assert_eq!(boxed.category(), Some("tests/handlers"));
1309        assert!(boxed.input_schema().is_some());
1310        assert_eq!(boxed.default_labels()["team"], "core");
1311        assert!(boxed.schedule().is_none());
1312        assert_eq!(boxed.default_max_cost_usd(), Some(Decimal::new(250, 2)));
1313        assert_eq!(boxed.guard_config().map(|g| g.max_depth), Some(4));
1314        assert!(boxed.is_version_compatible(Some("3.0.0")));
1315        assert!(!boxed.is_version_compatible(Some("0.1.0")));
1316        assert_eq!(boxed.describe().description, "A documented handler");
1317    }
1318
1319    #[test]
1320    fn boxed_handler_is_accepted_by_generic_register() {
1321        fn takes_handler(handler: impl WorkflowHandler + 'static) -> String {
1322            handler.name().to_string()
1323        }
1324        let boxed: Box<dyn WorkflowHandler> = Box::new(MinimalHandler);
1325        assert_eq!(takes_handler(boxed), "minimal");
1326    }
1327
1328    #[tokio::test]
1329    async fn boxed_handler_create_run_delegates_metadata() {
1330        use ironflow_store::memory::InMemoryStore;
1331        use ironflow_store::models::TriggerKind;
1332
1333        let store = InMemoryStore::new();
1334        let boxed: Box<dyn WorkflowHandler> = Box::new(Documented);
1335        let run = boxed
1336            .create_run(&store, CreateRunOpts::new().trigger(TriggerKind::Manual))
1337            .await
1338            .expect("run created")
1339            .into_run();
1340        assert_eq!(run.workflow_name, "documented");
1341        assert_eq!(run.handler_version.as_deref(), Some("3.1.0"));
1342        assert_eq!(run.max_cost_usd, Some(Decimal::new(250, 2)));
1343    }
1344}