1mod agent;
8mod decision;
9mod http;
10mod shell;
11
12use std::future::Future;
13use std::sync::Arc;
14
15use rust_decimal::Decimal;
16use serde::de::DeserializeOwned;
17use serde_json::{Value, from_value};
18use uuid::Uuid;
19
20use ironflow_core::provider::{AgentProvider, DebugMessage};
21use ironflow_store::entities::StepStatus;
22
23use crate::config::StepConfig;
24use crate::error::EngineError;
25use crate::log_sender::StepLogSender;
26
27pub use agent::AgentExecutor;
28pub use decision::{DecisionExecution, execute_decision};
29pub use http::HttpExecutor;
30pub use shell::ShellExecutor;
31
32#[derive(Debug, Clone)]
34pub struct StepOutput {
35 pub output: Value,
42 pub duration_ms: u64,
44 pub cost_usd: Decimal,
46 pub input_tokens: Option<u64>,
48 pub output_tokens: Option<u64>,
50 pub model: Option<String>,
52 pub debug_messages: Option<Vec<DebugMessage>>,
54}
55
56impl StepOutput {
57 pub fn debug_messages_json(&self) -> Option<Value> {
61 self.debug_messages
62 .as_ref()
63 .and_then(|msgs| serde_json::to_value(msgs).ok())
64 }
65
66 pub fn exit_code(&self) -> Option<i64> {
89 self.output.get("exit_code").and_then(Value::as_i64)
90 }
91
92 pub fn stdout(&self) -> &str {
113 self.output
114 .get("stdout")
115 .and_then(Value::as_str)
116 .unwrap_or_default()
117 }
118
119 pub fn stderr(&self) -> &str {
140 self.output
141 .get("stderr")
142 .and_then(Value::as_str)
143 .unwrap_or_default()
144 }
145
146 pub fn status(&self) -> Option<u16> {
169 self.output
170 .get("status")
171 .and_then(Value::as_u64)
172 .and_then(|s| u16::try_from(s).ok())
173 }
174
175 pub fn body(&self) -> &str {
196 self.output
197 .get("body")
198 .and_then(Value::as_str)
199 .unwrap_or_default()
200 }
201
202 pub fn is_success(&self) -> bool {
233 if let Some(code) = self.exit_code() {
234 return code == 0;
235 }
236 if let Some(status) = self.status() {
237 return (200..300).contains(&status);
238 }
239 false
240 }
241
242 pub fn json<T: DeserializeOwned>(&self) -> Result<T, EngineError> {
278 from_value(self.output.clone()).map_err(EngineError::Serialization)
279 }
280}
281
282#[derive(Debug, Clone)]
284pub struct ParallelStepResult {
285 pub name: String,
287 pub output: StepOutput,
289 pub step_id: Uuid,
291}
292
293#[derive(Debug, Clone, serde::Serialize)]
321pub struct StepResult {
322 pub trace_id: Uuid,
324 pub name: String,
326 pub status: StepStatus,
328 pub duration_ms: u64,
330 pub cost_usd: Decimal,
332 pub input_tokens: Option<u64>,
334 pub output_tokens: Option<u64>,
336 pub error: Option<String>,
338 pub output_summary: Option<String>,
340}
341
342const OUTPUT_SUMMARY_MAX_LEN: usize = 500;
344
345impl StepResult {
346 pub fn from_success(trace_id: Uuid, name: &str, output: &StepOutput) -> Self {
348 Self {
349 trace_id,
350 name: name.to_string(),
351 status: StepStatus::Completed,
352 duration_ms: output.duration_ms,
353 cost_usd: output.cost_usd,
354 input_tokens: output.input_tokens,
355 output_tokens: output.output_tokens,
356 error: None,
357 output_summary: summarize_output(&output.output),
358 }
359 }
360
361 pub fn from_failure(
363 trace_id: Uuid,
364 name: &str,
365 error: &str,
366 duration_ms: u64,
367 cost_usd: Decimal,
368 ) -> Self {
369 Self {
370 trace_id,
371 name: name.to_string(),
372 status: StepStatus::Failed,
373 duration_ms,
374 cost_usd,
375 input_tokens: None,
376 output_tokens: None,
377 error: Some(error.to_string()),
378 output_summary: None,
379 }
380 }
381}
382
383fn summarize_output(value: &Value) -> Option<String> {
384 let raw = value.to_string();
385 match raw.char_indices().nth(OUTPUT_SUMMARY_MAX_LEN) {
386 None => Some(raw),
387 Some((byte_idx, _)) => Some(raw[..byte_idx].to_string()),
388 }
389}
390
391pub trait StepExecutor: Send + Sync {
396 fn execute(
402 &self,
403 provider: &Arc<dyn AgentProvider>,
404 ) -> impl Future<Output = Result<StepOutput, EngineError>> + Send;
405}
406
407#[tracing::instrument(name = "executor.execute_step", skip_all, fields(step.kind))]
433pub async fn execute_step_config(
434 config: &StepConfig,
435 provider: &Arc<dyn AgentProvider>,
436 log_sender: Option<StepLogSender>,
437) -> Result<StepOutput, EngineError> {
438 let kind = match config {
439 StepConfig::Shell(_) => "shell",
440 StepConfig::Http(_) => "http",
441 StepConfig::Agent(_) => "agent",
442 StepConfig::Workflow(_) => "workflow",
443 StepConfig::Approval(_) => "approval",
444 StepConfig::Decision(_) => "decision",
445 StepConfig::Delay(_) => "delay",
446 };
447 tracing::Span::current().record("step.kind", kind);
448
449 let result = match config {
450 StepConfig::Shell(cfg) => {
451 let mut executor = ShellExecutor::new(cfg);
452 if let Some(sender) = log_sender {
453 executor = executor.with_log_sender(sender);
454 }
455 executor.execute(provider).await
456 }
457 StepConfig::Http(cfg) => HttpExecutor::new(cfg).execute(provider).await,
458 StepConfig::Agent(cfg) => {
459 let mut executor = AgentExecutor::new(cfg);
460 if let Some(sender) = log_sender {
461 executor = executor.with_log_sender(sender);
462 }
463 executor.execute(provider).await
464 }
465 StepConfig::Workflow(_) => Err(EngineError::StepConfig(
466 "workflow steps are executed by WorkflowContext, not the executor".to_string(),
467 )),
468 StepConfig::Approval(_) => Err(EngineError::StepConfig(
469 "approval steps are executed by WorkflowContext, not the executor".to_string(),
470 )),
471 StepConfig::Decision(_) => Err(EngineError::StepConfig(
472 "decision steps are executed by WorkflowContext, not the executor".to_string(),
473 )),
474 StepConfig::Delay(_) => Err(EngineError::StepConfig(
475 "delay steps are executed by WorkflowContext, not the executor".to_string(),
476 )),
477 };
478
479 #[cfg(feature = "prometheus")]
480 {
481 use ironflow_core::metric_names::{
482 STATUS_ERROR, STATUS_SUCCESS, STEP_DURATION_SECONDS, STEPS_TOTAL,
483 };
484 use metrics::{counter, histogram};
485 let status = if result.is_ok() {
486 STATUS_SUCCESS
487 } else {
488 STATUS_ERROR
489 };
490 counter!(STEPS_TOTAL, "kind" => kind, "status" => status).increment(1);
491 if let Ok(ref output) = result {
492 histogram!(STEP_DURATION_SECONDS, "kind" => kind)
493 .record(output.duration_ms as f64 / 1000.0);
494 }
495 }
496
497 result
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503 use ironflow_core::provider::DebugMessage;
504 use serde_json::json;
505
506 #[test]
507 fn step_output_with_no_debug_messages_returns_none() {
508 let output = StepOutput {
509 output: json!({"result": "ok"}),
510 duration_ms: 100,
511 cost_usd: rust_decimal::Decimal::ZERO,
512 input_tokens: None,
513 output_tokens: None,
514 model: None,
515 debug_messages: None,
516 };
517
518 assert_eq!(output.debug_messages_json(), None);
519 }
520
521 #[test]
522 fn step_output_with_empty_debug_messages_returns_some_empty_array() {
523 let output = StepOutput {
524 output: json!({"result": "ok"}),
525 duration_ms: 100,
526 cost_usd: rust_decimal::Decimal::ZERO,
527 input_tokens: None,
528 output_tokens: None,
529 model: None,
530 debug_messages: Some(Vec::new()),
531 };
532
533 let json_val = output.debug_messages_json();
534 assert!(json_val.is_some());
535 let arr = json_val.unwrap();
536 assert!(arr.is_array());
537 assert_eq!(arr.as_array().unwrap().len(), 0);
538 }
539
540 #[test]
541 fn step_output_debug_messages_json_serializes_messages() {
542 let json_msgs = json!([
543 {
544 "text": "Hello",
545 "thinking": null,
546 "thinking_redacted": false,
547 "tool_calls": [],
548 "tool_results": [],
549 "stop_reason": "end_turn",
550 "input_tokens": 10,
551 "output_tokens": 20
552 },
553 {
554 "text": "Hi there",
555 "thinking": null,
556 "thinking_redacted": false,
557 "tool_calls": [],
558 "tool_results": [],
559 "stop_reason": "end_turn",
560 "input_tokens": 15,
561 "output_tokens": 25
562 }
563 ]);
564
565 let messages: Vec<DebugMessage> =
566 serde_json::from_value(json_msgs.clone()).expect("deserialize debug messages");
567
568 let output = StepOutput {
569 output: json!({"result": "ok"}),
570 duration_ms: 100,
571 cost_usd: rust_decimal::Decimal::ZERO,
572 input_tokens: None,
573 output_tokens: None,
574 model: None,
575 debug_messages: Some(messages),
576 };
577
578 let json_val = output.debug_messages_json();
579 assert!(json_val.is_some());
580
581 let arr = json_val.unwrap();
582 assert!(arr.is_array());
583 let messages_array = arr.as_array().unwrap();
584 assert_eq!(messages_array.len(), 2);
585 assert_eq!(messages_array[0]["text"], "Hello");
586 assert_eq!(messages_array[1]["text"], "Hi there");
587 }
588
589 #[test]
590 fn step_output_contains_all_metrics() {
591 let output = StepOutput {
592 output: json!({"data": "test"}),
593 duration_ms: 5000,
594 cost_usd: rust_decimal::Decimal::new(123, 2),
595 input_tokens: Some(100),
596 output_tokens: Some(200),
597 model: Some("claude-sonnet".to_string()),
598 debug_messages: None,
599 };
600
601 assert_eq!(output.duration_ms, 5000);
602 assert_eq!(output.cost_usd, rust_decimal::Decimal::new(123, 2));
603 assert_eq!(output.input_tokens, Some(100));
604 assert_eq!(output.output_tokens, Some(200));
605 assert_eq!(output.model, Some("claude-sonnet".to_string()));
606 }
607
608 #[test]
609 fn step_output_default_tokens_and_model_are_none() {
610 let output = StepOutput {
611 output: json!({}),
612 duration_ms: 0,
613 cost_usd: rust_decimal::Decimal::ZERO,
614 input_tokens: None,
615 output_tokens: None,
616 model: None,
617 debug_messages: None,
618 };
619
620 assert!(output.input_tokens.is_none());
621 assert!(output.output_tokens.is_none());
622 assert!(output.model.is_none());
623 }
624
625 #[test]
626 fn parallel_step_result_contains_step_metadata() {
627 let step_id = uuid::Uuid::now_v7();
628 let output = StepOutput {
629 output: json!({"done": true}),
630 duration_ms: 1000,
631 cost_usd: rust_decimal::Decimal::ZERO,
632 input_tokens: None,
633 output_tokens: None,
634 model: None,
635 debug_messages: None,
636 };
637
638 let result = ParallelStepResult {
639 name: "build".to_string(),
640 output,
641 step_id,
642 };
643
644 assert_eq!(result.name, "build");
645 assert_eq!(result.step_id, step_id);
646 assert_eq!(result.output.duration_ms, 1000);
647 }
648
649 #[test]
650 fn step_output_serializes_complex_json_output() {
651 let complex_output = json!({
652 "status": "success",
653 "data": {
654 "items": [1, 2, 3],
655 "nested": {
656 "key": "value"
657 }
658 }
659 });
660
661 let output = StepOutput {
662 output: complex_output.clone(),
663 duration_ms: 100,
664 cost_usd: rust_decimal::Decimal::ZERO,
665 input_tokens: None,
666 output_tokens: None,
667 model: None,
668 debug_messages: None,
669 };
670
671 assert_eq!(output.output, complex_output);
672 assert_eq!(output.output["status"], "success");
673 assert_eq!(output.output["data"]["items"][0], 1);
674 assert_eq!(output.output["data"]["nested"]["key"], "value");
675 }
676
677 #[test]
678 fn step_result_from_success_captures_all_fields() {
679 let trace_id = Uuid::nil();
680 let output = StepOutput {
681 output: json!({"stdout": "ok"}),
682 duration_ms: 1500,
683 cost_usd: Decimal::new(42, 2),
684 input_tokens: Some(100),
685 output_tokens: Some(200),
686 model: Some("claude-sonnet".to_string()),
687 debug_messages: None,
688 };
689
690 let result = StepResult::from_success(trace_id, "build", &output);
691
692 assert_eq!(result.trace_id, trace_id);
693 assert_eq!(result.name, "build");
694 assert_eq!(result.status, StepStatus::Completed);
695 assert_eq!(result.duration_ms, 1500);
696 assert_eq!(result.cost_usd, Decimal::new(42, 2));
697 assert_eq!(result.input_tokens, Some(100));
698 assert_eq!(result.output_tokens, Some(200));
699 assert!(result.error.is_none());
700 assert!(result.output_summary.is_some());
701 assert!(result.output_summary.unwrap().contains("stdout"));
702 }
703
704 #[test]
705 fn step_result_from_failure_captures_error() {
706 let trace_id = Uuid::nil();
707 let result =
708 StepResult::from_failure(trace_id, "deploy", "connection refused", 500, Decimal::ZERO);
709
710 assert_eq!(result.trace_id, trace_id);
711 assert_eq!(result.name, "deploy");
712 assert_eq!(result.status, StepStatus::Failed);
713 assert_eq!(result.duration_ms, 500);
714 assert_eq!(result.error, Some("connection refused".to_string()));
715 assert!(result.output_summary.is_none());
716 }
717
718 #[test]
719 fn step_result_output_summary_truncates_long_output() {
720 let long_value = json!({"data": "x".repeat(1000)});
721 let output = StepOutput {
722 output: long_value,
723 duration_ms: 0,
724 cost_usd: Decimal::ZERO,
725 input_tokens: None,
726 output_tokens: None,
727 model: None,
728 debug_messages: None,
729 };
730
731 let result = StepResult::from_success(Uuid::nil(), "test", &output);
732 let summary = result.output_summary.unwrap();
733 assert_eq!(summary.len(), 500);
734 }
735}
736
737#[cfg(test)]
738mod output_helper_tests {
739 use super::*;
740 use serde::Deserialize;
741 use serde_json::json;
742
743 fn output(value: Value) -> StepOutput {
744 StepOutput {
745 output: value,
746 duration_ms: 1,
747 cost_usd: Decimal::ZERO,
748 input_tokens: None,
749 output_tokens: None,
750 model: None,
751 debug_messages: None,
752 }
753 }
754
755 #[test]
756 fn shell_helpers_read_shell_fields() {
757 let out = output(json!({"stdout": "hi\n", "stderr": "warn", "exit_code": 0}));
758 assert_eq!(out.exit_code(), Some(0));
759 assert_eq!(out.stdout(), "hi\n");
760 assert_eq!(out.stderr(), "warn");
761 assert!(out.is_success());
762 assert_eq!(out.status(), None);
763 assert_eq!(out.body(), "");
764 }
765
766 #[test]
767 fn shell_non_zero_exit_is_not_success() {
768 let out = output(json!({"stdout": "", "stderr": "", "exit_code": 127}));
769 assert_eq!(out.exit_code(), Some(127));
770 assert!(!out.is_success());
771 }
772
773 #[test]
774 fn http_helpers_read_http_fields() {
775 let out = output(json!({"status": 200, "body": "{\"ok\":true}"}));
776 assert_eq!(out.status(), Some(200));
777 assert_eq!(out.body(), "{\"ok\":true}");
778 assert!(out.is_success());
779 assert_eq!(out.exit_code(), None);
780 assert_eq!(out.stdout(), "");
781 }
782
783 #[test]
784 fn http_error_status_is_not_success() {
785 assert!(!output(json!({"status": 500, "body": ""})).is_success());
786 assert!(!output(json!({"status": 199, "body": ""})).is_success());
787 assert!(output(json!({"status": 299, "body": ""})).is_success());
788 }
789
790 #[test]
791 fn status_out_of_u16_range_is_none() {
792 assert_eq!(output(json!({"status": 70000})).status(), None);
793 assert_eq!(output(json!({"status": "200"})).status(), None);
794 }
795
796 #[test]
797 fn agent_output_without_markers_is_not_success() {
798 let out = output(json!({"summary": "fine"}));
799 assert!(!out.is_success());
800 assert_eq!(out.exit_code(), None);
801 assert_eq!(out.stdout(), "");
802 assert_eq!(out.body(), "");
803 }
804
805 #[test]
806 fn json_deserializes_structured_output() {
807 #[derive(Deserialize, Debug, PartialEq)]
808 struct Review {
809 score: u8,
810 summary: String,
811 }
812 let out = output(json!({"score": 9, "summary": "good"}));
813 let review: Review = out.json().expect("matches schema");
814 assert_eq!(
815 review,
816 Review {
817 score: 9,
818 summary: "good".to_string()
819 }
820 );
821 }
822
823 #[test]
824 fn json_reports_mismatch_as_serialization_error() {
825 #[derive(Deserialize, Debug)]
826 struct Review {
827 #[allow(dead_code)]
828 score: u8,
829 }
830 let out = output(json!({"score": "nine"}));
831 let err = out.json::<Review>().expect_err("type mismatch");
832 assert!(matches!(err, EngineError::Serialization(_)));
833 }
834
835 #[test]
836 fn helpers_tolerate_non_object_output() {
837 let out = output(json!("plain text"));
838 assert_eq!(out.exit_code(), None);
839 assert_eq!(out.status(), None);
840 assert_eq!(out.stdout(), "");
841 assert!(!out.is_success());
842 }
843}