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