Skip to main content

ironflow_engine/context/steps/
sub_workflow.rs

1//! Sub-workflow step for [`WorkflowContext`].
2//!
3//! A sub-workflow runs a registered [`WorkflowHandler`] in its own child run.
4//! The child context is built here from the parent's private fields, which is
5//! possible because this module is a descendant of `context`.
6
7use std::collections::HashMap;
8use std::time::Instant;
9
10use chrono::Utc;
11use rust_decimal::Decimal;
12use serde_json::{Value, json, to_value};
13use tracing::{error, info};
14
15use ironflow_store::models::{
16    NewRun, NewStep, RunStatus, RunUpdate, StepKind, StepStatus, StepUpdate, TriggerKind,
17    step_trace_id,
18};
19
20use crate::config::WorkflowStepConfig;
21use crate::context::WorkflowContext;
22use crate::error::EngineError;
23use crate::executor::StepOutput;
24use crate::guard::WorkflowRejection;
25use crate::handler::WorkflowHandler;
26
27impl WorkflowContext {
28    /// Execute a sub-workflow step.
29    ///
30    /// Creates a child run for the named workflow handler, executes it with
31    /// its own steps and lifecycle, and returns a [`StepOutput`] containing
32    /// the child run ID and aggregated metrics.
33    ///
34    /// Requires the context to be created with
35    /// `with_handler_resolver`.
36    ///
37    /// # Errors
38    ///
39    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered
40    /// with the given name, or if no handler resolver is available.
41    ///
42    /// # Examples
43    ///
44    /// ```no_run
45    /// use ironflow_engine::context::WorkflowContext;
46    /// use ironflow_engine::error::EngineError;
47    /// use serde_json::json;
48    ///
49    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
50    /// // let result = ctx.workflow(&MySubWorkflow, json!({})).await?;
51    /// # Ok(())
52    /// # }
53    /// ```
54    pub async fn workflow(
55        &mut self,
56        handler: &dyn WorkflowHandler,
57        payload: Value,
58    ) -> Result<StepOutput, EngineError> {
59        // Guard check: verify limits before creating the step.
60        if let (Some(guard_config), Some(guard_state)) = (&self.guard_config, &self.guard_state) {
61            let state = guard_state
62                .lock()
63                .map_err(|_| WorkflowRejection::GuardUnavailable)?;
64            state.check(guard_config, handler.name())?;
65        }
66
67        let config = WorkflowStepConfig::new(handler.name(), payload);
68        let position = self.position;
69        self.position += 1;
70
71        let trace_id = step_trace_id(self.run_id, &config.workflow_name, position);
72        let step = self
73            .store
74            .create_step(NewStep {
75                run_id: self.run_id,
76                trace_id,
77                name: config.workflow_name.clone(),
78                kind: StepKind::Workflow,
79                position,
80                input: Some(to_value(&config)?),
81                is_error_handler: false,
82            })
83            .await?;
84
85        self.start_step(step.id, Utc::now()).await?;
86
87        // Record invocation in guard state (fail-closed).
88        if let Some(guard_state) = &self.guard_state {
89            let mut state = guard_state
90                .lock()
91                .map_err(|_| WorkflowRejection::GuardUnavailable)?;
92            state.record_invocation(handler.name());
93        }
94
95        match self.execute_child_workflow(&config).await {
96            Ok((output, child_had_allowed_failure)) => {
97                self.total_cost_usd += output.cost_usd;
98                self.total_duration_ms += output.duration_ms;
99                if child_had_allowed_failure {
100                    self.has_allowed_failure = true;
101                }
102
103                let completed_at = Utc::now();
104                self.store
105                    .update_step(
106                        step.id,
107                        StepUpdate {
108                            status: Some(StepStatus::Completed),
109                            output: Some(output.output.clone()),
110                            duration_ms: Some(output.duration_ms),
111                            cost_usd: Some(output.cost_usd),
112                            completed_at: Some(completed_at),
113                            ..StepUpdate::default()
114                        },
115                    )
116                    .await?;
117
118                info!(
119                    run_id = %self.run_id,
120                    child_workflow = %config.workflow_name,
121                    duration_ms = output.duration_ms,
122                    "workflow step completed"
123                );
124
125                self.last_step_ids = vec![step.id];
126
127                self.guard_record_return();
128                Ok(output)
129            }
130            Err(err) => {
131                let completed_at = Utc::now();
132                if let Err(store_err) = self
133                    .store
134                    .update_step(
135                        step.id,
136                        StepUpdate {
137                            status: Some(StepStatus::Failed),
138                            error: Some(err.to_string()),
139                            completed_at: Some(completed_at),
140                            ..StepUpdate::default()
141                        },
142                    )
143                    .await
144                {
145                    error!(step_id = %step.id, error = %store_err, "failed to persist step failure");
146                }
147
148                self.guard_record_return();
149                Err(err)
150            }
151        }
152    }
153
154    /// Execute a child workflow and return aggregated output plus whether
155    /// at least one `allow_failure` step failed.
156    async fn execute_child_workflow(
157        &self,
158        config: &WorkflowStepConfig,
159    ) -> Result<(StepOutput, bool), EngineError> {
160        let resolver = self.handler_resolver.as_ref().ok_or_else(|| {
161            EngineError::InvalidWorkflow(
162                "sub-workflow requires a handler resolver (use Engine to execute)".to_string(),
163            )
164        })?;
165
166        let handler = resolver(&config.workflow_name).ok_or_else(|| {
167            EngineError::InvalidWorkflow(format!("no handler registered: {}", config.workflow_name))
168        })?;
169
170        // A child run inherits both the parent labels and the parent author:
171        // whoever triggered the parent workflow is accountable for its children.
172        let parent = self.store.get_run(self.run_id).await?;
173        let (parent_labels, parent_author) =
174            parent.map(|r| (r.labels, r.created_by)).unwrap_or_default();
175
176        let child_run = self
177            .store
178            .create_run(NewRun {
179                workflow_name: config.workflow_name.clone(),
180                trigger: TriggerKind::Workflow,
181                payload: config.payload.clone(),
182                max_retries: 0,
183                handler_version: None,
184                labels: parent_labels,
185                scheduled_at: None,
186                created_by: parent_author,
187                idempotency_key: None,
188                // The child shares the parent's cap; it does not get its own budget.
189                max_cost_usd: self.max_cost_usd,
190            })
191            .await?
192            .into_run();
193
194        let child_run_id = child_run.id;
195        info!(
196            parent_run_id = %self.run_id,
197            child_run_id = %child_run_id,
198            workflow = %config.workflow_name,
199            "child run created"
200        );
201
202        self.store
203            .update_run_status(child_run_id, RunStatus::Running)
204            .await?;
205
206        let run_start = Instant::now();
207        let mut child_ctx = WorkflowContext {
208            run_id: child_run_id,
209            workflow_name: config.workflow_name.clone(),
210            store: self.store.clone(),
211            provider: self.provider.clone(),
212            decision_provider: self.decision_provider.clone(),
213            handler_resolver: self.handler_resolver.clone(),
214            position: 0,
215            last_step_ids: Vec::new(),
216            total_cost_usd: Decimal::ZERO,
217            total_duration_ms: 0,
218            max_cost_usd: self.max_cost_usd,
219            // Everything the parent chain already spent counts against the
220            // shared cap, so the child cannot restart the budget from zero.
221            inherited_cost_usd: self.charged_cost_usd(),
222            replay_steps: HashMap::new(),
223            granted_approvals: HashMap::new(),
224            // A child run is created fresh here; it is never itself retried.
225            attempt: 1,
226            carried_duration_ms: 0,
227            log_sender: self.log_sender.clone(),
228            // A child shares the storage backend but not the parent's artifacts:
229            // input lookups are scoped to the child's own run.
230            artifact_sink: self.artifact_sink.clone(),
231            has_allowed_failure: false,
232            error_handlers: Vec::new(),
233            guard_state: self.guard_state.clone(),
234            guard_config: self.guard_config.clone(),
235            step_results: Vec::new(),
236            event_bus: self.event_bus.clone(),
237            trace_context: self.trace_context.child(),
238            operation_ctx: None,
239        };
240
241        let result = handler.execute(&mut child_ctx).await;
242        let total_duration = run_start.elapsed().as_millis() as u64;
243        let completed_at = Utc::now();
244
245        match result {
246            Ok(()) => {
247                let child_status = if child_ctx.has_allowed_failure {
248                    RunStatus::Warning
249                } else {
250                    RunStatus::Completed
251                };
252                self.store
253                    .update_run(
254                        child_run_id,
255                        RunUpdate {
256                            status: Some(child_status),
257                            cost_usd: Some(child_ctx.total_cost_usd),
258                            duration_ms: Some(total_duration),
259                            completed_at: Some(completed_at),
260                            ..RunUpdate::default()
261                        },
262                    )
263                    .await?;
264
265                let child_had_allowed_failure = child_ctx.has_allowed_failure;
266                Ok((
267                    StepOutput {
268                        output: json!({
269                            "run_id": child_run_id,
270                            "workflow_name": config.workflow_name,
271                            "status": child_status,
272                            "cost_usd": child_ctx.total_cost_usd,
273                            "duration_ms": total_duration,
274                        }),
275                        duration_ms: total_duration,
276                        cost_usd: child_ctx.total_cost_usd,
277                        input_tokens: None,
278                        output_tokens: None,
279                        model: None,
280                        debug_messages: None,
281                    },
282                    child_had_allowed_failure,
283                ))
284            }
285            Err(err) => {
286                if let Err(store_err) = self
287                    .store
288                    .update_run(
289                        child_run_id,
290                        RunUpdate {
291                            status: Some(RunStatus::Failed),
292                            error: Some(err.to_string()),
293                            cost_usd: Some(child_ctx.total_cost_usd),
294                            duration_ms: Some(total_duration),
295                            completed_at: Some(completed_at),
296                            ..RunUpdate::default()
297                        },
298                    )
299                    .await
300                {
301                    error!(
302                        child_run_id = %child_run_id,
303                        store_error = %store_err,
304                        "failed to persist child run failure"
305                    );
306                }
307
308                Err(err)
309            }
310        }
311    }
312}