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    /// Retry a failed run.
90    Retry {
91        /// Run UUID.
92        id: Uuid,
93    },
94}
95
96/// Resolve the payload from inline string or file.
97fn resolve_payload(
98    payload: Option<&str>,
99    payload_file: Option<&PathBuf>,
100) -> Result<serde_json::Value> {
101    match (payload, payload_file) {
102        (Some(raw), _) => serde_json::from_str(raw).context("invalid JSON in --payload"),
103        (_, Some(path)) => {
104            let content = fs::read_to_string(path)
105                .with_context(|| format!("cannot read payload file: {}", path.display()))?;
106            serde_json::from_str(&content)
107                .with_context(|| format!("invalid JSON in {}", path.display()))
108        }
109        (None, None) => Ok(serde_json::Value::Object(serde_json::Map::new())),
110    }
111}
112
113/// Reject a `--max-cost` value the API would refuse anyway.
114///
115/// Catching it client-side turns a 400 round-trip into an immediate, readable
116/// error.
117///
118/// # Errors
119///
120/// Returns an error when the value is negative or not a finite number.
121fn validate_max_cost(max_cost: Option<f64>) -> Result<()> {
122    match max_cost {
123        Some(value) if !value.is_finite() => {
124            anyhow::bail!("--max-cost must be a finite number, got {value}")
125        }
126        Some(value) if value < 0.0 => {
127            anyhow::bail!("--max-cost must be zero or positive, got {value}")
128        }
129        _ => Ok(()),
130    }
131}
132
133/// Execute a run subcommand.
134///
135/// # Errors
136///
137/// Returns an error on API failure or invalid input.
138pub async fn execute(
139    client: &IronflowClient,
140    args: &RunArgs,
141    json_mode: bool,
142    _verbose: bool,
143) -> Result<()> {
144    match &args.command {
145        RunCommands::Create {
146            workflow,
147            payload,
148            payload_file,
149            max_retries,
150            idempotency_key,
151            max_cost,
152        } => {
153            validate_max_cost(*max_cost)?;
154            let payload_value = resolve_payload(payload.as_deref(), payload_file.as_ref())?;
155            let payload_map = payload_value
156                .as_object()
157                .context("payload must be a JSON object")?
158                .clone();
159            let request: CreateRunRequest = CreateRunRequest::builder()
160                .workflow(workflow.clone())
161                .payload(Some(payload_map))
162                // The generated SDK models the field as i32; the API rejects
163                // anything negative, and clap already refuses it here.
164                .max_retries(max_retries.map(|n| n as i32))
165                .max_cost_usd(*max_cost)
166                .try_into()
167                .context("failed to build CreateRunRequest")?;
168
169            let response = match idempotency_key {
170                Some(key) => client.create_run_idempotent(&request, key).await?,
171                None => client.create_run(&request).await?,
172            };
173            output::print_output(json_mode, &response, || {
174                output::runs_table(slice::from_ref(&response.data))
175            })?;
176        }
177        RunCommands::List {
178            status,
179            workflow,
180            created_by,
181            page,
182            per_page,
183        } => {
184            let filter = ListRunsFilter {
185                status: status.as_deref(),
186                workflow: workflow.as_deref(),
187                created_by: *created_by,
188                page: *page,
189                per_page: *per_page,
190                ..Default::default()
191            };
192            let response = client.list_runs_filtered(&filter).await?;
193            output::print_output(json_mode, &response, || output::runs_table(&response.data))?;
194        }
195        RunCommands::Get { id } => {
196            let response = client.get_run(*id).await?;
197            output::print_output(json_mode, &response, || {
198                output::run_detail_table(&response.data)
199            })?;
200
201            if !json_mode && !response.data.steps.is_empty() {
202                let mut out = std::io::stdout().lock();
203                writeln!(out)?;
204                writeln!(out, "Steps:")?;
205                writeln!(out, "{}", output::steps_table(&response.data.steps))?;
206            }
207        }
208        RunCommands::Cancel { id } => {
209            let response = client.cancel_run(*id).await?;
210            output::print_output(json_mode, &response, || {
211                output::runs_table(slice::from_ref(&response.data))
212            })?;
213        }
214        RunCommands::Approve { id } => {
215            let response = client.approve_run(*id).await?;
216            output::print_output(json_mode, &response, || {
217                output::runs_table(slice::from_ref(&response.data))
218            })?;
219        }
220        RunCommands::Retry { id } => {
221            let response = client.retry_run(*id).await?;
222            output::print_output(json_mode, &response, || {
223                output::runs_table(slice::from_ref(&response.data))
224            })?;
225        }
226    }
227    Ok(())
228}
229
230#[cfg(test)]
231mod tests {
232    use std::io::Write;
233
234    use tempfile::NamedTempFile;
235
236    use super::*;
237
238    #[test]
239    fn resolve_payload_none_returns_empty_object() {
240        let value = resolve_payload(None, None).unwrap();
241        assert!(value.is_object());
242        assert!(value.as_object().unwrap().is_empty());
243    }
244
245    #[test]
246    fn resolve_payload_inline_valid_json() {
247        let value = resolve_payload(Some(r#"{"key": "value"}"#), None).unwrap();
248        assert_eq!(value["key"], "value");
249    }
250
251    #[test]
252    fn resolve_payload_inline_invalid_json() {
253        let result = resolve_payload(Some("not json"), None);
254        assert!(result.is_err());
255        assert!(result.unwrap_err().to_string().contains("invalid JSON"));
256    }
257
258    #[test]
259    fn resolve_payload_file_valid() {
260        let mut tmp = NamedTempFile::new().unwrap();
261        write!(tmp, r#"{{"workflow": "test"}}"#).unwrap();
262        let path = tmp.path().to_path_buf();
263
264        let value = resolve_payload(None, Some(&path)).unwrap();
265        assert_eq!(value["workflow"], "test");
266    }
267
268    #[test]
269    fn resolve_payload_file_not_found() {
270        let path = PathBuf::from("/nonexistent/payload.json");
271        let result = resolve_payload(None, Some(&path));
272        assert!(result.is_err());
273        assert!(result.unwrap_err().to_string().contains("cannot read"));
274    }
275
276    #[test]
277    fn resolve_payload_file_invalid_json() {
278        let mut tmp = NamedTempFile::new().unwrap();
279        write!(tmp, "not valid json").unwrap();
280        let path = tmp.path().to_path_buf();
281
282        let result = resolve_payload(None, Some(&path));
283        assert!(result.is_err());
284        assert!(result.unwrap_err().to_string().contains("invalid JSON"));
285    }
286}