Skip to main content

ironflow_api/entities/
step.rs

1//! Step-related DTOs.
2
3use 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/// Readable identity of the Provider Account a step ran under.
16///
17/// Deliberately minimal: it never carries the credential, the secret key or
18/// any scheduling data, since it is readable by anyone who can read the run.
19///
20/// # Examples
21///
22/// ```
23/// use ironflow_api::entities::StepAccountResponse;
24/// use uuid::Uuid;
25///
26/// let account = StepAccountResponse {
27///     id: Uuid::now_v7(),
28///     name: "perso".to_string(),
29///     display_name: "Compte perso".to_string(),
30/// };
31/// assert_eq!(account.name, "perso");
32/// ```
33#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct StepAccountResponse {
36    /// Account ID.
37    pub id: Uuid,
38    /// Unique slug.
39    pub name: String,
40    /// Human-readable name.
41    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/// Step response DTO — public API representation of a step.
55///
56/// # Examples
57///
58/// ```
59/// use ironflow_store::models::Step;
60/// use ironflow_api::entities::StepResponse;
61/// ```
62#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
63#[derive(Debug, Serialize, Deserialize)]
64pub struct StepResponse {
65    /// Unique step identifier.
66    pub id: Uuid,
67    /// Deterministic trace ID for log correlation.
68    pub trace_id: Uuid,
69    /// Parent run ID.
70    pub run_id: Uuid,
71    /// Step name.
72    pub name: String,
73    /// Step operation type.
74    #[cfg_attr(feature = "openapi", schema(value_type = String))]
75    pub kind: StepKind,
76    /// Execution order (0-based).
77    pub position: u32,
78    /// Current status.
79    pub status: StepStatus,
80    /// Which run attempt produced this step (1-based).
81    ///
82    /// A run retried twice exposes steps with `attempt` 1, 2 and 3. Steps from
83    /// earlier attempts are kept so a failed attempt stays inspectable.
84    pub attempt: u32,
85    /// Input configuration.
86    #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
87    pub input: Option<Value>,
88    /// Step output.
89    #[cfg_attr(feature = "openapi", schema(value_type = Option<std::collections::HashMap<String, serde_json::Value>>))]
90    pub output: Option<Value>,
91    /// Optional error message.
92    pub error: Option<String>,
93    /// Execution duration in milliseconds.
94    pub duration_ms: u64,
95    /// Cost in USD.
96    #[cfg_attr(feature = "openapi", schema(value_type = f64))]
97    pub cost_usd: Decimal,
98    /// Uncached input token count (agent steps).
99    pub input_tokens: Option<u64>,
100    /// Input tokens served from the prompt cache (agent steps).
101    pub cache_read_input_tokens: Option<u64>,
102    /// Input tokens written to the prompt cache (agent steps).
103    pub cache_creation_input_tokens: Option<u64>,
104    /// Output token count (agent steps).
105    pub output_tokens: Option<u64>,
106    /// When created.
107    pub created_at: DateTime<Utc>,
108    /// When updated.
109    pub updated_at: DateTime<Utc>,
110    /// When execution started.
111    pub started_at: Option<DateTime<Utc>>,
112    /// When execution completed.
113    pub completed_at: Option<DateTime<Utc>>,
114    /// IDs of steps this step depends on (direct dependencies).
115    pub dependencies: Vec<Uuid>,
116    /// Verbose conversation trace for agent steps (thinking blocks, tool
117    /// calls, tool results, per-turn usage). `None` when verbose mode was
118    /// off or the step is not an agent step.
119    #[cfg_attr(feature = "openapi", schema(value_type = Option<serde_json::Value>))]
120    pub debug_messages: Option<Value>,
121    /// Files this step produced, downloadable through the artifact route.
122    ///
123    /// Empty when the step produced none or when artifact storage is not
124    /// configured on the server.
125    #[serde(default)]
126    pub artifacts: Vec<ArtifactResponse>,
127    /// When this approval gate expires, if it carries an SLA deadline.
128    pub approval_deadline_at: Option<DateTime<Utc>>,
129    /// Seconds left before the gate escalates. Clamped at 0, `None` when the
130    /// step has no deadline.
131    pub approval_seconds_remaining: Option<i64>,
132    /// Who the approval is currently assigned to.
133    ///
134    /// Serialized as a prefixed string: `user:{name}` or `group:{name}`.
135    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
136    pub approval_assignee: Option<Assignee>,
137    /// Approvers the workflow handler required when the gate opened. `None`
138    /// for a gate opened without approvers.
139    #[serde(default)]
140    pub approval_requirement: Option<ApprovalRequirement>,
141    /// Votes cast on the approval gate so far, at most one per user.
142    #[serde(default)]
143    pub approvals: Vec<StepApproval>,
144    /// Distinct approvals the gate needs: the requirement's count, `1` for an
145    /// approval step without rules, `None` for any other step kind.
146    #[serde(default)]
147    pub approvals_required: Option<u32>,
148    /// Provider Account the agent step ran under, if any.
149    #[serde(default)]
150    pub account_id: Option<Uuid>,
151    /// Readable identity of the Provider Account the step ran under.
152    ///
153    /// `None` when the step has no account or the account was deleted since
154    /// (`account_id` is then still set). Readable by anyone who can read the
155    /// run; no other account data is exposed here.
156    #[serde(default)]
157    pub account: Option<StepAccountResponse>,
158    /// Persistent environment the agent step ran in, if any. Pass it to
159    /// `Agent::resume_environment` to continue a later step in the same
160    /// workspace.
161    #[serde(default)]
162    pub environment_id: Option<String>,
163    /// Claude Code session the agent step ran in, if any. A step interrupted
164    /// by a lost lease resumes it on its next execution.
165    #[serde(default)]
166    pub session_id: Option<String>,
167}
168
169impl StepResponse {
170    /// Attach the readable identity of the step's Provider Account.
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// use ironflow_api::entities::{StepAccountResponse, StepResponse};
176    ///
177    /// # fn example(response: StepResponse, account: StepAccountResponse) -> StepResponse {
178    /// response.with_account(account)
179    /// # }
180    /// ```
181    pub fn with_account(mut self, account: StepAccountResponse) -> Self {
182        self.account = Some(account);
183        self
184    }
185
186    /// Build a response from a step entity with pre-resolved dependencies.
187    ///
188    /// Artifacts are left empty; use
189    /// [`with_dependencies_and_artifacts`](Self::with_dependencies_and_artifacts)
190    /// when they have been fetched.
191    pub fn with_dependencies(step: Step, dependencies: Vec<Uuid>) -> Self {
192        Self::with_dependencies_and_artifacts(step, dependencies, Vec::new())
193    }
194
195    /// Build a response from a step entity with its dependencies and artifacts.
196    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            // The first valid answer resolves a human input: its requirement
206            // only restricts who may answer.
207            (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    /// A persisted step -- [`Step`] is `#[non_exhaustive]`, so it can only be
271    /// obtained from a store.
272    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    /// An approval step whose deadline is `offset_secs` from now.
394    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}