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