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