1use 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#[derive(Debug, Args)]
24pub struct RunArgs {
25 #[command(subcommand)]
27 pub command: RunCommands,
28}
29
30#[derive(Debug, Subcommand)]
32pub enum RunCommands {
33 Create {
35 workflow: String,
37 #[arg(long, group = "payload_source")]
39 payload: Option<String>,
40 #[arg(long, group = "payload_source")]
42 payload_file: Option<PathBuf>,
43 #[arg(long)]
46 max_retries: Option<u32>,
47 #[arg(long)]
53 idempotency_key: Option<String>,
54 #[arg(long = "max-cost", value_name = "USD")]
57 max_cost: Option<f64>,
58 },
59 List {
61 #[arg(long)]
63 status: Option<String>,
64 #[arg(long)]
66 workflow: Option<String>,
67 #[arg(long)]
71 created_by: Option<Uuid>,
72 #[arg(long)]
74 page: Option<u32>,
75 #[arg(long)]
77 per_page: Option<u32>,
78 },
79 Get {
81 id: Uuid,
83 },
84 Cancel {
86 id: Uuid,
88 },
89 Approve {
91 id: Uuid,
93 },
94 Reject {
96 id: Uuid,
98 },
99 Retry {
101 id: Uuid,
103 #[arg(long)]
106 force: bool,
107 },
108 Watch {
110 id: Uuid,
112 #[arg(long)]
114 no_logs: bool,
115 #[arg(long, value_parser = parse_humantime)]
117 timeout: Option<Duration>,
118 },
119 Diff {
121 run_a: Uuid,
123 run_b: Uuid,
125 },
126}
127
128fn parse_humantime(s: &str) -> Result<Duration, String> {
130 humantime::parse_duration(s).map_err(|e| e.to_string())
131}
132
133const TERMINAL_EVENTS: &[&str] = &["run_completed", "run_failed", "run_cancelled"];
135
136fn 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
149fn 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
169pub 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 .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
282async 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
357async 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}