1use chrono::{DateTime, Utc};
4use ironflow_store::entities::ProviderAccount;
5use ironflow_store::models::{
6 ApprovalRequirement, Assignee, Step, StepApproval, StepKind, StepStatus,
7};
8use rust_decimal::Decimal;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use uuid::Uuid;
12
13use super::ArtifactResponse;
14
15#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct StepAccountResponse {
36 pub id: Uuid,
38 pub name: String,
40 pub display_name: String,
42}
43
44impl From<ProviderAccount> for StepAccountResponse {
45 fn from(account: ProviderAccount) -> Self {
46 Self {
47 id: account.id,
48 name: account.name,
49 display_name: account.display_name,
50 }
51 }
52}
53
54#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
63#[derive(Debug, Serialize, Deserialize)]
64pub struct StepResponse {
65 pub id: Uuid,
67 pub trace_id: Uuid,
69 pub run_id: Uuid,
71 pub name: String,
73 #[cfg_attr(feature = "openapi", schema(value_type = String))]
75 pub kind: StepKind,
76 pub position: u32,
78 pub status: StepStatus,
80 pub attempt: u32,
85 #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
87 pub input: Option<Value>,
88 #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
90 pub output: Option<Value>,
91 pub error: Option<String>,
93 pub duration_ms: u64,
95 #[cfg_attr(feature = "openapi", schema(value_type = f64))]
97 pub cost_usd: Decimal,
98 pub input_tokens: Option<u64>,
100 pub cache_read_input_tokens: Option<u64>,
102 pub cache_creation_input_tokens: Option<u64>,
104 pub output_tokens: Option<u64>,
106 pub created_at: DateTime<Utc>,
108 pub updated_at: DateTime<Utc>,
110 pub started_at: Option<DateTime<Utc>>,
112 pub completed_at: Option<DateTime<Utc>>,
114 pub dependencies: Vec<Uuid>,
116 #[cfg_attr(feature = "openapi", schema(value_type = Option<serde_json::Value>))]
120 pub debug_messages: Option<Value>,
121 #[serde(default)]
126 pub artifacts: Vec<ArtifactResponse>,
127 pub approval_deadline_at: Option<DateTime<Utc>>,
129 pub approval_seconds_remaining: Option<i64>,
132 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
136 pub approval_assignee: Option<Assignee>,
137 #[serde(default)]
140 pub approval_requirement: Option<ApprovalRequirement>,
141 #[serde(default)]
143 pub approvals: Vec<StepApproval>,
144 #[serde(default)]
147 pub approvals_required: Option<u32>,
148 #[serde(default)]
150 pub account_id: Option<Uuid>,
151 #[serde(default)]
157 pub account: Option<StepAccountResponse>,
158 #[serde(default)]
162 pub environment_id: Option<String>,
163}
164
165impl StepResponse {
166 pub fn with_account(mut self, account: StepAccountResponse) -> Self {
178 self.account = Some(account);
179 self
180 }
181
182 pub fn with_dependencies(step: Step, dependencies: Vec<Uuid>) -> Self {
188 Self::with_dependencies_and_artifacts(step, dependencies, Vec::new())
189 }
190
191 pub fn with_dependencies_and_artifacts(
193 step: Step,
194 dependencies: Vec<Uuid>,
195 artifacts: Vec<ArtifactResponse>,
196 ) -> Self {
197 let approval_seconds_remaining = step
198 .approval_deadline_at
199 .map(|at| (at - Utc::now()).num_seconds().max(0));
200 let approvals_required = match (&step.kind, &step.approval_requirement) {
201 (StepKind::HumanInput, _) => None,
204 (_, Some(requirement)) => Some(requirement.required_approvers),
205 (StepKind::Approval, None) => Some(1),
206 _ => None,
207 };
208
209 StepResponse {
210 id: step.id,
211 trace_id: step.trace_id,
212 run_id: step.run_id,
213 name: step.name,
214 kind: step.kind,
215 position: step.position,
216 status: step.status.state,
217 attempt: step.attempt,
218 input: step.input,
219 output: step.output,
220 error: step.error,
221 duration_ms: step.duration_ms,
222 cost_usd: step.cost_usd,
223 input_tokens: step.input_tokens,
224 cache_read_input_tokens: step.cache_read_input_tokens,
225 cache_creation_input_tokens: step.cache_creation_input_tokens,
226 output_tokens: step.output_tokens,
227 created_at: step.created_at,
228 updated_at: step.updated_at,
229 started_at: step.started_at,
230 completed_at: step.completed_at,
231 dependencies,
232 debug_messages: step.debug_messages,
233 artifacts,
234 approval_deadline_at: step.approval_deadline_at,
235 approval_seconds_remaining,
236 approval_assignee: step.approval_assignee,
237 approval_requirement: step.approval_requirement,
238 approvals: step.approvals,
239 approvals_required,
240 account_id: step.account_id,
241 account: None,
242 environment_id: step.environment_id,
243 }
244 }
245}
246
247impl From<Step> for StepResponse {
248 fn from(step: Step) -> Self {
249 Self::with_dependencies(step, Vec::new())
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use std::collections::HashMap;
256
257 use chrono::TimeDelta;
258 use ironflow_store::memory::InMemoryStore;
259 use ironflow_store::models::{NewRun, NewStep, StepUpdate, TriggerKind, step_trace_id};
260 use ironflow_store::store::RunStore;
261 use serde_json::json;
262
263 use super::*;
264
265 async fn step() -> Step {
268 let store = InMemoryStore::new();
269 let run = store
270 .create_run(NewRun {
271 created_by: None,
272 workflow_name: "test".to_string(),
273 trigger: TriggerKind::Manual,
274 payload: json!({}),
275 max_retries: 0,
276 handler_version: None,
277 labels: HashMap::new(),
278 scheduled_at: None,
279 idempotency_key: None,
280 concurrency_key: None,
281 concurrency_limits: Vec::new(),
282 max_cost_usd: None,
283 })
284 .await
285 .expect("create run")
286 .into_run();
287
288 store
289 .create_step(NewStep {
290 run_id: run.id,
291 trace_id: step_trace_id(run.id, "build", 0),
292 name: "build".to_string(),
293 kind: StepKind::Shell,
294 position: 0,
295 input: None,
296 is_error_handler: false,
297 })
298 .await
299 .expect("create step")
300 }
301
302 #[tokio::test]
303 async fn a_step_without_artifacts_exposes_an_empty_list() {
304 let response = StepResponse::from(step().await);
305 assert!(response.artifacts.is_empty());
306 }
307
308 #[tokio::test]
309 async fn artifacts_are_carried_through() {
310 let step = step().await;
311 let artifact = ArtifactResponse {
312 id: Uuid::now_v7(),
313 step_id: step.id,
314 name: "report.html".to_string(),
315 content_type: "text/html".to_string(),
316 size_bytes: 1,
317 sha256: "0".repeat(64),
318 created_at: Utc::now(),
319 };
320
321 let response =
322 StepResponse::with_dependencies_and_artifacts(step, Vec::new(), vec![artifact]);
323
324 assert_eq!(response.artifacts.len(), 1);
325 assert_eq!(response.artifacts[0].name, "report.html");
326 }
327
328 #[tokio::test]
329 async fn account_is_null_by_default() {
330 let body = serde_json::to_value(StepResponse::from(step().await)).expect("serialize");
331 assert!(body["account"].is_null());
332 }
333
334 #[tokio::test]
335 async fn with_account_fills_the_nested_account() {
336 let id = Uuid::now_v7();
337 let response = StepResponse::from(step().await).with_account(StepAccountResponse {
338 id,
339 name: "perso".to_string(),
340 display_name: "Compte perso".to_string(),
341 });
342 let body = serde_json::to_value(response).expect("serialize");
343 assert_eq!(body["account"]["id"], id.to_string());
344 assert_eq!(body["account"]["name"], "perso");
345 assert_eq!(body["account"]["display_name"], "Compte perso");
346 }
347
348 #[tokio::test]
349 async fn environment_id_is_null_by_default() {
350 let body = serde_json::to_value(StepResponse::from(step().await)).expect("serialize");
351 assert!(body["environment_id"].is_null());
352 }
353
354 #[tokio::test]
355 async fn environment_id_is_carried_from_the_stored_step() {
356 let mut step = step().await;
357 step.environment_id = Some("ironflow-env-0192f0c1".to_string());
358
359 let body = serde_json::to_value(StepResponse::from(step)).expect("serialize");
360
361 assert_eq!(body["environment_id"], "ironflow-env-0192f0c1");
362 }
363
364 #[tokio::test]
365 async fn artifacts_serialize_as_a_json_array() {
366 let body = serde_json::to_value(StepResponse::from(step().await)).expect("serialize");
367 assert!(body["artifacts"].is_array());
368 }
369
370 async fn gate_with_deadline(offset_secs: i64) -> Step {
372 let store = InMemoryStore::new();
373 let run = store
374 .create_run(NewRun {
375 created_by: None,
376 workflow_name: "test".to_string(),
377 trigger: TriggerKind::Manual,
378 payload: json!({}),
379 max_retries: 0,
380 handler_version: None,
381 labels: HashMap::new(),
382 scheduled_at: None,
383 idempotency_key: None,
384 concurrency_key: None,
385 concurrency_limits: Vec::new(),
386 max_cost_usd: None,
387 })
388 .await
389 .expect("create run")
390 .into_run();
391
392 let step = store
393 .create_step(NewStep {
394 run_id: run.id,
395 trace_id: step_trace_id(run.id, "prod-gate", 0),
396 name: "prod-gate".to_string(),
397 kind: StepKind::Approval,
398 position: 0,
399 input: None,
400 is_error_handler: false,
401 })
402 .await
403 .expect("create step");
404
405 store
406 .update_step(
407 step.id,
408 StepUpdate {
409 status: Some(StepStatus::Running),
410 ..StepUpdate::default()
411 },
412 )
413 .await
414 .expect("to running");
415 store
416 .update_step(
417 step.id,
418 StepUpdate {
419 status: Some(StepStatus::AwaitingApproval),
420 approval_deadline_at: Some(Utc::now() + TimeDelta::seconds(offset_secs)),
421 approval_assignee: Some(Assignee::group("release-managers")),
422 ..StepUpdate::default()
423 },
424 )
425 .await
426 .expect("arm timer");
427
428 store.get_step(step.id).await.expect("get").expect("exists")
429 }
430
431 #[tokio::test]
432 async fn a_step_without_a_deadline_reports_no_sla() {
433 let response = StepResponse::from(step().await);
434 assert!(response.approval_deadline_at.is_none());
435 assert!(response.approval_seconds_remaining.is_none());
436 assert!(response.approval_assignee.is_none());
437 assert!(response.approval_requirement.is_none());
438 assert!(response.approvals.is_empty());
439 }
440
441 #[tokio::test]
442 async fn a_shell_step_requires_no_approvals() {
443 let response = StepResponse::from(step().await);
444 assert_eq!(response.approvals_required, None);
445 }
446
447 #[tokio::test]
448 async fn a_rule_less_approval_step_requires_one_approval() {
449 let response = StepResponse::from(gate_with_deadline(3600).await);
450 assert!(response.approval_requirement.is_none());
451 assert_eq!(response.approvals_required, Some(1));
452 }
453
454 #[tokio::test]
455 async fn an_approval_requirement_sets_the_required_count() {
456 let mut gate = gate_with_deadline(3600).await;
457 let requirement = ApprovalRequirement {
458 reason: Some("amount > 100k".to_string()),
459 required_approvers: 3,
460 approver_groups: vec!["finance".to_string()],
461 };
462 gate.approval_requirement = Some(requirement.clone());
463 gate.approvals = vec![StepApproval {
464 user_id: Uuid::now_v7(),
465 approved_by: "alice".to_string(),
466 at: Utc::now(),
467 }];
468
469 let response = StepResponse::from(gate);
470
471 assert_eq!(response.approvals_required, Some(3));
472 assert_eq!(response.approval_requirement, Some(requirement));
473 assert_eq!(response.approvals.len(), 1);
474 assert_eq!(response.approvals[0].approved_by, "alice");
475 }
476
477 #[tokio::test]
478 async fn a_human_input_step_requires_no_approval_count() {
479 let mut step = gate_with_deadline(3600).await;
480 step.kind = StepKind::HumanInput;
481 step.approval_requirement = Some(ApprovalRequirement {
482 reason: None,
483 required_approvers: 2,
484 approver_groups: vec!["product".to_string()],
485 });
486
487 let response = StepResponse::from(step);
488
489 assert_eq!(response.approvals_required, None);
490 assert!(response.approval_requirement.is_some());
491 }
492
493 #[tokio::test]
494 async fn a_future_deadline_reports_the_remaining_seconds() {
495 let response = StepResponse::from(gate_with_deadline(3600).await);
496
497 assert!(response.approval_deadline_at.is_some());
498 let remaining = response
499 .approval_seconds_remaining
500 .expect("a deadline yields a countdown");
501 assert!(remaining > 0 && remaining <= 3600, "got {remaining}");
502 assert_eq!(
503 response.approval_assignee,
504 Some(Assignee::group("release-managers"))
505 );
506 }
507
508 #[tokio::test]
509 async fn a_past_deadline_clamps_the_countdown_at_zero() {
510 let response = StepResponse::from(gate_with_deadline(-3600).await);
511 assert_eq!(response.approval_seconds_remaining, Some(0));
512 }
513
514 #[tokio::test]
515 async fn trace_id_is_exposed_in_step_response() {
516 let s = step().await;
517 let expected_trace_id = s.trace_id;
518 let response = StepResponse::from(s);
519
520 assert_eq!(response.trace_id, expected_trace_id);
521
522 let body = serde_json::to_value(&response).expect("serialize");
523 assert!(body["trace_id"].is_string());
524 }
525}