Skip to main content

ironflow_cli/commands/
run.rs

1//! Run subcommands: create, list, get, cancel, approve, retry.
2
3use std::fs;
4use std::io::Write as _;
5use std::path::PathBuf;
6use std::slice;
7
8use anyhow::{Context, Result};
9use clap::{Args, Subcommand};
10use ironflow_sdk::IronflowClient;
11use ironflow_sdk::client::ListRunsFilter;
12use ironflow_sdk::types::CreateRunRequest;
13use uuid::Uuid;
14
15use crate::output;
16
17/// Arguments for the `run` command group.
18#[derive(Debug, Args)]
19pub struct RunArgs {
20    /// Run subcommand.
21    #[command(subcommand)]
22    pub command: RunCommands,
23}
24
25/// Available run subcommands.
26#[derive(Debug, Subcommand)]
27pub enum RunCommands {
28    /// Create a new run for a workflow.
29    Create {
30        /// Workflow name to trigger.
31        workflow: String,
32        /// JSON payload (inline string).
33        #[arg(long, group = "payload_source")]
34        payload: Option<String>,
35        /// Path to a JSON file containing the payload.
36        #[arg(long, group = "payload_source")]
37        payload_file: Option<PathBuf>,
38        /// How many times to replay the run automatically after a transient
39        /// failure. Defaults to 0 (no automatic retry).
40        #[arg(long)]
41        max_retries: Option<u32>,
42        /// Idempotency key making the call safe to replay.
43        ///
44        /// Reusing the same key returns the run it already created instead of
45        /// starting a second one. Valid for 24 hours. At most 255 printable
46        /// ASCII characters.
47        #[arg(long)]
48        idempotency_key: Option<String>,
49        /// Maximum cumulative cost for this run, in USD. Overrides the
50        /// workflow and server defaults.
51        #[arg(long = "max-cost", value_name = "USD")]
52        max_cost: Option<f64>,
53    },
54    /// List runs with optional filters.
55    List {
56        /// Filter by run status (pending, running, completed, failed, etc.).
57        #[arg(long)]
58        status: Option<String>,
59        /// Filter by workflow name.
60        #[arg(long)]
61        workflow: Option<String>,
62        /// Filter by author: the user ID that triggered the run.
63        ///
64        /// Also matches runs triggered by one of that user's API keys.
65        #[arg(long)]
66        created_by: Option<Uuid>,
67        /// Page number (1-based).
68        #[arg(long)]
69        page: Option<u32>,
70        /// Items per page.
71        #[arg(long)]
72        per_page: Option<u32>,
73    },
74    /// Get details of a specific run.
75    Get {
76        /// Run UUID.
77        id: Uuid,
78    },
79    /// Cancel a pending or running run.
80    Cancel {
81        /// Run UUID.
82        id: Uuid,
83    },
84    /// Approve a run waiting for approval.
85    Approve {
86        /// Run UUID.
87        id: Uuid,
88    },
89    /// Reject a run waiting for approval, failing it.
90    Reject {
91        /// Run UUID.
92        id: Uuid,
93    },
94    /// Retry a failed run.
95    Retry {
96        /// Run UUID.
97        id: Uuid,
98        /// Force retry even when the handler version has changed since the
99        /// original run.
100        #[arg(long)]
101        force: bool,
102    },
103}
104
105/// Resolve the payload from inline string or file.
106fn resolve_payload(
107    payload: Option<&str>,
108    payload_file: Option<&PathBuf>,
109) -> Result<serde_json::Value> {
110    match (payload, payload_file) {
111        (Some(raw), _) => serde_json::from_str(raw).context("invalid JSON in --payload"),
112        (_, Some(path)) => {
113            let content = fs::read_to_string(path)
114                .with_context(|| format!("cannot read payload file: {}", path.display()))?;
115            serde_json::from_str(&content)
116                .with_context(|| format!("invalid JSON in {}", path.display()))
117        }
118        (None, None) => Ok(serde_json::Value::Object(serde_json::Map::new())),
119    }
120}
121
122/// Reject a `--max-cost` value the API would refuse anyway.
123///
124/// Catching it client-side turns a 400 round-trip into an immediate, readable
125/// error.
126///
127/// # Errors
128///
129/// Returns an error when the value is negative or not a finite number.
130fn validate_max_cost(max_cost: Option<f64>) -> Result<()> {
131    match max_cost {
132        Some(value) if !value.is_finite() => {
133            anyhow::bail!("--max-cost must be a finite number, got {value}")
134        }
135        Some(value) if value < 0.0 => {
136            anyhow::bail!("--max-cost must be zero or positive, got {value}")
137        }
138        _ => Ok(()),
139    }
140}
141
142/// Execute a run subcommand.
143///
144/// # Errors
145///
146/// Returns an error on API failure or invalid input.
147pub async fn execute(
148    client: &IronflowClient,
149    args: &RunArgs,
150    json_mode: bool,
151    _verbose: bool,
152) -> Result<()> {
153    match &args.command {
154        RunCommands::Create {
155            workflow,
156            payload,
157            payload_file,
158            max_retries,
159            idempotency_key,
160            max_cost,
161        } => {
162            validate_max_cost(*max_cost)?;
163            let payload_value = resolve_payload(payload.as_deref(), payload_file.as_ref())?;
164            let payload_map = payload_value
165                .as_object()
166                .context("payload must be a JSON object")?
167                .clone();
168            let request: CreateRunRequest = CreateRunRequest::builder()
169                .workflow(workflow.clone())
170                .payload(Some(payload_map))
171                // The generated SDK models the field as i32; the API rejects
172                // anything negative, and clap already refuses it here.
173                .max_retries(max_retries.map(|n| n as i32))
174                .max_cost_usd(*max_cost)
175                .try_into()
176                .context("failed to build CreateRunRequest")?;
177
178            let response = match idempotency_key {
179                Some(key) => client.create_run_idempotent(&request, key).await?,
180                None => client.create_run(&request).await?,
181            };
182            output::print_output(json_mode, &response, || {
183                output::runs_table(slice::from_ref(&response.data))
184            })?;
185        }
186        RunCommands::List {
187            status,
188            workflow,
189            created_by,
190            page,
191            per_page,
192        } => {
193            let filter = ListRunsFilter {
194                status: status.as_deref(),
195                workflow: workflow.as_deref(),
196                created_by: *created_by,
197                page: *page,
198                per_page: *per_page,
199                ..Default::default()
200            };
201            let response = client.list_runs_filtered(&filter).await?;
202            output::print_output(json_mode, &response, || output::runs_table(&response.data))?;
203        }
204        RunCommands::Get { id } => {
205            let response = client.get_run(*id).await?;
206            output::print_output(json_mode, &response, || {
207                output::run_detail_table(&response.data)
208            })?;
209
210            if !json_mode && !response.data.steps.is_empty() {
211                let mut out = std::io::stdout().lock();
212                writeln!(out)?;
213                writeln!(out, "Steps:")?;
214                writeln!(out, "{}", output::steps_table(&response.data.steps))?;
215            }
216        }
217        RunCommands::Cancel { id } => {
218            let response = client.cancel_run(*id).await?;
219            output::print_output(json_mode, &response, || {
220                output::runs_table(slice::from_ref(&response.data))
221            })?;
222        }
223        RunCommands::Approve { id } => {
224            let response = client.approve_run(*id).await?;
225            output::print_output(json_mode, &response, || {
226                output::runs_table(slice::from_ref(&response.data))
227            })?;
228        }
229        RunCommands::Reject { id } => {
230            let response = client.reject_run(*id).await?;
231            output::print_output(json_mode, &response, || {
232                output::runs_table(slice::from_ref(&response.data))
233            })?;
234        }
235        RunCommands::Retry { id, force } => {
236            let response = client.retry_run(*id, *force).await?;
237            output::print_output(json_mode, &response, || {
238                output::runs_table(slice::from_ref(&response.data))
239            })?;
240        }
241    }
242    Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247    use std::io::Write;
248
249    use tempfile::NamedTempFile;
250
251    use super::*;
252
253    #[test]
254    fn resolve_payload_none_returns_empty_object() {
255        let value = resolve_payload(None, None).unwrap();
256        assert!(value.is_object());
257        assert!(value.as_object().unwrap().is_empty());
258    }
259
260    #[test]
261    fn resolve_payload_inline_valid_json() {
262        let value = resolve_payload(Some(r#"{"key": "value"}"#), None).unwrap();
263        assert_eq!(value["key"], "value");
264    }
265
266    #[test]
267    fn resolve_payload_inline_invalid_json() {
268        let result = resolve_payload(Some("not json"), None);
269        assert!(result.is_err());
270        assert!(result.unwrap_err().to_string().contains("invalid JSON"));
271    }
272
273    #[test]
274    fn resolve_payload_file_valid() {
275        let mut tmp = NamedTempFile::new().unwrap();
276        write!(tmp, r#"{{"workflow": "test"}}"#).unwrap();
277        let path = tmp.path().to_path_buf();
278
279        let value = resolve_payload(None, Some(&path)).unwrap();
280        assert_eq!(value["workflow"], "test");
281    }
282
283    #[test]
284    fn resolve_payload_file_not_found() {
285        let path = PathBuf::from("/nonexistent/payload.json");
286        let result = resolve_payload(None, Some(&path));
287        assert!(result.is_err());
288        assert!(result.unwrap_err().to_string().contains("cannot read"));
289    }
290
291    #[test]
292    fn resolve_payload_file_invalid_json() {
293        let mut tmp = NamedTempFile::new().unwrap();
294        write!(tmp, "not valid json").unwrap();
295        let path = tmp.path().to_path_buf();
296
297        let result = resolve_payload(None, Some(&path));
298        assert!(result.is_err());
299        assert!(result.unwrap_err().to_string().contains("invalid JSON"));
300    }
301}