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 StepConfig::Delay(_) => "delay",
443 };
444 tracing::Span::current().record("step.kind", kind);
445
446 let result = match config {
447 StepConfig::Shell(cfg) => {
448 let mut executor = ShellExecutor::new(cfg);
449 if let Some(sender) = log_sender {
450 executor = executor.with_log_sender(sender);
451 }
452 executor.execute(provider).await
453 }
454 StepConfig::Http(cfg) => HttpExecutor::new(cfg).execute(provider).await,
455 StepConfig::Agent(cfg) => {
456 let mut executor = AgentExecutor::new(cfg);
457 if let Some(sender) = log_sender {
458 executor = executor.with_log_sender(sender);
459 }
460 executor.execute(provider).await
461 }
462 StepConfig::Workflow(_) => Err(EngineError::StepConfig(
463 "workflow steps are executed by WorkflowContext, not the executor".to_string(),
464 )),
465 StepConfig::Approval(_) => Err(EngineError::StepConfig(
466 "approval steps are executed by WorkflowContext, not the executor".to_string(),
467 )),
468 StepConfig::Delay(_) => Err(EngineError::StepConfig(
469 "delay steps are executed by WorkflowContext, not the executor".to_string(),
470 )),
471 };
472
473 #[cfg(feature = "prometheus")]
474 {
475 use ironflow_core::metric_names::{
476 STATUS_ERROR, STATUS_SUCCESS, STEP_DURATION_SECONDS, STEPS_TOTAL,
477 };
478 use metrics::{counter, histogram};
479 let status = if result.is_ok() {
480 STATUS_SUCCESS
481 } else {
482 STATUS_ERROR
483 };
484 counter!(STEPS_TOTAL, "kind" => kind, "status" => status).increment(1);
485 if let Ok(ref output) = result {
486 histogram!(STEP_DURATION_SECONDS, "kind" => kind)
487 .record(output.duration_ms as f64 / 1000.0);
488 }
489 }
490
491 result
492}
493
494#[cfg(test)]
495mod tests {
496 use super::*;
497 use ironflow_core::provider::DebugMessage;
498 use serde_json::json;
499
500 #[test]
501 fn step_output_with_no_debug_messages_returns_none() {
502 let output = StepOutput {
503 output: json!({"result": "ok"}),
504 duration_ms: 100,
505 cost_usd: rust_decimal::Decimal::ZERO,
506 input_tokens: None,
507 output_tokens: None,
508 model: None,
509 debug_messages: None,
510 };
511
512 assert_eq!(output.debug_messages_json(), None);
513 }
514
515 #[test]
516 fn step_output_with_empty_debug_messages_returns_some_empty_array() {
517 let output = StepOutput {
518 output: json!({"result": "ok"}),
519 duration_ms: 100,
520 cost_usd: rust_decimal::Decimal::ZERO,
521 input_tokens: None,
522 output_tokens: None,
523 model: None,
524 debug_messages: Some(Vec::new()),
525 };
526
527 let json_val = output.debug_messages_json();
528 assert!(json_val.is_some());
529 let arr = json_val.unwrap();
530 assert!(arr.is_array());
531 assert_eq!(arr.as_array().unwrap().len(), 0);
532 }
533
534 #[test]
535 fn step_output_debug_messages_json_serializes_messages() {
536 let json_msgs = json!([
537 {
538 "text": "Hello",
539 "thinking": null,
540 "thinking_redacted": false,
541 "tool_calls": [],
542 "tool_results": [],
543 "stop_reason": "end_turn",
544 "input_tokens": 10,
545 "output_tokens": 20
546 },
547 {
548 "text": "Hi there",
549 "thinking": null,
550 "thinking_redacted": false,
551 "tool_calls": [],
552 "tool_results": [],
553 "stop_reason": "end_turn",
554 "input_tokens": 15,
555 "output_tokens": 25
556 }
557 ]);
558
559 let messages: Vec<DebugMessage> =
560 serde_json::from_value(json_msgs.clone()).expect("deserialize debug messages");
561
562 let output = StepOutput {
563 output: json!({"result": "ok"}),
564 duration_ms: 100,
565 cost_usd: rust_decimal::Decimal::ZERO,
566 input_tokens: None,
567 output_tokens: None,
568 model: None,
569 debug_messages: Some(messages),
570 };
571
572 let json_val = output.debug_messages_json();
573 assert!(json_val.is_some());
574
575 let arr = json_val.unwrap();
576 assert!(arr.is_array());
577 let messages_array = arr.as_array().unwrap();
578 assert_eq!(messages_array.len(), 2);
579 assert_eq!(messages_array[0]["text"], "Hello");
580 assert_eq!(messages_array[1]["text"], "Hi there");
581 }
582
583 #[test]
584 fn step_output_contains_all_metrics() {
585 let output = StepOutput {
586 output: json!({"data": "test"}),
587 duration_ms: 5000,
588 cost_usd: rust_decimal::Decimal::new(123, 2),
589 input_tokens: Some(100),
590 output_tokens: Some(200),
591 model: Some("claude-sonnet".to_string()),
592 debug_messages: None,
593 };
594
595 assert_eq!(output.duration_ms, 5000);
596 assert_eq!(output.cost_usd, rust_decimal::Decimal::new(123, 2));
597 assert_eq!(output.input_tokens, Some(100));
598 assert_eq!(output.output_tokens, Some(200));
599 assert_eq!(output.model, Some("claude-sonnet".to_string()));
600 }
601
602 #[test]
603 fn step_output_default_tokens_and_model_are_none() {
604 let output = StepOutput {
605 output: json!({}),
606 duration_ms: 0,
607 cost_usd: rust_decimal::Decimal::ZERO,
608 input_tokens: None,
609 output_tokens: None,
610 model: None,
611 debug_messages: None,
612 };
613
614 assert!(output.input_tokens.is_none());
615 assert!(output.output_tokens.is_none());
616 assert!(output.model.is_none());
617 }
618
619 #[test]
620 fn parallel_step_result_contains_step_metadata() {
621 let step_id = uuid::Uuid::now_v7();
622 let output = StepOutput {
623 output: json!({"done": true}),
624 duration_ms: 1000,
625 cost_usd: rust_decimal::Decimal::ZERO,
626 input_tokens: None,
627 output_tokens: None,
628 model: None,
629 debug_messages: None,
630 };
631
632 let result = ParallelStepResult {
633 name: "build".to_string(),
634 output,
635 step_id,
636 };
637
638 assert_eq!(result.name, "build");
639 assert_eq!(result.step_id, step_id);
640 assert_eq!(result.output.duration_ms, 1000);
641 }
642
643 #[test]
644 fn step_output_serializes_complex_json_output() {
645 let complex_output = json!({
646 "status": "success",
647 "data": {
648 "items": [1, 2, 3],
649 "nested": {
650 "key": "value"
651 }
652 }
653 });
654
655 let output = StepOutput {
656 output: complex_output.clone(),
657 duration_ms: 100,
658 cost_usd: rust_decimal::Decimal::ZERO,
659 input_tokens: None,
660 output_tokens: None,
661 model: None,
662 debug_messages: None,
663 };
664
665 assert_eq!(output.output, complex_output);
666 assert_eq!(output.output["status"], "success");
667 assert_eq!(output.output["data"]["items"][0], 1);
668 assert_eq!(output.output["data"]["nested"]["key"], "value");
669 }
670
671 #[test]
672 fn step_result_from_success_captures_all_fields() {
673 let trace_id = Uuid::nil();
674 let output = StepOutput {
675 output: json!({"stdout": "ok"}),
676 duration_ms: 1500,
677 cost_usd: Decimal::new(42, 2),
678 input_tokens: Some(100),
679 output_tokens: Some(200),
680 model: Some("claude-sonnet".to_string()),
681 debug_messages: None,
682 };
683
684 let result = StepResult::from_success(trace_id, "build", &output);
685
686 assert_eq!(result.trace_id, trace_id);
687 assert_eq!(result.name, "build");
688 assert_eq!(result.status, StepStatus::Completed);
689 assert_eq!(result.duration_ms, 1500);
690 assert_eq!(result.cost_usd, Decimal::new(42, 2));
691 assert_eq!(result.input_tokens, Some(100));
692 assert_eq!(result.output_tokens, Some(200));
693 assert!(result.error.is_none());
694 assert!(result.output_summary.is_some());
695 assert!(result.output_summary.unwrap().contains("stdout"));
696 }
697
698 #[test]
699 fn step_result_from_failure_captures_error() {
700 let trace_id = Uuid::nil();
701 let result =
702 StepResult::from_failure(trace_id, "deploy", "connection refused", 500, Decimal::ZERO);
703
704 assert_eq!(result.trace_id, trace_id);
705 assert_eq!(result.name, "deploy");
706 assert_eq!(result.status, StepStatus::Failed);
707 assert_eq!(result.duration_ms, 500);
708 assert_eq!(result.error, Some("connection refused".to_string()));
709 assert!(result.output_summary.is_none());
710 }
711
712 #[test]
713 fn step_result_output_summary_truncates_long_output() {
714 let long_value = json!({"data": "x".repeat(1000)});
715 let output = StepOutput {
716 output: long_value,
717 duration_ms: 0,
718 cost_usd: Decimal::ZERO,
719 input_tokens: None,
720 output_tokens: None,
721 model: None,
722 debug_messages: None,
723 };
724
725 let result = StepResult::from_success(Uuid::nil(), "test", &output);
726 let summary = result.output_summary.unwrap();
727 assert_eq!(summary.len(), 500);
728 }
729}
730
731#[cfg(test)]
732mod output_helper_tests {
733 use super::*;
734 use serde::Deserialize;
735 use serde_json::json;
736
737 fn output(value: Value) -> StepOutput {
738 StepOutput {
739 output: value,
740 duration_ms: 1,
741 cost_usd: Decimal::ZERO,
742 input_tokens: None,
743 output_tokens: None,
744 model: None,
745 debug_messages: None,
746 }
747 }
748
749 #[test]
750 fn shell_helpers_read_shell_fields() {
751 let out = output(json!({"stdout": "hi\n", "stderr": "warn", "exit_code": 0}));
752 assert_eq!(out.exit_code(), Some(0));
753 assert_eq!(out.stdout(), "hi\n");
754 assert_eq!(out.stderr(), "warn");
755 assert!(out.is_success());
756 assert_eq!(out.status(), None);
757 assert_eq!(out.body(), "");
758 }
759
760 #[test]
761 fn shell_non_zero_exit_is_not_success() {
762 let out = output(json!({"stdout": "", "stderr": "", "exit_code": 127}));
763 assert_eq!(out.exit_code(), Some(127));
764 assert!(!out.is_success());
765 }
766
767 #[test]
768 fn http_helpers_read_http_fields() {
769 let out = output(json!({"status": 200, "body": "{\"ok\":true}"}));
770 assert_eq!(out.status(), Some(200));
771 assert_eq!(out.body(), "{\"ok\":true}");
772 assert!(out.is_success());
773 assert_eq!(out.exit_code(), None);
774 assert_eq!(out.stdout(), "");
775 }
776
777 #[test]
778 fn http_error_status_is_not_success() {
779 assert!(!output(json!({"status": 500, "body": ""})).is_success());
780 assert!(!output(json!({"status": 199, "body": ""})).is_success());
781 assert!(output(json!({"status": 299, "body": ""})).is_success());
782 }
783
784 #[test]
785 fn status_out_of_u16_range_is_none() {
786 assert_eq!(output(json!({"status": 70000})).status(), None);
787 assert_eq!(output(json!({"status": "200"})).status(), None);
788 }
789
790 #[test]
791 fn agent_output_without_markers_is_not_success() {
792 let out = output(json!({"summary": "fine"}));
793 assert!(!out.is_success());
794 assert_eq!(out.exit_code(), None);
795 assert_eq!(out.stdout(), "");
796 assert_eq!(out.body(), "");
797 }
798
799 #[test]
800 fn json_deserializes_structured_output() {
801 #[derive(Deserialize, Debug, PartialEq)]
802 struct Review {
803 score: u8,
804 summary: String,
805 }
806 let out = output(json!({"score": 9, "summary": "good"}));
807 let review: Review = out.json().expect("matches schema");
808 assert_eq!(
809 review,
810 Review {
811 score: 9,
812 summary: "good".to_string()
813 }
814 );
815 }
816
817 #[test]
818 fn json_reports_mismatch_as_serialization_error() {
819 #[derive(Deserialize, Debug)]
820 struct Review {
821 #[allow(dead_code)]
822 score: u8,
823 }
824 let out = output(json!({"score": "nine"}));
825 let err = out.json::<Review>().expect_err("type mismatch");
826 assert!(matches!(err, EngineError::Serialization(_)));
827 }
828
829 #[test]
830 fn helpers_tolerate_non_object_output() {
831 let out = output(json!("plain text"));
832 assert_eq!(out.exit_code(), None);
833 assert_eq!(out.status(), None);
834 assert_eq!(out.stdout(), "");
835 assert!(!out.is_success());
836 }
837}