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;
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 Get {
120 id: Uuid,
122 },
123 Cancel {
125 id: Uuid,
127 },
128 Pause {
130 id: Uuid,
132 },
133 Resume {
135 id: Uuid,
137 },
138 Approve {
140 id: Uuid,
142 },
143 Reject {
145 id: Uuid,
147 },
148 Input {
150 id: Uuid,
152 step_id: Uuid,
154 #[arg(long, group = "value_source")]
156 value: Option<String>,
157 #[arg(long, group = "value_source")]
159 value_file: Option<PathBuf>,
160 },
161 RejectInput {
163 id: Uuid,
165 step_id: Uuid,
167 #[arg(long)]
169 reason: Option<String>,
170 },
171 Retry {
173 id: Uuid,
175 #[arg(long)]
178 force: bool,
179 },
180 Replay {
182 id: Uuid,
184 },
185 Watch {
187 id: Uuid,
189 #[arg(long)]
191 no_logs: bool,
192 #[arg(long, value_parser = parse_humantime)]
194 timeout: Option<Duration>,
195 },
196 Plan {
198 workflow: String,
200 #[arg(long, group = "plan_input_source")]
202 input: Option<String>,
203 #[arg(long, group = "plan_input_source")]
205 input_file: Option<PathBuf>,
206 #[arg(long)]
208 max_depth: Option<u32>,
209 #[arg(long)]
211 no_estimates: bool,
212 },
213 Diff {
215 run_a: Uuid,
217 run_b: Uuid,
219 },
220}
221
222fn parse_humantime(s: &str) -> Result<Duration, String> {
224 humantime::parse_duration(s).map_err(|e| e.to_string())
225}
226
227fn 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
238const TERMINAL_EVENTS: &[&str] = &["run_completed", "run_failed", "run_cancelled"];
240
241fn 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
254fn 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
274pub 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 .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 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 .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
468async 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
543async 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}