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