1use std::collections::HashMap;
32use std::fmt;
33use std::panic::{AssertUnwindSafe, catch_unwind};
34use std::sync::Arc;
35
36use rust_decimal::Decimal;
37use serde_json::json;
38use tokio::sync::mpsc;
39use tokio_util::sync::CancellationToken;
40use tracing::{info, warn};
41use uuid::Uuid;
42
43use ironflow_engine::notify::{Event, EventSubscriber, SubscriberFuture};
44use ironflow_store::entities::{EventKind, TriggerKind};
45
46use super::{Trigger, TriggerEvent, TriggerFuture, TriggerSink};
47
48pub const CHAIN_DEPTH_LABEL: &str = "_chain_depth";
50
51#[derive(Debug, Clone)]
73pub struct TriggerContext {
74 pub labels: HashMap<String, String>,
76 pub error: Option<String>,
78 pub cost_usd: Decimal,
80 pub duration_ms: u64,
82}
83
84pub enum TriggerCondition {
102 Label {
104 key: String,
106 value: String,
108 },
109 Expression(Arc<dyn Fn(&TriggerContext) -> bool + Send + Sync>),
113}
114
115impl TriggerCondition {
116 pub fn evaluate(&self, ctx: &TriggerContext) -> bool {
144 match self {
145 TriggerCondition::Label { key, value } => {
146 ctx.labels.get(key).is_some_and(|v| v == value)
147 }
148 TriggerCondition::Expression(f) => match catch_unwind(AssertUnwindSafe(|| f(ctx))) {
149 Ok(result) => result,
150 Err(_) => {
151 warn!("expression condition panicked, treating as false");
152 false
153 }
154 },
155 }
156 }
157}
158
159impl Clone for TriggerCondition {
160 fn clone(&self) -> Self {
161 match self {
162 TriggerCondition::Label { key, value } => TriggerCondition::Label {
163 key: key.clone(),
164 value: value.clone(),
165 },
166 TriggerCondition::Expression(f) => TriggerCondition::Expression(Arc::clone(f)),
167 }
168 }
169}
170
171impl fmt::Debug for TriggerCondition {
172 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173 match self {
174 TriggerCondition::Label { key, value } => f
175 .debug_struct("Label")
176 .field("key", key)
177 .field("value", value)
178 .finish(),
179 TriggerCondition::Expression(_) => f.write_str("Expression(<closure>)"),
180 }
181 }
182}
183
184#[derive(Debug, Clone)]
202pub struct EventTriggerRule {
203 pub on_event: EventKind,
205 pub source_workflow: String,
207 pub target_workflow: String,
209 pub max_chain_depth: u8,
212 pub conditions: Vec<TriggerCondition>,
217}
218
219pub struct EventTrigger {
244 rules: Vec<EventTriggerRule>,
245 event_tx: mpsc::Sender<InternalEvent>,
247 event_rx: tokio::sync::Mutex<mpsc::Receiver<InternalEvent>>,
248}
249
250#[derive(Debug)]
252struct InternalEvent {
253 run_id: Uuid,
254 workflow_name: String,
255 event_kind: EventKind,
256 error: Option<String>,
257 labels: HashMap<String, String>,
258 cost_usd: Decimal,
259 duration_ms: u64,
260}
261
262impl EventTrigger {
263 pub fn new(rules: Vec<EventTriggerRule>) -> Self {
282 let (event_tx, event_rx) = mpsc::channel(256);
283 Self {
284 rules,
285 event_tx,
286 event_rx: tokio::sync::Mutex::new(event_rx),
287 }
288 }
289
290 pub fn subscribed_event_types(&self) -> Vec<&'static str> {
311 self.rules.iter().map(|r| r.on_event.as_str()).collect()
312 }
313
314 fn matching_rules(&self, event_kind: EventKind, workflow_name: &str) -> Vec<&EventTriggerRule> {
316 self.rules
317 .iter()
318 .filter(|r| r.on_event == event_kind && r.source_workflow == workflow_name)
319 .collect()
320 }
321
322 fn build_payload(
324 source_run_id: Uuid,
325 source_workflow: &str,
326 error: &Option<String>,
327 ) -> serde_json::Value {
328 json!({
329 "source_run_id": source_run_id,
330 "source_workflow": source_workflow,
331 "error": error,
332 })
333 }
334
335 fn chain_depth_from_event(_event_kind: &EventKind) -> u8 {
337 0
338 }
339}
340
341impl Trigger for EventTrigger {
342 fn name(&self) -> &str {
343 "event-trigger"
344 }
345
346 fn start<'a>(&'a self, sink: TriggerSink, token: &'a CancellationToken) -> TriggerFuture<'a> {
347 Box::pin(async move {
348 let mut rx = self.event_rx.lock().await;
349 loop {
350 tokio::select! {
351 _ = token.cancelled() => {
352 info!("event trigger shutting down");
353 return Ok(());
354 }
355 event = rx.recv() => {
356 let Some(event) = event else {
357 return Ok(());
358 };
359 let rules = self.matching_rules(event.event_kind, &event.workflow_name);
360 if rules.is_empty() {
361 continue;
362 }
363
364 let trigger_ctx = TriggerContext {
365 labels: event.labels.clone(),
366 error: event.error.clone(),
367 cost_usd: event.cost_usd,
368 duration_ms: event.duration_ms,
369 };
370
371 for rule in rules {
372 let depth = Self::chain_depth_from_event(&rule.on_event);
373 if depth >= rule.max_chain_depth {
374 warn!(
375 source_workflow = %event.workflow_name,
376 target_workflow = %rule.target_workflow,
377 chain_depth = depth,
378 max_chain_depth = rule.max_chain_depth,
379 "chain depth exceeded, ignoring event"
380 );
381 continue;
382 }
383
384 if !rule.conditions.iter().all(|c| c.evaluate(&trigger_ctx)) {
385 info!(
386 source_workflow = %event.workflow_name,
387 target_workflow = %rule.target_workflow,
388 "conditions not met, skipping rule"
389 );
390 continue;
391 }
392
393 let payload = Self::build_payload(
394 event.run_id,
395 &event.workflow_name,
396 &event.error,
397 );
398
399 let trigger_event = TriggerEvent {
400 workflow_name: rule.target_workflow.clone(),
401 payload,
402 trigger_kind: TriggerKind::RunEvent {
403 source_run_id: event.run_id,
404 event_kind: rule.on_event.as_str().to_string(),
405 },
406 };
407
408 if let Err(e) = sink.send(trigger_event).await {
409 warn!(error = %e, "failed to emit trigger event");
410 } else {
411 info!(
412 source_workflow = %event.workflow_name,
413 target_workflow = %rule.target_workflow,
414 source_run_id = %event.run_id,
415 "event trigger fired"
416 );
417 }
418 }
419 }
420 }
421 }
422 })
423 }
424}
425
426impl EventSubscriber for EventTrigger {
427 fn name(&self) -> &str {
428 "event-trigger"
429 }
430
431 fn handle<'a>(&'a self, event: &'a Event) -> SubscriberFuture<'a> {
432 Box::pin(async move {
433 let internal = match event {
434 Event::RunFailed {
435 run_id,
436 workflow_name,
437 error,
438 cost_usd,
439 duration_ms,
440 labels,
441 ..
442 } => InternalEvent {
443 run_id: *run_id,
444 workflow_name: workflow_name.clone(),
445 event_kind: EventKind::RunFailed,
446 error: error.clone(),
447 labels: labels.clone(),
448 cost_usd: *cost_usd,
449 duration_ms: *duration_ms,
450 },
451 Event::RunStatusChanged {
452 run_id,
453 workflow_name,
454 error,
455 cost_usd,
456 duration_ms,
457 labels,
458 ..
459 } => InternalEvent {
460 run_id: *run_id,
461 workflow_name: workflow_name.clone(),
462 event_kind: EventKind::RunStatusChanged,
463 error: error.clone(),
464 labels: labels.clone(),
465 cost_usd: *cost_usd,
466 duration_ms: *duration_ms,
467 },
468 Event::StepFailed {
469 run_id,
470 step_name,
471 error,
472 ..
473 } => InternalEvent {
474 run_id: *run_id,
475 workflow_name: step_name.clone(),
476 event_kind: EventKind::StepFailed,
477 error: Some(error.clone()),
478 labels: HashMap::new(),
479 cost_usd: Decimal::ZERO,
480 duration_ms: 0,
481 },
482 Event::ApprovalRejected {
483 run_id,
484 rejected_by,
485 ..
486 } => InternalEvent {
487 run_id: *run_id,
488 workflow_name: String::new(),
489 event_kind: EventKind::ApprovalRejected,
490 error: Some(format!("rejected by {rejected_by}")),
491 labels: HashMap::new(),
492 cost_usd: Decimal::ZERO,
493 duration_ms: 0,
494 },
495 _ => return,
496 };
497
498 if self.event_tx.send(internal).await.is_err() {
499 warn!("event trigger receiver dropped, event lost");
500 }
501 })
502 }
503}
504
505#[cfg(test)]
506mod tests {
507 use std::time::Duration;
508
509 use chrono::Utc;
510 use rust_decimal::Decimal;
511 use tokio::time::timeout;
512
513 use super::*;
514
515 fn make_trigger(rules: Vec<EventTriggerRule>) -> EventTrigger {
516 EventTrigger::new(rules)
517 }
518
519 fn deploy_to_rollback_rule() -> EventTriggerRule {
520 EventTriggerRule {
521 on_event: EventKind::RunFailed,
522 source_workflow: "deploy".to_string(),
523 target_workflow: "rollback".to_string(),
524 max_chain_depth: 3,
525 conditions: vec![],
526 }
527 }
528
529 fn internal_event(
530 run_id: Uuid,
531 workflow_name: &str,
532 event_kind: EventKind,
533 error: Option<String>,
534 ) -> InternalEvent {
535 InternalEvent {
536 run_id,
537 workflow_name: workflow_name.to_string(),
538 event_kind,
539 error,
540 labels: HashMap::new(),
541 cost_usd: Decimal::ZERO,
542 duration_ms: 0,
543 }
544 }
545
546 #[tokio::test]
547 async fn event_trigger_fires_on_matching_run_failed() {
548 let trigger = make_trigger(vec![deploy_to_rollback_rule()]);
549 let (sink, mut rx) = TriggerSink::channel(16);
550 let token = CancellationToken::new();
551 let token_clone = token.clone();
552
553 let run_id = Uuid::now_v7();
554 trigger
555 .event_tx
556 .send(internal_event(
557 run_id,
558 "deploy",
559 EventKind::RunFailed,
560 Some("step crashed".to_string()),
561 ))
562 .await
563 .unwrap();
564
565 let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
566
567 let event = timeout(Duration::from_secs(2), rx.recv())
568 .await
569 .expect("timed out")
570 .expect("channel closed");
571
572 assert_eq!(event.workflow_name, "rollback");
573 assert!(matches!(event.trigger_kind, TriggerKind::RunEvent { .. }));
574 if let TriggerKind::RunEvent {
575 source_run_id,
576 event_kind,
577 } = &event.trigger_kind
578 {
579 assert_eq!(*source_run_id, run_id);
580 assert_eq!(event_kind, "run_failed");
581 }
582
583 let payload = &event.payload;
584 assert_eq!(payload["source_workflow"], "deploy");
585 assert_eq!(payload["error"], "step crashed");
586
587 token.cancel();
588 let _ = handle.await;
589 }
590
591 #[tokio::test]
592 async fn event_trigger_ignores_non_matching_workflow() {
593 let trigger = make_trigger(vec![deploy_to_rollback_rule()]);
594 let (sink, mut rx) = TriggerSink::channel(16);
595 let token = CancellationToken::new();
596 let token_clone = token.clone();
597
598 trigger
599 .event_tx
600 .send(internal_event(
601 Uuid::now_v7(),
602 "build",
603 EventKind::RunFailed,
604 None,
605 ))
606 .await
607 .unwrap();
608
609 let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
610
611 tokio::time::sleep(Duration::from_millis(100)).await;
613 token.cancel();
614 let _ = handle.await;
615
616 assert!(rx.try_recv().is_err());
618 }
619
620 #[tokio::test]
621 async fn event_trigger_ignores_non_matching_event_kind() {
622 let trigger = make_trigger(vec![deploy_to_rollback_rule()]);
623 let (sink, mut rx) = TriggerSink::channel(16);
624 let token = CancellationToken::new();
625 let token_clone = token.clone();
626
627 trigger
628 .event_tx
629 .send(internal_event(
630 Uuid::now_v7(),
631 "deploy",
632 EventKind::RunStatusChanged,
633 None,
634 ))
635 .await
636 .unwrap();
637
638 let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
639
640 tokio::time::sleep(Duration::from_millis(100)).await;
641 token.cancel();
642 let _ = handle.await;
643
644 assert!(rx.try_recv().is_err());
645 }
646
647 #[tokio::test]
648 async fn event_trigger_payload_contains_source_info() {
649 let trigger = make_trigger(vec![deploy_to_rollback_rule()]);
650 let (sink, mut rx) = TriggerSink::channel(16);
651 let token = CancellationToken::new();
652 let token_clone = token.clone();
653
654 let run_id = Uuid::now_v7();
655 trigger
656 .event_tx
657 .send(internal_event(
658 run_id,
659 "deploy",
660 EventKind::RunFailed,
661 Some("timeout".to_string()),
662 ))
663 .await
664 .unwrap();
665
666 let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
667
668 let event = timeout(Duration::from_secs(2), rx.recv())
669 .await
670 .expect("timed out")
671 .expect("channel closed");
672
673 assert_eq!(event.payload["source_run_id"], run_id.to_string());
674 assert_eq!(event.payload["source_workflow"], "deploy");
675 assert_eq!(event.payload["error"], "timeout");
676
677 token.cancel();
678 let _ = handle.await;
679 }
680
681 #[test]
682 fn subscribed_event_types_reflects_rules() {
683 let trigger = make_trigger(vec![
684 EventTriggerRule {
685 on_event: EventKind::RunFailed,
686 source_workflow: "a".to_string(),
687 target_workflow: "b".to_string(),
688 max_chain_depth: 3,
689 conditions: vec![],
690 },
691 EventTriggerRule {
692 on_event: EventKind::StepFailed,
693 source_workflow: "c".to_string(),
694 target_workflow: "d".to_string(),
695 max_chain_depth: 3,
696 conditions: vec![],
697 },
698 ]);
699 let types = trigger.subscribed_event_types();
700 assert!(types.contains(&"run_failed"));
701 assert!(types.contains(&"step_failed"));
702 }
703
704 #[tokio::test]
705 async fn event_subscriber_forwards_run_failed() {
706 let trigger = make_trigger(vec![deploy_to_rollback_rule()]);
707
708 let event = Event::RunFailed {
709 run_id: Uuid::now_v7(),
710 workflow_name: "deploy".to_string(),
711 error: Some("crash".to_string()),
712 cost_usd: Decimal::ZERO,
713 duration_ms: 0,
714 labels: HashMap::new(),
715 at: Utc::now(),
716 };
717
718 EventSubscriber::handle(&trigger, &event).await;
720
721 let mut rx = trigger.event_rx.lock().await;
723 let internal = rx.try_recv().unwrap();
724 assert_eq!(internal.workflow_name, "deploy");
725 assert_eq!(internal.event_kind, EventKind::RunFailed);
726 }
727
728 #[tokio::test]
729 async fn event_subscriber_ignores_irrelevant_events() {
730 let trigger = make_trigger(vec![deploy_to_rollback_rule()]);
731
732 let event = Event::RunCreated {
733 run_id: Uuid::now_v7(),
734 workflow_name: "deploy".to_string(),
735 at: Utc::now(),
736 };
737
738 EventSubscriber::handle(&trigger, &event).await;
739
740 let mut rx = trigger.event_rx.lock().await;
741 assert!(rx.try_recv().is_err());
742 }
743
744 #[tokio::test]
745 async fn graceful_shutdown() {
746 let trigger = make_trigger(vec![deploy_to_rollback_rule()]);
747 let (sink, _rx) = TriggerSink::channel(16);
748 let token = CancellationToken::new();
749 let token_clone = token.clone();
750
751 let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
752
753 tokio::time::sleep(Duration::from_millis(50)).await;
755 assert!(!handle.is_finished());
756
757 token.cancel();
759 let result = timeout(Duration::from_secs(2), handle)
760 .await
761 .expect("timed out")
762 .expect("task panicked");
763 assert!(result.is_ok());
764 }
765
766 fn internal_event_with_labels(
767 run_id: Uuid,
768 workflow_name: &str,
769 event_kind: EventKind,
770 error: Option<String>,
771 labels: HashMap<String, String>,
772 ) -> InternalEvent {
773 InternalEvent {
774 run_id,
775 workflow_name: workflow_name.to_string(),
776 event_kind,
777 error,
778 labels,
779 cost_usd: Decimal::new(42, 2),
780 duration_ms: 5000,
781 }
782 }
783
784 #[tokio::test]
785 async fn condition_label_matches() {
786 let rule = EventTriggerRule {
787 on_event: EventKind::RunFailed,
788 source_workflow: "deploy".to_string(),
789 target_workflow: "rollback".to_string(),
790 max_chain_depth: 3,
791 conditions: vec![TriggerCondition::Label {
792 key: "env".to_string(),
793 value: "prod".to_string(),
794 }],
795 };
796 let trigger = make_trigger(vec![rule]);
797 let (sink, mut rx) = TriggerSink::channel(16);
798 let token = CancellationToken::new();
799 let token_clone = token.clone();
800
801 let run_id = Uuid::now_v7();
802 trigger
803 .event_tx
804 .send(internal_event_with_labels(
805 run_id,
806 "deploy",
807 EventKind::RunFailed,
808 Some("crash".to_string()),
809 HashMap::from([("env".to_string(), "prod".to_string())]),
810 ))
811 .await
812 .unwrap();
813
814 let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
815
816 let event = timeout(Duration::from_secs(2), rx.recv())
817 .await
818 .expect("timed out")
819 .expect("channel closed");
820
821 assert_eq!(event.workflow_name, "rollback");
822 token.cancel();
823 let _ = handle.await;
824 }
825
826 #[tokio::test]
827 async fn condition_label_absent_no_fire() {
828 let rule = EventTriggerRule {
829 on_event: EventKind::RunFailed,
830 source_workflow: "deploy".to_string(),
831 target_workflow: "rollback".to_string(),
832 max_chain_depth: 3,
833 conditions: vec![TriggerCondition::Label {
834 key: "env".to_string(),
835 value: "prod".to_string(),
836 }],
837 };
838 let trigger = make_trigger(vec![rule]);
839 let (sink, mut rx) = TriggerSink::channel(16);
840 let token = CancellationToken::new();
841 let token_clone = token.clone();
842
843 trigger
844 .event_tx
845 .send(internal_event_with_labels(
846 Uuid::now_v7(),
847 "deploy",
848 EventKind::RunFailed,
849 None,
850 HashMap::new(),
851 ))
852 .await
853 .unwrap();
854
855 let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
856
857 tokio::time::sleep(Duration::from_millis(100)).await;
858 token.cancel();
859 let _ = handle.await;
860
861 assert!(rx.try_recv().is_err());
862 }
863
864 #[tokio::test]
865 async fn condition_label_wrong_value_no_fire() {
866 let rule = EventTriggerRule {
867 on_event: EventKind::RunFailed,
868 source_workflow: "deploy".to_string(),
869 target_workflow: "rollback".to_string(),
870 max_chain_depth: 3,
871 conditions: vec![TriggerCondition::Label {
872 key: "env".to_string(),
873 value: "prod".to_string(),
874 }],
875 };
876 let trigger = make_trigger(vec![rule]);
877 let (sink, mut rx) = TriggerSink::channel(16);
878 let token = CancellationToken::new();
879 let token_clone = token.clone();
880
881 trigger
882 .event_tx
883 .send(internal_event_with_labels(
884 Uuid::now_v7(),
885 "deploy",
886 EventKind::RunFailed,
887 None,
888 HashMap::from([("env".to_string(), "staging".to_string())]),
889 ))
890 .await
891 .unwrap();
892
893 let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
894
895 tokio::time::sleep(Duration::from_millis(100)).await;
896 token.cancel();
897 let _ = handle.await;
898
899 assert!(rx.try_recv().is_err());
900 }
901
902 #[tokio::test]
903 async fn multiple_conditions_all_match() {
904 let rule = EventTriggerRule {
905 on_event: EventKind::RunFailed,
906 source_workflow: "deploy".to_string(),
907 target_workflow: "rollback".to_string(),
908 max_chain_depth: 3,
909 conditions: vec![
910 TriggerCondition::Label {
911 key: "env".to_string(),
912 value: "prod".to_string(),
913 },
914 TriggerCondition::Label {
915 key: "region".to_string(),
916 value: "eu-west-1".to_string(),
917 },
918 ],
919 };
920 let trigger = make_trigger(vec![rule]);
921 let (sink, mut rx) = TriggerSink::channel(16);
922 let token = CancellationToken::new();
923 let token_clone = token.clone();
924
925 trigger
926 .event_tx
927 .send(internal_event_with_labels(
928 Uuid::now_v7(),
929 "deploy",
930 EventKind::RunFailed,
931 None,
932 HashMap::from([
933 ("env".to_string(), "prod".to_string()),
934 ("region".to_string(), "eu-west-1".to_string()),
935 ]),
936 ))
937 .await
938 .unwrap();
939
940 let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
941
942 let event = timeout(Duration::from_secs(2), rx.recv())
943 .await
944 .expect("timed out")
945 .expect("channel closed");
946
947 assert_eq!(event.workflow_name, "rollback");
948 token.cancel();
949 let _ = handle.await;
950 }
951
952 #[tokio::test]
953 async fn multiple_conditions_one_fails() {
954 let rule = EventTriggerRule {
955 on_event: EventKind::RunFailed,
956 source_workflow: "deploy".to_string(),
957 target_workflow: "rollback".to_string(),
958 max_chain_depth: 3,
959 conditions: vec![
960 TriggerCondition::Label {
961 key: "env".to_string(),
962 value: "prod".to_string(),
963 },
964 TriggerCondition::Label {
965 key: "region".to_string(),
966 value: "eu-west-1".to_string(),
967 },
968 ],
969 };
970 let trigger = make_trigger(vec![rule]);
971 let (sink, mut rx) = TriggerSink::channel(16);
972 let token = CancellationToken::new();
973 let token_clone = token.clone();
974
975 trigger
976 .event_tx
977 .send(internal_event_with_labels(
978 Uuid::now_v7(),
979 "deploy",
980 EventKind::RunFailed,
981 None,
982 HashMap::from([("env".to_string(), "prod".to_string())]),
983 ))
984 .await
985 .unwrap();
986
987 let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
988
989 tokio::time::sleep(Duration::from_millis(100)).await;
990 token.cancel();
991 let _ = handle.await;
992
993 assert!(rx.try_recv().is_err());
994 }
995
996 #[tokio::test]
997 async fn empty_conditions_backward_compat() {
998 let rule = EventTriggerRule {
999 on_event: EventKind::RunFailed,
1000 source_workflow: "deploy".to_string(),
1001 target_workflow: "rollback".to_string(),
1002 max_chain_depth: 3,
1003 conditions: vec![],
1004 };
1005 let trigger = make_trigger(vec![rule]);
1006 let (sink, mut rx) = TriggerSink::channel(16);
1007 let token = CancellationToken::new();
1008 let token_clone = token.clone();
1009
1010 trigger
1011 .event_tx
1012 .send(internal_event(
1013 Uuid::now_v7(),
1014 "deploy",
1015 EventKind::RunFailed,
1016 Some("boom".to_string()),
1017 ))
1018 .await
1019 .unwrap();
1020
1021 let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
1022
1023 let event = timeout(Duration::from_secs(2), rx.recv())
1024 .await
1025 .expect("timed out")
1026 .expect("channel closed");
1027
1028 assert_eq!(event.workflow_name, "rollback");
1029 token.cancel();
1030 let _ = handle.await;
1031 }
1032
1033 #[tokio::test]
1034 async fn expression_condition_with_context() {
1035 let rule = EventTriggerRule {
1036 on_event: EventKind::RunFailed,
1037 source_workflow: "deploy".to_string(),
1038 target_workflow: "rollback".to_string(),
1039 max_chain_depth: 3,
1040 conditions: vec![TriggerCondition::Expression(Arc::new(|ctx| {
1041 ctx.cost_usd > Decimal::new(10, 2) && ctx.duration_ms > 1000
1042 }))],
1043 };
1044 let trigger = make_trigger(vec![rule]);
1045 let (sink, mut rx) = TriggerSink::channel(16);
1046 let token = CancellationToken::new();
1047 let token_clone = token.clone();
1048
1049 trigger
1050 .event_tx
1051 .send(internal_event_with_labels(
1052 Uuid::now_v7(),
1053 "deploy",
1054 EventKind::RunFailed,
1055 None,
1056 HashMap::new(),
1057 ))
1058 .await
1059 .unwrap();
1060
1061 let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
1062
1063 let event = timeout(Duration::from_secs(2), rx.recv())
1064 .await
1065 .expect("timed out")
1066 .expect("channel closed");
1067
1068 assert_eq!(event.workflow_name, "rollback");
1069 token.cancel();
1070 let _ = handle.await;
1071 }
1072
1073 #[tokio::test]
1074 async fn expression_returns_false_no_fire() {
1075 let rule = EventTriggerRule {
1076 on_event: EventKind::RunFailed,
1077 source_workflow: "deploy".to_string(),
1078 target_workflow: "rollback".to_string(),
1079 max_chain_depth: 3,
1080 conditions: vec![TriggerCondition::Expression(Arc::new(|ctx| {
1081 ctx.cost_usd > Decimal::new(100, 0)
1082 }))],
1083 };
1084 let trigger = make_trigger(vec![rule]);
1085 let (sink, mut rx) = TriggerSink::channel(16);
1086 let token = CancellationToken::new();
1087 let token_clone = token.clone();
1088
1089 trigger
1090 .event_tx
1091 .send(internal_event_with_labels(
1092 Uuid::now_v7(),
1093 "deploy",
1094 EventKind::RunFailed,
1095 None,
1096 HashMap::new(),
1097 ))
1098 .await
1099 .unwrap();
1100
1101 let handle = tokio::spawn(async move { trigger.start(sink, &token_clone).await });
1102
1103 tokio::time::sleep(Duration::from_millis(100)).await;
1104 token.cancel();
1105 let _ = handle.await;
1106
1107 assert!(rx.try_recv().is_err());
1108 }
1109}