ironflow_engine/executor/
mod.rs1mod agent;
8mod http;
9mod shell;
10
11use std::future::Future;
12use std::sync::Arc;
13
14use rust_decimal::Decimal;
15use serde_json::Value;
16use uuid::Uuid;
17
18use ironflow_core::provider::{AgentProvider, DebugMessage};
19
20use crate::config::StepConfig;
21use crate::error::EngineError;
22use crate::log_sender::StepLogSender;
23
24pub use agent::AgentExecutor;
25pub use http::HttpExecutor;
26pub use shell::ShellExecutor;
27
28#[derive(Debug, Clone)]
30pub struct StepOutput {
31 pub output: Value,
38 pub duration_ms: u64,
40 pub cost_usd: Decimal,
42 pub input_tokens: Option<u64>,
44 pub output_tokens: Option<u64>,
46 pub model: Option<String>,
48 pub debug_messages: Option<Vec<DebugMessage>>,
50}
51
52impl StepOutput {
53 pub fn debug_messages_json(&self) -> Option<Value> {
57 self.debug_messages
58 .as_ref()
59 .and_then(|msgs| serde_json::to_value(msgs).ok())
60 }
61}
62
63#[derive(Debug, Clone)]
65pub struct ParallelStepResult {
66 pub name: String,
68 pub output: StepOutput,
70 pub step_id: Uuid,
72}
73
74pub trait StepExecutor: Send + Sync {
79 fn execute(
85 &self,
86 provider: &Arc<dyn AgentProvider>,
87 ) -> impl Future<Output = Result<StepOutput, EngineError>> + Send;
88}
89
90#[tracing::instrument(name = "executor.execute_step", skip_all, fields(step.kind))]
116pub async fn execute_step_config(
117 config: &StepConfig,
118 provider: &Arc<dyn AgentProvider>,
119 log_sender: Option<StepLogSender>,
120) -> Result<StepOutput, EngineError> {
121 let kind = match config {
122 StepConfig::Shell(_) => "shell",
123 StepConfig::Http(_) => "http",
124 StepConfig::Agent(_) => "agent",
125 StepConfig::Workflow(_) => "workflow",
126 StepConfig::Approval(_) => "approval",
127 };
128 tracing::Span::current().record("step.kind", kind);
129
130 let result = match config {
131 StepConfig::Shell(cfg) => {
132 let mut executor = ShellExecutor::new(cfg);
133 if let Some(sender) = log_sender {
134 executor = executor.with_log_sender(sender);
135 }
136 executor.execute(provider).await
137 }
138 StepConfig::Http(cfg) => HttpExecutor::new(cfg).execute(provider).await,
139 StepConfig::Agent(cfg) => {
140 let mut executor = AgentExecutor::new(cfg);
141 if let Some(sender) = log_sender {
142 executor = executor.with_log_sender(sender);
143 }
144 executor.execute(provider).await
145 }
146 StepConfig::Workflow(_) => Err(EngineError::StepConfig(
147 "workflow steps are executed by WorkflowContext, not the executor".to_string(),
148 )),
149 StepConfig::Approval(_) => Err(EngineError::StepConfig(
150 "approval steps are executed by WorkflowContext, not the executor".to_string(),
151 )),
152 };
153
154 #[cfg(feature = "prometheus")]
155 {
156 use ironflow_core::metric_names::{
157 STATUS_ERROR, STATUS_SUCCESS, STEP_DURATION_SECONDS, STEPS_TOTAL,
158 };
159 use metrics::{counter, histogram};
160 let status = if result.is_ok() {
161 STATUS_SUCCESS
162 } else {
163 STATUS_ERROR
164 };
165 counter!(STEPS_TOTAL, "kind" => kind, "status" => status).increment(1);
166 if let Ok(ref output) = result {
167 histogram!(STEP_DURATION_SECONDS, "kind" => kind)
168 .record(output.duration_ms as f64 / 1000.0);
169 }
170 }
171
172 result
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use ironflow_core::provider::DebugMessage;
179 use serde_json::json;
180
181 #[test]
182 fn step_output_with_no_debug_messages_returns_none() {
183 let output = StepOutput {
184 output: json!({"result": "ok"}),
185 duration_ms: 100,
186 cost_usd: rust_decimal::Decimal::ZERO,
187 input_tokens: None,
188 output_tokens: None,
189 model: None,
190 debug_messages: None,
191 };
192
193 assert_eq!(output.debug_messages_json(), None);
194 }
195
196 #[test]
197 fn step_output_with_empty_debug_messages_returns_some_empty_array() {
198 let output = StepOutput {
199 output: json!({"result": "ok"}),
200 duration_ms: 100,
201 cost_usd: rust_decimal::Decimal::ZERO,
202 input_tokens: None,
203 output_tokens: None,
204 model: None,
205 debug_messages: Some(Vec::new()),
206 };
207
208 let json_val = output.debug_messages_json();
209 assert!(json_val.is_some());
210 let arr = json_val.unwrap();
211 assert!(arr.is_array());
212 assert_eq!(arr.as_array().unwrap().len(), 0);
213 }
214
215 #[test]
216 fn step_output_debug_messages_json_serializes_messages() {
217 let json_msgs = json!([
218 {
219 "text": "Hello",
220 "thinking": null,
221 "thinking_redacted": false,
222 "tool_calls": [],
223 "tool_results": [],
224 "stop_reason": "end_turn",
225 "input_tokens": 10,
226 "output_tokens": 20
227 },
228 {
229 "text": "Hi there",
230 "thinking": null,
231 "thinking_redacted": false,
232 "tool_calls": [],
233 "tool_results": [],
234 "stop_reason": "end_turn",
235 "input_tokens": 15,
236 "output_tokens": 25
237 }
238 ]);
239
240 let messages: Vec<DebugMessage> =
241 serde_json::from_value(json_msgs.clone()).expect("deserialize debug messages");
242
243 let output = StepOutput {
244 output: json!({"result": "ok"}),
245 duration_ms: 100,
246 cost_usd: rust_decimal::Decimal::ZERO,
247 input_tokens: None,
248 output_tokens: None,
249 model: None,
250 debug_messages: Some(messages),
251 };
252
253 let json_val = output.debug_messages_json();
254 assert!(json_val.is_some());
255
256 let arr = json_val.unwrap();
257 assert!(arr.is_array());
258 let messages_array = arr.as_array().unwrap();
259 assert_eq!(messages_array.len(), 2);
260 assert_eq!(messages_array[0]["text"], "Hello");
261 assert_eq!(messages_array[1]["text"], "Hi there");
262 }
263
264 #[test]
265 fn step_output_contains_all_metrics() {
266 let output = StepOutput {
267 output: json!({"data": "test"}),
268 duration_ms: 5000,
269 cost_usd: rust_decimal::Decimal::new(123, 2),
270 input_tokens: Some(100),
271 output_tokens: Some(200),
272 model: Some("claude-sonnet".to_string()),
273 debug_messages: None,
274 };
275
276 assert_eq!(output.duration_ms, 5000);
277 assert_eq!(output.cost_usd, rust_decimal::Decimal::new(123, 2));
278 assert_eq!(output.input_tokens, Some(100));
279 assert_eq!(output.output_tokens, Some(200));
280 assert_eq!(output.model, Some("claude-sonnet".to_string()));
281 }
282
283 #[test]
284 fn step_output_default_tokens_and_model_are_none() {
285 let output = StepOutput {
286 output: json!({}),
287 duration_ms: 0,
288 cost_usd: rust_decimal::Decimal::ZERO,
289 input_tokens: None,
290 output_tokens: None,
291 model: None,
292 debug_messages: None,
293 };
294
295 assert!(output.input_tokens.is_none());
296 assert!(output.output_tokens.is_none());
297 assert!(output.model.is_none());
298 }
299
300 #[test]
301 fn parallel_step_result_contains_step_metadata() {
302 let step_id = uuid::Uuid::now_v7();
303 let output = StepOutput {
304 output: json!({"done": true}),
305 duration_ms: 1000,
306 cost_usd: rust_decimal::Decimal::ZERO,
307 input_tokens: None,
308 output_tokens: None,
309 model: None,
310 debug_messages: None,
311 };
312
313 let result = ParallelStepResult {
314 name: "build".to_string(),
315 output,
316 step_id,
317 };
318
319 assert_eq!(result.name, "build");
320 assert_eq!(result.step_id, step_id);
321 assert_eq!(result.output.duration_ms, 1000);
322 }
323
324 #[test]
325 fn step_output_serializes_complex_json_output() {
326 let complex_output = json!({
327 "status": "success",
328 "data": {
329 "items": [1, 2, 3],
330 "nested": {
331 "key": "value"
332 }
333 }
334 });
335
336 let output = StepOutput {
337 output: complex_output.clone(),
338 duration_ms: 100,
339 cost_usd: rust_decimal::Decimal::ZERO,
340 input_tokens: None,
341 output_tokens: None,
342 model: None,
343 debug_messages: None,
344 };
345
346 assert_eq!(output.output, complex_output);
347 assert_eq!(output.output["status"], "success");
348 assert_eq!(output.output["data"]["items"][0], 1);
349 assert_eq!(output.output["data"]["nested"]["key"], "value");
350 }
351}