1use 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#[derive(Debug, Args)]
26pub struct RunArgs {
27 #[command(subcommand)]
29 pub command: RunCommands,
30}
31
32#[derive(Debug, Subcommand)]
34pub enum RunCommands {
35 Create {
37 workflow: String,
39 #[arg(long, group = "payload_source")]
41 payload: Option<String>,
42 #[arg(long, group = "payload_source")]
44 payload_file: Option<PathBuf>,
45 #[arg(long)]
48 max_retries: Option<u32>,
49 #[arg(long)]
55 idempotency_key: Option<String>,
56 #[arg(long = "max-cost", value_name = "USD")]
59 max_cost: Option<f64>,
60 #[arg(long)]
64 concurrency_key: Option<String>,
65 #[arg(
69 long = "concurrency-limit",
70 value_name = "GROUP=N",
71 value_parser = parse_concurrency_limit
72 )]
73 concurrency_limits: Vec<ConcurrencyLimit>,
74 #[arg(
78 long,
79 allow_negative_numbers = true,
80 value_parser = value_parser!(i16).range(-100..=100)
81 )]
82 priority: Option<i16>,
83 #[arg(long = "worker-tag", value_name = "TAG")]
86 worker_tags: Vec<String>,
87 },
88 List {
90 #[arg(long)]
92 status: Option<String>,
93 #[arg(long)]
95 workflow: Option<String>,
96 #[arg(long)]
100 created_by: Option<Uuid>,
101 #[arg(long)]
103 concurrency_group: Option<String>,
104 #[arg(
106 long,
107 allow_negative_numbers = true,
108 value_parser = value_parser!(i16).range(-100..=100)
109 )]
110 priority: Option<i16>,
111 #[arg(long)]
113 page: Option<u32>,
114 #[arg(long)]
116 per_page: Option<u32>,
117 },
118 Steps {
120 #[arg(long)]
122 role: String,
123 #[arg(long, default_value_t = 1)]
125 page: u32,
126 #[arg(long, default_value_t = 20)]
128 per_page: u32,
129 },
130 Get {
132 id: Uuid,
134 },
135 Cancel {
137 id: Uuid,
139 },
140 Pause {
142 id: Uuid,
144 },
145 Resume {
147 id: Uuid,
149 },
150 Approve {
152 id: Uuid,
154 },
155 Reject {
157 id: Uuid,
159 },
160 Input {
162 id: Uuid,
164 step_id: Uuid,
166 #[arg(long, group = "value_source")]
168 value: Option<String>,
169 #[arg(long, group = "value_source")]
171 value_file: Option<PathBuf>,
172 },
173 RejectInput {
175 id: Uuid,
177 step_id: Uuid,
179 #[arg(long)]
181 reason: Option<String>,
182 },
183 Retry {
185 id: Uuid,
187 #[arg(long)]
190 force: bool,
191 },
192 Replay {
194 id: Uuid,
196 },
197 Watch {
199 id: Uuid,
201 #[arg(long)]
203 no_logs: bool,
204 #[arg(long, value_parser = parse_humantime)]
206 timeout: Option<Duration>,
207 },
208 Plan {
210 workflow: String,
212 #[arg(long, group = "plan_input_source")]
214 input: Option<String>,
215 #[arg(long, group = "plan_input_source")]
217 input_file: Option<PathBuf>,
218 #[arg(long)]
220 max_depth: Option<u32>,
221 #[arg(long)]
223 no_estimates: bool,
224 },
225 Diff {
227 run_a: Uuid,
229 run_b: Uuid,
231 },
232}
233
234fn parse_humantime(s: &str) -> Result<Duration, String> {
236 humantime::parse_duration(s).map_err(|e| e.to_string())
237}
238
239fn 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
250const TERMINAL_EVENTS: &[&str] = &["run_completed", "run_failed", "run_cancelled"];
252
253fn 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
266fn 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
286pub 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 .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(¶ms).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 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 .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
495async 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
570async 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}