Skip to main content

ironflow_cli/commands/
run.rs

1//! Run subcommands: create, list, steps, 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, ListStepsParams};
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    /// List the agent steps of a role across every workflow and run.
119    Steps {
120        /// Name of the agent role (`AgentRole::name`), matched exactly.
121        #[arg(long)]
122        role: String,
123        /// Page number (1-based).
124        #[arg(long, default_value_t = 1)]
125        page: u32,
126        /// Items per page (max 100).
127        #[arg(long, default_value_t = 20)]
128        per_page: u32,
129    },
130    /// Get details of a specific run.
131    Get {
132        /// Run UUID.
133        id: Uuid,
134    },
135    /// Cancel a pending or running run.
136    Cancel {
137        /// Run UUID.
138        id: Uuid,
139    },
140    /// Pause a run and its sub-workflow runs. A running run stops at its next step.
141    Pause {
142        /// Run UUID.
143        id: Uuid,
144    },
145    /// Resume a paused run in the state it was paused from.
146    Resume {
147        /// Run UUID.
148        id: Uuid,
149    },
150    /// Approve a run waiting for approval.
151    Approve {
152        /// Run UUID.
153        id: Uuid,
154    },
155    /// Reject a run waiting for approval, failing it.
156    Reject {
157        /// Run UUID.
158        id: Uuid,
159    },
160    /// Answer a human input step; the value must match the step's JSON schema.
161    Input {
162        /// Run UUID.
163        id: Uuid,
164        /// Human input step UUID.
165        step_id: Uuid,
166        /// JSON answer (inline string).
167        #[arg(long, group = "value_source")]
168        value: Option<String>,
169        /// Path to a JSON file containing the answer.
170        #[arg(long, group = "value_source")]
171        value_file: Option<PathBuf>,
172    },
173    /// Reject a human input step; the handler receives the rejection.
174    RejectInput {
175        /// Run UUID.
176        id: Uuid,
177        /// Human input step UUID.
178        step_id: Uuid,
179        /// Why the input is refused.
180        #[arg(long)]
181        reason: Option<String>,
182    },
183    /// Retry a failed run.
184    Retry {
185        /// Run UUID.
186        id: Uuid,
187        /// Force retry even when the handler version has changed since the
188        /// original run.
189        #[arg(long)]
190        force: bool,
191    },
192    /// Replay a finished run on the current handler version.
193    Replay {
194        /// Run UUID.
195        id: Uuid,
196    },
197    /// Watch a run in real time via SSE.
198    Watch {
199        /// Run UUID.
200        id: Uuid,
201        /// Only show step transitions, not log data.
202        #[arg(long)]
203        no_logs: bool,
204        /// Stop watching after this duration (e.g. "30s", "5m", "1h").
205        #[arg(long, value_parser = parse_humantime)]
206        timeout: Option<Duration>,
207    },
208    /// Show the execution plan for a workflow without running it.
209    Plan {
210        /// Workflow name.
211        workflow: String,
212        /// JSON input (inline string).
213        #[arg(long, group = "plan_input_source")]
214        input: Option<String>,
215        /// Path to a JSON file containing the input.
216        #[arg(long, group = "plan_input_source")]
217        input_file: Option<PathBuf>,
218        /// How deep sub-workflows are expanded (default 3).
219        #[arg(long)]
220        max_depth: Option<u32>,
221        /// Skip duration estimation from run history.
222        #[arg(long)]
223        no_estimates: bool,
224    },
225    /// Compare two runs of the same workflow side by side.
226    Diff {
227        /// First run UUID.
228        run_a: Uuid,
229        /// Second run UUID.
230        run_b: Uuid,
231    },
232}
233
234/// Parse a human-readable duration string (e.g. "30s", "5m", "1h").
235fn parse_humantime(s: &str) -> Result<Duration, String> {
236    humantime::parse_duration(s).map_err(|e| e.to_string())
237}
238
239/// Parse a `GROUP=N` concurrency limit into the SDK type.
240///
241/// Delegates to [`ironflow_types::parse_concurrency_limit`]; the group and the
242/// limit are validated by the API.
243fn parse_concurrency_limit(s: &str) -> Result<ConcurrencyLimit, String> {
244    let (group, limit) = shared_parse_concurrency_limit(s)?;
245    let limit =
246        i32::try_from(limit).map_err(|e| format!("invalid limit '{limit}' in '{s}': {e}"))?;
247    Ok(ConcurrencyLimit { group, limit })
248}
249
250/// Terminal event types that signal the run is done.
251const TERMINAL_EVENTS: &[&str] = &["run_completed", "run_failed", "run_cancelled"];
252
253/// Resolve the payload from inline string or file.
254fn resolve_payload(payload: Option<&str>, payload_file: Option<&PathBuf>) -> Result<Value> {
255    match (payload, payload_file) {
256        (Some(raw), _) => from_str(raw).context("invalid JSON in --payload"),
257        (_, Some(path)) => {
258            let content = fs::read_to_string(path)
259                .with_context(|| format!("cannot read payload file: {}", path.display()))?;
260            from_str(&content).with_context(|| format!("invalid JSON in {}", path.display()))
261        }
262        (None, None) => Ok(Value::Object(Map::new())),
263    }
264}
265
266/// Reject a `--max-cost` value the API would refuse anyway.
267///
268/// Catching it client-side turns a 400 round-trip into an immediate, readable
269/// error.
270///
271/// # Errors
272///
273/// Returns an error when the value is negative or not a finite number.
274fn validate_max_cost(max_cost: Option<f64>) -> Result<()> {
275    match max_cost {
276        Some(value) if !value.is_finite() => {
277            anyhow::bail!("--max-cost must be a finite number, got {value}")
278        }
279        Some(value) if value < 0.0 => {
280            anyhow::bail!("--max-cost must be zero or positive, got {value}")
281        }
282        _ => Ok(()),
283    }
284}
285
286/// Execute a run subcommand.
287///
288/// # Errors
289///
290/// Returns an error on API failure or invalid input.
291pub async fn execute(
292    client: &IronflowClient,
293    args: &RunArgs,
294    json_mode: bool,
295    _verbose: bool,
296) -> Result<()> {
297    match &args.command {
298        RunCommands::Create {
299            workflow,
300            payload,
301            payload_file,
302            max_retries,
303            idempotency_key,
304            max_cost,
305            concurrency_key,
306            concurrency_limits,
307            priority,
308            worker_tags,
309        } => {
310            validate_max_cost(*max_cost)?;
311            let payload_value = resolve_payload(payload.as_deref(), payload_file.as_ref())?;
312            let payload_map = payload_value
313                .as_object()
314                .context("payload must be a JSON object")?
315                .clone();
316            let request: CreateRunRequest = CreateRunRequest::builder()
317                .workflow(workflow.clone())
318                .payload(Some(payload_map))
319                // The generated SDK models the field as i32; the API rejects
320                // anything negative, and clap already refuses it here.
321                .max_retries(max_retries.map(|n| n as i32))
322                .max_cost_usd(*max_cost)
323                .concurrency_key(concurrency_key.clone())
324                .concurrency_limits(concurrency_limits.clone())
325                .priority(priority.map(i32::from))
326                .worker_tags(worker_tags.clone())
327                .try_into()
328                .context("failed to build CreateRunRequest")?;
329
330            let response = match idempotency_key {
331                Some(key) => client.create_run_idempotent(&request, key).await?,
332                None => client.create_run(&request).await?,
333            };
334            output::print_output(json_mode, &response, || {
335                output::runs_table(slice::from_ref(&response.data))
336            })?;
337        }
338        RunCommands::List {
339            status,
340            workflow,
341            created_by,
342            concurrency_group,
343            priority,
344            page,
345            per_page,
346        } => {
347            let filter = ListRunsFilter {
348                status: status.as_deref(),
349                workflow: workflow.as_deref(),
350                created_by: *created_by,
351                concurrency_group: concurrency_group.as_deref(),
352                priority: *priority,
353                page: *page,
354                per_page: *per_page,
355                ..Default::default()
356            };
357            let response = client.list_runs_filtered(&filter).await?;
358            output::print_output(json_mode, &response, || output::runs_table(&response.data))?;
359        }
360        RunCommands::Steps {
361            role,
362            page,
363            per_page,
364        } => {
365            let params = ListStepsParams {
366                agent_role: role,
367                page: Some(*page),
368                per_page: Some(*per_page),
369            };
370            let response = client.list_steps(&params).await?;
371            output::print_output(json_mode, &response, || {
372                output::role_steps_table(&response.data)
373            })?;
374        }
375        RunCommands::Get { id } => {
376            let response = client.get_run(*id).await?;
377            output::print_output(json_mode, &response, || {
378                output::run_detail_table(&response.data)
379            })?;
380
381            if !json_mode && !response.data.steps.is_empty() {
382                let mut out = stdout().lock();
383                writeln!(out)?;
384                writeln!(out, "Steps:")?;
385                writeln!(out, "{}", output::steps_table(&response.data.steps))?;
386            }
387        }
388        RunCommands::Cancel { id } => {
389            let response = client.cancel_run(*id).await?;
390            output::print_output(json_mode, &response, || {
391                output::cancelled_table(&response.data)
392            })?;
393        }
394        RunCommands::Pause { id } => {
395            let response = client.pause_run(*id).await?;
396            output::print_output(json_mode, &response, || {
397                output::paused_table(&response.data)
398            })?;
399        }
400        RunCommands::Resume { id } => {
401            let response = client.resume_run(*id).await?;
402            output::print_output(json_mode, &response, || {
403                output::resumed_table(&response.data)
404            })?;
405        }
406        RunCommands::Approve { id } => {
407            let response = client.approve_run(*id).await?;
408            output::print_output(json_mode, &response, || {
409                output::runs_table(slice::from_ref(&response.data))
410            })?;
411            // A multi-approver gate stays open until enough distinct users voted.
412            if !json_mode && matches!(response.data.status, RunStatus::AwaitingApproval) {
413                println!("Approval recorded; more approvals are required.");
414            }
415        }
416        RunCommands::Reject { id } => {
417            let response = client.reject_run(*id).await?;
418            output::print_output(json_mode, &response, || {
419                output::runs_table(slice::from_ref(&response.data))
420            })?;
421        }
422        RunCommands::Input {
423            id,
424            step_id,
425            value,
426            value_file,
427        } => {
428            let answer = resolve_payload(value.as_deref(), value_file.as_ref())?;
429            let response = client.submit_input(*id, *step_id, &answer).await?;
430            output::print_output(json_mode, &response, || {
431                output::runs_table(slice::from_ref(&response.data))
432            })?;
433        }
434        RunCommands::RejectInput {
435            id,
436            step_id,
437            reason,
438        } => {
439            let response = client
440                .reject_input(*id, *step_id, reason.as_deref())
441                .await?;
442            output::print_output(json_mode, &response, || {
443                output::runs_table(slice::from_ref(&response.data))
444            })?;
445        }
446        RunCommands::Retry { id, force } => {
447            let response = client.retry_run(*id, *force).await?;
448            output::print_output(json_mode, &response, || {
449                output::runs_table(slice::from_ref(&response.data))
450            })?;
451        }
452        RunCommands::Replay { id } => {
453            let response = client.replay_run(*id).await?;
454            output::print_output(json_mode, &response, || {
455                output::runs_table(slice::from_ref(&response.data))
456            })?;
457        }
458        RunCommands::Watch {
459            id,
460            no_logs,
461            timeout,
462        } => {
463            execute_watch(client, *id, *no_logs, *timeout, json_mode).await?;
464        }
465        RunCommands::Plan {
466            workflow,
467            input,
468            input_file,
469            max_depth,
470            no_estimates,
471        } => {
472            let payload = resolve_payload(input.as_deref(), input_file.as_ref())?;
473            let payload_map = payload
474                .as_object()
475                .context("input must be a JSON object")?
476                .clone();
477            let request: PlanWorkflowRequest = PlanWorkflowRequest::builder()
478                .payload(Some(payload_map))
479                // The generated SDK models the depth as i32; the API rejects
480                // anything outside 1..=10.
481                .max_depth(max_depth.map(|d| d as i32))
482                .estimate_durations(Some(!*no_estimates))
483                .try_into()
484                .context("failed to build PlanWorkflowRequest")?;
485            let response = client.plan_workflow(workflow, &request).await?;
486            output::render_execution_plan(&mut stdout().lock(), json_mode, &response)?;
487        }
488        RunCommands::Diff { run_a, run_b } => {
489            execute_diff(client, *run_a, *run_b, json_mode).await?;
490        }
491    }
492    Ok(())
493}
494
495/// Watch a run in real time via SSE.
496async fn execute_watch(
497    client: &IronflowClient,
498    run_id: Uuid,
499    no_logs: bool,
500    timeout: Option<Duration>,
501    json_mode: bool,
502) -> Result<()> {
503    let run = client.get_run(run_id).await?;
504    let status = run.data.run.status;
505    if matches!(
506        status,
507        RunStatus::Completed | RunStatus::Failed | RunStatus::Cancelled
508    ) {
509        if json_mode {
510            output::print_output(json_mode, &run, || output::run_detail_table(&run.data))?;
511        } else {
512            let mut out = stdout().lock();
513            writeln!(out, "Run {run_id} already in terminal state: {status}")?;
514        }
515        return Ok(());
516    }
517
518    let watch_fut = async {
519        let mut stream = client.events(Some(run_id), None).await?;
520        let mut out = stdout().lock();
521
522        while let Some(event) = stream.next().await {
523            match event {
524                Ok(ev) => {
525                    if no_logs
526                        && !ev.event_type.starts_with("run_")
527                        && !ev.event_type.starts_with("step_")
528                    {
529                        continue;
530                    }
531
532                    if json_mode {
533                        let obj = json!({
534                            "event": ev.event_type,
535                            "data": ev.data,
536                        });
537                        writeln!(out, "{}", to_string(&obj)?)?;
538                    } else {
539                        writeln!(out, "[{}] {}", ev.event_type, ev.data)?;
540                    }
541
542                    if TERMINAL_EVENTS.contains(&ev.event_type.as_str()) {
543                        break;
544                    }
545                }
546                Err(e) => {
547                    return Err(anyhow!("SSE stream error: {e}"));
548                }
549            }
550        }
551
552        Ok::<(), anyhow::Error>(())
553    };
554
555    match timeout {
556        Some(dur) => {
557            tokio_timeout(dur, watch_fut).await.unwrap_or_else(|_| {
558                eprintln!("Timeout reached after {}", format_duration(dur));
559                Ok(())
560            })?;
561        }
562        None => {
563            watch_fut.await?;
564        }
565    }
566
567    Ok(())
568}
569
570/// Compare two runs of the same workflow side by side.
571async fn execute_diff(
572    client: &IronflowClient,
573    run_a_id: Uuid,
574    run_b_id: Uuid,
575    json_mode: bool,
576) -> Result<()> {
577    if run_a_id == run_b_id {
578        bail!("both run IDs are the same; nothing to diff");
579    }
580
581    let (a, b) = tokio::try_join!(client.get_run(run_a_id), client.get_run(run_b_id))?;
582
583    if a.data.run.workflow_name != b.data.run.workflow_name {
584        bail!(
585            "cannot diff runs from different workflows: '{}' vs '{}'",
586            a.data.run.workflow_name,
587            b.data.run.workflow_name
588        );
589    }
590
591    if json_mode {
592        let diff = json!({
593            "run_a": a.data,
594            "run_b": b.data,
595        });
596        output::print_json(&diff)?;
597    } else {
598        let table = output::run_diff_table(&a.data, &b.data);
599        let mut out = stdout().lock();
600        writeln!(out, "{table}")?;
601    }
602
603    Ok(())
604}
605
606#[cfg(test)]
607mod tests {
608    use std::io::Write;
609
610    use tempfile::NamedTempFile;
611
612    use super::*;
613
614    #[test]
615    fn parse_concurrency_limit_reads_group_and_limit() {
616        let limit = parse_concurrency_limit("repo:acme=2").unwrap();
617        assert_eq!(limit.group, "repo:acme");
618        assert_eq!(limit.limit, 2);
619    }
620
621    #[test]
622    fn parse_concurrency_limit_rejects_a_limit_above_i32() {
623        assert!(parse_concurrency_limit("repo:acme=4294967295").is_err());
624    }
625
626    #[test]
627    fn parse_concurrency_limit_propagates_shape_errors() {
628        assert!(parse_concurrency_limit("repo:acme").is_err());
629    }
630
631    #[test]
632    fn resolve_payload_none_returns_empty_object() {
633        let value = resolve_payload(None, None).unwrap();
634        assert!(value.is_object());
635        assert!(value.as_object().unwrap().is_empty());
636    }
637
638    #[test]
639    fn resolve_payload_inline_valid_json() {
640        let value = resolve_payload(Some(r#"{"key": "value"}"#), None).unwrap();
641        assert_eq!(value["key"], "value");
642    }
643
644    #[test]
645    fn resolve_payload_inline_invalid_json() {
646        let result = resolve_payload(Some("not json"), None);
647        assert!(result.is_err());
648        assert!(result.unwrap_err().to_string().contains("invalid JSON"));
649    }
650
651    #[test]
652    fn resolve_payload_file_valid() {
653        let mut tmp = NamedTempFile::new().unwrap();
654        write!(tmp, r#"{{"workflow": "test"}}"#).unwrap();
655        let path = tmp.path().to_path_buf();
656
657        let value = resolve_payload(None, Some(&path)).unwrap();
658        assert_eq!(value["workflow"], "test");
659    }
660
661    #[test]
662    fn resolve_payload_file_not_found() {
663        let path = PathBuf::from("/nonexistent/payload.json");
664        let result = resolve_payload(None, Some(&path));
665        assert!(result.is_err());
666        assert!(result.unwrap_err().to_string().contains("cannot read"));
667    }
668
669    #[test]
670    fn resolve_payload_file_invalid_json() {
671        let mut tmp = NamedTempFile::new().unwrap();
672        write!(tmp, "not valid json").unwrap();
673        let path = tmp.path().to_path_buf();
674
675        let result = resolve_payload(None, Some(&path));
676        assert!(result.is_err());
677        assert!(result.unwrap_err().to_string().contains("invalid JSON"));
678    }
679}