Skip to main content

ironflow_cli/commands/
run.rs

1//! Run subcommands: create, list, get, cancel, pause, resume, approve, reject, input,
2//! reject-input, retry, replay, watch, plan, diff.
3
4use std::fs;
5use std::io::{Write as _, stdout};
6use std::path::PathBuf;
7use std::slice;
8use std::time::Duration;
9
10use anyhow::{Context, Result, anyhow, bail};
11use clap::{Args, Subcommand, value_parser};
12use futures_util::StreamExt;
13use humantime::format_duration;
14use ironflow_sdk::IronflowClient;
15use ironflow_sdk::client::ListRunsFilter;
16use ironflow_sdk::types::{ConcurrencyLimit, CreateRunRequest, PlanWorkflowRequest, RunStatus};
17use ironflow_types::parse_concurrency_limit as shared_parse_concurrency_limit;
18use serde_json::{Map, Value, from_str, json, to_string};
19use tokio::time::timeout as tokio_timeout;
20use uuid::Uuid;
21
22use crate::output;
23
24/// Arguments for the `run` command group.
25#[derive(Debug, Args)]
26pub struct RunArgs {
27    /// Run subcommand.
28    #[command(subcommand)]
29    pub command: RunCommands,
30}
31
32/// Available run subcommands.
33#[derive(Debug, Subcommand)]
34pub enum RunCommands {
35    /// Create a new run for a workflow.
36    Create {
37        /// Workflow name to trigger.
38        workflow: String,
39        /// JSON payload (inline string).
40        #[arg(long, group = "payload_source")]
41        payload: Option<String>,
42        /// Path to a JSON file containing the payload.
43        #[arg(long, group = "payload_source")]
44        payload_file: Option<PathBuf>,
45        /// How many times to replay the run automatically after a transient
46        /// failure. Defaults to 0 (no automatic retry).
47        #[arg(long)]
48        max_retries: Option<u32>,
49        /// Idempotency key making the call safe to replay.
50        ///
51        /// Reusing the same key returns the run it already created instead of
52        /// starting a second one. Valid for 24 hours. At most 255 printable
53        /// ASCII characters.
54        #[arg(long)]
55        idempotency_key: Option<String>,
56        /// Maximum cumulative cost for this run, in USD. Overrides the
57        /// workflow and server defaults.
58        #[arg(long = "max-cost", value_name = "USD")]
59        max_cost: Option<f64>,
60        /// Exclusivity key: the call is refused while a non-terminal run holds
61        /// the same key. Released once that run completes, fails or is
62        /// cancelled. At most 255 bytes.
63        #[arg(long)]
64        concurrency_key: Option<String>,
65        /// Concurrency group the run joins, as `GROUP=N`: a worker only starts
66        /// the run while fewer than N root runs of GROUP are running. Repeat
67        /// the flag to join several groups.
68        #[arg(
69            long = "concurrency-limit",
70            value_name = "GROUP=N",
71            value_parser = parse_concurrency_limit
72        )]
73        concurrency_limits: Vec<ConcurrencyLimit>,
74        /// Queue priority, from -100 to 100. Workers pick the pending run with
75        /// the highest priority first. Defaults to the workflow priority, `0`
76        /// unless the handler declares one.
77        #[arg(
78            long,
79            allow_negative_numbers = true,
80            value_parser = value_parser!(i16).range(-100..=100)
81        )]
82        priority: Option<i16>,
83        /// Tag a worker must carry to take the run, added to the ones the
84        /// workflow requires. Repeat the flag to require several tags.
85        #[arg(long = "worker-tag", value_name = "TAG")]
86        worker_tags: Vec<String>,
87    },
88    /// List runs with optional filters.
89    List {
90        /// Filter by run status (pending, running, completed, failed, etc.).
91        #[arg(long)]
92        status: Option<String>,
93        /// Filter by workflow name.
94        #[arg(long)]
95        workflow: Option<String>,
96        /// Filter by author: the user ID that triggered the run.
97        ///
98        /// Also matches runs triggered by one of that user's API keys.
99        #[arg(long)]
100        created_by: Option<Uuid>,
101        /// Filter by concurrency group: only runs that belong to this group.
102        #[arg(long)]
103        concurrency_group: Option<String>,
104        /// Filter by priority: only runs with exactly this priority.
105        #[arg(
106            long,
107            allow_negative_numbers = true,
108            value_parser = value_parser!(i16).range(-100..=100)
109        )]
110        priority: Option<i16>,
111        /// Page number (1-based).
112        #[arg(long)]
113        page: Option<u32>,
114        /// Items per page.
115        #[arg(long)]
116        per_page: Option<u32>,
117    },
118    /// Get details of a specific run.
119    Get {
120        /// Run UUID.
121        id: Uuid,
122    },
123    /// Cancel a pending or running run.
124    Cancel {
125        /// Run UUID.
126        id: Uuid,
127    },
128    /// Pause a run and its sub-workflow runs. A running run stops at its next step.
129    Pause {
130        /// Run UUID.
131        id: Uuid,
132    },
133    /// Resume a paused run in the state it was paused from.
134    Resume {
135        /// Run UUID.
136        id: Uuid,
137    },
138    /// Approve a run waiting for approval.
139    Approve {
140        /// Run UUID.
141        id: Uuid,
142    },
143    /// Reject a run waiting for approval, failing it.
144    Reject {
145        /// Run UUID.
146        id: Uuid,
147    },
148    /// Answer a human input step; the value must match the step's JSON schema.
149    Input {
150        /// Run UUID.
151        id: Uuid,
152        /// Human input step UUID.
153        step_id: Uuid,
154        /// JSON answer (inline string).
155        #[arg(long, group = "value_source")]
156        value: Option<String>,
157        /// Path to a JSON file containing the answer.
158        #[arg(long, group = "value_source")]
159        value_file: Option<PathBuf>,
160    },
161    /// Reject a human input step; the handler receives the rejection.
162    RejectInput {
163        /// Run UUID.
164        id: Uuid,
165        /// Human input step UUID.
166        step_id: Uuid,
167        /// Why the input is refused.
168        #[arg(long)]
169        reason: Option<String>,
170    },
171    /// Retry a failed run.
172    Retry {
173        /// Run UUID.
174        id: Uuid,
175        /// Force retry even when the handler version has changed since the
176        /// original run.
177        #[arg(long)]
178        force: bool,
179    },
180    /// Replay a finished run on the current handler version.
181    Replay {
182        /// Run UUID.
183        id: Uuid,
184    },
185    /// Watch a run in real time via SSE.
186    Watch {
187        /// Run UUID.
188        id: Uuid,
189        /// Only show step transitions, not log data.
190        #[arg(long)]
191        no_logs: bool,
192        /// Stop watching after this duration (e.g. "30s", "5m", "1h").
193        #[arg(long, value_parser = parse_humantime)]
194        timeout: Option<Duration>,
195    },
196    /// Show the execution plan for a workflow without running it.
197    Plan {
198        /// Workflow name.
199        workflow: String,
200        /// JSON input (inline string).
201        #[arg(long, group = "plan_input_source")]
202        input: Option<String>,
203        /// Path to a JSON file containing the input.
204        #[arg(long, group = "plan_input_source")]
205        input_file: Option<PathBuf>,
206        /// How deep sub-workflows are expanded (default 3).
207        #[arg(long)]
208        max_depth: Option<u32>,
209        /// Skip duration estimation from run history.
210        #[arg(long)]
211        no_estimates: bool,
212    },
213    /// Compare two runs of the same workflow side by side.
214    Diff {
215        /// First run UUID.
216        run_a: Uuid,
217        /// Second run UUID.
218        run_b: Uuid,
219    },
220}
221
222/// Parse a human-readable duration string (e.g. "30s", "5m", "1h").
223fn parse_humantime(s: &str) -> Result<Duration, String> {
224    humantime::parse_duration(s).map_err(|e| e.to_string())
225}
226
227/// Parse a `GROUP=N` concurrency limit into the SDK type.
228///
229/// Delegates to [`ironflow_types::parse_concurrency_limit`]; the group and the
230/// limit are validated by the API.
231fn parse_concurrency_limit(s: &str) -> Result<ConcurrencyLimit, String> {
232    let (group, limit) = shared_parse_concurrency_limit(s)?;
233    let limit =
234        i32::try_from(limit).map_err(|e| format!("invalid limit '{limit}' in '{s}': {e}"))?;
235    Ok(ConcurrencyLimit { group, limit })
236}
237
238/// Terminal event types that signal the run is done.
239const TERMINAL_EVENTS: &[&str] = &["run_completed", "run_failed", "run_cancelled"];
240
241/// Resolve the payload from inline string or file.
242fn resolve_payload(payload: Option<&str>, payload_file: Option<&PathBuf>) -> Result<Value> {
243    match (payload, payload_file) {
244        (Some(raw), _) => from_str(raw).context("invalid JSON in --payload"),
245        (_, Some(path)) => {
246            let content = fs::read_to_string(path)
247                .with_context(|| format!("cannot read payload file: {}", path.display()))?;
248            from_str(&content).with_context(|| format!("invalid JSON in {}", path.display()))
249        }
250        (None, None) => Ok(Value::Object(Map::new())),
251    }
252}
253
254/// Reject a `--max-cost` value the API would refuse anyway.
255///
256/// Catching it client-side turns a 400 round-trip into an immediate, readable
257/// error.
258///
259/// # Errors
260///
261/// Returns an error when the value is negative or not a finite number.
262fn validate_max_cost(max_cost: Option<f64>) -> Result<()> {
263    match max_cost {
264        Some(value) if !value.is_finite() => {
265            anyhow::bail!("--max-cost must be a finite number, got {value}")
266        }
267        Some(value) if value < 0.0 => {
268            anyhow::bail!("--max-cost must be zero or positive, got {value}")
269        }
270        _ => Ok(()),
271    }
272}
273
274/// Execute a run subcommand.
275///
276/// # Errors
277///
278/// Returns an error on API failure or invalid input.
279pub async fn execute(
280    client: &IronflowClient,
281    args: &RunArgs,
282    json_mode: bool,
283    _verbose: bool,
284) -> Result<()> {
285    match &args.command {
286        RunCommands::Create {
287            workflow,
288            payload,
289            payload_file,
290            max_retries,
291            idempotency_key,
292            max_cost,
293            concurrency_key,
294            concurrency_limits,
295            priority,
296            worker_tags,
297        } => {
298            validate_max_cost(*max_cost)?;
299            let payload_value = resolve_payload(payload.as_deref(), payload_file.as_ref())?;
300            let payload_map = payload_value
301                .as_object()
302                .context("payload must be a JSON object")?
303                .clone();
304            let request: CreateRunRequest = CreateRunRequest::builder()
305                .workflow(workflow.clone())
306                .payload(Some(payload_map))
307                // The generated SDK models the field as i32; the API rejects
308                // anything negative, and clap already refuses it here.
309                .max_retries(max_retries.map(|n| n as i32))
310                .max_cost_usd(*max_cost)
311                .concurrency_key(concurrency_key.clone())
312                .concurrency_limits(concurrency_limits.clone())
313                .priority(priority.map(i32::from))
314                .worker_tags(worker_tags.clone())
315                .try_into()
316                .context("failed to build CreateRunRequest")?;
317
318            let response = match idempotency_key {
319                Some(key) => client.create_run_idempotent(&request, key).await?,
320                None => client.create_run(&request).await?,
321            };
322            output::print_output(json_mode, &response, || {
323                output::runs_table(slice::from_ref(&response.data))
324            })?;
325        }
326        RunCommands::List {
327            status,
328            workflow,
329            created_by,
330            concurrency_group,
331            priority,
332            page,
333            per_page,
334        } => {
335            let filter = ListRunsFilter {
336                status: status.as_deref(),
337                workflow: workflow.as_deref(),
338                created_by: *created_by,
339                concurrency_group: concurrency_group.as_deref(),
340                priority: *priority,
341                page: *page,
342                per_page: *per_page,
343                ..Default::default()
344            };
345            let response = client.list_runs_filtered(&filter).await?;
346            output::print_output(json_mode, &response, || output::runs_table(&response.data))?;
347        }
348        RunCommands::Get { id } => {
349            let response = client.get_run(*id).await?;
350            output::print_output(json_mode, &response, || {
351                output::run_detail_table(&response.data)
352            })?;
353
354            if !json_mode && !response.data.steps.is_empty() {
355                let mut out = stdout().lock();
356                writeln!(out)?;
357                writeln!(out, "Steps:")?;
358                writeln!(out, "{}", output::steps_table(&response.data.steps))?;
359            }
360        }
361        RunCommands::Cancel { id } => {
362            let response = client.cancel_run(*id).await?;
363            output::print_output(json_mode, &response, || {
364                output::cancelled_table(&response.data)
365            })?;
366        }
367        RunCommands::Pause { id } => {
368            let response = client.pause_run(*id).await?;
369            output::print_output(json_mode, &response, || {
370                output::paused_table(&response.data)
371            })?;
372        }
373        RunCommands::Resume { id } => {
374            let response = client.resume_run(*id).await?;
375            output::print_output(json_mode, &response, || {
376                output::resumed_table(&response.data)
377            })?;
378        }
379        RunCommands::Approve { id } => {
380            let response = client.approve_run(*id).await?;
381            output::print_output(json_mode, &response, || {
382                output::runs_table(slice::from_ref(&response.data))
383            })?;
384            // A multi-approver gate stays open until enough distinct users voted.
385            if !json_mode && matches!(response.data.status, RunStatus::AwaitingApproval) {
386                println!("Approval recorded; more approvals are required.");
387            }
388        }
389        RunCommands::Reject { id } => {
390            let response = client.reject_run(*id).await?;
391            output::print_output(json_mode, &response, || {
392                output::runs_table(slice::from_ref(&response.data))
393            })?;
394        }
395        RunCommands::Input {
396            id,
397            step_id,
398            value,
399            value_file,
400        } => {
401            let answer = resolve_payload(value.as_deref(), value_file.as_ref())?;
402            let response = client.submit_input(*id, *step_id, &answer).await?;
403            output::print_output(json_mode, &response, || {
404                output::runs_table(slice::from_ref(&response.data))
405            })?;
406        }
407        RunCommands::RejectInput {
408            id,
409            step_id,
410            reason,
411        } => {
412            let response = client
413                .reject_input(*id, *step_id, reason.as_deref())
414                .await?;
415            output::print_output(json_mode, &response, || {
416                output::runs_table(slice::from_ref(&response.data))
417            })?;
418        }
419        RunCommands::Retry { id, force } => {
420            let response = client.retry_run(*id, *force).await?;
421            output::print_output(json_mode, &response, || {
422                output::runs_table(slice::from_ref(&response.data))
423            })?;
424        }
425        RunCommands::Replay { id } => {
426            let response = client.replay_run(*id).await?;
427            output::print_output(json_mode, &response, || {
428                output::runs_table(slice::from_ref(&response.data))
429            })?;
430        }
431        RunCommands::Watch {
432            id,
433            no_logs,
434            timeout,
435        } => {
436            execute_watch(client, *id, *no_logs, *timeout, json_mode).await?;
437        }
438        RunCommands::Plan {
439            workflow,
440            input,
441            input_file,
442            max_depth,
443            no_estimates,
444        } => {
445            let payload = resolve_payload(input.as_deref(), input_file.as_ref())?;
446            let payload_map = payload
447                .as_object()
448                .context("input must be a JSON object")?
449                .clone();
450            let request: PlanWorkflowRequest = PlanWorkflowRequest::builder()
451                .payload(Some(payload_map))
452                // The generated SDK models the depth as i32; the API rejects
453                // anything outside 1..=10.
454                .max_depth(max_depth.map(|d| d as i32))
455                .estimate_durations(Some(!*no_estimates))
456                .try_into()
457                .context("failed to build PlanWorkflowRequest")?;
458            let response = client.plan_workflow(workflow, &request).await?;
459            output::render_execution_plan(&mut stdout().lock(), json_mode, &response)?;
460        }
461        RunCommands::Diff { run_a, run_b } => {
462            execute_diff(client, *run_a, *run_b, json_mode).await?;
463        }
464    }
465    Ok(())
466}
467
468/// Watch a run in real time via SSE.
469async fn execute_watch(
470    client: &IronflowClient,
471    run_id: Uuid,
472    no_logs: bool,
473    timeout: Option<Duration>,
474    json_mode: bool,
475) -> Result<()> {
476    let run = client.get_run(run_id).await?;
477    let status = run.data.run.status;
478    if matches!(
479        status,
480        RunStatus::Completed | RunStatus::Failed | RunStatus::Cancelled
481    ) {
482        if json_mode {
483            output::print_output(json_mode, &run, || output::run_detail_table(&run.data))?;
484        } else {
485            let mut out = stdout().lock();
486            writeln!(out, "Run {run_id} already in terminal state: {status}")?;
487        }
488        return Ok(());
489    }
490
491    let watch_fut = async {
492        let mut stream = client.events(Some(run_id), None).await?;
493        let mut out = stdout().lock();
494
495        while let Some(event) = stream.next().await {
496            match event {
497                Ok(ev) => {
498                    if no_logs
499                        && !ev.event_type.starts_with("run_")
500                        && !ev.event_type.starts_with("step_")
501                    {
502                        continue;
503                    }
504
505                    if json_mode {
506                        let obj = json!({
507                            "event": ev.event_type,
508                            "data": ev.data,
509                        });
510                        writeln!(out, "{}", to_string(&obj)?)?;
511                    } else {
512                        writeln!(out, "[{}] {}", ev.event_type, ev.data)?;
513                    }
514
515                    if TERMINAL_EVENTS.contains(&ev.event_type.as_str()) {
516                        break;
517                    }
518                }
519                Err(e) => {
520                    return Err(anyhow!("SSE stream error: {e}"));
521                }
522            }
523        }
524
525        Ok::<(), anyhow::Error>(())
526    };
527
528    match timeout {
529        Some(dur) => {
530            tokio_timeout(dur, watch_fut).await.unwrap_or_else(|_| {
531                eprintln!("Timeout reached after {}", format_duration(dur));
532                Ok(())
533            })?;
534        }
535        None => {
536            watch_fut.await?;
537        }
538    }
539
540    Ok(())
541}
542
543/// Compare two runs of the same workflow side by side.
544async fn execute_diff(
545    client: &IronflowClient,
546    run_a_id: Uuid,
547    run_b_id: Uuid,
548    json_mode: bool,
549) -> Result<()> {
550    if run_a_id == run_b_id {
551        bail!("both run IDs are the same; nothing to diff");
552    }
553
554    let (a, b) = tokio::try_join!(client.get_run(run_a_id), client.get_run(run_b_id))?;
555
556    if a.data.run.workflow_name != b.data.run.workflow_name {
557        bail!(
558            "cannot diff runs from different workflows: '{}' vs '{}'",
559            a.data.run.workflow_name,
560            b.data.run.workflow_name
561        );
562    }
563
564    if json_mode {
565        let diff = json!({
566            "run_a": a.data,
567            "run_b": b.data,
568        });
569        output::print_json(&diff)?;
570    } else {
571        let table = output::run_diff_table(&a.data, &b.data);
572        let mut out = stdout().lock();
573        writeln!(out, "{table}")?;
574    }
575
576    Ok(())
577}
578
579#[cfg(test)]
580mod tests {
581    use std::io::Write;
582
583    use tempfile::NamedTempFile;
584
585    use super::*;
586
587    #[test]
588    fn parse_concurrency_limit_reads_group_and_limit() {
589        let limit = parse_concurrency_limit("repo:acme=2").unwrap();
590        assert_eq!(limit.group, "repo:acme");
591        assert_eq!(limit.limit, 2);
592    }
593
594    #[test]
595    fn parse_concurrency_limit_rejects_a_limit_above_i32() {
596        assert!(parse_concurrency_limit("repo:acme=4294967295").is_err());
597    }
598
599    #[test]
600    fn parse_concurrency_limit_propagates_shape_errors() {
601        assert!(parse_concurrency_limit("repo:acme").is_err());
602    }
603
604    #[test]
605    fn resolve_payload_none_returns_empty_object() {
606        let value = resolve_payload(None, None).unwrap();
607        assert!(value.is_object());
608        assert!(value.as_object().unwrap().is_empty());
609    }
610
611    #[test]
612    fn resolve_payload_inline_valid_json() {
613        let value = resolve_payload(Some(r#"{"key": "value"}"#), None).unwrap();
614        assert_eq!(value["key"], "value");
615    }
616
617    #[test]
618    fn resolve_payload_inline_invalid_json() {
619        let result = resolve_payload(Some("not json"), None);
620        assert!(result.is_err());
621        assert!(result.unwrap_err().to_string().contains("invalid JSON"));
622    }
623
624    #[test]
625    fn resolve_payload_file_valid() {
626        let mut tmp = NamedTempFile::new().unwrap();
627        write!(tmp, r#"{{"workflow": "test"}}"#).unwrap();
628        let path = tmp.path().to_path_buf();
629
630        let value = resolve_payload(None, Some(&path)).unwrap();
631        assert_eq!(value["workflow"], "test");
632    }
633
634    #[test]
635    fn resolve_payload_file_not_found() {
636        let path = PathBuf::from("/nonexistent/payload.json");
637        let result = resolve_payload(None, Some(&path));
638        assert!(result.is_err());
639        assert!(result.unwrap_err().to_string().contains("cannot read"));
640    }
641
642    #[test]
643    fn resolve_payload_file_invalid_json() {
644        let mut tmp = NamedTempFile::new().unwrap();
645        write!(tmp, "not valid json").unwrap();
646        let path = tmp.path().to_path_buf();
647
648        let result = resolve_payload(None, Some(&path));
649        assert!(result.is_err());
650        assert!(result.unwrap_err().to_string().contains("invalid JSON"));
651    }
652}