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