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            // A child run is mocked exactly like its parent.
302            interceptor: self.interceptor.clone(),
303            trace_context: self.trace_context.child(),
304            operation_ctx: None,
305            plan: None,
306        };
307
308        let result = handler.execute(&mut child_ctx).await;
309        let total_duration = run_start.elapsed().as_millis() as u64;
310        let completed_at = Utc::now();
311
312        match result {
313            Ok(()) => {
314                let child_status = if child_ctx.has_allowed_failure {
315                    RunStatus::Warning
316                } else {
317                    RunStatus::Completed
318                };
319                self.store
320                    .update_run(
321                        child_run_id,
322                        RunUpdate {
323                            status: Some(child_status),
324                            cost_usd: Some(child_ctx.total_cost_usd),
325                            duration_ms: Some(total_duration),
326                            completed_at: Some(completed_at),
327                            ..RunUpdate::default()
328                        },
329                    )
330                    .await?;
331
332                let child_had_allowed_failure = child_ctx.has_allowed_failure;
333                Ok((
334                    StepOutput {
335                        output: json!({
336                            "run_id": child_run_id,
337                            "workflow_name": config.workflow_name,
338                            "status": child_status,
339                            "cost_usd": child_ctx.total_cost_usd,
340                            "duration_ms": total_duration,
341                        }),
342                        duration_ms: total_duration,
343                        cost_usd: child_ctx.total_cost_usd,
344                        input_tokens: None,
345                        output_tokens: None,
346                        model: None,
347                        debug_messages: None,
348                    },
349                    child_had_allowed_failure,
350                ))
351            }
352            Err(err) => {
353                if let Err(store_err) = self
354                    .store
355                    .update_run(
356                        child_run_id,
357                        RunUpdate {
358                            status: Some(RunStatus::Failed),
359                            error: Some(err.to_string()),
360                            cost_usd: Some(child_ctx.total_cost_usd),
361                            duration_ms: Some(total_duration),
362                            completed_at: Some(completed_at),
363                            ..RunUpdate::default()
364                        },
365                    )
366                    .await
367                {
368                    error!(
369                        child_run_id = %child_run_id,
370                        store_error = %store_err,
371                        "failed to persist child run failure"
372                    );
373                }
374
375                Err(err)
376            }
377        }
378    }
379}