Skip to main content

ironflow_engine/
handler.rs

1//! [`WorkflowHandler`] trait — dynamic workflows with context chaining.
2//!
3//! Implement this trait to define workflows where steps can reference
4//! outputs from previous steps. The handler receives a [`WorkflowContext`]
5//! that provides step execution methods with automatic persistence.
6//!
7//! # Examples
8//!
9//! ```no_run
10//! use ironflow_engine::handler::WorkflowHandler;
11//! use ironflow_engine::context::WorkflowContext;
12//! use ironflow_engine::config::{ShellConfig, AgentStepConfig};
13//! use ironflow_engine::error::EngineError;
14//! use std::future::Future;
15//! use std::pin::Pin;
16//!
17//! struct DeployWorkflow;
18//!
19//! impl WorkflowHandler for DeployWorkflow {
20//!     fn name(&self) -> &str {
21//!         "deploy"
22//!     }
23//!
24//!     fn execute<'a>(
25//!         &'a self,
26//!         ctx: &'a mut WorkflowContext,
27//!     ) -> Pin<Box<dyn Future<Output = Result<(), EngineError>> + Send + 'a>> {
28//!         Box::pin(async move {
29//!             let build = ctx.shell("build", ShellConfig::new("cargo build --release")).await?;
30//!             let tests = ctx.shell("test", ShellConfig::new("cargo test")).await?;
31//!
32//!             let review = ctx.agent("review", AgentStepConfig::new(
33//!                 &format!("Build:\n{}\nTests:\n{}\nReview.",
34//!                     build.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}