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 #[serde(default)]
166 pub session_id: Option<String>,
167}
168
169impl StepResponse {
170 pub fn with_account(mut self, account: StepAccountResponse) -> Self {
182 self.account = Some(account);
183 self
184 }
185
186 pub fn with_dependencies(step: Step, dependencies: Vec<Uuid>) -> Self {
192 Self::with_dependencies_and_artifacts(step, dependencies, Vec::new())
193 }
194
195 pub fn with_dependencies_and_artifacts(
197 step: Step,
198 dependencies: Vec<Uuid>,
199 artifacts: Vec<ArtifactResponse>,
200 ) -> Self {
201 let approval_seconds_remaining = step
202 .approval_deadline_at
203 .map(|at| (at - Utc::now()).num_seconds().max(0));
204 let approvals_required = match (&step.kind, &step.approval_requirement) {
205 (StepKind::HumanInput, _) => None,
208 (_, Some(requirement)) => Some(requirement.required_approvers),
209 (StepKind::Approval, None) => Some(1),
210 _ => None,
211 };
212
213 StepResponse {
214 id: step.id,
215 trace_id: step.trace_id,
216 run_id: step.run_id,
217 name: step.name,
218 kind: step.kind,
219 position: step.position,
220 status: step.status.state,
221 attempt: step.attempt,
222 input: step.input,
223 output: step.output,
224 error: step.error,
225 duration_ms: step.duration_ms,
226 cost_usd: step.cost_usd,
227 input_tokens: step.input_tokens,
228 cache_read_input_tokens: step.cache_read_input_tokens,
229 cache_creation_input_tokens: step.cache_creation_input_tokens,
230 output_tokens: step.output_tokens,
231 created_at: step.created_at,
232 updated_at: step.updated_at,
233 started_at: step.started_at,
234 completed_at: step.completed_at,
235 dependencies,
236 debug_messages: step.debug_messages,
237 artifacts,
238 approval_deadline_at: step.approval_deadline_at,
239 approval_seconds_remaining,
240 approval_assignee: step.approval_assignee,
241 approval_requirement: step.approval_requirement,
242 approvals: step.approvals,
243 approvals_required,
244 account_id: step.account_id,
245 account: None,
246 environment_id: step.environment_id,
247 session_id: step.session_id,
248 }
249 }
250}
251
252impl From<Step> for StepResponse {
253 fn from(step: Step) -> Self {
254 Self::with_dependencies(step, Vec::new())
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use std::collections::HashMap;
261
262 use chrono::TimeDelta;
263 use ironflow_store::memory::InMemoryStore;
264 use ironflow_store::models::{NewRun, NewStep, StepUpdate, TriggerKind, step_trace_id};
265 use ironflow_store::store::RunStore;
266 use serde_json::json;
267
268 use super::*;
269
270 async fn step() -> Step {
273 let store = InMemoryStore::new();
274 let run = store
275 .create_run(NewRun {
276 created_by: None,
277 workflow_name: "test".to_string(),
278 trigger: TriggerKind::Manual,
279 payload: json!({}),
280 max_retries: 0,
281 handler_version: None,
282 labels: HashMap::new(),
283 scheduled_at: None,
284 idempotency_key: None,
285 concurrency_key: None,
286 priority: 0,
287 concurrency_limits: Vec::new(),
288 max_cost_usd: None,
289 worker_tags: Vec::new(),
290 })
291 .await
292 .expect("create run")
293 .into_run();
294
295 store
296 .create_step(NewStep {
297 run_id: run.id,
298 trace_id: step_trace_id(run.id, "build", 0),
299 name: "build".to_string(),
300 kind: StepKind::Shell,
301 position: 0,
302 input: None,
303 is_error_handler: false,
304 })
305 .await
306 .expect("create step")
307 }
308
309 #[tokio::test]
310 async fn a_step_without_artifacts_exposes_an_empty_list() {
311 let response = StepResponse::from(step().await);
312 assert!(response.artifacts.is_empty());
313 }
314
315 #[tokio::test]
316 async fn artifacts_are_carried_through() {
317 let step = step().await;
318 let artifact = ArtifactResponse {
319 id: Uuid::now_v7(),
320 step_id: step.id,
321 name: "report.html".to_string(),
322 content_type: "text/html".to_string(),
323 size_bytes: 1,
324 sha256: "0".repeat(64),
325 created_at: Utc::now(),
326 };
327
328 let response =
329 StepResponse::with_dependencies_and_artifacts(step, Vec::new(), vec![artifact]);
330
331 assert_eq!(response.artifacts.len(), 1);
332 assert_eq!(response.artifacts[0].name, "report.html");
333 }
334
335 #[tokio::test]
336 async fn account_is_null_by_default() {
337 let body = serde_json::to_value(StepResponse::from(step().await)).expect("serialize");
338 assert!(body["account"].is_null());
339 }
340
341 #[tokio::test]
342 async fn with_account_fills_the_nested_account() {
343 let id = Uuid::now_v7();
344 let response = StepResponse::from(step().await).with_account(StepAccountResponse {
345 id,
346 name: "perso".to_string(),
347 display_name: "Compte perso".to_string(),
348 });
349 let body = serde_json::to_value(response).expect("serialize");
350 assert_eq!(body["account"]["id"], id.to_string());
351 assert_eq!(body["account"]["name"], "perso");
352 assert_eq!(body["account"]["display_name"], "Compte perso");
353 }
354
355 #[tokio::test]
356 async fn environment_id_is_null_by_default() {
357 let body = serde_json::to_value(StepResponse::from(step().await)).expect("serialize");
358 assert!(body["environment_id"].is_null());
359 }
360
361 #[tokio::test]
362 async fn environment_id_is_carried_from_the_stored_step() {
363 let mut step = step().await;
364 step.environment_id = Some("ironflow-env-0192f0c1".to_string());
365
366 let body = serde_json::to_value(StepResponse::from(step)).expect("serialize");
367
368 assert_eq!(body["environment_id"], "ironflow-env-0192f0c1");
369 }
370
371 #[tokio::test]
372 async fn session_id_is_null_by_default() {
373 let body = serde_json::to_value(StepResponse::from(step().await)).expect("serialize");
374 assert!(body["session_id"].is_null());
375 }
376
377 #[tokio::test]
378 async fn session_id_is_carried_from_the_stored_step() {
379 let mut step = step().await;
380 step.session_id = Some("0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d".to_string());
381
382 let body = serde_json::to_value(StepResponse::from(step)).expect("serialize");
383
384 assert_eq!(body["session_id"], "0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d");
385 }
386
387 #[tokio::test]
388 async fn artifacts_serialize_as_a_json_array() {
389 let body = serde_json::to_value(StepResponse::from(step().await)).expect("serialize");
390 assert!(body["artifacts"].is_array());
391 }
392
393 async fn gate_with_deadline(offset_secs: i64) -> Step {
395 let store = InMemoryStore::new();
396 let run = store
397 .create_run(NewRun {
398 created_by: None,
399 workflow_name: "test".to_string(),
400 trigger: TriggerKind::Manual,
401 payload: json!({}),
402 max_retries: 0,
403 handler_version: None,
404 labels: HashMap::new(),
405 scheduled_at: None,
406 idempotency_key: None,
407 concurrency_key: None,
408 priority: 0,
409 concurrency_limits: Vec::new(),
410 max_cost_usd: None,
411 worker_tags: Vec::new(),
412 })
413 .await
414 .expect("create run")
415 .into_run();
416
417 let step = store
418 .create_step(NewStep {
419 run_id: run.id,
420 trace_id: step_trace_id(run.id, "prod-gate", 0),
421 name: "prod-gate".to_string(),
422 kind: StepKind::Approval,
423 position: 0,
424 input: None,
425 is_error_handler: false,
426 })
427 .await
428 .expect("create step");
429
430 store
431 .update_step(
432 step.id,
433 StepUpdate {
434 status: Some(StepStatus::Running),
435 ..StepUpdate::default()
436 },
437 )
438 .await
439 .expect("to running");
440 store
441 .update_step(
442 step.id,
443 StepUpdate {
444 status: Some(StepStatus::AwaitingApproval),
445 approval_deadline_at: Some(Utc::now() + TimeDelta::seconds(offset_secs)),
446 approval_assignee: Some(Assignee::group("release-managers")),
447 ..StepUpdate::default()
448 },
449 )
450 .await
451 .expect("arm timer");
452
453 store.get_step(step.id).await.expect("get").expect("exists")
454 }
455
456 #[tokio::test]
457 async fn a_step_without_a_deadline_reports_no_sla() {
458 let response = StepResponse::from(step().await);
459 assert!(response.approval_deadline_at.is_none());
460 assert!(response.approval_seconds_remaining.is_none());
461 assert!(response.approval_assignee.is_none());
462 assert!(response.approval_requirement.is_none());
463 assert!(response.approvals.is_empty());
464 }
465
466 #[tokio::test]
467 async fn a_shell_step_requires_no_approvals() {
468 let response = StepResponse::from(step().await);
469 assert_eq!(response.approvals_required, None);
470 }
471
472 #[tokio::test]
473 async fn a_rule_less_approval_step_requires_one_approval() {
474 let response = StepResponse::from(gate_with_deadline(3600).await);
475 assert!(response.approval_requirement.is_none());
476 assert_eq!(response.approvals_required, Some(1));
477 }
478
479 #[tokio::test]
480 async fn an_approval_requirement_sets_the_required_count() {
481 let mut gate = gate_with_deadline(3600).await;
482 let requirement = ApprovalRequirement {
483 reason: Some("amount > 100k".to_string()),
484 required_approvers: 3,
485 approver_groups: vec!["finance".to_string()],
486 };
487 gate.approval_requirement = Some(requirement.clone());
488 gate.approvals = vec![StepApproval {
489 user_id: Uuid::now_v7(),
490 approved_by: "alice".to_string(),
491 at: Utc::now(),
492 }];
493
494 let response = StepResponse::from(gate);
495
496 assert_eq!(response.approvals_required, Some(3));
497 assert_eq!(response.approval_requirement, Some(requirement));
498 assert_eq!(response.approvals.len(), 1);
499 assert_eq!(response.approvals[0].approved_by, "alice");
500 }
501
502 #[tokio::test]
503 async fn a_human_input_step_requires_no_approval_count() {
504 let mut step = gate_with_deadline(3600).await;
505 step.kind = StepKind::HumanInput;
506 step.approval_requirement = Some(ApprovalRequirement {
507 reason: None,
508 required_approvers: 2,
509 approver_groups: vec!["product".to_string()],
510 });
511
512 let response = StepResponse::from(step);
513
514 assert_eq!(response.approvals_required, None);
515 assert!(response.approval_requirement.is_some());
516 }
517
518 #[tokio::test]
519 async fn a_future_deadline_reports_the_remaining_seconds() {
520 let response = StepResponse::from(gate_with_deadline(3600).await);
521
522 assert!(response.approval_deadline_at.is_some());
523 let remaining = response
524 .approval_seconds_remaining
525 .expect("a deadline yields a countdown");
526 assert!(remaining > 0 && remaining <= 3600, "got {remaining}");
527 assert_eq!(
528 response.approval_assignee,
529 Some(Assignee::group("release-managers"))
530 );
531 }
532
533 #[tokio::test]
534 async fn a_past_deadline_clamps_the_countdown_at_zero() {
535 let response = StepResponse::from(gate_with_deadline(-3600).await);
536 assert_eq!(response.approval_seconds_remaining, Some(0));
537 }
538
539 #[tokio::test]
540 async fn trace_id_is_exposed_in_step_response() {
541 let s = step().await;
542 let expected_trace_id = s.trace_id;
543 let response = StepResponse::from(s);
544
545 assert_eq!(response.trace_id, expected_trace_id);
546
547 let body = serde_json::to_value(&response).expect("serialize");
548 assert!(body["trace_id"].is_string());
549 }
550}