1use std::collections::HashMap;
4use std::ops::Not;
5
6use ironflow_core::retry::RetryPolicy;
7use ironflow_store::entities::{MAX_CONCURRENCY_KEY_LEN, validate_priority};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11const ENGINE_LABEL_PREFIX: &str = "ironflow.io/";
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct WorkflowStepConfig {
30 pub workflow_name: String,
32 pub payload: Value,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub retry: Option<RetryPolicy>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub concurrency_key: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub priority: Option<i16>,
49 #[serde(default, skip_serializing_if = "Not::not")]
52 pub allow_failure: bool,
53 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
58 pub labels: HashMap<String, String>,
59}
60
61impl WorkflowStepConfig {
62 pub fn new(workflow_name: &str, payload: Value) -> Self {
74 Self {
75 workflow_name: workflow_name.to_string(),
76 payload,
77 retry: None,
78 concurrency_key: None,
79 priority: None,
80 allow_failure: false,
81 labels: HashMap::new(),
82 }
83 }
84
85 pub fn retry_policy(mut self, policy: RetryPolicy) -> Self {
99 self.retry = Some(policy);
100 self
101 }
102
103 pub fn allow_failure(mut self) -> Self {
117 self.allow_failure = true;
118 self
119 }
120}
121
122#[derive(Debug, Clone, Default, PartialEq, Eq)]
136pub struct WorkflowOptions {
137 pub allow_failure: bool,
139 concurrency_key: Option<String>,
140 priority: Option<i16>,
141 step_name: Option<String>,
142 labels: HashMap<String, String>,
143}
144
145impl WorkflowOptions {
146 pub fn new() -> Self {
156 Self::default()
157 }
158
159 pub fn allow_failure(mut self) -> Self {
171 self.allow_failure = true;
172 self
173 }
174
175 pub fn concurrency_key(mut self, key: impl Into<String>) -> Self {
202 let key = key.into();
203 assert!(!key.trim().is_empty(), "concurrency key must not be empty");
204 assert!(
205 key.len() <= MAX_CONCURRENCY_KEY_LEN,
206 "concurrency key must be at most {MAX_CONCURRENCY_KEY_LEN} bytes"
207 );
208 self.concurrency_key = Some(key);
209 self
210 }
211
212 pub fn concurrency_key_ref(&self) -> Option<&str> {
223 self.concurrency_key.as_deref()
224 }
225
226 pub fn priority(mut self, priority: i16) -> Self {
252 if let Err(message) = validate_priority(priority) {
253 panic!("{message}");
254 }
255 self.priority = Some(priority);
256 self
257 }
258
259 pub fn priority_ref(&self) -> Option<i16> {
270 self.priority
271 }
272
273 pub fn step_name(mut self, name: impl Into<String>) -> Self {
299 let name = name.into();
300 assert!(!name.trim().is_empty(), "step name must not be empty");
301 self.step_name = Some(name);
302 self
303 }
304
305 pub fn step_name_ref(&self) -> Option<&str> {
315 self.step_name.as_deref()
316 }
317
318 pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
344 let key = key.into();
345 assert!(
346 !key.starts_with(ENGINE_LABEL_PREFIX),
347 "label {key:?} is reserved for the engine"
348 );
349 self.labels.insert(key, value.into());
350 self
351 }
352
353 pub fn labels<K, V>(self, labels: impl IntoIterator<Item = (K, V)>) -> Self
369 where
370 K: Into<String>,
371 V: Into<String>,
372 {
373 labels
374 .into_iter()
375 .fold(self, |options, (key, value)| options.label(key, value))
376 }
377
378 pub fn labels_ref(&self) -> &HashMap<String, String> {
388 &self.labels
389 }
390
391 pub(crate) fn into_step_config(
394 self,
395 workflow_name: &str,
396 payload: Value,
397 ) -> (String, WorkflowStepConfig) {
398 let step_name = self.step_name.unwrap_or_else(|| workflow_name.to_string());
399 let config = WorkflowStepConfig {
400 concurrency_key: self.concurrency_key,
401 priority: self.priority,
402 allow_failure: self.allow_failure,
403 labels: self.labels,
404 ..WorkflowStepConfig::new(workflow_name, payload)
405 };
406 (step_name, config)
407 }
408}
409
410#[cfg(test)]
411mod tests {
412 use super::*;
413 use ironflow_store::entities::{MAX_PRIORITY, MIN_PRIORITY};
414 use serde_json::{from_str, json, to_string, to_value};
415
416 #[test]
417 fn new_sets_fields() {
418 let config = WorkflowStepConfig::new("build", json!({"key": "val"}));
419 assert_eq!(config.workflow_name, "build");
420 assert_eq!(config.payload["key"], "val");
421 }
422
423 #[test]
424 fn serde_roundtrip() {
425 let config = WorkflowStepConfig::new("deploy", json!({"env": "prod"}));
426 let json = serde_json::to_string(&config).unwrap();
427 let back: WorkflowStepConfig = serde_json::from_str(&json).unwrap();
428 assert_eq!(back.workflow_name, "deploy");
429 assert_eq!(back.payload["env"], "prod");
430 }
431
432 #[test]
433 fn a_config_predating_retry_still_deserializes() {
434 let config: WorkflowStepConfig =
435 serde_json::from_str(r#"{"workflow_name":"build","payload":{"key":"val"}}"#)
436 .expect("deserialize");
437 assert!(config.retry.is_none());
438 }
439
440 #[test]
441 fn a_config_without_concurrency_key_omits_it() {
442 let config = WorkflowStepConfig::new("build", json!({}));
443 let value = to_value(&config).expect("serialize");
444 assert!(value.get("concurrency_key").is_none());
445 }
446
447 #[test]
448 fn concurrency_key_roundtrip() {
449 let mut config = WorkflowStepConfig::new("build", json!({}));
450 config.concurrency_key = Some("issue:12".to_string());
451 let json = to_string(&config).expect("serialize");
452 let back: WorkflowStepConfig = from_str(&json).expect("deserialize");
453 assert_eq!(back.concurrency_key.as_deref(), Some("issue:12"));
454 }
455
456 #[test]
457 fn options_carry_the_concurrency_key() {
458 let options = WorkflowOptions::new().concurrency_key("issue:12");
459 assert_eq!(options.concurrency_key_ref(), Some("issue:12"));
460 let (_, config) = options.into_step_config("build", json!({}));
461 assert_eq!(config.concurrency_key.as_deref(), Some("issue:12"));
462 assert!(WorkflowOptions::new().concurrency_key_ref().is_none());
463 }
464
465 #[test]
466 fn options_accept_a_key_at_the_length_limit() {
467 let key = "a".repeat(MAX_CONCURRENCY_KEY_LEN);
468 let options = WorkflowOptions::new().concurrency_key(key.clone());
469 assert_eq!(options.concurrency_key_ref(), Some(key.as_str()));
470 }
471
472 #[test]
473 #[should_panic(expected = "concurrency key must not be empty")]
474 fn options_reject_an_empty_key() {
475 let _ = WorkflowOptions::new().concurrency_key("");
476 }
477
478 #[test]
479 #[should_panic(expected = "concurrency key must not be empty")]
480 fn options_reject_a_blank_key() {
481 let _ = WorkflowOptions::new().concurrency_key(" \t ");
482 }
483
484 #[test]
485 #[should_panic(expected = "concurrency key must be at most")]
486 fn options_reject_a_key_over_the_limit() {
487 let _ = WorkflowOptions::new().concurrency_key("a".repeat(MAX_CONCURRENCY_KEY_LEN + 1));
488 }
489
490 #[test]
491 fn options_priority_is_carried_and_split() {
492 let options = WorkflowOptions::new()
493 .concurrency_key("issue:12")
494 .priority(MAX_PRIORITY);
495 assert_eq!(options.priority_ref(), Some(MAX_PRIORITY));
496 let (_, config) = options.into_step_config("build", json!({}));
497 assert_eq!(config.concurrency_key.as_deref(), Some("issue:12"));
498 assert_eq!(config.priority, Some(MAX_PRIORITY));
499 assert_eq!(
500 WorkflowOptions::new().priority(MIN_PRIORITY).priority_ref(),
501 Some(MIN_PRIORITY)
502 );
503 assert!(WorkflowOptions::new().priority_ref().is_none());
504 }
505
506 #[test]
507 #[should_panic(expected = "priority must be between -100 and 100")]
508 fn options_priority_rejects_above_the_maximum() {
509 let _ = WorkflowOptions::new().priority(MAX_PRIORITY + 1);
510 }
511
512 #[test]
513 #[should_panic(expected = "priority must be between -100 and 100")]
514 fn options_priority_rejects_below_the_minimum() {
515 let _ = WorkflowOptions::new().priority(MIN_PRIORITY - 1);
516 }
517
518 #[test]
519 fn step_config_priority_is_omitted_when_unset_and_round_trips() {
520 let config = WorkflowStepConfig::new("build", json!({}));
521 assert!(
522 to_value(&config)
523 .expect("serialize")
524 .get("priority")
525 .is_none()
526 );
527
528 let mut config = WorkflowStepConfig::new("build", json!({}));
529 config.priority = Some(-30);
530 let back: WorkflowStepConfig =
531 from_str(&to_string(&config).expect("serialize")).expect("deserialize");
532 assert_eq!(back.priority, Some(-30));
533 }
534
535 #[test]
536 fn allow_failure_defaults_to_false() {
537 let config = WorkflowStepConfig::new("build", json!({}));
538 assert!(!config.allow_failure);
539 }
540
541 #[test]
542 fn allow_failure_is_omitted_from_json_when_false() {
543 let config = WorkflowStepConfig::new("build", json!({}));
544 let value = serde_json::to_value(&config).expect("serialize");
545 assert!(value.get("allow_failure").is_none());
546 }
547
548 #[test]
549 fn allow_failure_roundtrip() {
550 let config = WorkflowStepConfig::new("build", json!({})).allow_failure();
551 let json = serde_json::to_string(&config).expect("serialize");
552 let back: WorkflowStepConfig = serde_json::from_str(&json).expect("deserialize");
553 assert!(back.allow_failure);
554 }
555
556 #[test]
557 fn options_builder_sets_allow_failure() {
558 assert!(!WorkflowOptions::new().allow_failure);
559 assert!(WorkflowOptions::new().allow_failure().allow_failure);
560 }
561
562 #[test]
563 fn options_carry_the_step_name() {
564 let options = WorkflowOptions::new().step_name("delegate-1-alice");
565 assert_eq!(options.step_name_ref(), Some("delegate-1-alice"));
566 assert!(WorkflowOptions::new().step_name_ref().is_none());
567 }
568
569 #[test]
570 #[should_panic(expected = "step name must not be empty")]
571 fn options_reject_an_empty_step_name() {
572 let _ = WorkflowOptions::new().step_name("");
573 }
574
575 #[test]
576 #[should_panic(expected = "step name must not be empty")]
577 fn options_reject_a_blank_step_name() {
578 let _ = WorkflowOptions::new().step_name(" \n ");
579 }
580
581 #[test]
582 fn options_merge_labels_and_the_last_value_wins() {
583 let options = WorkflowOptions::new()
584 .label("paperflow.io/tour", "1")
585 .labels([("paperflow.io/agent", "alice"), ("paperflow.io/tour", "2")]);
586 let labels = options.labels_ref();
587 assert_eq!(labels.len(), 2);
588 assert_eq!(labels["paperflow.io/tour"], "2");
589 assert_eq!(labels["paperflow.io/agent"], "alice");
590 assert!(WorkflowOptions::new().labels_ref().is_empty());
591 }
592
593 #[test]
594 #[should_panic(expected = "reserved for the engine")]
595 fn options_reject_an_engine_label() {
596 let _ = WorkflowOptions::new().label("ironflow.io/parent-run-id", "x");
597 }
598
599 #[test]
600 #[should_panic(expected = "reserved for the engine")]
601 fn options_reject_an_engine_label_among_several() {
602 let _ = WorkflowOptions::new().labels([("team", "a"), ("ironflow.io/root-run-id", "x")]);
603 }
604
605 #[test]
606 fn step_config_defaults_the_step_name_to_the_handler() {
607 let (name, config) = WorkflowOptions::new().into_step_config("review", json!({}));
608 assert_eq!(name, "review");
609 assert_eq!(config.workflow_name, "review");
610 assert!(config.labels.is_empty());
611 }
612
613 #[test]
614 fn step_config_takes_every_option() {
615 let (name, config) = WorkflowOptions::new()
616 .step_name("delegate-1-alice")
617 .label("agent", "alice")
618 .concurrency_key("issue:12")
619 .priority(7)
620 .allow_failure()
621 .into_step_config("review", json!({"n": 1}));
622 assert_eq!(name, "delegate-1-alice");
623 assert_eq!(config.workflow_name, "review");
624 assert_eq!(config.payload, json!({"n": 1}));
625 assert_eq!(config.labels["agent"], "alice");
626 assert_eq!(config.concurrency_key.as_deref(), Some("issue:12"));
627 assert_eq!(config.priority, Some(7));
628 assert!(config.allow_failure);
629 }
630
631 #[test]
632 fn step_config_labels_are_omitted_when_empty_and_round_trip() {
633 let config = WorkflowStepConfig::new("build", json!({}));
634 assert!(
635 to_value(&config)
636 .expect("serialize")
637 .get("labels")
638 .is_none()
639 );
640
641 let (_, config) = WorkflowOptions::new()
642 .label("agent", "alice")
643 .into_step_config("build", json!({}));
644 let back: WorkflowStepConfig =
645 from_str(&to_string(&config).expect("serialize")).expect("deserialize");
646 assert_eq!(back.labels["agent"], "alice");
647 }
648
649 #[test]
650 fn a_config_predating_labels_still_deserializes() {
651 let config: WorkflowStepConfig =
652 from_str(r#"{"workflow_name":"build","payload":{}}"#).expect("deserialize");
653 assert!(config.labels.is_empty());
654 }
655
656 #[test]
657 fn retry_policy_roundtrip() {
658 let config = WorkflowStepConfig::new("deploy", json!({})).retry_policy(RetryPolicy::new(3));
659 let json = serde_json::to_string(&config).expect("serialize");
660 let back: WorkflowStepConfig = serde_json::from_str(&json).expect("deserialize");
661 assert_eq!(back.retry.as_ref().unwrap().max_retries(), 3);
662 }
663}