ironflow_engine/context/steps/operation.rs
1//! Custom operation step for [`WorkflowContext`].
2
3use std::time::Instant;
4
5use chrono::Utc;
6use rust_decimal::Decimal;
7use tracing::{error, info};
8
9use ironflow_store::models::{NewStep, StepKind, StepStatus, StepUpdate, step_trace_id};
10
11use crate::context::WorkflowContext;
12use crate::error::EngineError;
13use crate::executor::{StepArtifacts, StepOutput};
14use crate::operation::Operation;
15use crate::plan::{lock_plan, planned_custom_output};
16
17impl WorkflowContext {
18 /// Execute a custom operation step.
19 ///
20 /// Runs a user-defined [`Operation`] with full step lifecycle management:
21 /// creates the step record, transitions to Running, executes the operation,
22 /// persists the output and duration, and marks the step Completed or Failed.
23 ///
24 /// The operation's [`kind()`](Operation::kind) is stored as
25 /// [`StepKind::Custom`].
26 ///
27 /// On resume, a step that already completed in a prior execution of the
28 /// current attempt is replayed from the store instead of calling
29 /// [`Operation::execute`] again.
30 ///
31 /// # Errors
32 ///
33 /// Returns [`EngineError`] if the operation fails or the store errors.
34 /// Returns [`EngineError::ReplayDivergence`] when the step recorded at
35 /// this position has a different name or kind.
36 ///
37 /// # Examples
38 ///
39 /// ```no_run
40 /// use async_trait::async_trait;
41 /// use ironflow_engine::context::WorkflowContext;
42 /// use ironflow_engine::operation::{Operation, OperationContext};
43 /// use ironflow_core::error::OperationError;
44 /// use ironflow_engine::error::EngineError;
45 /// use serde_json::{Value, json};
46 ///
47 /// struct MyOp;
48 /// #[async_trait]
49 /// impl Operation for MyOp {
50 /// fn kind(&self) -> &str { "my-service" }
51 /// async fn execute(&self, _ctx: &OperationContext) -> Result<Value, OperationError> {
52 /// Ok(json!({"ok": true}))
53 /// }
54 /// }
55 ///
56 /// # async fn example(ctx: &mut WorkflowContext) -> Result<(), EngineError> {
57 /// let result = ctx.operation("call-service", &MyOp).await?;
58 /// println!("output: {}", result.output);
59 /// # Ok(())
60 /// # }
61 /// ```
62 pub async fn operation(
63 &mut self,
64 name: &str,
65 op: &dyn Operation,
66 ) -> Result<StepOutput, EngineError> {
67 let kind = StepKind::Custom(op.kind().to_string());
68
69 // Plan mode: `op.execute` is never called, so no third-party API is
70 // touched while planning.
71 if let Some(plan) = self.plan().cloned() {
72 self.position += 1;
73 let mut recorder = lock_plan(&plan);
74 let estimate = recorder.estimate_for(name);
75 recorder.record_or_stop(name, kind, &self.workflow_name, None)?;
76 recorder.set_last(vec![name.to_string()]);
77 let mut output = planned_custom_output(estimate);
78 output.artifacts = StepArtifacts::new(name, None, &[]);
79 return Ok(output);
80 }
81
82 let position = self.position;
83 self.position += 1;
84
85 // Replay: if this step already completed in a prior execution of the
86 // current attempt, return its cached output without calling
87 // `op.execute` or creating a new step.
88 if let Some(mut output) = self.try_replay_step(position, name, &kind)? {
89 let step_id = self.last_step_ids.last().copied();
90 output.artifacts = StepArtifacts::new(name, step_id, &[]);
91 return Ok(output);
92 }
93
94 let trace_id = step_trace_id(self.run_id, name, position);
95 let step = self
96 .store
97 .create_step(NewStep {
98 run_id: self.run_id,
99 trace_id,
100 name: name.to_string(),
101 kind,
102 position,
103 input: op.input(),
104 is_error_handler: false,
105 })
106 .await?;
107
108 self.start_step(step.id, Utc::now()).await?;
109
110 let start = Instant::now();
111
112 let op_ctx = self.ensure_operation_ctx();
113
114 match op.execute(op_ctx).await {
115 Ok(output_value) => {
116 let duration_ms = start.elapsed().as_millis() as u64;
117 self.total_duration_ms += duration_ms;
118
119 let completed_at = Utc::now();
120 let step_id = self
121 .complete_step(
122 &step,
123 StepUpdate {
124 status: Some(StepStatus::Completed),
125 output: Some(output_value.clone()),
126 duration_ms: Some(duration_ms),
127 cost_usd: Some(Decimal::ZERO),
128 completed_at: Some(completed_at),
129 ..StepUpdate::default()
130 },
131 )
132 .await?;
133
134 info!(
135 run_id = %self.run_id,
136 step = %name,
137 kind = op.kind(),
138 duration_ms,
139 "operation step completed"
140 );
141
142 self.last_step_ids = vec![step_id];
143
144 Ok(StepOutput {
145 output: output_value,
146 duration_ms,
147 cost_usd: Decimal::ZERO,
148 input_tokens: None,
149 cache_read_input_tokens: None,
150 cache_creation_input_tokens: None,
151 output_tokens: None,
152 model: None,
153 debug_messages: None,
154 // An operation declares no output; its record is what
155 // `put_artifact` attaches bytes to.
156 artifacts: StepArtifacts::new(name, Some(step_id), &[]),
157 account_id: None,
158 environment_id: None,
159 })
160 }
161 Err(err) => {
162 let completed_at = Utc::now();
163 let engine_err = EngineError::Operation(err);
164 if let Err(store_err) = self
165 .store
166 .update_step(
167 step.id,
168 StepUpdate {
169 status: Some(StepStatus::Failed),
170 error: Some(engine_err.to_string()),
171 completed_at: Some(completed_at),
172 ..StepUpdate::default()
173 },
174 )
175 .await
176 {
177 error!(step_id = %step.id, error = %store_err, "failed to persist step failure");
178 }
179
180 Err(engine_err)
181 }
182 }
183 }
184}