Skip to main content

ironflow_engine/context/steps/
decision.rs

1//! Typed machine-decision step for [`WorkflowContext`].
2//!
3//! Holds the public [`decision`](WorkflowContext::decision) entry point and the
4//! replay, execute and escalate paths it dispatches to. As a descendant module
5//! of `context`, it can access `WorkflowContext`'s private fields.
6//!
7//! The decision step is a hybrid of an agent step (it calls a provider, costs
8//! money, and stores typed output) and an approval gate (a low-confidence answer
9//! suspends the run and replays its stored answers on resume).
10
11use chrono::Utc;
12use serde_json::{Value, from_value, to_value};
13use tracing::info;
14use uuid::Uuid;
15
16use ironflow_core::decision::DecisionOutput;
17use ironflow_store::models::{NewStep, StepKind, StepStatus, StepUpdate, step_trace_id};
18
19use crate::config::DecisionConfig;
20use crate::context::WorkflowContext;
21use crate::error::EngineError;
22use crate::executor::{DecisionExecution, StepOutput, StepResult, execute_decision};
23use crate::notify::{
24    WorkflowApprovalRequiredEvent, WorkflowEvent, WorkflowStepCompletedEvent,
25    WorkflowStepStartedEvent,
26};
27
28impl WorkflowContext {
29    /// Execute a typed machine-decision step (System One / Jev).
30    ///
31    /// See [`DecisionConfig`]. Returns a
32    /// [`DecisionOutput`] whose answers are accessed by name. When
33    /// `escalate_below` is set and any answer falls below it, the run suspends
34    /// with [`EngineError::ApprovalRequired`] and replays the stored answers on
35    /// resume without re-calling the provider.
36    ///
37    /// # Errors
38    ///
39    /// [`EngineError::NoDecisionProvider`], [`EngineError::ApprovalRequired`], or
40    /// [`EngineError::Operation`].
41    pub async fn decision(
42        &mut self,
43        name: &str,
44        config: DecisionConfig,
45    ) -> Result<DecisionOutput, EngineError> {
46        if let Some(output) = self.decision_replay(name, &config).await? {
47            return Ok(output);
48        }
49        self.decision_execute(name, config).await
50    }
51
52    /// Replay a decision step recorded in this attempt, if any.
53    ///
54    /// Returns `Ok(Some(output))` when the step at the current position was
55    /// already decided (its answers are returned as-is, without re-calling the
56    /// provider), advancing the position. Returns `Ok(None)` when there is
57    /// nothing to replay, leaving the position untouched for a fresh execution.
58    async fn decision_replay(
59        &mut self,
60        name: &str,
61        _config: &DecisionConfig,
62    ) -> Result<Option<DecisionOutput>, EngineError> {
63        let position = self.position;
64
65        let Some(existing) = self.replay_steps.get(&position).cloned() else {
66            return Ok(None);
67        };
68        if existing.kind != StepKind::Decision {
69            return Ok(None);
70        }
71
72        self.position += 1;
73
74        let stored: DecisionOutput = existing
75            .output
76            .clone()
77            .ok_or_else(|| {
78                EngineError::StepConfig(format!(
79                    "decision step '{name}' has no stored output to replay"
80                ))
81            })
82            .and_then(|v| from_value(v).map_err(EngineError::from))?;
83
84        // An escalated decision suspended in `AwaitingApproval`; the handler only
85        // re-runs on an approved resume, so mark it completed and continue.
86        if existing.status.state == StepStatus::AwaitingApproval {
87            self.store
88                .update_step(
89                    existing.id,
90                    StepUpdate {
91                        status: Some(StepStatus::Completed),
92                        completed_at: Some(Utc::now()),
93                        ..StepUpdate::default()
94                    },
95                )
96                .await?;
97            info!(
98                run_id = %self.run_id,
99                step = %name,
100                position,
101                "decision step replayed (approved after escalation)"
102            );
103        } else {
104            info!(
105                run_id = %self.run_id,
106                step = %name,
107                position,
108                "decision step replayed from previous execution"
109            );
110        }
111
112        // Do not re-add cost/duration here. A replay only happens on a resume
113        // within the same attempt, where `carry_over_run_totals` has already
114        // seeded `total_cost_usd`/`total_duration_ms` from the run totals the
115        // suspend snapshot persisted -- totals that already include this step.
116        // Adding them again would double-count the escalated decision.
117        self.last_step_ids = vec![existing.id];
118        Ok(Some(stored))
119    }
120
121    /// Execute a fresh decision step: call the provider, persist the answers, and
122    /// either complete or escalate to a human approval gate.
123    async fn decision_execute(
124        &mut self,
125        name: &str,
126        config: DecisionConfig,
127    ) -> Result<DecisionOutput, EngineError> {
128        self.check_guard_timeout()?;
129
130        let position = self.position;
131        self.position += 1;
132
133        let provider =
134            self.decision_provider
135                .clone()
136                .ok_or_else(|| EngineError::NoDecisionProvider {
137                    step: name.to_string(),
138                })?;
139
140        let trace_id = step_trace_id(self.run_id, name, position);
141        let step = self
142            .store
143            .create_step(NewStep {
144                run_id: self.run_id,
145                trace_id,
146                name: name.to_string(),
147                kind: StepKind::Decision,
148                position,
149                input: Some(to_value(&config)?),
150                is_error_handler: false,
151            })
152            .await?;
153        self.start_step(step.id, Utc::now()).await?;
154
155        if let Some(ref bus) = self.event_bus {
156            bus.publish(
157                self.run_id,
158                WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
159                    step_name: name.to_string(),
160                    step_index: position,
161                    timestamp: Utc::now(),
162                }),
163            );
164        }
165
166        let execution = match execute_decision(&provider, &config).await {
167            Ok(execution) => execution,
168            Err(err) => {
169                self.fail_step(step.id, &err).await;
170                return Err(err);
171            }
172        };
173
174        // Impute cost and duration to the run, like an agent step.
175        self.total_cost_usd += execution.cost_usd;
176        self.total_duration_ms += execution.duration_ms;
177
178        let output_value = to_value(&execution.output)?;
179
180        let escalated = config
181            .escalate_below
182            .zip(execution.output.min_confidence())
183            .map(|(threshold, min)| min < threshold)
184            .unwrap_or(false);
185
186        if escalated {
187            return self
188                .decision_escalate(name, position, step.id, &config, &execution, output_value)
189                .await;
190        }
191
192        let step_output = StepOutput {
193            output: output_value.clone(),
194            duration_ms: execution.duration_ms,
195            cost_usd: execution.cost_usd,
196            input_tokens: Some(execution.input_tokens),
197            output_tokens: Some(execution.output_tokens),
198            model: execution.output.model.as_ref().map(ToString::to_string),
199            debug_messages: None,
200        };
201
202        let completed_at = Utc::now();
203        self.store
204            .update_step(
205                step.id,
206                StepUpdate {
207                    status: Some(StepStatus::Completed),
208                    output: Some(output_value),
209                    duration_ms: Some(execution.duration_ms),
210                    cost_usd: Some(execution.cost_usd),
211                    input_tokens: Some(execution.input_tokens),
212                    output_tokens: Some(execution.output_tokens),
213                    completed_at: Some(completed_at),
214                    ..StepUpdate::default()
215                },
216            )
217            .await?;
218
219        self.step_results
220            .push(StepResult::from_success(trace_id, name, &step_output));
221        self.persist_progress().await;
222        self.last_step_ids = vec![step.id];
223
224        info!(
225            run_id = %self.run_id,
226            step = %name,
227            trace_id = %trace_id,
228            cost_usd = %execution.cost_usd,
229            "decision step completed"
230        );
231
232        if let Some(ref bus) = self.event_bus {
233            bus.publish(
234                self.run_id,
235                WorkflowEvent::StepCompleted(WorkflowStepCompletedEvent {
236                    step_name: name.to_string(),
237                    step_index: position,
238                    duration_ms: execution.duration_ms,
239                    output_summary: None,
240                }),
241            );
242        }
243
244        Ok(execution.output)
245    }
246
247    /// Persist an escalated decision, suspend the run, and return
248    /// [`EngineError::ApprovalRequired`].
249    async fn decision_escalate(
250        &mut self,
251        name: &str,
252        position: u32,
253        step_id: Uuid,
254        config: &DecisionConfig,
255        execution: &DecisionExecution,
256        output_value: Value,
257    ) -> Result<DecisionOutput, EngineError> {
258        let threshold = config.escalate_below.unwrap_or_default();
259        let min = execution.output.min_confidence().unwrap_or_default();
260
261        self.store
262            .update_step(
263                step_id,
264                StepUpdate {
265                    status: Some(StepStatus::AwaitingApproval),
266                    output: Some(output_value),
267                    duration_ms: Some(execution.duration_ms),
268                    cost_usd: Some(execution.cost_usd),
269                    input_tokens: Some(execution.input_tokens),
270                    output_tokens: Some(execution.output_tokens),
271                    ..StepUpdate::default()
272                },
273            )
274            .await?;
275        self.last_step_ids = vec![step_id];
276
277        info!(
278            run_id = %self.run_id,
279            step = %name,
280            position,
281            confidence = min,
282            threshold,
283            "decision escalated to human approval"
284        );
285
286        if let Some(ref bus) = self.event_bus {
287            bus.publish(
288                self.run_id,
289                WorkflowEvent::ApprovalRequired(WorkflowApprovalRequiredEvent {
290                    step_name: name.to_string(),
291                    step_index: position,
292                    approval_id: step_id,
293                }),
294            );
295        }
296
297        Err(EngineError::ApprovalRequired {
298            run_id: self.run_id,
299            step_id,
300            message: format!(
301                "decision '{name}' escalated: confidence {min:.3} below threshold {threshold:.3}"
302            ),
303        })
304    }
305}