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