Skip to main content

ironflow_api/entities/
run.rs

1//! Run-related DTOs and query parameters.
2
3use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use ironflow_store::models::{ConcurrencyLimit, Run, RunStatus, TriggerKind};
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use uuid::Uuid;
11
12use super::{CreatedBy, StepResponse};
13
14/// Run response DTO — public API representation of a run.
15///
16/// Maps from the internal [`Run`] model, exposing only necessary fields.
17///
18/// # Examples
19///
20/// ```
21/// use ironflow_store::models::{Run, RunStatus, TriggerKind};
22/// use ironflow_api::entities::RunResponse;
23/// ```
24#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
25#[derive(Debug, Serialize, Deserialize)]
26pub struct RunResponse {
27    /// Unique run identifier.
28    pub id: Uuid,
29    /// Workflow name.
30    pub workflow_name: String,
31    /// Current status.
32    pub status: RunStatus,
33    /// How the run was triggered.
34    pub trigger: TriggerKind,
35    /// Optional error message.
36    pub error: Option<String>,
37    /// Number of times retried.
38    pub retry_count: u32,
39    /// Maximum allowed retries.
40    pub max_retries: u32,
41    /// Aggregated cost in USD.
42    #[cfg_attr(feature = "openapi", schema(value_type = f64))]
43    pub cost_usd: Decimal,
44    /// Total duration in milliseconds.
45    pub duration_ms: u64,
46    /// When created.
47    pub created_at: DateTime<Utc>,
48    /// When last updated.
49    pub updated_at: DateTime<Utc>,
50    /// When execution started.
51    pub started_at: Option<DateTime<Utc>>,
52    /// When execution completed.
53    pub completed_at: Option<DateTime<Utc>>,
54    /// Version of the handler that created this run.
55    pub handler_version: Option<String>,
56    /// User-defined key-value labels.
57    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
58    pub labels: HashMap<String, String>,
59    /// Scheduled execution time. `None` means the run executed immediately.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub scheduled_at: Option<DateTime<Utc>>,
62    /// Who triggered the run. Always present.
63    pub created_by: CreatedBy,
64    /// Idempotency key that produced this run, when one was supplied.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub idempotency_key: Option<String>,
67    /// Exclusivity key held by this run until it reaches a terminal state,
68    /// when one was supplied.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub concurrency_key: Option<String>,
71    /// Concurrency groups the run belongs to, with the limit it was created
72    /// with. Omitted when the run belongs to no group.
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub concurrency_limits: Vec<ConcurrencyLimit>,
75    /// Cumulative cost cap for this run, in USD. `None` means no cap.
76    #[cfg_attr(feature = "openapi", schema(value_type = Option<f64>))]
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub max_cost_usd: Option<Decimal>,
79    /// Typed output the handler set with `WorkflowContext::set_output`.
80    ///
81    /// Written when the run ends (completed, warning, failed or cancelled).
82    /// Omitted when the handler set no output.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub output: Option<Value>,
85}
86
87impl From<Run> for RunResponse {
88    fn from(run: Run) -> Self {
89        let created_by = CreatedBy::from(&run);
90        RunResponse {
91            id: run.id,
92            workflow_name: run.workflow_name,
93            status: run.status.state,
94            trigger: run.trigger,
95            error: run.error,
96            retry_count: run.retry_count,
97            max_retries: run.max_retries,
98            cost_usd: run.cost_usd,
99            duration_ms: run.duration_ms,
100            created_at: run.created_at,
101            updated_at: run.updated_at,
102            started_at: run.started_at,
103            completed_at: run.completed_at,
104            handler_version: run.handler_version,
105            labels: run.labels,
106            scheduled_at: run.scheduled_at,
107            created_by,
108            idempotency_key: run.idempotency_key,
109            concurrency_key: run.concurrency_key,
110            concurrency_limits: run.concurrency_limits,
111            max_cost_usd: run.max_cost_usd,
112            output: run.output,
113        }
114    }
115}
116
117/// Run detail response — includes steps and payload.
118#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
119#[derive(Debug, Serialize)]
120pub struct RunDetailResponse {
121    /// The run.
122    pub run: RunResponse,
123    /// Associated steps, ordered by position.
124    pub steps: Vec<StepResponse>,
125    /// Input payload that triggered this run.
126    pub payload: serde_json::Value,
127    /// Sub-workflow runs below this run, at any depth, that are not finished:
128    /// cancelling the run cancels them too.
129    pub active_descendant_count: u64,
130}
131
132/// Response of `POST /api/v1/runs/:id/cancel`: the cancelled run, with the
133/// sub-workflow runs cancelled along with it.
134///
135/// The run's fields stay at the top level, as before the descendants were
136/// listed, so existing clients read the same document.
137#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
138#[derive(Debug, Serialize)]
139pub struct CancelRunResponse {
140    /// The cancelled run.
141    #[serde(flatten)]
142    pub run: RunResponse,
143    /// Sub-workflow runs below it that this request cancelled, oldest first.
144    /// Empty when none was still active.
145    pub cancelled_descendants: Vec<Uuid>,
146}
147
148/// Query parameters for listing runs.
149#[cfg_attr(feature = "openapi", derive(utoipa::IntoParams, utoipa::ToSchema))]
150#[derive(Debug, Deserialize)]
151pub struct ListRunsQuery {
152    /// Filter by workflow name.
153    pub workflow: Option<String>,
154    /// Filter by run status.
155    pub status: Option<RunStatus>,
156    /// Filter by step presence (only applies to completed/cancelled runs).
157    /// Non-terminal runs (pending, running, etc.) are always included.
158    /// When `true`, only return completed/cancelled runs that have steps.
159    /// When `false`, only return completed/cancelled runs without steps.
160    pub has_steps: Option<bool>,
161    /// Filter by labels. Comma-separated `key:value` pairs.
162    pub label: Option<String>,
163    /// Filter by author: the user ID that triggered the run.
164    ///
165    /// Also matches runs triggered by one of that user's API keys.
166    pub created_by: Option<Uuid>,
167    /// Filter by concurrency group: only runs that belong to this group.
168    pub concurrency_group: Option<String>,
169    /// Page number (1-based).
170    pub page: Option<u32>,
171    /// Items per page.
172    pub per_page: Option<u32>,
173}
174
175impl ListRunsQuery {
176    /// Parse the comma-separated `label` param into a `HashMap`.
177    pub fn parse_labels(&self) -> Option<HashMap<String, String>> {
178        parse_label_param(&self.label)
179    }
180}
181
182/// Parse a comma-separated `key:value` label query param into a `HashMap`.
183///
184/// Entries without a `:` are ignored. Returns `None` when the param is
185/// absent or contains no valid entry.
186pub(crate) fn parse_label_param(raw: &Option<String>) -> Option<HashMap<String, String>> {
187    raw.as_ref().and_then(|raw| {
188        let mut map = HashMap::new();
189        for entry in raw.split(',') {
190            let entry = entry.trim();
191            if let Some((k, v)) = entry.split_once(':') {
192                map.insert(k.to_string(), v.to_string());
193            }
194        }
195        if map.is_empty() { None } else { Some(map) }
196    })
197}