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}