Skip to main content

ironflow_engine/config/
mod.rs

1//! Serializable step configurations — one per operation type.
2//!
3//! These types mirror the builder options in [`ironflow_core`] operations but
4//! are fully serializable, allowing them to be stored as JSON in the database
5//! and reconstructed by the executor at runtime.
6
7mod agent;
8mod approval;
9mod approvers;
10mod artifact;
11mod decision;
12pub mod delay;
13mod escalation;
14mod http;
15mod human_input;
16mod shell;
17mod workflow;
18
19pub use agent::{
20    AgentStep, AgentStepConfig, DEFAULT_RESUME_PROMPT, DEFAULT_UNTRUSTED_INSTRUCTION, Prompt, Tool,
21    ToolProfile,
22};
23pub use approval::ApprovalConfig;
24pub use approvers::Approvers;
25pub use artifact::{ArtifactInput, ArtifactOutput, ArtifactRef};
26pub use decision::{DEFAULT_DECISION_MODEL, DecisionConfig, NoAnswers};
27pub use delay::DelayConfig;
28pub use escalation::{EscalationPolicy, NotificationTarget};
29pub use http::HttpConfig;
30pub use human_input::{HUMAN_INPUT_SCHEMA_KEY, HumanInputConfig};
31// Re-exported so workflow authors can name approval assignees and read approval
32// requirements without depending on `ironflow-store` directly.
33pub use ironflow_store::entities::{ApprovalRequirement, Assignee};
34pub use shell::ShellConfig;
35pub use workflow::{WorkflowOptions, WorkflowStepConfig};
36
37use ironflow_core::retry::RetryPolicy;
38use ironflow_store::entities::StepKind;
39use serde::{Deserialize, Serialize};
40
41/// A serializable step configuration, wrapping one of the operation-specific configs.
42///
43/// Stored as JSON in the `steps.input` column and reconstructed by the
44/// executor at runtime.
45///
46/// # Examples
47///
48/// ```
49/// use ironflow_engine::config::{StepConfig, ShellConfig};
50///
51/// let config = StepConfig::Shell(ShellConfig::new("echo hello"));
52/// let json = serde_json::to_string(&config).unwrap();
53/// assert!(json.contains("echo hello"));
54/// ```
55#[derive(Debug, Clone, Serialize, Deserialize)]
56#[serde(tag = "type", rename_all = "snake_case")]
57// Built once per step and stored as JSON, so the size of `Agent` costs nothing.
58// Boxing it would make authors write `StepConfig::Agent(Box::new(..))`.
59#[allow(clippy::large_enum_variant)]
60pub enum StepConfig {
61    /// A shell command step.
62    Shell(ShellConfig),
63    /// An HTTP request step.
64    Http(HttpConfig),
65    /// An AI agent step.
66    Agent(AgentStepConfig),
67    /// A sub-workflow invocation step.
68    Workflow(WorkflowStepConfig),
69    /// A human approval gate step.
70    Approval(ApprovalConfig),
71    /// A typed machine-decision step (System One / Jev).
72    Decision(DecisionConfig),
73    /// A timed delay/sleep step.
74    Delay(DelayConfig),
75}
76
77impl StepConfig {
78    /// Whether this step is allowed to fail without stopping the run.
79    ///
80    /// # Examples
81    ///
82    /// ```
83    /// use ironflow_engine::config::{StepConfig, ShellConfig};
84    ///
85    /// let config = StepConfig::Shell(ShellConfig::new("cargo clippy").allow_failure());
86    /// assert!(config.allow_failure());
87    ///
88    /// let config = StepConfig::Shell(ShellConfig::new("cargo build"));
89    /// assert!(!config.allow_failure());
90    /// ```
91    pub fn allow_failure(&self) -> bool {
92        match self {
93            StepConfig::Shell(c) => c.allow_failure,
94            StepConfig::Http(c) => c.allow_failure,
95            StepConfig::Agent(c) => c.allow_failure,
96            StepConfig::Workflow(_)
97            | StepConfig::Approval(_)
98            | StepConfig::Decision(_)
99            | StepConfig::Delay(_) => false,
100        }
101    }
102
103    /// The files this step declared it produces. Only shell steps declare any.
104    ///
105    /// # Examples
106    ///
107    /// ```
108    /// use ironflow_engine::config::{HttpConfig, ShellConfig, StepConfig};
109    ///
110    /// let config = StepConfig::Shell(ShellConfig::new("./gen").output("report.html"));
111    /// assert_eq!(config.declared_outputs()[0].pattern, "report.html");
112    ///
113    /// let config = StepConfig::Http(HttpConfig::get("https://example.com"));
114    /// assert!(config.declared_outputs().is_empty());
115    /// ```
116    pub fn declared_outputs(&self) -> &[ArtifactOutput] {
117        match self {
118            StepConfig::Shell(c) => &c.outputs,
119            _ => &[],
120        }
121    }
122
123    /// Get the step-level retry policy, if any.
124    ///
125    /// # Examples
126    ///
127    /// ```
128    /// use ironflow_core::retry::RetryPolicy;
129    /// use ironflow_engine::config::{StepConfig, ShellConfig};
130    ///
131    /// let config = StepConfig::Shell(ShellConfig::new("echo test").retry_policy(RetryPolicy::new(3)));
132    /// assert!(config.retry().is_some());
133    ///
134    /// let config = StepConfig::Shell(ShellConfig::new("echo test"));
135    /// assert!(config.retry().is_none());
136    /// ```
137    pub fn retry(&self) -> Option<&RetryPolicy> {
138        match self {
139            StepConfig::Shell(c) => c.retry.as_ref(),
140            StepConfig::Http(c) => c.retry.as_ref(),
141            StepConfig::Agent(c) => c.retry.as_ref(),
142            StepConfig::Workflow(c) => c.retry.as_ref(),
143            StepConfig::Approval(_) | StepConfig::Decision(_) | StepConfig::Delay(_) => None,
144        }
145    }
146
147    /// Get the kind of step this configuration represents.
148    ///
149    /// # Examples
150    ///
151    /// ```
152    /// use ironflow_engine::config::{StepConfig, ShellConfig};
153    /// use ironflow_store::entities::StepKind;
154    ///
155    /// let config = StepConfig::Shell(ShellConfig::new("echo test"));
156    /// assert_eq!(config.kind(), StepKind::Shell);
157    /// ```
158    pub fn kind(&self) -> StepKind {
159        match self {
160            StepConfig::Shell(_) => StepKind::Shell,
161            StepConfig::Http(_) => StepKind::Http,
162            StepConfig::Agent(_) => StepKind::Agent,
163            StepConfig::Workflow(_) => StepKind::Workflow,
164            StepConfig::Approval(_) => StepKind::Approval,
165            StepConfig::Decision(_) => StepKind::Decision,
166            StepConfig::Delay(_) => StepKind::Custom("delay".to_string()),
167        }
168    }
169}
170
171impl From<ShellConfig> for StepConfig {
172    fn from(c: ShellConfig) -> Self {
173        StepConfig::Shell(c)
174    }
175}
176
177impl From<HttpConfig> for StepConfig {
178    fn from(c: HttpConfig) -> Self {
179        StepConfig::Http(c)
180    }
181}
182
183impl From<AgentStepConfig> for StepConfig {
184    fn from(c: AgentStepConfig) -> Self {
185        StepConfig::Agent(c)
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use ironflow_core::retry::RetryPolicy;
192
193    use super::*;
194
195    #[test]
196    fn retry_accessor_returns_policy_for_each_variant() {
197        let shell = StepConfig::Shell(ShellConfig::new("echo").retry_policy(RetryPolicy::new(2)));
198        assert_eq!(shell.retry().unwrap().max_retries(), 2);
199
200        let http = StepConfig::Http(HttpConfig::get("http://x").retry_policy(RetryPolicy::new(3)));
201        assert_eq!(http.retry().unwrap().max_retries(), 3);
202
203        let workflow = StepConfig::Workflow(
204            WorkflowStepConfig::new("build", serde_json::json!({}))
205                .retry_policy(RetryPolicy::new(4)),
206        );
207        assert_eq!(workflow.retry().unwrap().max_retries(), 4);
208
209        let agent = StepConfig::Agent(AgentStepConfig::new("test"));
210        assert!(agent.retry().is_none());
211
212        let approval = StepConfig::Approval(ApprovalConfig::new("ok?"));
213        assert!(approval.retry().is_none());
214    }
215
216    #[test]
217    fn serde_roundtrip() {
218        let configs = vec![
219            StepConfig::Shell(ShellConfig::new("echo test")),
220            StepConfig::Http(HttpConfig::get("http://example.com")),
221            StepConfig::Agent(AgentStepConfig::new("summarize")),
222            StepConfig::Workflow(WorkflowStepConfig::new("build", serde_json::json!({}))),
223            StepConfig::Approval(ApprovalConfig::new("Deploy to production?")),
224            StepConfig::Delay(DelayConfig::from_secs(60)),
225        ];
226
227        for config in configs {
228            let json = serde_json::to_string(&config).expect("serialize");
229            let back: StepConfig = serde_json::from_str(&json).expect("deserialize");
230            let json2 = serde_json::to_string(&back).expect("serialize2");
231            assert_eq!(json, json2);
232        }
233    }
234
235    #[test]
236    fn approval_with_escalation_roundtrips() {
237        let config = StepConfig::Approval(
238            ApprovalConfig::new("Deploy to production?")
239                .with_deadline_secs(3600)
240                .assigned_to(Assignee::group("release-managers"))
241                .on_timeout(EscalationPolicy::Chain(vec![
242                    EscalationPolicy::Notify(vec![NotificationTarget::Webhook {
243                        url: "https://example.com/sla".to_string(),
244                    }]),
245                    EscalationPolicy::AutoReject,
246                ])),
247        );
248
249        let json = serde_json::to_string(&config).expect("serialize");
250        let back: StepConfig = serde_json::from_str(&json).expect("deserialize");
251        let json2 = serde_json::to_string(&back).expect("serialize2");
252        assert_eq!(json, json2);
253
254        let StepConfig::Approval(approval) = back else {
255            panic!("expected an approval config");
256        };
257        assert_eq!(approval.effective_deadline_secs(), Some(3600));
258        assert_eq!(
259            approval.assignee(),
260            Some(&Assignee::group("release-managers"))
261        );
262        assert_eq!(approval.effective_policy().len(), 2);
263    }
264}