ironflow_engine/context/steps/
decision.rs1use 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 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 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 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 self.last_step_ids = vec![existing.id];
118 Ok(Some(stored))
119 }
120
121 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 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 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}