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
16use std::collections::HashMap;
17use std::time::Instant;
18
19use chrono::Utc;
20use rust_decimal::Decimal;
21use serde_json::{Value, from_value, json, to_value};
22use tracing::{error, info, warn};
23use uuid::Uuid;
24
25use ironflow_core::provider::LABEL_ROOT_RUN_ID;
26use ironflow_store::error::StoreError;
27use ironflow_store::models::{
28 NewRun, NewStep, ProviderKind, Run, RunStatus, RunUpdate, Step, StepKind, StepStatus,
29 StepUpdate, TriggerKind, normalize_worker_tags, step_trace_id,
30};
31
32use crate::config::{WorkflowOptions, WorkflowStepConfig};
33use crate::context::lifecycle::check_replay_identity;
34use crate::context::{PARENT_RUN_ID_LABEL, WorkflowContext, interrupt_running_steps};
35use crate::error::EngineError;
36use crate::executor::{
37 ConcurrencyConflict, RecordedWorkflowStep, SubWorkflowOutcome, SubWorkflowOutput,
38};
39use crate::guard::WorkflowRejection;
40use crate::handler::{TypedWorkflow, WorkflowHandler, clamp_priority};
41use crate::plan::{SharedPlanRecorder, lock_plan};
42
43/// Key of the open `Workflow` step output that records the child run id.
44const CHILD_RUN_ID_KEY: &str = "child_run_id";
45
46/// The child run id recorded on an open or interrupted `Workflow` step, if any.
47///
48/// A missing or unparsable id means the parent stopped before the child run
49/// was recorded: a new child run is started.
50pub(crate) fn recorded_child_run_id(step: &Step) -> Option<Uuid> {
51 let raw = step.output.as_ref()?.get(CHILD_RUN_ID_KEY)?.as_str()?;
52 match Uuid::parse_str(raw) {
53 Ok(id) => Some(id),
54 Err(err) => {
55 warn!(
56 step_id = %step.id,
57 value = %raw,
58 error = %err,
59 "open workflow step records an invalid child run id"
60 );
61 None
62 }
63 }
64}
65
66/// How a `Workflow` step reaches a child run it already started.
67#[derive(Clone, Copy)]
68enum ChildResume {
69 /// The step was left open by a child that suspended.
70 Suspended(Uuid),
71 /// The step was interrupted by a lost worker lease: the child run may
72 /// still hold the steps that were running in the dead worker.
73 Interrupted(Uuid),
74}
75
76impl ChildResume {
77 /// The child run to re-enter.
78 fn run_id(self) -> Uuid {
79 match self {
80 Self::Suspended(id) | Self::Interrupted(id) => id,
81 }
82 }
83}
84
85/// How a child workflow execution ended, short of a suspension or an error.
86enum ChildOutcome {
87 /// The child run finished; the flag tells whether at least one
88 /// `allow_failure` step failed.
89 Finished(SubWorkflowOutput, bool),
90 /// No child run was created: another active run holds the concurrency key.
91 Conflict(ConcurrencyConflict),
92}
93
94/// The outcome of a child run found `Cancelled`: with `allow_failure`, a
95/// `Cancelled` output carrying the child's error; otherwise
96/// [`EngineError::ChildRunCancelled`], which fails the step and the parent.
97fn cancelled_child_outcome(
98 config: &WorkflowStepConfig,
99 child: &Run,
100 cost_usd: Decimal,
101 duration_ms: u64,
102 output: Option<Value>,
103) -> Result<ChildOutcome, EngineError> {
104 let cancelled = EngineError::ChildRunCancelled { run_id: child.id };
105 if !config.allow_failure {
106 return Err(cancelled);
107 }
108 let error = child.error.clone().unwrap_or_else(|| cancelled.to_string());
109 let status = RunStatus::Cancelled;
110 Ok(ChildOutcome::Finished(
111 SubWorkflowOutput::new(
112 child.id,
113 &config.workflow_name,
114 status,
115 cost_usd,
116 duration_ms,
117 )
118 .with_output(output)
119 .with_error(error),
120 true,
121 ))
122}
123
124/// The output of a `workflow` or `workflow_dyn` step, which records no
125/// concurrency key and so can only complete.
126///
127/// A conflict is only met on replay, when the step was recorded by
128/// `workflow_with` before the handler code changed.
129fn expect_completed(
130 outcome: SubWorkflowOutcome,
131 position: u32,
132) -> Result<SubWorkflowOutput, EngineError> {
133 match outcome {
134 SubWorkflowOutcome::Completed(output) => Ok(output),
135 SubWorkflowOutcome::Conflict(_) => Err(EngineError::StepConfig(format!(
136 "workflow step at position {position} recorded a concurrency conflict; \
137 call workflow_with to read it"
138 ))),
139 }
140}
141
142impl WorkflowContext {
143 /// Execute a sub-workflow step.
144 ///
145 /// Creates a child run of `handler` whose payload is `input`, executes it
146 /// with its own steps and lifecycle, and returns its run ID and aggregated
147 /// metrics. The child declares its input type through [`TypedWorkflow`],
148 /// so only a `W::Input` is accepted.
149 ///
150 /// When the child suspends (approval, human input, delay or signal), the
151 /// parent is suspended with it and this step stays open. Resuming the
152 /// child resumes the whole chain: the parent replays and re-enters the
153 /// same child run, whose completed steps are replayed.
154 ///
155 /// To tolerate a failed child, see [`workflow_with`](Self::workflow_with).
156 ///
157 /// Requires the context to be created with
158 /// `with_handler_resolver`.
159 ///
160 /// # Errors
161 ///
162 /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered
163 /// with the given name, or if no handler resolver is available, and
164 /// [`EngineError::Serialization`] if `input` cannot be serialized. Returns
165 /// [`EngineError::ReplayDivergence`] when the step recorded at this
166 /// position has a different name or kind, and
167 /// [`EngineError::ChildSuspended`] when the child run suspended.
168 ///
169 /// # Examples
170 ///
171 /// ```no_run
172 /// use ironflow_engine::context::WorkflowContext;
173 /// use ironflow_engine::error::EngineError;
174 /// use ironflow_engine::handler::{HandlerFuture, TypedWorkflow, WorkflowHandler};
175 /// use serde::{Deserialize, Serialize};
176 ///
177 /// #[derive(Serialize, Deserialize)]
178 /// struct CollectInput {
179 /// scope: String,
180 /// }
181 ///
182 /// struct Collect;
183 ///
184 /// impl WorkflowHandler for Collect {
185 /// fn name(&self) -> &str { "collect" }
186 /// fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
187 /// Box::pin(async move { Ok(()) })
188 /// }
189 /// }
190 ///
191 /// impl TypedWorkflow for Collect {
192 /// type Input = CollectInput;
193 /// }
194 ///
195 /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
196 /// let child = ctx.workflow(&Collect, CollectInput { scope: "system".to_string() }).await?;
197 /// let steps = ctx.store()?.list_steps(child.run_id()).await?;
198 /// # Ok(())
199 /// # }
200 /// ```
201 ///
202 /// Any other input type is a compile error:
203 ///
204 /// ```compile_fail,E0308
205 /// # use ironflow_engine::context::WorkflowContext;
206 /// # use ironflow_engine::error::EngineError;
207 /// # use ironflow_engine::handler::{HandlerFuture, TypedWorkflow, WorkflowHandler};
208 /// # #[derive(serde::Serialize, serde::Deserialize)]
209 /// # struct CollectInput { scope: String }
210 /// # struct Collect;
211 /// # impl WorkflowHandler for Collect {
212 /// # fn name(&self) -> &str { "collect" }
213 /// # fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
214 /// # Box::pin(async move { Ok(()) })
215 /// # }
216 /// # }
217 /// # impl TypedWorkflow for Collect { type Input = CollectInput; }
218 /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
219 /// ctx.workflow(&Collect, serde_json::json!({"scope": "system"})).await?;
220 /// # Ok(())
221 /// # }
222 /// ```
223 pub async fn workflow<W: TypedWorkflow>(
224 &mut self,
225 handler: &W,
226 input: W::Input,
227 ) -> Result<SubWorkflowOutput, EngineError> {
228 let payload = to_value(&input)?;
229 let position = self.position;
230 let outcome = self
231 .run_sub_workflow(handler, payload, WorkflowOptions::default())
232 .await?;
233 expect_completed(outcome, position)
234 }
235
236 /// Execute a sub-workflow step with [`WorkflowOptions`].
237 ///
238 /// Same as [`workflow`](Self::workflow), plus a concurrency key set with
239 /// [`WorkflowOptions::concurrency_key`]: the key is held by the child run
240 /// until it reaches a terminal state (Completed, Failed, Warning,
241 /// Cancelled). While another non-terminal run holds it, no child run is
242 /// created and the step completes at once with
243 /// [`SubWorkflowOutcome::Conflict`], naming the run in place. A conflict
244 /// never fails the parent, and it is replayed as-is on resume: the child
245 /// is not attempted again.
246 ///
247 /// A parent that itself holds the key gets a conflict naming its own run.
248 ///
249 /// With [`allow_failure`](WorkflowOptions::allow_failure), a child whose
250 /// handler fails does not fail the parent: the child run is still marked
251 /// failed, but this step completes with a
252 /// [`SubWorkflowOutcome::Completed`] whose [`SubWorkflowOutput`] has a
253 /// [`status`](SubWorkflowOutput::status) of `Failed` (or `Cancelled` when a
254 /// guardrail stopped it) and an [`error`](SubWorkflowOutput::error)
255 /// carrying the child error. The parent run then ends as `Warning`. A
256 /// resumed parent replays the completed step and creates no new child.
257 ///
258 /// A suspension is never tolerated: a child that suspends suspends the
259 /// parent. Errors raised outside the child (no resolver, unknown handler,
260 /// store errors, replay divergence, guard rejection of the invocation) are
261 /// not tolerated either.
262 ///
263 /// # Errors
264 ///
265 /// Same as [`workflow`](Self::workflow), except that the failure of the
266 /// child handler is returned in the output when `allow_failure` is set. A
267 /// conflict raised while creating the child is data, not an error.
268 ///
269 /// # Examples
270 ///
271 /// ```no_run
272 /// use ironflow_engine::config::WorkflowOptions;
273 /// use ironflow_engine::context::WorkflowContext;
274 /// use ironflow_engine::error::EngineError;
275 /// use ironflow_engine::executor::SubWorkflowOutcome;
276 /// use ironflow_engine::handler::{HandlerFuture, TypedWorkflow, WorkflowHandler};
277 /// use serde::{Deserialize, Serialize};
278 ///
279 /// #[derive(Serialize, Deserialize)]
280 /// struct FixInput {
281 /// issue: u64,
282 /// }
283 ///
284 /// struct FixIssue;
285 ///
286 /// impl WorkflowHandler for FixIssue {
287 /// fn name(&self) -> &str { "fix-issue" }
288 /// fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
289 /// Box::pin(async move { Ok(()) })
290 /// }
291 /// }
292 ///
293 /// impl TypedWorkflow for FixIssue {
294 /// type Input = FixInput;
295 /// }
296 ///
297 /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
298 /// let options = WorkflowOptions::new().concurrency_key("issue:12");
299 /// match ctx.workflow_with(&FixIssue, FixInput { issue: 12 }, options).await? {
300 /// SubWorkflowOutcome::Completed(child) => println!("fixed in run {}", child.run_id()),
301 /// SubWorkflowOutcome::Conflict(c) => println!("already handled by run {}", c.run_id()),
302 /// }
303 /// # Ok(())
304 /// # }
305 /// ```
306 pub async fn workflow_with<W: TypedWorkflow>(
307 &mut self,
308 handler: &W,
309 input: W::Input,
310 options: WorkflowOptions,
311 ) -> Result<SubWorkflowOutcome, EngineError> {
312 let payload = to_value(&input)?;
313 self.run_sub_workflow(handler, payload, options).await
314 }
315
316 /// Execute a sub-workflow step whose child is only known at run time.
317 ///
318 /// Same as [`workflow`](Self::workflow), without the compile-time check of
319 /// the payload: the child must deserialize `payload` itself.
320 ///
321 /// # Errors
322 ///
323 /// Same as [`workflow`](Self::workflow).
324 ///
325 /// # Examples
326 ///
327 /// ```no_run
328 /// use ironflow_engine::context::WorkflowContext;
329 /// use ironflow_engine::error::EngineError;
330 /// use ironflow_engine::handler::WorkflowHandler;
331 /// use serde_json::json;
332 ///
333 /// # #[allow(deprecated)]
334 /// # async fn example(ctx: &mut WorkflowContext, child: &dyn WorkflowHandler) -> Result<(), EngineError> {
335 /// let result = ctx.workflow_dyn(child, json!({"scope": "system"})).await?;
336 /// println!("child run {}", result.run_id());
337 /// # Ok(())
338 /// # }
339 /// ```
340 #[deprecated(
341 note = "implement `TypedWorkflow` on the child and call `workflow`: its payload is then checked at compile time"
342 )]
343 pub async fn workflow_dyn(
344 &mut self,
345 handler: &dyn WorkflowHandler,
346 payload: Value,
347 ) -> Result<SubWorkflowOutput, EngineError> {
348 let position = self.position;
349 let outcome = self
350 .run_sub_workflow(handler, payload, WorkflowOptions::default())
351 .await?;
352 expect_completed(outcome, position)
353 }
354
355 /// Record, then run or plan, a sub-workflow step.
356 ///
357 /// A `Workflow` step completed in a previous execution is replayed without
358 /// running the child again. A step left open (`Running`) by a suspended
359 /// child is reused and re-enters the child run it recorded.
360 /// A step interrupted by a lost worker lease is recorded again at the same
361 /// position and re-enters the child run the interrupted step recorded.
362 async fn run_sub_workflow(
363 &mut self,
364 handler: &dyn WorkflowHandler,
365 payload: Value,
366 options: WorkflowOptions,
367 ) -> Result<SubWorkflowOutcome, EngineError> {
368 // Plan mode: record the invocation, expand the child handler in the
369 // same recorder, and return a synthetic output. No child run is
370 // created and no step of the child is executed.
371 if let Some(plan) = self.plan().cloned() {
372 let planned = self.plan_sub_workflow(&plan, handler, payload).await?;
373 return Ok(SubWorkflowOutcome::Completed(planned));
374 }
375
376 let mut config = WorkflowStepConfig::new(handler.name(), payload);
377 config.allow_failure = options.allow_failure;
378 let (concurrency_key, priority) = options.into_parts();
379 config.concurrency_key = concurrency_key;
380 config.priority = priority;
381 let position = self.position;
382
383 let existing = self.replay_steps.get(&position).cloned();
384 if let Some(existing) = &existing {
385 check_replay_identity(
386 existing,
387 position,
388 &config.workflow_name,
389 &StepKind::Workflow,
390 )?;
391 if existing.status.state == StepStatus::Completed {
392 return self.replay_sub_workflow(existing);
393 }
394 }
395
396 // A step interrupted by a lost lease must be the same step the handler
397 // calls now before its child run is re-entered.
398 let interrupted = self.interrupted_children.get(&position).cloned();
399 if let Some(interrupted) = &interrupted {
400 check_replay_identity(
401 interrupted,
402 position,
403 &config.workflow_name,
404 &StepKind::Workflow,
405 )?;
406 }
407
408 // A child runs on the worker that runs its parent: refuse it here
409 // rather than run it on a host missing what it requires.
410 self.check_child_worker_tags(handler)?;
411
412 // Guard check: verify limits before creating the step.
413 if let (Some(guard_config), Some(guard_state)) = (&self.guard_config, &self.guard_state) {
414 let state = guard_state
415 .lock()
416 .map_err(|_| WorkflowRejection::GuardUnavailable)?;
417 state.check(guard_config, handler.name())?;
418 }
419
420 // Paused while it ran: no child is started, the run stops here.
421 self.check_not_paused().await?;
422
423 self.position += 1;
424
425 // An open step was left by a child that suspended: reuse it instead of
426 // recording a second step at the same position.
427 let (step, resume) = match existing.filter(|s| s.status.state == StepStatus::Running) {
428 Some(step) => {
429 let resume = recorded_child_run_id(&step).map(ChildResume::Suspended);
430 (step, resume)
431 }
432 None => {
433 let trace_id = step_trace_id(self.run_id, &config.workflow_name, position);
434 let step = self
435 .store
436 .create_step(NewStep {
437 run_id: self.run_id,
438 trace_id,
439 name: config.workflow_name.clone(),
440 kind: StepKind::Workflow,
441 position,
442 input: Some(to_value(&config)?),
443 is_error_handler: false,
444 })
445 .await?;
446
447 self.start_step(step.id, Utc::now()).await?;
448 // A step interrupted by a lost lease re-enters the child run it
449 // recorded instead of starting a new one.
450 let resume = interrupted
451 .as_ref()
452 .and_then(recorded_child_run_id)
453 .map(ChildResume::Interrupted);
454 (step, resume)
455 }
456 };
457
458 // Record invocation in guard state (fail-closed).
459 if let Some(guard_state) = &self.guard_state {
460 let mut state = guard_state
461 .lock()
462 .map_err(|_| WorkflowRejection::GuardUnavailable)?;
463 state.record_invocation(handler.name());
464 }
465
466 match self.execute_child_workflow(&config, step.id, resume).await {
467 // No child run was created: the step completes with the conflict
468 // as its output, so a replay serves the same outcome.
469 Ok(ChildOutcome::Conflict(conflict)) => {
470 self.store
471 .update_step(
472 step.id,
473 StepUpdate {
474 status: Some(StepStatus::Completed),
475 output: Some(json!({ "concurrency_conflict": conflict })),
476 duration_ms: Some(0),
477 cost_usd: Some(Decimal::ZERO),
478 completed_at: Some(Utc::now()),
479 ..StepUpdate::default()
480 },
481 )
482 .await?;
483
484 info!(
485 run_id = %self.run_id,
486 child_workflow = %config.workflow_name,
487 key = %conflict.key(),
488 holder = %conflict.run_id(),
489 "workflow step skipped: concurrency conflict"
490 );
491
492 self.last_step_ids = vec![step.id];
493
494 self.guard_record_return();
495 Ok(SubWorkflowOutcome::Conflict(conflict))
496 }
497 Ok(ChildOutcome::Finished(output, child_had_allowed_failure)) => {
498 self.total_cost_usd += output.cost_usd();
499 self.total_duration_ms += output.duration_ms();
500 if child_had_allowed_failure {
501 self.has_allowed_failure = true;
502 }
503
504 let completed_at = Utc::now();
505 self.store
506 .update_step(
507 step.id,
508 StepUpdate {
509 status: Some(StepStatus::Completed),
510 output: Some(to_value(&output)?),
511 duration_ms: Some(output.duration_ms()),
512 cost_usd: Some(output.cost_usd()),
513 completed_at: Some(completed_at),
514 ..StepUpdate::default()
515 },
516 )
517 .await?;
518
519 info!(
520 run_id = %self.run_id,
521 child_workflow = %config.workflow_name,
522 duration_ms = output.duration_ms(),
523 "workflow step completed"
524 );
525
526 self.last_step_ids = vec![step.id];
527
528 self.guard_record_return();
529 Ok(SubWorkflowOutcome::Completed(output))
530 }
531 // The child suspended or was paused: the step stays open, neither
532 // failed nor completed, so the next replay re-enters the same child run.
533 Err(err) if err.is_suspension() || matches!(err, EngineError::RunPaused { .. }) => {
534 self.guard_record_return();
535 Err(err)
536 }
537 Err(err) => {
538 let completed_at = Utc::now();
539 if let Err(store_err) = self
540 .store
541 .update_step(
542 step.id,
543 StepUpdate {
544 status: Some(StepStatus::Failed),
545 error: Some(err.to_string()),
546 completed_at: Some(completed_at),
547 ..StepUpdate::default()
548 },
549 )
550 .await
551 {
552 error!(step_id = %step.id, error = %store_err, "failed to persist step failure");
553 }
554
555 self.guard_record_return();
556 Err(err)
557 }
558 }
559 }
560
561 /// Replay a `Workflow` step completed in a previous execution: the child
562 /// run is not executed again and nothing is re-counted by the guard.
563 ///
564 /// A step skipped on a concurrency conflict replays the same conflict: the
565 /// child is not attempted again, even if the key has been released since.
566 fn replay_sub_workflow(&mut self, step: &Step) -> Result<SubWorkflowOutcome, EngineError> {
567 let recorded = step.output.clone().ok_or_else(|| {
568 EngineError::StepConfig(format!(
569 "completed workflow step {} has no recorded output",
570 step.id
571 ))
572 })?;
573 let recorded: RecordedWorkflowStep = from_value(recorded)?;
574
575 self.position += 1;
576 self.last_step_ids = vec![step.id];
577
578 let output = match SubWorkflowOutcome::from(recorded) {
579 SubWorkflowOutcome::Completed(output) => output,
580 SubWorkflowOutcome::Conflict(conflict) => {
581 info!(
582 run_id = %self.run_id,
583 step = %step.name,
584 key = %conflict.key(),
585 holder = %conflict.run_id(),
586 "workflow step replayed: concurrency conflict"
587 );
588 return Ok(SubWorkflowOutcome::Conflict(conflict));
589 }
590 };
591
592 // Cost is not added: `carry_over_run_totals` seeded `total_cost_usd`
593 // from the run totals persisted before the suspension, which already
594 // include this child.
595 self.total_duration_ms += output.duration_ms();
596 if matches!(
597 output.status(),
598 RunStatus::Warning | RunStatus::Failed | RunStatus::Cancelled
599 ) {
600 self.has_allowed_failure = true;
601 }
602
603 info!(
604 run_id = %self.run_id,
605 child_run_id = %output.run_id(),
606 step = %step.name,
607 "workflow step replayed from previous execution"
608 );
609 Ok(SubWorkflowOutcome::Completed(output))
610 }
611
612 /// Refuse a child whose required worker tags are not all carried by the
613 /// worker running this context.
614 ///
615 /// No check is made outside a tagged worker (API or local mode).
616 fn check_child_worker_tags(&self, handler: &dyn WorkflowHandler) -> Result<(), EngineError> {
617 let Some(carried) = &self.worker_tags else {
618 return Ok(());
619 };
620 let missing: Vec<String> = normalize_worker_tags(handler.required_worker_tags())
621 .into_iter()
622 .filter(|tag| !carried.contains(tag))
623 .collect();
624 if missing.is_empty() {
625 return Ok(());
626 }
627 Err(EngineError::InvalidWorkflow(format!(
628 "sub-workflow '{}' requires worker tags [{}] that this worker does not carry",
629 handler.name(),
630 missing.join(", ")
631 )))
632 }
633
634 /// Record a sub-workflow invocation while planning, expanding the child
635 /// handler into the same plan when the depth limit allows it.
636 ///
637 /// The child plans against its own payload and under its own workflow
638 /// name; the parent's payload is restored on the way out.
639 async fn plan_sub_workflow(
640 &mut self,
641 plan: &SharedPlanRecorder,
642 handler: &dyn WorkflowHandler,
643 payload: Value,
644 ) -> Result<SubWorkflowOutput, EngineError> {
645 self.position += 1;
646 let sub_name = handler.name().to_string();
647 // No child run exists while planning: a nil id and zero metrics.
648 let planned = SubWorkflowOutput::new(
649 Uuid::nil(),
650 &sub_name,
651 RunStatus::Completed,
652 Decimal::ZERO,
653 0,
654 );
655
656 {
657 let mut recorder = lock_plan(plan);
658 recorder.record_or_stop(&sub_name, StepKind::Workflow, &self.workflow_name, None)?;
659 recorder.set_last(vec![sub_name.clone()]);
660 }
661
662 let expand = lock_plan(plan).enter_workflow();
663 if expand {
664 let previous_payload = lock_plan(plan).swap_payload(payload.clone());
665
666 let mut child = WorkflowContext::new(
667 Uuid::now_v7(),
668 sub_name.clone(),
669 self.store.clone(),
670 self.provider.clone(),
671 );
672 child.handler_resolver = self.handler_resolver.clone();
673 child.set_plan(plan.clone());
674
675 if let Err(err) = handler.execute(&mut child).await {
676 lock_plan(plan).fail(format!(
677 "sub-workflow {sub_name} could not be planned: {err}"
678 ));
679 }
680
681 let mut recorder = lock_plan(plan);
682 recorder.swap_payload(previous_payload);
683 recorder.leave_workflow();
684 }
685
686 Ok(planned)
687 }
688
689 /// Execute a child workflow and return aggregated output plus whether
690 /// at least one `allow_failure` step failed.
691 ///
692 /// When another active run holds the step's concurrency key, no child run
693 /// is created and [`ChildOutcome::Conflict`] is returned.
694 ///
695 /// `resume` is the child run recorded on an open or interrupted step: that
696 /// run is re-entered, with its completed steps replayed, instead of
697 /// creating a new one. A child re-entered after a lost lease has its
698 /// `Running` steps marked interrupted first, like a requeued run, and the
699 /// new step records the same child run so a second interruption re-enters
700 /// it again. A child that suspends is left in its suspension status and
701 /// [`EngineError::ChildSuspended`] is returned.
702 async fn execute_child_workflow(
703 &self,
704 config: &WorkflowStepConfig,
705 step_id: Uuid,
706 resume: Option<ChildResume>,
707 ) -> Result<ChildOutcome, EngineError> {
708 let resolver = self.handler_resolver.as_ref().ok_or_else(|| {
709 EngineError::InvalidWorkflow(
710 "sub-workflow requires a handler resolver (use Engine to execute)".to_string(),
711 )
712 })?;
713
714 let handler = resolver(&config.workflow_name).ok_or_else(|| {
715 EngineError::InvalidWorkflow(format!("no handler registered: {}", config.workflow_name))
716 })?;
717
718 let (child_run_id, carried_cost_usd, carried_duration_ms) = match resume {
719 Some(resume) => {
720 let child_run_id = resume.run_id();
721 if let ChildResume::Interrupted(_) = resume {
722 self.store
723 .update_step(
724 step_id,
725 StepUpdate {
726 output: Some(json!({ CHILD_RUN_ID_KEY: child_run_id })),
727 ..StepUpdate::default()
728 },
729 )
730 .await?;
731 }
732
733 let child_run = self
734 .store
735 .get_run(child_run_id)
736 .await?
737 .ok_or(EngineError::Store(StoreError::RunNotFound(child_run_id)))?;
738
739 match child_run.status.state {
740 // Left running by the worker that lost the lease: its open
741 // steps are executed again, like those of a requeued run.
742 RunStatus::Running if matches!(resume, ChildResume::Interrupted(_)) => {
743 interrupt_running_steps(self.store.as_ref(), child_run_id).await?;
744 }
745 // Already moved to Running by the path that resumed it.
746 RunStatus::Running => {}
747 RunStatus::AwaitingApproval | RunStatus::Pending => {
748 self.store
749 .update_run_status(child_run_id, RunStatus::Running)
750 .await?;
751 }
752 RunStatus::Sleeping => {
753 self.store
754 .update_run_status(child_run_id, RunStatus::Pending)
755 .await?;
756 self.store
757 .update_run_status(child_run_id, RunStatus::Running)
758 .await?;
759 }
760 // Cancelled while the chain waited, or before the parent
761 // closed its step: the cancellation is the outcome.
762 RunStatus::Cancelled => {
763 return cancelled_child_outcome(
764 config,
765 &child_run,
766 child_run.cost_usd,
767 child_run.duration_ms,
768 child_run.output.clone(),
769 );
770 }
771 // The child finished but the parent stopped before closing
772 // its step: report the recorded outcome, run nothing.
773 status @ RunStatus::Failed if config.allow_failure => {
774 let error = child_run
775 .error
776 .clone()
777 .unwrap_or_else(|| "child run failed".to_string());
778 return Ok(ChildOutcome::Finished(
779 SubWorkflowOutput::new(
780 child_run_id,
781 &config.workflow_name,
782 status,
783 child_run.cost_usd,
784 child_run.duration_ms,
785 )
786 .with_output(child_run.output.clone())
787 .with_error(error),
788 true,
789 ));
790 }
791 status @ (RunStatus::Completed | RunStatus::Warning) => {
792 return Ok(ChildOutcome::Finished(
793 SubWorkflowOutput::new(
794 child_run_id,
795 &config.workflow_name,
796 status,
797 child_run.cost_usd,
798 child_run.duration_ms,
799 )
800 .with_output(child_run.output.clone()),
801 status == RunStatus::Warning,
802 ));
803 }
804 other => {
805 return Err(EngineError::InvalidWorkflow(format!(
806 "child run {child_run_id} is {other}"
807 )));
808 }
809 }
810
811 info!(
812 parent_run_id = %self.run_id,
813 child_run_id = %child_run_id,
814 workflow = %config.workflow_name,
815 "child run re-entered"
816 );
817 (child_run_id, child_run.cost_usd, child_run.duration_ms)
818 }
819 None => {
820 let priority = config
821 .priority
822 .unwrap_or_else(|| clamp_priority(handler.priority()));
823 let child_run_id = match self
824 .create_child_run(config, priority, handler.as_ref())
825 .await
826 {
827 Ok(id) => id,
828 Err(EngineError::ConcurrencyConflict { key, run_id }) => {
829 return Ok(ChildOutcome::Conflict(ConcurrencyConflict::new(
830 key, run_id,
831 )));
832 }
833 Err(err) => return Err(err),
834 };
835
836 // Recorded before the child runs, so a suspension of the child
837 // can be resumed into this same run.
838 self.store
839 .update_step(
840 step_id,
841 StepUpdate {
842 output: Some(json!({ CHILD_RUN_ID_KEY: child_run_id })),
843 ..StepUpdate::default()
844 },
845 )
846 .await?;
847
848 self.store
849 .update_run_status(child_run_id, RunStatus::Running)
850 .await?;
851 (child_run_id, Decimal::ZERO, 0)
852 }
853 };
854
855 let run_start = Instant::now();
856 let mut child_ctx = WorkflowContext {
857 run_id: child_run_id,
858 root_run_id: self.root_run_id,
859 workflow_name: config.workflow_name.clone(),
860 store: self.store.clone(),
861 provider: self.provider.clone(),
862 decision_provider: self.decision_provider.clone(),
863 handler_resolver: self.handler_resolver.clone(),
864 position: 0,
865 last_step_ids: Vec::new(),
866 // A re-entered child starts from what it already spent, like a
867 // resumed top-level run.
868 total_cost_usd: carried_cost_usd,
869 total_duration_ms: 0,
870 max_cost_usd: self.max_cost_usd,
871 // Everything the parent chain already spent counts against the
872 // shared cap, so the child cannot restart the budget from zero.
873 inherited_cost_usd: self.charged_cost_usd(),
874 replay_steps: HashMap::new(),
875 replay_wave_steps: HashMap::new(),
876 granted_approvals: HashMap::new(),
877 answered_inputs: HashMap::new(),
878 interrupted_children: HashMap::new(),
879 // A child run is never itself retried.
880 attempt: 1,
881 carried_duration_ms,
882 log_sender: self.log_sender.clone(),
883 // A child shares the storage backend but not the parent's artifacts:
884 // input lookups are scoped to the child's own run.
885 artifact_sink: self.artifact_sink.clone(),
886 has_allowed_failure: false,
887 error_handlers: Vec::new(),
888 guard_state: self.guard_state.clone(),
889 guard_config: self.guard_config.clone(),
890 step_results: Vec::new(),
891 event_bus: self.event_bus.clone(),
892 // A child run is mocked exactly like its parent.
893 interceptor: self.interceptor.clone(),
894 trace_context: self.trace_context.child(),
895 operation_ctx: None,
896 run_created_at: None,
897 plan: None,
898 output: None,
899 worker_tags: self.worker_tags.clone(),
900 };
901
902 // A re-entered child replays its completed steps and is served the
903 // answer, signal or elapsed delay it was suspended on.
904 let loaded = if resume.is_some() {
905 child_ctx.load_replay_steps().await
906 } else {
907 Ok(())
908 };
909 let result = match loaded {
910 Ok(()) => handler.execute(&mut child_ctx).await,
911 Err(err) => Err(err),
912 };
913 let total_duration = child_ctx.carried_duration_ms + run_start.elapsed().as_millis() as u64;
914 let completed_at = Utc::now();
915
916 // Cancelled while it ran: whatever the handler returned, the child
917 // stays cancelled and the cancellation is its outcome.
918 if let Some(child_run) = self.store.get_run(child_run_id).await? {
919 match child_run.status.state {
920 RunStatus::Cancelled => {
921 return cancelled_child_outcome(
922 config,
923 &child_run,
924 child_ctx.total_cost_usd,
925 total_duration,
926 child_ctx.output().cloned(),
927 );
928 }
929 // Paused with its root while it ran: the child is left as the
930 // pause recorded it and the parent stops with it.
931 RunStatus::Paused => {
932 info!(
933 parent_run_id = %self.run_id,
934 child_run_id = %child_run_id,
935 "child run paused"
936 );
937 return Err(EngineError::RunPaused {
938 run_id: child_run_id,
939 });
940 }
941 _ => {}
942 }
943 }
944
945 match result {
946 Ok(()) => {
947 let child_status = if child_ctx.has_allowed_failure {
948 RunStatus::Warning
949 } else {
950 RunStatus::Completed
951 };
952 self.store
953 .update_run(
954 child_run_id,
955 RunUpdate {
956 status: Some(child_status),
957 cost_usd: Some(child_ctx.total_cost_usd),
958 duration_ms: Some(total_duration),
959 completed_at: Some(completed_at),
960 output: child_ctx.output().cloned(),
961 ..RunUpdate::default()
962 },
963 )
964 .await?;
965
966 let child_had_allowed_failure = child_ctx.has_allowed_failure;
967 Ok(ChildOutcome::Finished(
968 SubWorkflowOutput::new(
969 child_run_id,
970 &config.workflow_name,
971 child_status,
972 child_ctx.total_cost_usd,
973 total_duration,
974 )
975 .with_output(child_ctx.output().cloned()),
976 child_had_allowed_failure,
977 ))
978 }
979 Err(err) if err.is_suspension() => {
980 match self
981 .suspend_child_run(child_run_id, &err, child_ctx.total_cost_usd, total_duration)
982 .await
983 {
984 Ok(()) => {
985 info!(
986 parent_run_id = %self.run_id,
987 child_run_id = %child_run_id,
988 cause = %err.suspension_leaf(),
989 "child run suspended"
990 );
991 Err(EngineError::ChildSuspended {
992 run_id: child_run_id,
993 cause: Box::new(err),
994 })
995 }
996 Err(store_err) => {
997 self.fail_child_run(
998 child_run_id,
999 RunStatus::Failed,
1000 &store_err,
1001 child_ctx.total_cost_usd,
1002 total_duration,
1003 child_ctx.output().cloned(),
1004 )
1005 .await;
1006 Err(store_err)
1007 }
1008 }
1009 }
1010 Err(err) => {
1011 // The engine cancels top-level runs stopped by a guardrail.
1012 let status = if matches!(
1013 err,
1014 EngineError::RunBudgetExceeded { .. } | EngineError::WorkflowGuardRejected(_)
1015 ) {
1016 RunStatus::Cancelled
1017 } else {
1018 RunStatus::Failed
1019 };
1020 self.fail_child_run(
1021 child_run_id,
1022 status,
1023 &err,
1024 child_ctx.total_cost_usd,
1025 total_duration,
1026 child_ctx.output().cloned(),
1027 )
1028 .await;
1029 if config.allow_failure {
1030 return Ok(ChildOutcome::Finished(
1031 SubWorkflowOutput::new(
1032 child_run_id,
1033 &config.workflow_name,
1034 status,
1035 child_ctx.total_cost_usd,
1036 total_duration,
1037 )
1038 .with_output(child_ctx.output().cloned())
1039 .with_error(err.to_string()),
1040 true,
1041 ));
1042 }
1043 Err(err)
1044 }
1045 }
1046 }
1047
1048 /// Create the child run of a sub-workflow step and return its id.
1049 ///
1050 /// The child inherits the parent labels and author, and is linked to its
1051 /// parent and to the root of the chain by two labels, so a suspended child
1052 /// can be found and resumed like a top-level run.
1053 async fn create_child_run(
1054 &self,
1055 config: &WorkflowStepConfig,
1056 priority: i16,
1057 handler: &dyn WorkflowHandler,
1058 ) -> Result<Uuid, EngineError> {
1059 // Whoever triggered the parent workflow is accountable for its children.
1060 let parent = self.store.get_run(self.run_id).await?;
1061 let (mut labels, parent_author) =
1062 parent.map(|r| (r.labels, r.created_by)).unwrap_or_default();
1063 // Overwritten, never inherited: a grand-child must point at its own
1064 // parent, not at its grand-parent.
1065 labels.insert(PARENT_RUN_ID_LABEL.to_string(), self.run_id.to_string());
1066 labels.insert(LABEL_ROOT_RUN_ID.to_string(), self.root_run_id.to_string());
1067
1068 let child_run = self
1069 .store
1070 .create_run(NewRun {
1071 workflow_name: config.workflow_name.clone(),
1072 trigger: TriggerKind::Workflow,
1073 payload: config.payload.clone(),
1074 max_retries: 0,
1075 handler_version: None,
1076 labels,
1077 scheduled_at: None,
1078 created_by: parent_author,
1079 idempotency_key: None,
1080 concurrency_key: config.concurrency_key.clone(),
1081 priority,
1082 // A child runs inside its parent's slot: it never consumes a
1083 // concurrency group slot of its own.
1084 concurrency_limits: Vec::new(),
1085 // The child shares the parent's cap; it does not get its own budget.
1086 max_cost_usd: self.max_cost_usd,
1087 // Recorded for display: the child runs on its parent's worker.
1088 worker_tags: normalize_worker_tags(handler.required_worker_tags()),
1089 })
1090 .await?
1091 .into_run();
1092
1093 info!(
1094 parent_run_id = %self.run_id,
1095 child_run_id = %child_run.id,
1096 workflow = %config.workflow_name,
1097 "child run created"
1098 );
1099 Ok(child_run.id)
1100 }
1101
1102 /// Persist the suspension of a child run, with no event: the root run
1103 /// publishes the suspension once the whole chain is suspended.
1104 ///
1105 /// A direct suspension is persisted like a top-level run's (a delay, a
1106 /// capacity wait or a signal deadline arms `scheduled_at`; a capacity wait
1107 /// also records its provider kind). A child suspended because of its own
1108 /// child gets no `scheduled_at`: only the deepest run owns the
1109 /// wake-up, so the chain is never resumed twice.
1110 async fn suspend_child_run(
1111 &self,
1112 child_run_id: Uuid,
1113 err: &EngineError,
1114 cost_usd: Decimal,
1115 duration_ms: u64,
1116 ) -> Result<(), EngineError> {
1117 let totals = RunUpdate {
1118 cost_usd: Some(cost_usd),
1119 duration_ms: Some(duration_ms),
1120 ..RunUpdate::default()
1121 };
1122
1123 let update = match err {
1124 EngineError::DelaySleeping { wake_at, .. } => RunUpdate {
1125 status: Some(RunStatus::Sleeping),
1126 scheduled_at: Some(*wake_at),
1127 ..totals
1128 },
1129 EngineError::CapacitySleeping { kind, wake_at, .. } => RunUpdate {
1130 status: Some(RunStatus::Sleeping),
1131 scheduled_at: Some(*wake_at),
1132 capacity_wait_kind: Some(ProviderKind::new(kind.as_str())),
1133 ..totals
1134 },
1135 EngineError::SignalWaiting {
1136 step_id,
1137 deadline_at,
1138 ..
1139 } => {
1140 // Atomic with the step lock, like a top-level run.
1141 self.store
1142 .suspend_run_on_signal(child_run_id, *step_id, *deadline_at)
1143 .await?;
1144 totals
1145 }
1146 EngineError::ChildSuspended { cause, .. } => RunUpdate {
1147 status: Some(cause.suspension_status()),
1148 ..totals
1149 },
1150 _ => RunUpdate {
1151 status: Some(RunStatus::AwaitingApproval),
1152 ..totals
1153 },
1154 };
1155
1156 self.store.update_run(child_run_id, update).await?;
1157 Ok(())
1158 }
1159
1160 /// Mark a child run failed after its handler (or its suspension) failed.
1161 ///
1162 /// Best effort: the original error is what the parent reports.
1163 async fn fail_child_run(
1164 &self,
1165 child_run_id: Uuid,
1166 status: RunStatus,
1167 err: &EngineError,
1168 cost_usd: Decimal,
1169 duration_ms: u64,
1170 output: Option<Value>,
1171 ) {
1172 if let Err(store_err) = self
1173 .store
1174 .update_run(
1175 child_run_id,
1176 RunUpdate {
1177 status: Some(status),
1178 error: Some(err.to_string()),
1179 cost_usd: Some(cost_usd),
1180 duration_ms: Some(duration_ms),
1181 completed_at: Some(Utc::now()),
1182 output,
1183 ..RunUpdate::default()
1184 },
1185 )
1186 .await
1187 {
1188 error!(
1189 child_run_id = %child_run_id,
1190 store_error = %store_err,
1191 "failed to persist child run failure"
1192 );
1193 }
1194 }
1195}