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::IntoParams))]
69#[derive(Debug, Deserialize)]
70pub struct ListStepsQuery {
71 pub agent_role: String,
74 pub page: Option<u32>,
76 pub per_page: Option<u32>,
78}
79
80#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
89#[derive(Debug, Serialize, Deserialize)]
90pub struct StepResponse {
91 pub id: Uuid,
93 pub trace_id: Uuid,
95 pub run_id: Uuid,
97 pub name: String,
99 #[cfg_attr(feature = "openapi", schema(value_type = String))]
101 pub kind: StepKind,
102 pub position: u32,
104 pub status: StepStatus,
106 pub attempt: u32,
111 #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
113 pub input: Option<Value>,
114 #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
116 pub output: Option<Value>,
117 pub error: Option<String>,
119 pub duration_ms: u64,
121 #[cfg_attr(feature = "openapi", schema(value_type = f64))]
123 pub cost_usd: Decimal,
124 pub input_tokens: Option<u64>,
126 pub cache_read_input_tokens: Option<u64>,
128 pub cache_creation_input_tokens: Option<u64>,
130 pub output_tokens: Option<u64>,
132 pub created_at: DateTime<Utc>,
134 pub updated_at: DateTime<Utc>,
136 pub started_at: Option<DateTime<Utc>>,
138 pub completed_at: Option<DateTime<Utc>>,
140 pub dependencies: Vec<Uuid>,
142 #[cfg_attr(feature = "openapi", schema(value_type = Option<serde_json::Value>))]
146 pub debug_messages: Option<Value>,
147 #[serde(default)]
152 pub artifacts: Vec<ArtifactResponse>,
153 pub approval_deadline_at: Option<DateTime<Utc>>,
155 pub approval_seconds_remaining: Option<i64>,
158 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
162 pub approval_assignee: Option<Assignee>,
163 #[serde(default)]
166 pub approval_requirement: Option<ApprovalRequirement>,
167 #[serde(default)]
169 pub approvals: Vec<StepApproval>,
170 #[serde(default)]
173 pub approvals_required: Option<u32>,
174 #[serde(default)]
176 pub account_id: Option<Uuid>,
177 #[serde(default)]
183 pub account: Option<StepAccountResponse>,
184 #[serde(default)]
188 pub environment_id: Option<String>,
189 #[serde(default)]
192 pub session_id: Option<String>,
193 #[serde(default)]
197 pub agent_role: Option<String>,
198}
199
200impl StepResponse {
201 pub fn with_account(mut self, account: StepAccountResponse) -> Self {
213 self.account = Some(account);
214 self
215 }
216
217 pub fn with_dependencies(step: Step, dependencies: Vec<Uuid>) -> Self {
223 Self::with_dependencies_and_artifacts(step, dependencies, Vec::new())
224 }
225
226 pub fn with_dependencies_and_artifacts(
228 step: Step,
229 dependencies: Vec<Uuid>,
230 artifacts: Vec<ArtifactResponse>,
231 ) -> Self {
232 let approval_seconds_remaining = step
233 .approval_deadline_at
234 .map(|at| (at - Utc::now()).num_seconds().max(0));
235 let approvals_required = match (&step.kind, &step.approval_requirement) {
236 (StepKind::HumanInput, _) => None,
239 (_, Some(requirement)) => Some(requirement.required_approvers),
240 (StepKind::Approval, None) => Some(1),
241 _ => None,
242 };
243
244 StepResponse {
245 id: step.id,
246 trace_id: step.trace_id,
247 run_id: step.run_id,
248 name: step.name,
249 kind: step.kind,
250 position: step.position,
251 status: step.status.state,
252 attempt: step.attempt,
253 input: step.input,
254 output: step.output,
255 error: step.error,
256 duration_ms: step.duration_ms,
257 cost_usd: step.cost_usd,
258 input_tokens: step.input_tokens,
259 cache_read_input_tokens: step.cache_read_input_tokens,
260 cache_creation_input_tokens: step.cache_creation_input_tokens,
261 output_tokens: step.output_tokens,
262 created_at: step.created_at,
263 updated_at: step.updated_at,
264 started_at: step.started_at,
265 completed_at: step.completed_at,
266 dependencies,
267 debug_messages: step.debug_messages,
268 artifacts,
269 approval_deadline_at: step.approval_deadline_at,
270 approval_seconds_remaining,
271 approval_assignee: step.approval_assignee,
272 approval_requirement: step.approval_requirement,
273 approvals: step.approvals,
274 approvals_required,
275 account_id: step.account_id,
276 account: None,
277 environment_id: step.environment_id,
278 session_id: step.session_id,
279 agent_role: step.agent_role,
280 }
281 }
282}
283
284impl From<Step> for StepResponse {
285 fn from(step: Step) -> Self {
286 Self::with_dependencies(step, Vec::new())
287 }
288}
289
290#[cfg(test)]
291mod tests {
292 use std::collections::HashMap;
293
294 use chrono::TimeDelta;
295 use ironflow_store::memory::InMemoryStore;
296 use ironflow_store::models::{NewRun, NewStep, StepUpdate, TriggerKind, step_trace_id};
297 use ironflow_store::store::RunStore;
298 use serde_json::{json, to_value};
299
300 use super::*;
301
302 async fn step() -> Step {
305 let store = InMemoryStore::new();
306 let run = store
307 .create_run(NewRun {
308 created_by: None,
309 workflow_name: "test".to_string(),
310 trigger: TriggerKind::Manual,
311 payload: json!({}),
312 max_retries: 0,
313 handler_version: None,
314 labels: HashMap::new(),
315 scheduled_at: None,
316 idempotency_key: None,
317 concurrency_key: None,
318 priority: 0,
319 concurrency_limits: Vec::new(),
320 max_cost_usd: None,
321 worker_tags: Vec::new(),
322 })
323 .await
324 .expect("create run")
325 .into_run();
326
327 store
328 .create_step(NewStep {
329 run_id: run.id,
330 trace_id: step_trace_id(run.id, "build", 0),
331 name: "build".to_string(),
332 kind: StepKind::Shell,
333 position: 0,
334 input: None,
335 is_error_handler: false,
336 })
337 .await
338 .expect("create step")
339 }
340
341 #[tokio::test]
342 async fn a_step_without_artifacts_exposes_an_empty_list() {
343 let response = StepResponse::from(step().await);
344 assert!(response.artifacts.is_empty());
345 }
346
347 #[tokio::test]
348 async fn artifacts_are_carried_through() {
349 let step = step().await;
350 let artifact = ArtifactResponse {
351 id: Uuid::now_v7(),
352 step_id: step.id,
353 name: "report.html".to_string(),
354 content_type: "text/html".to_string(),
355 size_bytes: 1,
356 sha256: "0".repeat(64),
357 created_at: Utc::now(),
358 };
359
360 let response =
361 StepResponse::with_dependencies_and_artifacts(step, Vec::new(), vec![artifact]);
362
363 assert_eq!(response.artifacts.len(), 1);
364 assert_eq!(response.artifacts[0].name, "report.html");
365 }
366
367 #[tokio::test]
368 async fn account_is_null_by_default() {
369 let body = to_value(StepResponse::from(step().await)).expect("serialize");
370 assert!(body["account"].is_null());
371 }
372
373 #[tokio::test]
374 async fn with_account_fills_the_nested_account() {
375 let id = Uuid::now_v7();
376 let response = StepResponse::from(step().await).with_account(StepAccountResponse {
377 id,
378 name: "perso".to_string(),
379 display_name: "Compte perso".to_string(),
380 });
381 let body = to_value(response).expect("serialize");
382 assert_eq!(body["account"]["id"], id.to_string());
383 assert_eq!(body["account"]["name"], "perso");
384 assert_eq!(body["account"]["display_name"], "Compte perso");
385 }
386
387 #[tokio::test]
388 async fn environment_id_is_null_by_default() {
389 let body = to_value(StepResponse::from(step().await)).expect("serialize");
390 assert!(body["environment_id"].is_null());
391 }
392
393 #[tokio::test]
394 async fn environment_id_is_carried_from_the_stored_step() {
395 let mut step = step().await;
396 step.environment_id = Some("ironflow-env-0192f0c1".to_string());
397
398 let body = to_value(StepResponse::from(step)).expect("serialize");
399
400 assert_eq!(body["environment_id"], "ironflow-env-0192f0c1");
401 }
402
403 #[tokio::test]
404 async fn session_id_is_null_by_default() {
405 let body = to_value(StepResponse::from(step().await)).expect("serialize");
406 assert!(body["session_id"].is_null());
407 }
408
409 #[tokio::test]
410 async fn session_id_is_carried_from_the_stored_step() {
411 let mut step = step().await;
412 step.session_id = Some("0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d".to_string());
413
414 let body = to_value(StepResponse::from(step)).expect("serialize");
415
416 assert_eq!(body["session_id"], "0192f0c1-7d2e-7a4b-9c3d-1e2f3a4b5c6d");
417 }
418
419 #[tokio::test]
420 async fn agent_role_is_null_by_default() {
421 let body = to_value(StepResponse::from(step().await)).expect("serialize");
422 assert!(body["agent_role"].is_null());
423 }
424
425 #[tokio::test]
426 async fn agent_role_is_carried_from_the_stored_step() {
427 let mut step = step().await;
428 step.agent_role = Some("Reviewer".to_string());
429
430 let body = to_value(StepResponse::from(step)).expect("serialize");
431
432 assert_eq!(body["agent_role"], "Reviewer");
433 }
434
435 #[tokio::test]
436 async fn artifacts_serialize_as_a_json_array() {
437 let body = to_value(StepResponse::from(step().await)).expect("serialize");
438 assert!(body["artifacts"].is_array());
439 }
440
441 async fn gate_with_deadline(offset_secs: i64) -> Step {
443 let store = InMemoryStore::new();
444 let run = store
445 .create_run(NewRun {
446 created_by: None,
447 workflow_name: "test".to_string(),
448 trigger: TriggerKind::Manual,
449 payload: json!({}),
450 max_retries: 0,
451 handler_version: None,
452 labels: HashMap::new(),
453 scheduled_at: None,
454 idempotency_key: None,
455 concurrency_key: None,
456 priority: 0,
457 concurrency_limits: Vec::new(),
458 max_cost_usd: None,
459 worker_tags: Vec::new(),
460 })
461 .await
462 .expect("create run")
463 .into_run();
464
465 let step = store
466 .create_step(NewStep {
467 run_id: run.id,
468 trace_id: step_trace_id(run.id, "prod-gate", 0),
469 name: "prod-gate".to_string(),
470 kind: StepKind::Approval,
471 position: 0,
472 input: None,
473 is_error_handler: false,
474 })
475 .await
476 .expect("create step");
477
478 store
479 .update_step(
480 step.id,
481 StepUpdate {
482 status: Some(StepStatus::Running),
483 ..StepUpdate::default()
484 },
485 )
486 .await
487 .expect("to running");
488 store
489 .update_step(
490 step.id,
491 StepUpdate {
492 status: Some(StepStatus::AwaitingApproval),
493 approval_deadline_at: Some(Utc::now() + TimeDelta::seconds(offset_secs)),
494 approval_assignee: Some(Assignee::group("release-managers")),
495 ..StepUpdate::default()
496 },
497 )
498 .await
499 .expect("arm timer");
500
501 store.get_step(step.id).await.expect("get").expect("exists")
502 }
503
504 #[tokio::test]
505 async fn a_step_without_a_deadline_reports_no_sla() {
506 let response = StepResponse::from(step().await);
507 assert!(response.approval_deadline_at.is_none());
508 assert!(response.approval_seconds_remaining.is_none());
509 assert!(response.approval_assignee.is_none());
510 assert!(response.approval_requirement.is_none());
511 assert!(response.approvals.is_empty());
512 }
513
514 #[tokio::test]
515 async fn a_shell_step_requires_no_approvals() {
516 let response = StepResponse::from(step().await);
517 assert_eq!(response.approvals_required, None);
518 }
519
520 #[tokio::test]
521 async fn a_rule_less_approval_step_requires_one_approval() {
522 let response = StepResponse::from(gate_with_deadline(3600).await);
523 assert!(response.approval_requirement.is_none());
524 assert_eq!(response.approvals_required, Some(1));
525 }
526
527 #[tokio::test]
528 async fn an_approval_requirement_sets_the_required_count() {
529 let mut gate = gate_with_deadline(3600).await;
530 let requirement = ApprovalRequirement {
531 reason: Some("amount > 100k".to_string()),
532 required_approvers: 3,
533 approver_groups: vec!["finance".to_string()],
534 };
535 gate.approval_requirement = Some(requirement.clone());
536 gate.approvals = vec![StepApproval {
537 user_id: Uuid::now_v7(),
538 approved_by: "alice".to_string(),
539 at: Utc::now(),
540 }];
541
542 let response = StepResponse::from(gate);
543
544 assert_eq!(response.approvals_required, Some(3));
545 assert_eq!(response.approval_requirement, Some(requirement));
546 assert_eq!(response.approvals.len(), 1);
547 assert_eq!(response.approvals[0].approved_by, "alice");
548 }
549
550 #[tokio::test]
551 async fn a_human_input_step_requires_no_approval_count() {
552 let mut step = gate_with_deadline(3600).await;
553 step.kind = StepKind::HumanInput;
554 step.approval_requirement = Some(ApprovalRequirement {
555 reason: None,
556 required_approvers: 2,
557 approver_groups: vec!["product".to_string()],
558 });
559
560 let response = StepResponse::from(step);
561
562 assert_eq!(response.approvals_required, None);
563 assert!(response.approval_requirement.is_some());
564 }
565
566 #[tokio::test]
567 async fn a_future_deadline_reports_the_remaining_seconds() {
568 let response = StepResponse::from(gate_with_deadline(3600).await);
569
570 assert!(response.approval_deadline_at.is_some());
571 let remaining = response
572 .approval_seconds_remaining
573 .expect("a deadline yields a countdown");
574 assert!(remaining > 0 && remaining <= 3600, "got {remaining}");
575 assert_eq!(
576 response.approval_assignee,
577 Some(Assignee::group("release-managers"))
578 );
579 }
580
581 #[tokio::test]
582 async fn a_past_deadline_clamps_the_countdown_at_zero() {
583 let response = StepResponse::from(gate_with_deadline(-3600).await);
584 assert_eq!(response.approval_seconds_remaining, Some(0));
585 }
586
587 #[tokio::test]
588 async fn trace_id_is_exposed_in_step_response() {
589 let s = step().await;
590 let expected_trace_id = s.trace_id;
591 let response = StepResponse::from(s);
592
593 assert_eq!(response.trace_id, expected_trace_id);
594
595 let body = to_value(&response).expect("serialize");
596 assert!(body["trace_id"].is_string());
597 }
598}