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 max_cost_usd: None,
211 })
212 .await
213 .expect("create run")
214 .into_run();
215
216 store
217 .create_step(NewStep {
218 run_id: run.id,
219 trace_id: step_trace_id(run.id, "build", 0),
220 name: "build".to_string(),
221 kind: StepKind::Shell,
222 position: 0,
223 input: None,
224 is_error_handler: false,
225 })
226 .await
227 .expect("create step")
228 }
229
230 #[tokio::test]
231 async fn a_step_without_artifacts_exposes_an_empty_list() {
232 let response = StepResponse::from(step().await);
233 assert!(response.artifacts.is_empty());
234 }
235
236 #[tokio::test]
237 async fn artifacts_are_carried_through() {
238 let step = step().await;
239 let artifact = ArtifactResponse {
240 id: Uuid::now_v7(),
241 step_id: step.id,
242 name: "report.html".to_string(),
243 content_type: "text/html".to_string(),
244 size_bytes: 1,
245 sha256: "0".repeat(64),
246 created_at: Utc::now(),
247 };
248
249 let response =
250 StepResponse::with_dependencies_and_artifacts(step, Vec::new(), vec![artifact]);
251
252 assert_eq!(response.artifacts.len(), 1);
253 assert_eq!(response.artifacts[0].name, "report.html");
254 }
255
256 #[tokio::test]
257 async fn artifacts_serialize_as_a_json_array() {
258 let body = serde_json::to_value(StepResponse::from(step().await)).expect("serialize");
259 assert!(body["artifacts"].is_array());
260 }
261
262 async fn gate_with_deadline(offset_secs: i64) -> Step {
264 let store = InMemoryStore::new();
265 let run = store
266 .create_run(NewRun {
267 created_by: None,
268 workflow_name: "test".to_string(),
269 trigger: TriggerKind::Manual,
270 payload: json!({}),
271 max_retries: 0,
272 handler_version: None,
273 labels: HashMap::new(),
274 scheduled_at: None,
275 idempotency_key: None,
276 max_cost_usd: None,
277 })
278 .await
279 .expect("create run")
280 .into_run();
281
282 let step = store
283 .create_step(NewStep {
284 run_id: run.id,
285 trace_id: step_trace_id(run.id, "prod-gate", 0),
286 name: "prod-gate".to_string(),
287 kind: StepKind::Approval,
288 position: 0,
289 input: None,
290 is_error_handler: false,
291 })
292 .await
293 .expect("create step");
294
295 store
296 .update_step(
297 step.id,
298 StepUpdate {
299 status: Some(StepStatus::Running),
300 ..StepUpdate::default()
301 },
302 )
303 .await
304 .expect("to running");
305 store
306 .update_step(
307 step.id,
308 StepUpdate {
309 status: Some(StepStatus::AwaitingApproval),
310 approval_deadline_at: Some(Utc::now() + TimeDelta::seconds(offset_secs)),
311 approval_assignee: Some(Assignee::group("release-managers")),
312 ..StepUpdate::default()
313 },
314 )
315 .await
316 .expect("arm timer");
317
318 store.get_step(step.id).await.expect("get").expect("exists")
319 }
320
321 #[tokio::test]
322 async fn a_step_without_a_deadline_reports_no_sla() {
323 let response = StepResponse::from(step().await);
324 assert!(response.approval_deadline_at.is_none());
325 assert!(response.approval_seconds_remaining.is_none());
326 assert!(response.approval_assignee.is_none());
327 assert!(response.approval_requirement.is_none());
328 assert!(response.approvals.is_empty());
329 }
330
331 #[tokio::test]
332 async fn a_shell_step_requires_no_approvals() {
333 let response = StepResponse::from(step().await);
334 assert_eq!(response.approvals_required, None);
335 }
336
337 #[tokio::test]
338 async fn a_rule_less_approval_step_requires_one_approval() {
339 let response = StepResponse::from(gate_with_deadline(3600).await);
340 assert!(response.approval_requirement.is_none());
341 assert_eq!(response.approvals_required, Some(1));
342 }
343
344 #[tokio::test]
345 async fn an_approval_requirement_sets_the_required_count() {
346 let mut gate = gate_with_deadline(3600).await;
347 let requirement = ApprovalRequirement {
348 reason: Some("amount > 100k".to_string()),
349 required_approvers: 3,
350 approver_groups: vec!["finance".to_string()],
351 };
352 gate.approval_requirement = Some(requirement.clone());
353 gate.approvals = vec![StepApproval {
354 user_id: Uuid::now_v7(),
355 approved_by: "alice".to_string(),
356 at: Utc::now(),
357 }];
358
359 let response = StepResponse::from(gate);
360
361 assert_eq!(response.approvals_required, Some(3));
362 assert_eq!(response.approval_requirement, Some(requirement));
363 assert_eq!(response.approvals.len(), 1);
364 assert_eq!(response.approvals[0].approved_by, "alice");
365 }
366
367 #[tokio::test]
368 async fn a_human_input_step_requires_no_approval_count() {
369 let mut step = gate_with_deadline(3600).await;
370 step.kind = StepKind::HumanInput;
371 step.approval_requirement = Some(ApprovalRequirement {
372 reason: None,
373 required_approvers: 2,
374 approver_groups: vec!["product".to_string()],
375 });
376
377 let response = StepResponse::from(step);
378
379 assert_eq!(response.approvals_required, None);
380 assert!(response.approval_requirement.is_some());
381 }
382
383 #[tokio::test]
384 async fn a_future_deadline_reports_the_remaining_seconds() {
385 let response = StepResponse::from(gate_with_deadline(3600).await);
386
387 assert!(response.approval_deadline_at.is_some());
388 let remaining = response
389 .approval_seconds_remaining
390 .expect("a deadline yields a countdown");
391 assert!(remaining > 0 && remaining <= 3600, "got {remaining}");
392 assert_eq!(
393 response.approval_assignee,
394 Some(Assignee::group("release-managers"))
395 );
396 }
397
398 #[tokio::test]
399 async fn a_past_deadline_clamps_the_countdown_at_zero() {
400 let response = StepResponse::from(gate_with_deadline(-3600).await);
401 assert_eq!(response.approval_seconds_remaining, Some(0));
402 }
403
404 #[tokio::test]
405 async fn trace_id_is_exposed_in_step_response() {
406 let s = step().await;
407 let expected_trace_id = s.trace_id;
408 let response = StepResponse::from(s);
409
410 assert_eq!(response.trace_id, expected_trace_id);
411
412 let body = serde_json::to_value(&response).expect("serialize");
413 assert!(body["trace_id"].is_string());
414 }
415}