Skip to main content

ironflow_api/entities/
step.rs

1//! Step-related DTOs.
2
3use chrono::{DateTime, Utc};
4use ironflow_store::models::{Step, StepKind, StepStatus};
5use rust_decimal::Decimal;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use uuid::Uuid;
9
10use super::ArtifactResponse;
11
12/// Step response DTO — public API representation of a step.
13///
14/// # Examples
15///
16/// ```
17/// use ironflow_store::models::Step;
18/// use ironflow_api::entities::StepResponse;
19/// ```
20#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
21#[derive(Debug, Serialize, Deserialize)]
22pub struct StepResponse {
23    /// Unique step identifier.
24    pub id: Uuid,
25    /// Deterministic trace ID for log correlation.
26    pub trace_id: Uuid,
27    /// Parent run ID.
28    pub run_id: Uuid,
29    /// Step name.
30    pub name: String,
31    /// Step operation type.
32    #[cfg_attr(feature = "openapi", schema(value_type = String))]
33    pub kind: StepKind,
34    /// Execution order (0-based).
35    pub position: u32,
36    /// Current status.
37    pub status: StepStatus,
38    /// Which run attempt produced this step (1-based).
39    ///
40    /// A run retried twice exposes steps with `attempt` 1, 2 and 3. Steps from
41    /// earlier attempts are kept so a failed attempt stays inspectable.
42    pub attempt: u32,
43    /// Input configuration.
44    #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
45    pub input: Option<Value>,
46    /// Step output.
47    #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
48    pub output: Option<Value>,
49    /// Optional error message.
50    pub error: Option<String>,
51    /// Execution duration in milliseconds.
52    pub duration_ms: u64,
53    /// Cost in USD.
54    #[cfg_attr(feature = "openapi", schema(value_type = f64))]
55    pub cost_usd: Decimal,
56    /// Input token count (agent steps).
57    pub input_tokens: Option<u64>,
58    /// Output token count (agent steps).
59    pub output_tokens: Option<u64>,
60    /// When created.
61    pub created_at: DateTime<Utc>,
62    /// When updated.
63    pub updated_at: DateTime<Utc>,
64    /// When execution started.
65    pub started_at: Option<DateTime<Utc>>,
66    /// When execution completed.
67    pub completed_at: Option<DateTime<Utc>>,
68    /// IDs of steps this step depends on (direct dependencies).
69    pub dependencies: Vec<Uuid>,
70    /// Verbose conversation trace for agent steps (thinking blocks, tool
71    /// calls, tool results, per-turn usage). `None` when verbose mode was
72    /// off or the step is not an agent step.
73    #[cfg_attr(feature = "openapi", schema(value_type = Option<serde_json::Value>))]
74    pub debug_messages: Option<Value>,
75    /// Files this step produced, downloadable through the artifact route.
76    ///
77    /// Empty when the step produced none or when artifact storage is not
78    /// configured on the server.
79    #[serde(default)]
80    pub artifacts: Vec<ArtifactResponse>,
81}
82
83impl StepResponse {
84    /// Build a response from a step entity with pre-resolved dependencies.
85    ///
86    /// Artifacts are left empty; use
87    /// [`with_dependencies_and_artifacts`](Self::with_dependencies_and_artifacts)
88    /// when they have been fetched.
89    pub fn with_dependencies(step: Step, dependencies: Vec<Uuid>) -> Self {
90        Self::with_dependencies_and_artifacts(step, dependencies, Vec::new())
91    }
92
93    /// Build a response from a step entity with its dependencies and artifacts.
94    pub fn with_dependencies_and_artifacts(
95        step: Step,
96        dependencies: Vec<Uuid>,
97        artifacts: Vec<ArtifactResponse>,
98    ) -> Self {
99        StepResponse {
100            id: step.id,
101            trace_id: step.trace_id,
102            run_id: step.run_id,
103            name: step.name,
104            kind: step.kind,
105            position: step.position,
106            status: step.status.state,
107            attempt: step.attempt,
108            input: step.input,
109            output: step.output,
110            error: step.error,
111            duration_ms: step.duration_ms,
112            cost_usd: step.cost_usd,
113            input_tokens: step.input_tokens,
114            output_tokens: step.output_tokens,
115            created_at: step.created_at,
116            updated_at: step.updated_at,
117            started_at: step.started_at,
118            completed_at: step.completed_at,
119            dependencies,
120            debug_messages: step.debug_messages,
121            artifacts,
122        }
123    }
124}
125
126impl From<Step> for StepResponse {
127    fn from(step: Step) -> Self {
128        Self::with_dependencies(step, Vec::new())
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use std::collections::HashMap;
135
136    use ironflow_store::memory::InMemoryStore;
137    use ironflow_store::models::{NewRun, NewStep, TriggerKind, step_trace_id};
138    use ironflow_store::store::RunStore;
139    use serde_json::json;
140
141    use super::*;
142
143    /// A persisted step -- [`Step`] is `#[non_exhaustive]`, so it can only be
144    /// obtained from a store.
145    async fn step() -> Step {
146        let store = InMemoryStore::new();
147        let run = store
148            .create_run(NewRun {
149                created_by: None,
150                workflow_name: "test".to_string(),
151                trigger: TriggerKind::Manual,
152                payload: json!({}),
153                max_retries: 0,
154                handler_version: None,
155                labels: HashMap::new(),
156                scheduled_at: None,
157                idempotency_key: None,
158                max_cost_usd: None,
159            })
160            .await
161            .expect("create run")
162            .into_run();
163
164        store
165            .create_step(NewStep {
166                run_id: run.id,
167                trace_id: step_trace_id(run.id, "build", 0),
168                name: "build".to_string(),
169                kind: StepKind::Shell,
170                position: 0,
171                input: None,
172                is_error_handler: false,
173            })
174            .await
175            .expect("create step")
176    }
177
178    #[tokio::test]
179    async fn a_step_without_artifacts_exposes_an_empty_list() {
180        let response = StepResponse::from(step().await);
181        assert!(response.artifacts.is_empty());
182    }
183
184    #[tokio::test]
185    async fn artifacts_are_carried_through() {
186        let step = step().await;
187        let artifact = ArtifactResponse {
188            id: Uuid::now_v7(),
189            step_id: step.id,
190            name: "report.html".to_string(),
191            content_type: "text/html".to_string(),
192            size_bytes: 1,
193            sha256: "0".repeat(64),
194            created_at: Utc::now(),
195        };
196
197        let response =
198            StepResponse::with_dependencies_and_artifacts(step, Vec::new(), vec![artifact]);
199
200        assert_eq!(response.artifacts.len(), 1);
201        assert_eq!(response.artifacts[0].name, "report.html");
202    }
203
204    #[tokio::test]
205    async fn artifacts_serialize_as_a_json_array() {
206        let body = serde_json::to_value(StepResponse::from(step().await)).expect("serialize");
207        assert!(body["artifacts"].is_array());
208    }
209
210    #[tokio::test]
211    async fn trace_id_is_exposed_in_step_response() {
212        let s = step().await;
213        let expected_trace_id = s.trace_id;
214        let response = StepResponse::from(s);
215
216        assert_eq!(response.trace_id, expected_trace_id);
217
218        let body = serde_json::to_value(&response).expect("serialize");
219        assert!(body["trace_id"].is_string());
220    }
221}