Skip to main content

ironflow_engine/
handler.rs

1//! [`WorkflowHandler`] trait — dynamic workflows with context chaining.
2//!
3//! Implement this trait to define workflows where steps can reference
4//! outputs from previous steps. The handler receives a [`WorkflowContext`]
5//! that provides step execution methods with automatic persistence.
6//!
7//! # Examples
8//!
9//! ```no_run
10//! use ironflow_engine::handler::WorkflowHandler;
11//! use ironflow_engine::context::WorkflowContext;
12//! use ironflow_engine::config::{ShellConfig, AgentStepConfig};
13//! use ironflow_engine::error::EngineError;
14//! use std::future::Future;
15//! use std::pin::Pin;
16//!
17//! struct DeployWorkflow;
18//!
19//! impl WorkflowHandler for DeployWorkflow {
20//!     fn name(&self) -> &str {
21//!         "deploy"
22//!     }
23//!
24//!     fn execute<'a>(
25//!         &'a self,
26//!         ctx: &'a mut WorkflowContext,
27//!     ) -> Pin<Box<dyn Future<Output = Result<(), EngineError>> + Send + 'a>> {
28//!         Box::pin(async move {
29//!             let build = ctx.shell("build", ShellConfig::new("cargo build --release")).await?;
30//!             let tests = ctx.shell("test", ShellConfig::new("cargo test")).await?;
31//!
32//!             let review = ctx.agent("review", AgentStepConfig::new(
33//!                 &format!("Build:\n{}\nTests:\n{}\nReview.",
34//!                     build.output["stdout"], tests.output["stdout"])
35//!             )).await?;
36//!
37//!             if review.output.as_str().unwrap_or("").contains("LGTM") {
38//!                 ctx.shell("deploy", ShellConfig::new("./deploy.sh")).await?;
39//!             }
40//!
41//!             Ok(())
42//!         })
43//!     }
44//! }
45//! ```
46
47use std::collections::HashMap;
48use std::future::Future;
49use std::pin::Pin;
50
51use schemars::JsonSchema;
52use serde::Serialize;
53use serde_json::Value;
54
55use crate::context::WorkflowContext;
56use crate::error::EngineError;
57
58/// Generate a JSON Schema [`Value`] from a type that derives [`JsonSchema`].
59///
60/// Use this in [`WorkflowHandler::input_schema`] to automatically derive the
61/// schema from your input struct instead of writing JSON by hand.
62///
63/// # Examples
64///
65/// ```
66/// use schemars::JsonSchema;
67/// use serde::Deserialize;
68/// use ironflow_engine::handler::input_schema_for;
69///
70/// #[derive(Deserialize, JsonSchema)]
71/// struct DeployInput {
72///     environment: String,
73///     dry_run: Option<bool>,
74/// }
75///
76/// let schema = input_schema_for::<DeployInput>();
77/// assert_eq!(schema["type"], "object");
78/// assert!(schema["properties"]["environment"].is_object());
79/// ```
80pub fn input_schema_for<T: JsonSchema>() -> Value {
81    let schema = schemars::schema_for!(T);
82    serde_json::to_value(schema).expect("schema serialization cannot fail")
83}
84
85/// Boxed future returned by [`WorkflowHandler::execute`].
86pub type HandlerFuture<'a> = Pin<Box<dyn Future<Output = Result<(), EngineError>> + Send + 'a>>;
87
88/// Metadata about a workflow, returned by [`WorkflowHandler::describe`].
89///
90/// Contains a human-readable description and optional Rust source code
91/// for display in the dashboard.
92#[derive(Debug, Clone, Serialize)]
93pub struct WorkflowInfo {
94    /// Human-readable description of what the workflow does.
95    pub description: String,
96    /// Optional Rust source code of the handler (for UI display).
97    pub source_code: Option<String>,
98    /// Names of sub-workflows invoked by this handler.
99    #[serde(default, skip_serializing_if = "Vec::is_empty")]
100    pub sub_workflows: Vec<String>,
101    /// Optional `/`-separated category path used to group workflows in the UI tree.
102    ///
103    /// A value like `"data/etl"` places the workflow under `data` → `etl`.
104    /// `None` means the workflow is uncategorized.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub category: Option<String>,
107    /// Handler version string, used to trace which code produced a given run.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub version: Option<String>,
110    /// JSON Schema describing the expected input payload.
111    ///
112    /// When present, the dashboard renders a dynamic form from this schema
113    /// and the engine validates the payload before creating a run.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub input_schema: Option<Value>,
116    /// Labels automatically applied to every run of this workflow.
117    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
118    pub default_labels: HashMap<String, String>,
119}
120
121/// A dynamic workflow handler with context-aware step chaining.
122///
123/// Implement this trait to define workflows where each step can use
124/// the output of previous steps. Register handlers with
125/// [`Engine::register`](crate::engine::Engine::register) and execute
126/// them by name.
127///
128/// # Why `Pin<Box<dyn Future>>` instead of `async fn`?
129///
130/// The handler must be object-safe (`dyn WorkflowHandler`) to allow
131/// registering different handler types in the engine's registry.
132pub trait WorkflowHandler: Send + Sync {
133    /// The workflow name used for registration and lookup.
134    fn name(&self) -> &str;
135
136    /// Handler version string, used to trace which code version produced a run.
137    ///
138    /// Override this to return a meaningful version (semver, git SHA, build
139    /// hash, etc.). The default is `None`.
140    fn version(&self) -> Option<&str> {
141        None
142    }
143
144    /// Optional `/`-separated category path used to group workflows in the UI tree.
145    ///
146    /// Return a value like `"data/etl"` to place the workflow under `data` → `etl`.
147    /// The default is `None` (uncategorized).
148    ///
149    /// Validation (empty segments, leading or trailing `/`, `//`, whitespace
150    /// segments) is enforced at registration time by
151    /// [`Engine::register`](crate::engine::Engine::register).
152    fn category(&self) -> Option<&str> {
153        None
154    }
155
156    /// Return a JSON Schema describing the expected input payload.
157    ///
158    /// When present, the dashboard renders a dynamic form from this schema
159    /// and the engine validates the payload before creating a run.
160    /// The default is `None` (no schema, free-form payload).
161    fn input_schema(&self) -> Option<Value> {
162        None
163    }
164
165    /// Labels automatically applied to every run of this workflow.
166    ///
167    /// These are merged with any labels provided at run creation time.
168    /// User-provided labels take precedence over defaults.
169    fn default_labels(&self) -> HashMap<String, String> {
170        HashMap::new()
171    }
172
173    /// Return metadata about this workflow (description, source code).
174    ///
175    /// Override this to provide a description and source code for the
176    /// dashboard UI. The default returns an empty description with no source
177    /// but propagates [`WorkflowHandler::category`],
178    /// [`WorkflowHandler::version`], [`WorkflowHandler::input_schema`],
179    /// and [`WorkflowHandler::default_labels`].
180    fn describe(&self) -> WorkflowInfo {
181        WorkflowInfo {
182            description: String::new(),
183            source_code: None,
184            sub_workflows: Vec::new(),
185            category: self.category().map(str::to_string),
186            version: self.version().map(str::to_string),
187            input_schema: self.input_schema(),
188            default_labels: self.default_labels(),
189        }
190    }
191
192    /// Execute the workflow with the given context.
193    ///
194    /// The context provides [`shell`](WorkflowContext::shell),
195    /// [`http`](WorkflowContext::http), and [`agent`](WorkflowContext::agent)
196    /// methods that automatically persist each step.
197    ///
198    /// # Errors
199    ///
200    /// Return [`EngineError`] if any step fails. The engine will mark
201    /// the run as `Failed` and record the error.
202    fn execute<'a>(&'a self, ctx: &'a mut WorkflowContext) -> HandlerFuture<'a>;
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use serde::{Deserialize, Serialize};
209
210    #[derive(Debug, Serialize, Deserialize, JsonSchema)]
211    struct TestInput {
212        environment: String,
213        #[serde(default)]
214        dry_run: bool,
215    }
216
217    struct MinimalHandler;
218
219    impl WorkflowHandler for MinimalHandler {
220        fn name(&self) -> &str {
221            "minimal"
222        }
223
224        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
225            Box::pin(async { Ok(()) })
226        }
227    }
228
229    struct FullFeaturedHandler;
230
231    impl WorkflowHandler for FullFeaturedHandler {
232        fn name(&self) -> &str {
233            "full"
234        }
235
236        fn version(&self) -> Option<&str> {
237            Some("1.2.0")
238        }
239
240        fn category(&self) -> Option<&str> {
241            Some("data/etl")
242        }
243
244        fn input_schema(&self) -> Option<Value> {
245            Some(input_schema_for::<TestInput>())
246        }
247
248        fn default_labels(&self) -> HashMap<String, String> {
249            HashMap::from([
250                ("team".to_string(), "platform".to_string()),
251                ("env".to_string(), "prod".to_string()),
252            ])
253        }
254
255        fn describe(&self) -> WorkflowInfo {
256            WorkflowInfo {
257                description: "Full-featured test handler".to_string(),
258                source_code: Some("fn test() {}".to_string()),
259                sub_workflows: vec!["helper".to_string()],
260                category: self.category().map(str::to_string),
261                version: self.version().map(str::to_string),
262                input_schema: self.input_schema(),
263                default_labels: self.default_labels(),
264            }
265        }
266
267        fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
268            Box::pin(async { Ok(()) })
269        }
270    }
271
272    #[test]
273    fn minimal_handler_has_required_name() {
274        let handler = MinimalHandler;
275        assert_eq!(handler.name(), "minimal");
276    }
277
278    #[test]
279    fn minimal_handler_defaults_to_no_version() {
280        let handler = MinimalHandler;
281        assert_eq!(handler.version(), None);
282    }
283
284    #[test]
285    fn minimal_handler_defaults_to_no_category() {
286        let handler = MinimalHandler;
287        assert_eq!(handler.category(), None);
288    }
289
290    #[test]
291    fn minimal_handler_defaults_to_no_schema() {
292        let handler = MinimalHandler;
293        assert_eq!(handler.input_schema(), None);
294    }
295
296    #[test]
297    fn minimal_handler_defaults_to_empty_labels() {
298        let handler = MinimalHandler;
299        let labels = handler.default_labels();
300        assert!(labels.is_empty());
301    }
302
303    #[test]
304    fn minimal_handler_describe_reflects_defaults() {
305        let handler = MinimalHandler;
306        let info = handler.describe();
307        assert_eq!(info.description, "");
308        assert_eq!(info.source_code, None);
309        assert_eq!(info.sub_workflows, Vec::<String>::new());
310        assert_eq!(info.category, None);
311        assert_eq!(info.version, None);
312        assert_eq!(info.input_schema, None);
313        assert!(info.default_labels.is_empty());
314    }
315
316    #[test]
317    fn full_handler_returns_all_metadata() {
318        let handler = FullFeaturedHandler;
319        assert_eq!(handler.name(), "full");
320        assert_eq!(handler.version(), Some("1.2.0"));
321        assert_eq!(handler.category(), Some("data/etl"));
322        assert!(handler.input_schema().is_some());
323    }
324
325    #[test]
326    fn full_handler_default_labels_are_set() {
327        let handler = FullFeaturedHandler;
328        let labels = handler.default_labels();
329        assert_eq!(labels.get("team"), Some(&"platform".to_string()));
330        assert_eq!(labels.get("env"), Some(&"prod".to_string()));
331    }
332
333    #[test]
334    fn full_handler_describe_includes_all_fields() {
335        let handler = FullFeaturedHandler;
336        let info = handler.describe();
337        assert_eq!(info.description, "Full-featured test handler");
338        assert_eq!(info.source_code, Some("fn test() {}".to_string()));
339        assert_eq!(info.sub_workflows, vec!["helper".to_string()]);
340        assert_eq!(info.category, Some("data/etl".to_string()));
341        assert_eq!(info.version, Some("1.2.0".to_string()));
342        assert!(info.input_schema.is_some());
343        assert_eq!(info.default_labels.len(), 2);
344    }
345
346    #[test]
347    fn input_schema_for_generates_json_schema() {
348        let schema = input_schema_for::<TestInput>();
349        assert_eq!(schema["type"], "object");
350        assert!(schema["properties"]["environment"].is_object());
351        assert!(schema["properties"]["dry_run"].is_object());
352    }
353
354    #[test]
355    fn input_schema_for_preserves_serde_attributes() {
356        let schema = input_schema_for::<TestInput>();
357        let properties = &schema["properties"];
358        assert!(properties.is_object());
359        assert!(properties.get("environment").is_some());
360        assert!(properties.get("dry_run").is_some());
361    }
362
363    #[test]
364    fn workflow_info_serializes_with_skip_empty() {
365        let info = WorkflowInfo {
366            description: "test".to_string(),
367            source_code: None,
368            sub_workflows: Vec::new(),
369            category: None,
370            version: None,
371            input_schema: None,
372            default_labels: HashMap::new(),
373        };
374
375        let json = serde_json::to_value(&info).expect("serialize");
376        assert_eq!(json["description"], "test");
377        // Optional fields with skip_serializing_if may still be present or absent
378        // depending on the serde configuration. Just verify the description is there.
379        assert!(json.is_object());
380    }
381
382    #[test]
383    fn workflow_info_serializes_with_values() {
384        let info = WorkflowInfo {
385            description: "test".to_string(),
386            source_code: Some("code".to_string()),
387            sub_workflows: vec!["sub".to_string()],
388            category: Some("cat".to_string()),
389            version: Some("1.0.0".to_string()),
390            input_schema: Some(serde_json::json!({"type": "object"})),
391            default_labels: HashMap::from([("key".to_string(), "value".to_string())]),
392        };
393
394        let json = serde_json::to_value(&info).expect("serialize");
395        assert_eq!(json["description"], "test");
396        assert_eq!(json["source_code"], "code");
397        assert_eq!(json["sub_workflows"][0], "sub");
398        assert_eq!(json["category"], "cat");
399        assert_eq!(json["version"], "1.0.0");
400        assert_eq!(json["default_labels"]["key"], "value");
401    }
402}