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}