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, 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::WorkflowStepConfig;
22use crate::context::WorkflowContext;
23use crate::context::lifecycle::check_replay_identity;
24use crate::error::EngineError;
25use crate::executor::SubWorkflowOutput;
26use crate::guard::WorkflowRejection;
27use crate::handler::{TypedWorkflow, WorkflowHandler};
28use crate::plan::{SharedPlanRecorder, lock_plan};
29
30impl WorkflowContext {
31    /// Execute a sub-workflow step.
32    ///
33    /// Creates a child run of `handler` whose payload is `input`, executes it
34    /// with its own steps and lifecycle, and returns its run ID and aggregated
35    /// metrics. The child declares its input type through [`TypedWorkflow`],
36    /// so only a `W::Input` is accepted.
37    ///
38    /// Requires the context to be created with
39    /// `with_handler_resolver`.
40    ///
41    /// # Errors
42    ///
43    /// Returns [`EngineError::InvalidWorkflow`] if no handler is registered
44    /// with the given name, or if no handler resolver is available, and
45    /// [`EngineError::Serialization`] if `input` cannot be serialized. Returns
46    /// [`EngineError::ReplayDivergence`] when the step recorded at this
47    /// position has a different name or kind.
48    ///
49    /// # Examples
50    ///
51    /// ```no_run
52    /// use ironflow_engine::context::WorkflowContext;
53    /// use ironflow_engine::error::EngineError;
54    /// use ironflow_engine::handler::{HandlerFuture, TypedWorkflow, WorkflowHandler};
55    /// use serde::{Deserialize, Serialize};
56    ///
57    /// #[derive(Serialize, Deserialize)]
58    /// struct CollectInput {
59    ///     scope: String,
60    /// }
61    ///
62    /// struct Collect;
63    ///
64    /// impl WorkflowHandler for Collect {
65    ///     fn name(&self) -> &str { "collect" }
66    ///     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
67    ///         Box::pin(async move { Ok(()) })
68    ///     }
69    /// }
70    ///
71    /// impl TypedWorkflow for Collect {
72    ///     type Input = CollectInput;
73    /// }
74    ///
75    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
76    /// let child = ctx.workflow(&Collect, CollectInput { scope: "system".to_string() }).await?;
77    /// let steps = ctx.store().list_steps(child.run_id()).await?;
78    /// # Ok(())
79    /// # }
80    /// ```
81    ///
82    /// Any other input type is a compile error:
83    ///
84    /// ```compile_fail,E0308
85    /// # use ironflow_engine::context::WorkflowContext;
86    /// # use ironflow_engine::error::EngineError;
87    /// # use ironflow_engine::handler::{HandlerFuture, TypedWorkflow, WorkflowHandler};
88    /// # #[derive(serde::Serialize, serde::Deserialize)]
89    /// # struct CollectInput { scope: String }
90    /// # struct Collect;
91    /// # impl WorkflowHandler for Collect {
92    /// #     fn name(&self) -> &str { "collect" }
93    /// #     fn execute<'a>(&'a self, _ctx: &'a mut WorkflowContext) -> HandlerFuture<'a> {
94    /// #         Box::pin(async move { Ok(()) })
95    /// #     }
96    /// # }
97    /// # impl TypedWorkflow for Collect { type Input = CollectInput; }
98    /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
99    /// ctx.workflow(&Collect, serde_json::json!({"scope": "system"})).await?;
100    /// # Ok(())
101    /// # }
102    /// ```
103    pub async fn workflow<W: TypedWorkflow>(
104        &mut self,
105        handler: &W,
106        input: W::Input,
107    ) -> Result<SubWorkflowOutput, EngineError> {
108        let payload = to_value(&input)?;
109        self.run_sub_workflow(handler, payload).await
110    }
111
112    /// Execute a sub-workflow step whose child is only known at run time.
113    ///
114    /// Same as [`workflow`](Self::workflow), without the compile-time check of
115    /// the payload: the child must deserialize `payload` itself.
116    ///
117    /// # Errors
118    ///
119    /// Same as [`workflow`](Self::workflow).
120    ///
121    /// # Examples
122    ///
123    /// ```no_run
124    /// use ironflow_engine::context::WorkflowContext;
125    /// use ironflow_engine::error::EngineError;
126    /// use ironflow_engine::handler::WorkflowHandler;
127    /// use serde_json::json;
128    ///
129    /// # #[allow(deprecated)]
130    /// # async fn example(ctx: &mut WorkflowContext, child: &dyn WorkflowHandler) -> Result<(), EngineError> {
131    /// let result = ctx.workflow_dyn(child, json!({"scope": "system"})).await?;
132    /// println!("child run {}", result.run_id());
133    /// # Ok(())
134    /// # }
135    /// ```
136    #[deprecated(
137        note = "implement `TypedWorkflow` on the child and call `workflow`: its payload is then checked at compile time"
138    )]
139    pub async fn workflow_dyn(
140        &mut self,
141        handler: &dyn WorkflowHandler,
142        payload: Value,
143    ) -> Result<SubWorkflowOutput, EngineError> {
144        self.run_sub_workflow(handler, payload).await
145    }
146
147    /// Record, then run or plan, a sub-workflow step.
148    async fn run_sub_workflow(
149        &mut self,
150        handler: &dyn WorkflowHandler,
151        payload: Value,
152    ) -> Result<SubWorkflowOutput, EngineError> {
153        // Plan mode: record the invocation, expand the child handler in the
154        // same recorder, and return a synthetic output. No child run is
155        // created and no step of the child is executed.
156        if let Some(plan) = self.plan().cloned() {
157            return self.plan_sub_workflow(&plan, handler, payload).await;
158        }
159
160        // Guard check: verify limits before creating the step.
161        if let (Some(guard_config), Some(guard_state)) = (&self.guard_config, &self.guard_state) {
162            let state = guard_state
163                .lock()
164                .map_err(|_| WorkflowRejection::GuardUnavailable)?;
165            state.check(guard_config, handler.name())?;
166        }
167
168        let config = WorkflowStepConfig::new(handler.name(), payload);
169        let position = self.position;
170
171        if let Some(existing) = self.replay_steps.get(&position) {
172            check_replay_identity(
173                existing,
174                position,
175                &config.workflow_name,
176                &StepKind::Workflow,
177            )?;
178        }
179
180        self.position += 1;
181
182        let trace_id = step_trace_id(self.run_id, &config.workflow_name, position);
183        let step = self
184            .store
185            .create_step(NewStep {
186                run_id: self.run_id,
187                trace_id,
188                name: config.workflow_name.clone(),
189                kind: StepKind::Workflow,
190                position,
191                input: Some(to_value(&config)?),
192                is_error_handler: false,
193            })
194            .await?;
195
196        self.start_step(step.id, Utc::now()).await?;
197
198        // Record invocation in guard state (fail-closed).
199        if let Some(guard_state) = &self.guard_state {
200            let mut state = guard_state
201                .lock()
202                .map_err(|_| WorkflowRejection::GuardUnavailable)?;
203            state.record_invocation(handler.name());
204        }
205
206        match self.execute_child_workflow(&config).await {
207            Ok((output, child_had_allowed_failure)) => {
208                self.total_cost_usd += output.cost_usd();
209                self.total_duration_ms += output.duration_ms();
210                if child_had_allowed_failure {
211                    self.has_allowed_failure = true;
212                }
213
214                let completed_at = Utc::now();
215                self.store
216                    .update_step(
217                        step.id,
218                        StepUpdate {
219                            status: Some(StepStatus::Completed),
220                            output: Some(to_value(&output)?),
221                            duration_ms: Some(output.duration_ms()),
222                            cost_usd: Some(output.cost_usd()),
223                            completed_at: Some(completed_at),
224                            ..StepUpdate::default()
225                        },
226                    )
227                    .await?;
228
229                info!(
230                    run_id = %self.run_id,
231                    child_workflow = %config.workflow_name,
232                    duration_ms = output.duration_ms(),
233                    "workflow step completed"
234                );
235
236                self.last_step_ids = vec![step.id];
237
238                self.guard_record_return();
239                Ok(output)
240            }
241            Err(err) => {
242                let completed_at = Utc::now();
243                if let Err(store_err) = self
244                    .store
245                    .update_step(
246                        step.id,
247                        StepUpdate {
248                            status: Some(StepStatus::Failed),
249                            error: Some(err.to_string()),
250                            completed_at: Some(completed_at),
251                            ..StepUpdate::default()
252                        },
253                    )
254                    .await
255                {
256                    error!(step_id = %step.id, error = %store_err, "failed to persist step failure");
257                }
258
259                self.guard_record_return();
260                Err(err)
261            }
262        }
263    }
264
265    /// Record a sub-workflow invocation while planning, expanding the child
266    /// handler into the same plan when the depth limit allows it.
267    ///
268    /// The child plans against its own payload and under its own workflow
269    /// name; the parent's payload is restored on the way out.
270    async fn plan_sub_workflow(
271        &mut self,
272        plan: &SharedPlanRecorder,
273        handler: &dyn WorkflowHandler,
274        payload: Value,
275    ) -> Result<SubWorkflowOutput, EngineError> {
276        self.position += 1;
277        let sub_name = handler.name().to_string();
278        // No child run exists while planning: a nil id and zero metrics.
279        let planned = SubWorkflowOutput::new(
280            Uuid::nil(),
281            &sub_name,
282            RunStatus::Completed,
283            Decimal::ZERO,
284            0,
285        );
286
287        {
288            let mut recorder = lock_plan(plan);
289            if !recorder.record(&sub_name, StepKind::Workflow, &self.workflow_name, None) {
290                return Ok(planned);
291            }
292            recorder.set_last(vec![sub_name.clone()]);
293        }
294
295        let expand = lock_plan(plan).enter_workflow();
296        if expand {
297            let previous_payload = lock_plan(plan).swap_payload(payload.clone());
298
299            let mut child = WorkflowContext::new(
300                Uuid::now_v7(),
301                sub_name.clone(),
302                self.store.clone(),
303                self.provider.clone(),
304            );
305            child.handler_resolver = self.handler_resolver.clone();
306            child.set_plan(plan.clone());
307
308            if let Err(err) = handler.execute(&mut child).await {
309                lock_plan(plan).fail(format!(
310                    "sub-workflow {sub_name} could not be planned: {err}"
311                ));
312            }
313
314            let mut recorder = lock_plan(plan);
315            recorder.swap_payload(previous_payload);
316            recorder.leave_workflow();
317        }
318
319        Ok(planned)
320    }
321
322    /// Execute a child workflow and return aggregated output plus whether
323    /// at least one `allow_failure` step failed.
324    async fn execute_child_workflow(
325        &self,
326        config: &WorkflowStepConfig,
327    ) -> Result<(SubWorkflowOutput, bool), EngineError> {
328        let resolver = self.handler_resolver.as_ref().ok_or_else(|| {
329            EngineError::InvalidWorkflow(
330                "sub-workflow requires a handler resolver (use Engine to execute)".to_string(),
331            )
332        })?;
333
334        let handler = resolver(&config.workflow_name).ok_or_else(|| {
335            EngineError::InvalidWorkflow(format!("no handler registered: {}", config.workflow_name))
336        })?;
337
338        // A child run inherits both the parent labels and the parent author:
339        // whoever triggered the parent workflow is accountable for its children.
340        let parent = self.store.get_run(self.run_id).await?;
341        let (parent_labels, parent_author) =
342            parent.map(|r| (r.labels, r.created_by)).unwrap_or_default();
343
344        let child_run = self
345            .store
346            .create_run(NewRun {
347                workflow_name: config.workflow_name.clone(),
348                trigger: TriggerKind::Workflow,
349                payload: config.payload.clone(),
350                max_retries: 0,
351                handler_version: None,
352                labels: parent_labels,
353                scheduled_at: None,
354                created_by: parent_author,
355                idempotency_key: None,
356                // The child shares the parent's cap; it does not get its own budget.
357                max_cost_usd: self.max_cost_usd,
358            })
359            .await?
360            .into_run();
361
362        let child_run_id = child_run.id;
363        info!(
364            parent_run_id = %self.run_id,
365            child_run_id = %child_run_id,
366            workflow = %config.workflow_name,
367            "child run created"
368        );
369
370        self.store
371            .update_run_status(child_run_id, RunStatus::Running)
372            .await?;
373
374        let run_start = Instant::now();
375        let mut child_ctx = WorkflowContext {
376            run_id: child_run_id,
377            root_run_id: self.root_run_id,
378            workflow_name: config.workflow_name.clone(),
379            store: self.store.clone(),
380            provider: self.provider.clone(),
381            decision_provider: self.decision_provider.clone(),
382            handler_resolver: self.handler_resolver.clone(),
383            position: 0,
384            last_step_ids: Vec::new(),
385            total_cost_usd: Decimal::ZERO,
386            total_duration_ms: 0,
387            max_cost_usd: self.max_cost_usd,
388            // Everything the parent chain already spent counts against the
389            // shared cap, so the child cannot restart the budget from zero.
390            inherited_cost_usd: self.charged_cost_usd(),
391            replay_steps: HashMap::new(),
392            replay_wave_steps: HashMap::new(),
393            granted_approvals: HashMap::new(),
394            answered_inputs: HashMap::new(),
395            // A child run is created fresh here; it is never itself retried.
396            attempt: 1,
397            carried_duration_ms: 0,
398            log_sender: self.log_sender.clone(),
399            // A child shares the storage backend but not the parent's artifacts:
400            // input lookups are scoped to the child's own run.
401            artifact_sink: self.artifact_sink.clone(),
402            has_allowed_failure: false,
403            error_handlers: Vec::new(),
404            guard_state: self.guard_state.clone(),
405            guard_config: self.guard_config.clone(),
406            step_results: Vec::new(),
407            event_bus: self.event_bus.clone(),
408            // A child run is mocked exactly like its parent.
409            interceptor: self.interceptor.clone(),
410            trace_context: self.trace_context.child(),
411            operation_ctx: None,
412            plan: None,
413        };
414
415        let result = handler.execute(&mut child_ctx).await;
416        let total_duration = run_start.elapsed().as_millis() as u64;
417        let completed_at = Utc::now();
418
419        match result {
420            Ok(()) => {
421                let child_status = if child_ctx.has_allowed_failure {
422                    RunStatus::Warning
423                } else {
424                    RunStatus::Completed
425                };
426                self.store
427                    .update_run(
428                        child_run_id,
429                        RunUpdate {
430                            status: Some(child_status),
431                            cost_usd: Some(child_ctx.total_cost_usd),
432                            duration_ms: Some(total_duration),
433                            completed_at: Some(completed_at),
434                            ..RunUpdate::default()
435                        },
436                    )
437                    .await?;
438
439                let child_had_allowed_failure = child_ctx.has_allowed_failure;
440                Ok((
441                    SubWorkflowOutput::new(
442                        child_run_id,
443                        &config.workflow_name,
444                        child_status,
445                        child_ctx.total_cost_usd,
446                        total_duration,
447                    ),
448                    child_had_allowed_failure,
449                ))
450            }
451            Err(err) => {
452                if let Err(store_err) = self
453                    .store
454                    .update_run(
455                        child_run_id,
456                        RunUpdate {
457                            status: Some(RunStatus::Failed),
458                            error: Some(err.to_string()),
459                            cost_usd: Some(child_ctx.total_cost_usd),
460                            duration_ms: Some(total_duration),
461                            completed_at: Some(completed_at),
462                            ..RunUpdate::default()
463                        },
464                    )
465                    .await
466                {
467                    error!(
468                        child_run_id = %child_run_id,
469                        store_error = %store_err,
470                        "failed to persist child run failure"
471                    );
472                }
473
474                Err(err)
475            }
476        }
477    }
478}