Skip to main content

ironflow_cli/commands/
run.rs

1//! Run subcommands: create, list, get, cancel, approve, retry, watch, diff.
2
3use std::fs;
4use std::io::{Write as _, stdout};
5use std::path::PathBuf;
6use std::slice;
7use std::time::Duration;
8
9use anyhow::{Context, Result, anyhow, bail};
10use clap::{Args, Subcommand};
11use futures_util::StreamExt;
12use humantime::format_duration;
13use ironflow_sdk::IronflowClient;
14use ironflow_sdk::client::ListRunsFilter;
15use ironflow_sdk::types::{CreateRunRequest, RunStatus};
16use serde_json::{Map, Value, from_str, json, to_string};
17use tokio::time::timeout as tokio_timeout;
18use uuid::Uuid;
19
20use crate::output;
21
22/// Arguments for the `run` command group.
23#[derive(Debug, Args)]
24pub struct RunArgs {
25    /// Run subcommand.
26    #[command(subcommand)]
27    pub command: RunCommands,
28}
29
30/// Available run subcommands.
31#[derive(Debug, Subcommand)]
32pub enum RunCommands {
33    /// Create a new run for a workflow.
34    Create {
35        /// Workflow name to trigger.
36        workflow: String,
37        /// JSON payload (inline string).
38        #[arg(long, group = "payload_source")]
39        payload: Option<String>,
40        /// Path to a JSON file containing the payload.
41        #[arg(long, group = "payload_source")]
42        payload_file: Option<PathBuf>,
43        /// How many times to replay the run automatically after a transient
44        /// failure. Defaults to 0 (no automatic retry).
45        #[arg(long)]
46        max_retries: Option<u32>,
47        /// Idempotency key making the call safe to replay.
48        ///
49        /// Reusing the same key returns the run it already created instead of
50        /// starting a second one. Valid for 24 hours. At most 255 printable
51        /// ASCII characters.
52        #[arg(long)]
53        idempotency_key: Option<String>,
54        /// Maximum cumulative cost for this run, in USD. Overrides the
55        /// workflow and server defaults.
56        #[arg(long = "max-cost", value_name = "USD")]
57        max_cost: Option<f64>,
58    },
59    /// List runs with optional filters.
60    List {
61        /// Filter by run status (pending, running, completed, failed, etc.).
62        #[arg(long)]
63        status: Option<String>,
64        /// Filter by workflow name.
65        #[arg(long)]
66        workflow: Option<String>,
67        /// Filter by author: the user ID that triggered the run.
68        ///
69        /// Also matches runs triggered by one of that user's API keys.
70        #[arg(long)]
71        created_by: Option<Uuid>,
72        /// Page number (1-based).
73        #[arg(long)]
74        page: Option<u32>,
75        /// Items per page.
76        #[arg(long)]
77        per_page: Option<u32>,
78    },
79    /// Get details of a specific run.
80    Get {
81        /// Run UUID.
82        id: Uuid,
83    },
84    /// Cancel a pending or running run.
85    Cancel {
86        /// Run UUID.
87        id: Uuid,
88    },
89    /// Approve a run waiting for approval.
90    Approve {
91        /// Run UUID.
92        id: Uuid,
93    },
94    /// Reject a run waiting for approval, failing it.
95    Reject {
96        /// Run UUID.
97        id: Uuid,
98    },
99    /// Retry a failed run.
100    Retry {
101        /// Run UUID.
102        id: Uuid,
103        /// Force retry even when the handler version has changed since the
104        /// original run.
105        #[arg(long)]
106        force: bool,
107    },
108    /// Watch a run in real time via SSE.
109    Watch {
110        /// Run UUID.
111        id: Uuid,
112        /// Only show step transitions, not log data.
113        #[arg(long)]
114        no_logs: bool,
115        /// Stop watching after this duration (e.g. "30s", "5m", "1h").
116        #[arg(long, value_parser = parse_humantime)]
117        timeout: Option<Duration>,
118    },
119    /// Compare two runs of the same workflow side by side.
120    Diff {
121        /// First run UUID.
122        run_a: Uuid,
123        /// Second run UUID.
124        run_b: Uuid,
125    },
126}
127
128/// Parse a human-readable duration string (e.g. "30s", "5m", "1h").
129fn parse_humantime(s: &str) -> Result<Duration, String> {
130    humantime::parse_duration(s).map_err(|e| e.to_string())
131}
132
133/// Terminal event types that signal the run is done.
134const TERMINAL_EVENTS: &[&str] = &["run_completed", "run_failed", "run_cancelled"];
135
136/// Resolve the payload from inline string or file.
137fn resolve_payload(payload: Option<&str>, payload_file: Option<&PathBuf>) -> Result<Value> {
138    match (payload, payload_file) {
139        (Some(raw), _) => from_str(raw).context("invalid JSON in --payload"),
140        (_, Some(path)) => {
141            let content = fs::read_to_string(path)
142                .with_context(|| format!("cannot read payload file: {}", path.display()))?;
143            from_str(&content).with_context(|| format!("invalid JSON in {}", path.display()))
144        }
145        (None, None) => Ok(Value::Object(Map::new())),
146    }
147}
148
149/// Reject a `--max-cost` value the API would refuse anyway.
150///
151/// Catching it client-side turns a 400 round-trip into an immediate, readable
152/// error.
153///
154/// # Errors
155///
156/// Returns an error when the value is negative or not a finite number.
157fn validate_max_cost(max_cost: Option<f64>) -> Result<()> {
158    match max_cost {
159        Some(value) if !value.is_finite() => {
160            anyhow::bail!("--max-cost must be a finite number, got {value}")
161        }
162        Some(value) if value < 0.0 => {
163            anyhow::bail!("--max-cost must be zero or positive, got {value}")
164        }
165        _ => Ok(()),
166    }
167}
168
169/// Execute a run subcommand.
170///
171/// # Errors
172///
173/// Returns an error on API failure or invalid input.
174pub async fn execute(
175    client: &IronflowClient,
176    args: &RunArgs,
177    json_mode: bool,
178    _verbose: bool,
179) -> Result<()> {
180    match &args.command {
181        RunCommands::Create {
182            workflow,
183            payload,
184            payload_file,
185            max_retries,
186            idempotency_key,
187            max_cost,
188        } => {
189            validate_max_cost(*max_cost)?;
190            let payload_value = resolve_payload(payload.as_deref(), payload_file.as_ref())?;
191            let payload_map = payload_value
192                .as_object()
193                .context("payload must be a JSON object")?
194                .clone();
195            let request: CreateRunRequest = CreateRunRequest::builder()
196                .workflow(workflow.clone())
197                .payload(Some(payload_map))
198                // The generated SDK models the field as i32; the API rejects
199                // anything negative, and clap already refuses it here.
200                .max_retries(max_retries.map(|n| n as i32))
201                .max_cost_usd(*max_cost)
202                .try_into()
203                .context("failed to build CreateRunRequest")?;
204
205            let response = match idempotency_key {
206                Some(key) => client.create_run_idempotent(&request, key).await?,
207                None => client.create_run(&request).await?,
208            };
209            output::print_output(json_mode, &response, || {
210                output::runs_table(slice::from_ref(&response.data))
211            })?;
212        }
213        RunCommands::List {
214            status,
215            workflow,
216            created_by,
217            page,
218            per_page,
219        } => {
220            let filter = ListRunsFilter {
221                status: status.as_deref(),
222                workflow: workflow.as_deref(),
223                created_by: *created_by,
224                page: *page,
225                per_page: *per_page,
226                ..Default::default()
227            };
228            let response = client.list_runs_filtered(&filter).await?;
229            output::print_output(json_mode, &response, || output::runs_table(&response.data))?;
230        }
231        RunCommands::Get { id } => {
232            let response = client.get_run(*id).await?;
233            output::print_output(json_mode, &response, || {
234                output::run_detail_table(&response.data)
235            })?;
236
237            if !json_mode && !response.data.steps.is_empty() {
238                let mut out = stdout().lock();
239                writeln!(out)?;
240                writeln!(out, "Steps:")?;
241                writeln!(out, "{}", output::steps_table(&response.data.steps))?;
242            }
243        }
244        RunCommands::Cancel { id } => {
245            let response = client.cancel_run(*id).await?;
246            output::print_output(json_mode, &response, || {
247                output::runs_table(slice::from_ref(&response.data))
248            })?;
249        }
250        RunCommands::Approve { id } => {
251            let response = client.approve_run(*id).await?;
252            output::print_output(json_mode, &response, || {
253                output::runs_table(slice::from_ref(&response.data))
254            })?;
255        }
256        RunCommands::Reject { id } => {
257            let response = client.reject_run(*id).await?;
258            output::print_output(json_mode, &response, || {
259                output::runs_table(slice::from_ref(&response.data))
260            })?;
261        }
262        RunCommands::Retry { id, force } => {
263            let response = client.retry_run(*id, *force).await?;
264            output::print_output(json_mode, &response, || {
265                output::runs_table(slice::from_ref(&response.data))
266            })?;
267        }
268        RunCommands::Watch {
269            id,
270            no_logs,
271            timeout,
272        } => {
273            execute_watch(client, *id, *no_logs, *timeout, json_mode).await?;
274        }
275        RunCommands::Diff { run_a, run_b } => {
276            execute_diff(client, *run_a, *run_b, json_mode).await?;
277        }
278    }
279    Ok(())
280}
281
282/// Watch a run in real time via SSE.
283async fn execute_watch(
284    client: &IronflowClient,
285    run_id: Uuid,
286    no_logs: bool,
287    timeout: Option<Duration>,
288    json_mode: bool,
289) -> Result<()> {
290    let run = client.get_run(run_id).await?;
291    let status = run.data.run.status;
292    if matches!(
293        status,
294        RunStatus::Completed | RunStatus::Failed | RunStatus::Cancelled
295    ) {
296        if json_mode {
297            output::print_output(json_mode, &run, || output::run_detail_table(&run.data))?;
298        } else {
299            let mut out = stdout().lock();
300            writeln!(out, "Run {run_id} already in terminal state: {status}")?;
301        }
302        return Ok(());
303    }
304
305    let watch_fut = async {
306        let mut stream = client.events(Some(run_id), None).await?;
307        let mut out = stdout().lock();
308
309        while let Some(event) = stream.next().await {
310            match event {
311                Ok(ev) => {
312                    if no_logs
313                        && !ev.event_type.starts_with("run_")
314                        && !ev.event_type.starts_with("step_")
315                    {
316                        continue;
317                    }
318
319                    if json_mode {
320                        let obj = json!({
321                            "event": ev.event_type,
322                            "data": ev.data,
323                        });
324                        writeln!(out, "{}", to_string(&obj)?)?;
325                    } else {
326                        writeln!(out, "[{}] {}", ev.event_type, ev.data)?;
327                    }
328
329                    if TERMINAL_EVENTS.contains(&ev.event_type.as_str()) {
330                        break;
331                    }
332                }
333                Err(e) => {
334                    return Err(anyhow!("SSE stream error: {e}"));
335                }
336            }
337        }
338
339        Ok::<(), anyhow::Error>(())
340    };
341
342    match timeout {
343        Some(dur) => {
344            tokio_timeout(dur, watch_fut).await.unwrap_or_else(|_| {
345                eprintln!("Timeout reached after {}", format_duration(dur));
346                Ok(())
347            })?;
348        }
349        None => {
350            watch_fut.await?;
351        }
352    }
353
354    Ok(())
355}
356
357/// Compare two runs of the same workflow side by side.
358async fn execute_diff(
359    client: &IronflowClient,
360    run_a_id: Uuid,
361    run_b_id: Uuid,
362    json_mode: bool,
363) -> Result<()> {
364    if run_a_id == run_b_id {
365        bail!("both run IDs are the same; nothing to diff");
366    }
367
368    let (a, b) = tokio::try_join!(client.get_run(run_a_id), client.get_run(run_b_id))?;
369
370    if a.data.run.workflow_name != b.data.run.workflow_name {
371        bail!(
372            "cannot diff runs from different workflows: '{}' vs '{}'",
373            a.data.run.workflow_name,
374            b.data.run.workflow_name
375        );
376    }
377
378    if json_mode {
379        let diff = json!({
380            "run_a": a.data,
381            "run_b": b.data,
382        });
383        output::print_json(&diff)?;
384    } else {
385        let table = output::run_diff_table(&a.data, &b.data);
386        let mut out = stdout().lock();
387        writeln!(out, "{table}")?;
388    }
389
390    Ok(())
391}
392
393#[cfg(test)]
394mod tests {
395    use std::io::Write;
396
397    use tempfile::NamedTempFile;
398
399    use super::*;
400
401    #[test]
402    fn resolve_payload_none_returns_empty_object() {
403        let value = resolve_payload(None, None).unwrap();
404        assert!(value.is_object());
405        assert!(value.as_object().unwrap().is_empty());
406    }
407
408    #[test]
409    fn resolve_payload_inline_valid_json() {
410        let value = resolve_payload(Some(r#"{"key": "value"}"#), None).unwrap();
411        assert_eq!(value["key"], "value");
412    }
413
414    #[test]
415    fn resolve_payload_inline_invalid_json() {
416        let result = resolve_payload(Some("not json"), None);
417        assert!(result.is_err());
418        assert!(result.unwrap_err().to_string().contains("invalid JSON"));
419    }
420
421    #[test]
422    fn resolve_payload_file_valid() {
423        let mut tmp = NamedTempFile::new().unwrap();
424        write!(tmp, r#"{{"workflow": "test"}}"#).unwrap();
425        let path = tmp.path().to_path_buf();
426
427        let value = resolve_payload(None, Some(&path)).unwrap();
428        assert_eq!(value["workflow"], "test");
429    }
430
431    #[test]
432    fn resolve_payload_file_not_found() {
433        let path = PathBuf::from("/nonexistent/payload.json");
434        let result = resolve_payload(None, Some(&path));
435        assert!(result.is_err());
436        assert!(result.unwrap_err().to_string().contains("cannot read"));
437    }
438
439    #[test]
440    fn resolve_payload_file_invalid_json() {
441        let mut tmp = NamedTempFile::new().unwrap();
442        write!(tmp, "not valid json").unwrap();
443        let path = tmp.path().to_path_buf();
444
445        let result = resolve_payload(None, Some(&path));
446        assert!(result.is_err());
447        assert!(result.unwrap_err().to_string().contains("invalid JSON"));
448    }
449}