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