1use chrono::{DateTime, Utc};
4use ironflow_store::models::{
5 ApprovalRequirement, Assignee, Step, StepApproval, StepKind, StepStatus,
6};
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use uuid::Uuid;
11
12use super::ArtifactResponse;
13
14#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
23#[derive(Debug, Serialize, Deserialize)]
24pub struct StepResponse {
25 pub id: Uuid,
27 pub trace_id: Uuid,
29 pub run_id: Uuid,
31 pub name: String,
33 #[cfg_attr(feature = "openapi", schema(value_type = String))]
35 pub kind: StepKind,
36 pub position: u32,
38 pub status: StepStatus,
40 pub attempt: u32,
45 #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
47 pub input: Option<Value>,
48 #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
50 pub output: Option<Value>,
51 pub error: Option<String>,
53 pub duration_ms: u64,
55 #[cfg_attr(feature = "openapi", schema(value_type = f64))]
57 pub cost_usd: Decimal,
58 pub input_tokens: Option<u64>,
60 pub cache_read_input_tokens: Option<u64>,
62 pub cache_creation_input_tokens: Option<u64>,
64 pub output_tokens: Option<u64>,
66 pub created_at: DateTime<Utc>,
68 pub updated_at: DateTime<Utc>,
70 pub started_at: Option<DateTime<Utc>>,
72 pub completed_at: Option<DateTime<Utc>>,
74 pub dependencies: Vec<Uuid>,
76 #[cfg_attr(feature = "openapi", schema(value_type = Option<serde_json::Value>))]
80 pub debug_messages: Option<Value>,
81 #[serde(default)]
86 pub artifacts: Vec<ArtifactResponse>,
87 pub approval_deadline_at: Option<DateTime<Utc>>,
89 pub approval_seconds_remaining: Option<i64>,
92 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
96 pub approval_assignee: Option<Assignee>,
97 #[serde(default)]
100 pub approval_requirement: Option<ApprovalRequirement>,
101 #[serde(default)]
103 pub approvals: Vec<StepApproval>,
104 #[serde(default)]
107 pub approvals_required: Option<u32>,
108 #[serde(default)]
110 pub account_id: Option<Uuid>,
111}
112
113impl StepResponse {
114 pub fn with_dependencies(step: Step, dependencies: Vec<Uuid>) -> Self {
120 Self::with_dependencies_and_artifacts(step, dependencies, Vec::new())
121 }
122
123 pub fn with_dependencies_and_artifacts(
125 step: Step,
126 dependencies: Vec<Uuid>,
127 artifacts: Vec<ArtifactResponse>,
128 ) -> Self {
129 let approval_seconds_remaining = step
130 .approval_deadline_at
131 .map(|at| (at - Utc::now()).num_seconds().max(0));
132 let approvals_required = match (&step.kind, &step.approval_requirement) {
133 (StepKind::HumanInput, _) => None,
136 (_, Some(requirement)) => Some(requirement.required_approvers),
137 (StepKind::Approval, None) => Some(1),
138 _ => None,
139 };
140
141 StepResponse {
142 id: step.id,
143 trace_id: step.trace_id,
144 run_id: step.run_id,
145 name: step.name,
146 kind: step.kind,
147 position: step.position,
148 status: step.status.state,
149 attempt: step.attempt,
150 input: step.input,
151 output: step.output,
152 error: step.error,
153 duration_ms: step.duration_ms,
154 cost_usd: step.cost_usd,
155 input_tokens: step.input_tokens,
156 cache_read_input_tokens: step.cache_read_input_tokens,
157 cache_creation_input_tokens: step.cache_creation_input_tokens,
158 output_tokens: step.output_tokens,
159 created_at: step.created_at,
160 updated_at: step.updated_at,
161 started_at: step.started_at,
162 completed_at: step.completed_at,
163 dependencies,
164 debug_messages: step.debug_messages,
165 artifacts,
166 approval_deadline_at: step.approval_deadline_at,
167 approval_seconds_remaining,
168 approval_assignee: step.approval_assignee,
169 approval_requirement: step.approval_requirement,
170 approvals: step.approvals,
171 approvals_required,
172 account_id: step.account_id,
173 }
174 }
175}
176
177impl From<Step> for StepResponse {
178 fn from(step: Step) -> Self {
179 Self::with_dependencies(step, Vec::new())
180 }
181}
182
183#[cfg(test)]
184mod tests {
185 use std::collections::HashMap;
186
187 use chrono::TimeDelta;
188 use ironflow_store::memory::InMemoryStore;
189 use ironflow_store::models::{NewRun, NewStep, StepUpdate, TriggerKind, step_trace_id};
190 use ironflow_store::store::RunStore;
191 use serde_json::json;
192
193 use super::*;
194
195 async fn step() -> Step {
198 let store = InMemoryStore::new();
199 let run = store
200 .create_run(NewRun {
201 created_by: None,
202 workflow_name: "test".to_string(),
203 trigger: TriggerKind::Manual,
204 payload: json!({}),
205 max_retries: 0,
206 handler_version: None,
207 labels: HashMap::new(),
208 scheduled_at: None,
209 idempotency_key: None,
210 concurrency_key: None,
211 max_cost_usd: None,
212 })
213 .await
214 .expect("create run")
215 .into_run();
216
217 store
218 .create_step(NewStep {
219 run_id: run.id,
220 trace_id: step_trace_id(run.id, "build", 0),
221 name: "build".to_string(),
222 kind: StepKind::Shell,
223 position: 0,
224 input: None,
225 is_error_handler: false,
226 })
227 .await
228 .expect("create step")
229 }
230
231 #[tokio::test]
232 async fn a_step_without_artifacts_exposes_an_empty_list() {
233 let response = StepResponse::from(step().await);
234 assert!(response.artifacts.is_empty());
235 }
236
237 #[tokio::test]
238 async fn artifacts_are_carried_through() {
239 let step = step().await;
240 let artifact = ArtifactResponse {
241 id: Uuid::now_v7(),
242 step_id: step.id,
243 name: "report.html".to_string(),
244 content_type: "text/html".to_string(),
245 size_bytes: 1,
246 sha256: "0".repeat(64),
247 created_at: Utc::now(),
248 };
249
250 let response =
251 StepResponse::with_dependencies_and_artifacts(step, Vec::new(), vec![artifact]);
252
253 assert_eq!(response.artifacts.len(), 1);
254 assert_eq!(response.artifacts[0].name, "report.html");
255 }
256
257 #[tokio::test]
258 async fn artifacts_serialize_as_a_json_array() {
259 let body = serde_json::to_value(StepResponse::from(step().await)).expect("serialize");
260 assert!(body["artifacts"].is_array());
261 }
262
263 async fn gate_with_deadline(offset_secs: i64) -> Step {
265 let store = InMemoryStore::new();
266 let run = store
267 .create_run(NewRun {
268 created_by: None,
269 workflow_name: "test".to_string(),
270 trigger: TriggerKind::Manual,
271 payload: json!({}),
272 max_retries: 0,
273 handler_version: None,
274 labels: HashMap::new(),
275 scheduled_at: None,
276 idempotency_key: None,
277 concurrency_key: None,
278 max_cost_usd: None,
279 })
280 .await
281 .expect("create run")
282 .into_run();
283
284 let step = store
285 .create_step(NewStep {
286 run_id: run.id,
287 trace_id: step_trace_id(run.id, "prod-gate", 0),
288 name: "prod-gate".to_string(),
289 kind: StepKind::Approval,
290 position: 0,
291 input: None,
292 is_error_handler: false,
293 })
294 .await
295 .expect("create step");
296
297 store
298 .update_step(
299 step.id,
300 StepUpdate {
301 status: Some(StepStatus::Running),
302 ..StepUpdate::default()
303 },
304 )
305 .await
306 .expect("to running");
307 store
308 .update_step(
309 step.id,
310 StepUpdate {
311 status: Some(StepStatus::AwaitingApproval),
312 approval_deadline_at: Some(Utc::now() + TimeDelta::seconds(offset_secs)),
313 approval_assignee: Some(Assignee::group("release-managers")),
314 ..StepUpdate::default()
315 },
316 )
317 .await
318 .expect("arm timer");
319
320 store.get_step(step.id).await.expect("get").expect("exists")
321 }
322
323 #[tokio::test]
324 async fn a_step_without_a_deadline_reports_no_sla() {
325 let response = StepResponse::from(step().await);
326 assert!(response.approval_deadline_at.is_none());
327 assert!(response.approval_seconds_remaining.is_none());
328 assert!(response.approval_assignee.is_none());
329 assert!(response.approval_requirement.is_none());
330 assert!(response.approvals.is_empty());
331 }
332
333 #[tokio::test]
334 async fn a_shell_step_requires_no_approvals() {
335 let response = StepResponse::from(step().await);
336 assert_eq!(response.approvals_required, None);
337 }
338
339 #[tokio::test]
340 async fn a_rule_less_approval_step_requires_one_approval() {
341 let response = StepResponse::from(gate_with_deadline(3600).await);
342 assert!(response.approval_requirement.is_none());
343 assert_eq!(response.approvals_required, Some(1));
344 }
345
346 #[tokio::test]
347 async fn an_approval_requirement_sets_the_required_count() {
348 let mut gate = gate_with_deadline(3600).await;
349 let requirement = ApprovalRequirement {
350 reason: Some("amount > 100k".to_string()),
351 required_approvers: 3,
352 approver_groups: vec!["finance".to_string()],
353 };
354 gate.approval_requirement = Some(requirement.clone());
355 gate.approvals = vec![StepApproval {
356 user_id: Uuid::now_v7(),
357 approved_by: "alice".to_string(),
358 at: Utc::now(),
359 }];
360
361 let response = StepResponse::from(gate);
362
363 assert_eq!(response.approvals_required, Some(3));
364 assert_eq!(response.approval_requirement, Some(requirement));
365 assert_eq!(response.approvals.len(), 1);
366 assert_eq!(response.approvals[0].approved_by, "alice");
367 }
368
369 #[tokio::test]
370 async fn a_human_input_step_requires_no_approval_count() {
371 let mut step = gate_with_deadline(3600).await;
372 step.kind = StepKind::HumanInput;
373 step.approval_requirement = Some(ApprovalRequirement {
374 reason: None,
375 required_approvers: 2,
376 approver_groups: vec!["product".to_string()],
377 });
378
379 let response = StepResponse::from(step);
380
381 assert_eq!(response.approvals_required, None);
382 assert!(response.approval_requirement.is_some());
383 }
384
385 #[tokio::test]
386 async fn a_future_deadline_reports_the_remaining_seconds() {
387 let response = StepResponse::from(gate_with_deadline(3600).await);
388
389 assert!(response.approval_deadline_at.is_some());
390 let remaining = response
391 .approval_seconds_remaining
392 .expect("a deadline yields a countdown");
393 assert!(remaining > 0 && remaining <= 3600, "got {remaining}");
394 assert_eq!(
395 response.approval_assignee,
396 Some(Assignee::group("release-managers"))
397 );
398 }
399
400 #[tokio::test]
401 async fn a_past_deadline_clamps_the_countdown_at_zero() {
402 let response = StepResponse::from(gate_with_deadline(-3600).await);
403 assert_eq!(response.approval_seconds_remaining, Some(0));
404 }
405
406 #[tokio::test]
407 async fn trace_id_is_exposed_in_step_response() {
408 let s = step().await;
409 let expected_trace_id = s.trace_id;
410 let response = StepResponse::from(s);
411
412 assert_eq!(response.trace_id, expected_trace_id);
413
414 let body = serde_json::to_value(&response).expect("serialize");
415 assert!(body["trace_id"].is_string());
416 }
417}