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//!
7//! A child that suspends (approval, human input, delay, signal) keeps its own
8//! suspension status and the parent's `Workflow` step stays open with the
9//! child run id in its output. The whole chain is suspended with it; when the
10//! root run replays, the open step re-enters the same child run.
11//!
12//! With `allow_failure` (see [`WorkflowContext::workflow_with`]) a child whose
13//! handler fails is still marked failed, but the parent's step completes with
14//! the failure in its [`SubWorkflowOutput`].
15//!
16//! [`WorkflowContext::workflows_with`] records a wave of `Workflow` steps at
17//! one position and runs their children concurrently (see [`wave`]).
18//!
19//! The child run itself is prepared in [`child`] and executed in
20//! [`child_run`].
21
22mod child;
23mod child_run;
24mod wave;
25
26use chrono::Utc;
27use rust_decimal::Decimal;
28use serde_json::{Value, from_value, json, to_value};
29use tracing::{error, info, warn};
30use uuid::Uuid;
31
32use ironflow_store::models::{
33    NewStep, RunStatus, Step, StepKind, StepStatus, StepUpdate, normalize_worker_tags,
34    step_trace_id,
35};
36
37use crate::config::{WorkflowOptions, WorkflowStepConfig};
38use crate::context::WorkflowContext;
39use crate::context::lifecycle::check_replay_identity;
40use crate::error::EngineError;
41use crate::executor::{
42    ConcurrencyConflict, RecordedWorkflowStep, SubWorkflowOutcome, SubWorkflowOutput,
43};
44use crate::guard::WorkflowRejection;
45use crate::handler::{TypedWorkflow, WorkflowHandler};
46use crate::plan::{SharedPlanRecorder, lock_plan};
47
48/// Key of the open `Workflow` step output that records the child run id.
49const CHILD_RUN_ID_KEY: &str = "child_run_id";
50
51/// The child run id recorded on an open or interrupted `Workflow` step, if any.
52///
53/// A missing or unparsable id means the parent stopped before the child run
54/// was recorded: a new child run is started.
55pub(crate) fn recorded_child_run_id(step: &Step) -> Option<Uuid> {
56    let raw = step.output.as_ref()?.get(CHILD_RUN_ID_KEY)?.as_str()?;
57    match Uuid::parse_str(raw) {
58        Ok(id) => Some(id),
59        Err(err) => {
60            warn!(
61                step_id = %step.id,
62                value = %raw,
63                error = %err,
64                "open workflow step records an invalid child run id"
65            );
66            None
67        }
68    }
69}
70
71/// How a `Workflow` step reaches a child run it already started.
72#[derive(Clone, Copy)]
73enum ChildResume {
74    /// The step was left open by a child that suspended.
75    Suspended(Uuid),
76    /// The step was interrupted by a lost worker lease: the child run may
77    /// still hold the steps that were running in the dead worker.
78    Interrupted(Uuid),
79}
80
81impl ChildResume {
82    /// The child run to re-enter.
83    fn run_id(self) -> Uuid {
84        match self {
85            Self::Suspended(id) | Self::Interrupted(id) => id,
86        }
87    }
88}
89
90/// How a child workflow execution ended, short of a suspension or an error.
91enum ChildOutcome {
92    /// The child run finished; the flag tells whether at least one
93    /// `allow_failure` step failed.
94    Finished(SubWorkflowOutput, bool),
95    /// No child run was created: another active run holds the concurrency key.
96    Conflict(ConcurrencyConflict),
97}
98
99/// Whether `err` stops the parent without closing the child's step: the next
100/// replay re-enters the same child run.
101fn leaves_step_open(err: &EngineError) -> bool {
102    err.is_suspension() || matches!(err, EngineError::RunPaused { .. })
103}
104
105/// The output of a `workflow` or `workflow_dyn` step, which records no
106/// concurrency key and so can only complete.
107///
108/// A conflict is only met on replay, when the step was recorded by
109/// `workflow_with` before the handler code changed.
110fn expect_completed(
111    outcome: SubWorkflowOutcome,
112    position: u32,
113) -> Result<SubWorkflowOutput, EngineError> {
114    match outcome {
115        SubWorkflowOutcome::Completed(output) => Ok(output),
116        SubWorkflowOutcome::Conflict(_) => Err(EngineError::StepConfig(format!(
117            "workflow step at position {position} recorded a concurrency conflict; \
118             call workflow_with to read it"
119        ))),
120    }
121}
122
123impl WorkflowContext {
124    /// Execute a sub-workflow step.
125    ///
126    /// Creates a child run of `handler` whose payload is `input`, executes it
127    /// with its own steps and lifecycle, and returns its run ID and aggregated
128    /// metrics. The child declares its input type through [`TypedWorkflow`],
129    /// so only a `W::Input` is accepted.
130    ///
131    /// When the child suspends (approval, human input, delay or signal), the
132    /// parent is suspended with it and this step stays open. Resuming the
133    /// child resumes the whole chain: the parent replays and re-enters the
134    /// same child run, whose completed steps are replayed.
135    ///
136    /// To tolerate a failed child, see [`workflow_with`](Self::workflow_with).
137    ///
138    /// Requires the context to be created with
139    /// `with_handler_resolver`.
140    ///
141    /// # Errors
142    ///
143    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered
144    /// with the given name, or if no handler resolver is available, and
145    /// [`EngineError::Serialization`] if `input` cannot be serialized. Returns
146    /// [`EngineError::ReplayDivergence`] when the step recorded at this
147    /// position has a different name or kind, and
148    /// [`EngineError::ChildSuspended`] when the child run suspended.
149    ///
150    /// # Examples
151    ///
152    /// ```no_run
153    /// use ironflow_engine::context::WorkflowContext;
154    /// use ironflow_engine::error::EngineError;
155    /// use ironflow_engine::handler::{HandlerFuture, TypedWorkflow, WorkflowHandler};
156    /// use serde::{Deserialize, Serialize};
157    ///
158    /// #[derive(Serialize, Deserialize)]
159    /// struct CollectInput {
160    ///     scope: String,
161    /// }
162    ///
163    /// struct Collect;
164    ///
165    /// impl WorkflowHandler for Collect {
166    ///     fn name(&self) -> &str { "collect" }
167    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
168    ///         Box::pin(async move { Ok(()) })
169    ///     }
170    /// }
171    ///
172    /// impl TypedWorkflow for Collect {
173    ///     type Input = CollectInput;
174    /// }
175    ///
176    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
177    /// let child = ctx.workflow(&Collect, CollectInput { scope: "system".to_string() }).await?;
178    /// let steps = ctx.store()?.list_steps(child.run_id()).await?;
179    /// # Ok(())
180    /// # }
181    /// ```
182    ///
183    /// Any other input type is a compile error:
184    ///
185    /// ```compile_fail,E0308
186    /// # use ironflow_engine::context::WorkflowContext;
187    /// # use ironflow_engine::error::EngineError;
188    /// # use ironflow_engine::handler::{HandlerFuture, TypedWorkflow, WorkflowHandler};
189    /// # #[derive(serde::Serialize, serde::Deserialize)]
190    /// # struct CollectInput { scope: String }
191    /// # struct Collect;
192    /// # impl WorkflowHandler for Collect {
193    /// #     fn name(&self) -> &str { "collect" }
194    /// #     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
195    /// #         Box::pin(async move { Ok(()) })
196    /// #     }
197    /// # }
198    /// # impl TypedWorkflow for Collect { type Input = CollectInput; }
199    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
200    /// ctx.workflow(&Collect, serde_json::json!({"scope": "system"})).await?;
201    /// # Ok(())
202    /// # }
203    /// ```
204    pub async fn workflow<W: TypedWorkflow>(
205        &mut self,
206        handler: &W,
207        input: W::Input,
208    ) -> Result<SubWorkflowOutput, EngineError> {
209        let payload = to_value(&input)?;
210        let position = self.position;
211        let outcome = self
212            .run_sub_workflow(handler, payload, WorkflowOptions::default())
213            .await?;
214        expect_completed(outcome, position)
215    }
216
217    /// Execute a sub-workflow step with [`WorkflowOptions`].
218    ///
219    /// Same as [`workflow`](Self::workflow), plus a concurrency key set with
220    /// [`WorkflowOptions::concurrency_key`]: the key is held by the child run
221    /// until it reaches a terminal state (Completed, Failed, Warning,
222    /// Cancelled). While another non-terminal run holds it, no child run is
223    /// created and the step completes at once with
224    /// [`SubWorkflowOutcome::Conflict`], naming the run in place. A conflict
225    /// never fails the parent, and it is replayed as-is on resume: the child
226    /// is not attempted again.
227    ///
228    /// A parent that itself holds the key gets a conflict naming its own run.
229    ///
230    /// With [`allow_failure`](WorkflowOptions::allow_failure), a child whose
231    /// handler fails does not fail the parent: the child run is still marked
232    /// failed, but this step completes with a
233    /// [`SubWorkflowOutcome::Completed`] whose [`SubWorkflowOutput`] has a
234    /// [`status`](SubWorkflowOutput::status) of `Failed` (or `Cancelled` when a
235    /// guardrail stopped it) and an [`error`](SubWorkflowOutput::error)
236    /// carrying the child error. The parent run then ends as `Warning`. A
237    /// resumed parent replays the completed step and creates no new child.
238    ///
239    /// A suspension is never tolerated: a child that suspends suspends the
240    /// parent. Errors raised outside the child (no resolver, unknown handler,
241    /// store errors, replay divergence, guard rejection of the invocation) are
242    /// not tolerated either.
243    ///
244    /// # Errors
245    ///
246    /// Same as [`workflow`](Self::workflow), except that the failure of the
247    /// child handler is returned in the output when `allow_failure` is set. A
248    /// conflict raised while creating the child is data, not an error.
249    ///
250    /// # Examples
251    ///
252    /// ```no_run
253    /// use ironflow_engine::config::WorkflowOptions;
254    /// use ironflow_engine::context::WorkflowContext;
255    /// use ironflow_engine::error::EngineError;
256    /// use ironflow_engine::executor::SubWorkflowOutcome;
257    /// use ironflow_engine::handler::{HandlerFuture, TypedWorkflow, WorkflowHandler};
258    /// use serde::{Deserialize, Serialize};
259    ///
260    /// #[derive(Serialize, Deserialize)]
261    /// struct FixInput {
262    ///     issue: u64,
263    /// }
264    ///
265    /// struct FixIssue;
266    ///
267    /// impl WorkflowHandler for FixIssue {
268    ///     fn name(&self) -> &str { "fix-issue" }
269    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
270    ///         Box::pin(async move { Ok(()) })
271    ///     }
272    /// }
273    ///
274    /// impl TypedWorkflow for FixIssue {
275    ///     type Input = FixInput;
276    /// }
277    ///
278    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
279    /// let options = WorkflowOptions::new().concurrency_key("issue:12");
280    /// match ctx.workflow_with(&FixIssue, FixInput { issue: 12 }, options).await? {
281    ///     SubWorkflowOutcome::Completed(child) => println!("fixed in run {}", child.run_id()),
282    ///     SubWorkflowOutcome::Conflict(c) => println!("already handled by run {}", c.run_id()),
283    /// }
284    /// # Ok(())
285    /// # }
286    /// ```
287    pub async fn workflow_with<W: TypedWorkflow>(
288        &mut self,
289        handler: &W,
290        input: W::Input,
291        options: WorkflowOptions,
292    ) -> Result<SubWorkflowOutcome, EngineError> {
293        let payload = to_value(&input)?;
294        self.run_sub_workflow(handler, payload, options).await
295    }
296
297    /// Execute a sub-workflow step whose child is only known at run time.
298    ///
299    /// Same as [`workflow`](Self::workflow), without the compile-time check of
300    /// the payload: the child must deserialize `payload` itself.
301    ///
302    /// # Errors
303    ///
304    /// Same as [`workflow`](Self::workflow).
305    ///
306    /// # Examples
307    ///
308    /// ```no_run
309    /// use ironflow_engine::context::WorkflowContext;
310    /// use ironflow_engine::error::EngineError;
311    /// use ironflow_engine::handler::WorkflowHandler;
312    /// use serde_json::json;
313    ///
314    /// # #[allow(deprecated)]
315    /// # async fn example(ctx: &mut WorkflowContext, child: &dyn WorkflowHandler) -> Result<(), EngineError> {
316    /// let result = ctx.workflow_dyn(child, json!({"scope": "system"})).await?;
317    /// println!("child run {}", result.run_id());
318    /// # Ok(())
319    /// # }
320    /// ```
321    #[deprecated(
322        note = "implement `TypedWorkflow` on the child and call `workflow`: its payload is then checked at compile time"
323    )]
324    pub async fn workflow_dyn(
325        &mut self,
326        handler: &dyn WorkflowHandler,
327        payload: Value,
328    ) -> Result<SubWorkflowOutput, EngineError> {
329        let position = self.position;
330        let outcome = self
331            .run_sub_workflow(handler, payload, WorkflowOptions::default())
332            .await?;
333        expect_completed(outcome, position)
334    }
335
336    /// Record, then run or plan, a sub-workflow step.
337    ///
338    /// A `Workflow` step completed in a previous execution is replayed without
339    /// running the child again. A step left open (`Running`) by a suspended
340    /// child is reused and re-enters the child run it recorded.
341    /// A step interrupted by a lost worker lease is recorded again at the same
342    /// position and re-enters the child run the interrupted step recorded.
343    async fn run_sub_workflow(
344        &mut self,
345        handler: &dyn WorkflowHandler,
346        payload: Value,
347        options: WorkflowOptions,
348    ) -> Result<SubWorkflowOutcome, EngineError> {
349        let (step_name, config) = options.into_step_config(handler.name(), payload);
350
351        // Plan mode: record the invocation, expand the child handler in the
352        // same recorder, and return a synthetic output. No child run is
353        // created and no step of the child is executed.
354        if let Some(plan) = self.plan().cloned() {
355            let planned = self
356                .plan_sub_workflow(&plan, handler, &step_name, config.payload)
357                .await?;
358            return Ok(SubWorkflowOutcome::Completed(planned));
359        }
360
361        let position = self.position;
362
363        let existing = self.replay_steps.get(&position).cloned();
364        if let Some(existing) = &existing {
365            check_replay_identity(existing, position, &step_name, &StepKind::Workflow)?;
366            if existing.status.state == StepStatus::Completed {
367                return self.replay_sub_workflow(existing);
368            }
369        }
370
371        // A step interrupted by a lost lease must be the same step the handler
372        // calls now before its child run is re-entered.
373        let interrupted = self
374            .interrupted_children
375            .get(&(position, step_name.clone()))
376            .cloned();
377        if let Some(interrupted) = &interrupted {
378            check_replay_identity(interrupted, position, &step_name, &StepKind::Workflow)?;
379        }
380
381        // A child runs on the worker that runs its parent: refuse it here
382        // rather than run it on a host missing what it requires.
383        self.check_child_worker_tags(handler)?;
384
385        // Guard: verify the limits and claim the invocation before creating the
386        // step (fail-closed). The claim is atomic, so a sibling of a wave
387        // running its own child at the same time cannot cross the fan-out too.
388        if let (Some(guard_config), Some(guard_state)) = (&self.guard_config, &self.guard_state) {
389            guard_state
390                .lock()
391                .map_err(|_| WorkflowRejection::GuardUnavailable)?
392                .enter(guard_config, handler.name())?;
393        }
394
395        // Paused while it ran: no child is started, the run stops here.
396        if let Err(err) = self.check_not_paused().await {
397            self.guard_record_return();
398            return Err(err);
399        }
400
401        self.position += 1;
402
403        // An open step was left by a child that suspended: reuse it instead of
404        // recording a second step at the same position.
405        let (step_id, resume) = match existing.filter(|s| s.status.state == StepStatus::Running) {
406            Some(step) => (
407                step.id,
408                recorded_child_run_id(&step).map(ChildResume::Suspended),
409            ),
410            None => {
411                let recorded = self
412                    .record_workflow_step(&step_name, &config, position)
413                    .await;
414                let step_id = match recorded {
415                    Ok(step_id) => step_id,
416                    Err(err) => {
417                        self.guard_record_return();
418                        return Err(err);
419                    }
420                };
421                // A step interrupted by a lost lease re-enters the child run it
422                // recorded instead of starting a new one.
423                let resume = interrupted
424                    .as_ref()
425                    .and_then(recorded_child_run_id)
426                    .map(ChildResume::Interrupted);
427                (step_id, resume)
428            }
429        };
430
431        let run = self.execute_child_workflow(&config, step_id, resume).await;
432        if matches!(&run.result, Err(err) if err.is_suspension()) {
433            self.sibling_suspensions.extend(run.sibling_suspensions);
434        }
435        let settled = self.settle_child_step(step_id, &config, run.result).await;
436        self.guard_record_return();
437        if settled.is_ok() {
438            self.last_step_ids = vec![step_id];
439        }
440        settled
441    }
442
443    /// Record a `Workflow` step at `position` and start it.
444    async fn record_workflow_step(
445        &self,
446        step_name: &str,
447        config: &WorkflowStepConfig,
448        position: u32,
449    ) -> Result<Uuid, EngineError> {
450        let step = self
451            .store
452            .create_step(NewStep {
453                run_id: self.run_id,
454                trace_id: step_trace_id(self.run_id, step_name, position),
455                name: step_name.to_string(),
456                kind: StepKind::Workflow,
457                position,
458                input: Some(to_value(config)?),
459                is_error_handler: false,
460            })
461            .await?;
462        self.start_step(step.id, Utc::now()).await?;
463        Ok(step.id)
464    }
465
466    /// Close the `Workflow` step `step_id` from what its child produced, and
467    /// account for the child on this run.
468    ///
469    /// A suspended or paused child leaves the step open, neither failed nor
470    /// completed, so the next replay re-enters the same child run.
471    async fn settle_child_step(
472        &mut self,
473        step_id: Uuid,
474        config: &WorkflowStepConfig,
475        result: Result<ChildOutcome, EngineError>,
476    ) -> Result<SubWorkflowOutcome, EngineError> {
477        match result {
478            // No child run was created: the step completes with the conflict
479            // as its output, so a replay serves the same outcome.
480            Ok(ChildOutcome::Conflict(conflict)) => {
481                self.store
482                    .update_step(
483                        step_id,
484                        StepUpdate {
485                            status: Some(StepStatus::Completed),
486                            output: Some(json!({ "concurrency_conflict": conflict })),
487                            duration_ms: Some(0),
488                            cost_usd: Some(Decimal::ZERO),
489                            completed_at: Some(Utc::now()),
490                            ..StepUpdate::default()
491                        },
492                    )
493                    .await?;
494
495                info!(
496                    run_id = %self.run_id,
497                    child_workflow = %config.workflow_name,
498                    key = %conflict.key(),
499                    holder = %conflict.run_id(),
500                    "workflow step skipped: concurrency conflict"
501                );
502                Ok(SubWorkflowOutcome::Conflict(conflict))
503            }
504            Ok(ChildOutcome::Finished(output, child_had_allowed_failure)) => {
505                // Not `charge_cost`: a child sharing this run's live total
506                // charged its cost there already, step by step.
507                self.total_cost_usd += output.cost_usd();
508                self.total_duration_ms += output.duration_ms();
509                if child_had_allowed_failure {
510                    self.has_allowed_failure = true;
511                }
512
513                self.store
514                    .update_step(
515                        step_id,
516                        StepUpdate {
517                            status: Some(StepStatus::Completed),
518                            output: Some(to_value(&output)?),
519                            duration_ms: Some(output.duration_ms()),
520                            cost_usd: Some(output.cost_usd()),
521                            completed_at: Some(Utc::now()),
522                            ..StepUpdate::default()
523                        },
524                    )
525                    .await?;
526
527                info!(
528                    run_id = %self.run_id,
529                    child_workflow = %config.workflow_name,
530                    duration_ms = output.duration_ms(),
531                    "workflow step completed"
532                );
533                Ok(SubWorkflowOutcome::Completed(output))
534            }
535            Err(err) if leaves_step_open(&err) => Err(err),
536            Err(err) => {
537                if let Err(store_err) = self
538                    .store
539                    .update_step(
540                        step_id,
541                        StepUpdate {
542                            status: Some(StepStatus::Failed),
543                            error: Some(err.to_string()),
544                            completed_at: Some(Utc::now()),
545                            ..StepUpdate::default()
546                        },
547                    )
548                    .await
549                {
550                    error!(step_id = %step_id, error = %store_err, "failed to persist step failure");
551                }
552                Err(err)
553            }
554        }
555    }
556
557    /// Replay a `Workflow` step completed in a previous execution: the child
558    /// run is not executed again and nothing is re-counted by the guard.
559    ///
560    /// A step skipped on a concurrency conflict replays the same conflict: the
561    /// child is not attempted again, even if the key has been released since.
562    fn replay_sub_workflow(&mut self, step: &Step) -> Result<SubWorkflowOutcome, EngineError> {
563        let outcome = self.replay_recorded_child(step)?;
564        self.position += 1;
565        self.last_step_ids = vec![step.id];
566        Ok(outcome)
567    }
568
569    /// The outcome recorded on a completed `Workflow` step, accounted for on
570    /// this run like the first time, without moving the position.
571    fn replay_recorded_child(&mut self, step: &Step) -> Result<SubWorkflowOutcome, EngineError> {
572        let recorded = step.output.clone().ok_or_else(|| {
573            EngineError::StepConfig(format!(
574                "completed workflow step {} has no recorded output",
575                step.id
576            ))
577        })?;
578        let recorded: RecordedWorkflowStep = from_value(recorded)?;
579
580        let output = match SubWorkflowOutcome::from(recorded) {
581            SubWorkflowOutcome::Completed(output) => output,
582            SubWorkflowOutcome::Conflict(conflict) => {
583                info!(
584                    run_id = %self.run_id,
585                    step = %step.name,
586                    key = %conflict.key(),
587                    holder = %conflict.run_id(),
588                    "workflow step replayed: concurrency conflict"
589                );
590                return Ok(SubWorkflowOutcome::Conflict(conflict));
591            }
592        };
593
594        // Cost is not added: `carry_over_run_totals` seeded `total_cost_usd`
595        // from the run totals persisted before the suspension, which already
596        // include this child.
597        self.total_duration_ms += output.duration_ms();
598        if matches!(
599            output.status(),
600            RunStatus::Warning | RunStatus::Failed | RunStatus::Cancelled
601        ) {
602            self.has_allowed_failure = true;
603        }
604
605        info!(
606            run_id = %self.run_id,
607            child_run_id = %output.run_id(),
608            step = %step.name,
609            "workflow step replayed from previous execution"
610        );
611        Ok(SubWorkflowOutcome::Completed(output))
612    }
613
614    /// Refuse a child whose required worker tags are not all carried by the
615    /// worker running this context.
616    ///
617    /// No check is made outside a tagged worker (API or local mode).
618    fn check_child_worker_tags(&self, handler: &dyn WorkflowHandler) -> Result<(), EngineError> {
619        let Some(carried) = &self.worker_tags else {
620            return Ok(());
621        };
622        let missing: Vec<String> = normalize_worker_tags(handler.required_worker_tags())
623            .into_iter()
624            .filter(|tag| !carried.contains(tag))
625            .collect();
626        if missing.is_empty() {
627            return Ok(());
628        }
629        Err(EngineError::InvalidWorkflow(format!(
630            "sub-workflow '{}' requires worker tags [{}] that this worker does not carry",
631            handler.name(),
632            missing.join(", ")
633        )))
634    }
635
636    /// Record a sub-workflow invocation while planning, expanding the child
637    /// handler into the same plan when the depth limit allows it.
638    ///
639    /// The child plans against its own payload and under its own workflow
640    /// name; the parent's payload is restored on the way out.
641    async fn plan_sub_workflow(
642        &mut self,
643        plan: &SharedPlanRecorder,
644        handler: &dyn WorkflowHandler,
645        step_name: &str,
646        payload: Value,
647    ) -> Result<SubWorkflowOutput, EngineError> {
648        self.position += 1;
649        {
650            let mut recorder = lock_plan(plan);
651            recorder.record_or_stop(step_name, StepKind::Workflow, &self.workflow_name, None)?;
652            recorder.set_last(vec![step_name.to_string()]);
653        }
654        Ok(self.expand_planned_child(plan, handler, payload).await)
655    }
656
657    /// Expand `handler` into the plan, when the depth limit allows it, and
658    /// return the synthetic output of its step.
659    ///
660    /// The child plans against its own payload and under its own workflow
661    /// name; the parent's payload is restored on the way out.
662    async fn expand_planned_child(
663        &self,
664        plan: &SharedPlanRecorder,
665        handler: &dyn WorkflowHandler,
666        payload: Value,
667    ) -> SubWorkflowOutput {
668        let sub_name = handler.name().to_string();
669        // No child run exists while planning: a nil id and zero metrics.
670        let planned = SubWorkflowOutput::new(
671            Uuid::nil(),
672            &sub_name,
673            RunStatus::Completed,
674            Decimal::ZERO,
675            0,
676        );
677
678        let expand = lock_plan(plan).enter_workflow();
679        if expand {
680            let previous_payload = lock_plan(plan).swap_payload(payload.clone());
681
682            let mut child = WorkflowContext::new(
683                Uuid::now_v7(),
684                sub_name.clone(),
685                self.store.clone(),
686                self.provider.clone(),
687            );
688            child.handler_resolver = self.handler_resolver.clone();
689            child.set_plan(plan.clone());
690
691            if let Err(err) = handler.execute(&mut child).await {
692                lock_plan(plan).fail(format!(
693                    "sub-workflow {sub_name} could not be planned: {err}"
694                ));
695            }
696
697            let mut recorder = lock_plan(plan);
698            recorder.swap_payload(previous_payload);
699            recorder.leave_workflow();
700        }
701
702        planned
703    }
704}