1use std::collections::HashMap;
4
5use chrono::{DateTime, Utc};
6use rust_decimal::Decimal;
7use serde::{Deserialize, Serialize};
8use uuid::Uuid;
9
10pub use ironflow_store::entities::LogStream;
11use ironflow_store::models::{RunStatus, StepKind};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31pub struct RunCreatedEvent {
32 pub run_id: Uuid,
34 pub workflow_name: String,
36 pub at: DateTime<Utc>,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
67#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
68pub struct RunStatusChangedEvent {
69 pub run_id: Uuid,
71 pub workflow_name: String,
73 pub from: RunStatus,
75 pub to: RunStatus,
77 pub error: Option<String>,
79 pub cost_usd: Decimal,
81 pub duration_ms: u64,
83 #[serde(default)]
85 pub labels: HashMap<String, String>,
86 pub at: DateTime<Utc>,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
114#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
115pub struct RunFailedEvent {
116 pub run_id: Uuid,
118 pub workflow_name: String,
120 pub error: Option<String>,
122 pub cost_usd: Decimal,
124 pub duration_ms: u64,
126 #[serde(default)]
128 pub labels: HashMap<String, String>,
129 pub at: DateTime<Utc>,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
154#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
155pub struct RunBudgetExceededEvent {
156 pub run_id: Uuid,
158 pub workflow_name: String,
160 pub limit_usd: Decimal,
162 pub spent_usd: Decimal,
164 pub step_budget_usd: Decimal,
166 pub at: DateTime<Utc>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize)]
189#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
190pub struct RetryForcedEvent {
191 pub run_id: Uuid,
193 pub workflow_name: String,
195 pub original_version: String,
197 pub current_version: String,
199 pub at: DateTime<Utc>,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize)]
226#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
227pub struct StepCompletedEvent {
228 pub run_id: Uuid,
230 pub step_id: Uuid,
232 pub step_name: String,
234 #[cfg_attr(feature = "openapi", schema(value_type = String))]
236 pub kind: StepKind,
237 pub duration_ms: u64,
239 pub cost_usd: Decimal,
241 pub at: DateTime<Utc>,
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
266#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
267pub struct StepFailedEvent {
268 pub run_id: Uuid,
270 pub step_id: Uuid,
272 pub step_name: String,
274 #[cfg_attr(feature = "openapi", schema(value_type = String))]
276 pub kind: StepKind,
277 pub error: String,
279 pub at: DateTime<Utc>,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
301#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
302pub struct ApprovalRequestedEvent {
303 pub run_id: Uuid,
305 pub step_id: Uuid,
307 pub message: String,
309 pub at: DateTime<Utc>,
311}
312
313#[derive(Debug, Clone, Serialize, Deserialize)]
330#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
331pub struct ApprovalGrantedEvent {
332 pub run_id: Uuid,
334 pub approved_by: String,
336 pub at: DateTime<Utc>,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
357#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
358pub struct ApprovalRejectedEvent {
359 pub run_id: Uuid,
361 pub rejected_by: String,
363 pub at: DateTime<Utc>,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
387#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
388pub struct LogLineEvent {
389 pub run_id: Uuid,
391 pub step_id: Uuid,
393 pub step_name: String,
395 pub stream: LogStream,
397 pub line: String,
399 pub at: DateTime<Utc>,
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize)]
420#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
421pub struct UserSignedInEvent {
422 pub user_id: Uuid,
424 pub username: String,
426 pub at: DateTime<Utc>,
428}
429
430#[derive(Debug, Clone, Serialize, Deserialize)]
447#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
448pub struct UserSignedUpEvent {
449 pub user_id: Uuid,
451 pub username: String,
453 pub at: DateTime<Utc>,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
474#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
475pub struct UserSignedOutEvent {
476 pub user_id: Uuid,
478 pub at: DateTime<Utc>,
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize)]
514#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
515#[serde(tag = "type", rename_all = "snake_case")]
516pub enum Event {
517 RunCreated(RunCreatedEvent),
520
521 RunStatusChanged(RunStatusChangedEvent),
523
524 RunFailed(RunFailedEvent),
530
531 RunBudgetExceeded(RunBudgetExceededEvent),
539
540 RetryForced(RetryForcedEvent),
547
548 StepCompleted(StepCompletedEvent),
551
552 StepFailed(StepFailedEvent),
554
555 ApprovalRequested(ApprovalRequestedEvent),
558
559 ApprovalGranted(ApprovalGrantedEvent),
561
562 ApprovalRejected(ApprovalRejectedEvent),
564
565 LogLine(LogLineEvent),
571
572 UserSignedIn(UserSignedInEvent),
575
576 UserSignedUp(UserSignedUpEvent),
578
579 UserSignedOut(UserSignedOutEvent),
581}
582
583impl Event {
584 pub const RUN_CREATED: &'static str = "run_created";
586 pub const RUN_STATUS_CHANGED: &'static str = "run_status_changed";
588 pub const RUN_FAILED: &'static str = "run_failed";
590 pub const RUN_BUDGET_EXCEEDED: &'static str = "run_budget_exceeded";
592 pub const RETRY_FORCED: &'static str = "retry_forced";
594 pub const STEP_COMPLETED: &'static str = "step_completed";
596 pub const STEP_FAILED: &'static str = "step_failed";
598 pub const APPROVAL_REQUESTED: &'static str = "approval_requested";
600 pub const APPROVAL_GRANTED: &'static str = "approval_granted";
602 pub const APPROVAL_REJECTED: &'static str = "approval_rejected";
604 pub const LOG_LINE: &'static str = "log_line";
606 pub const USER_SIGNED_IN: &'static str = "user_signed_in";
608 pub const USER_SIGNED_UP: &'static str = "user_signed_up";
610 pub const USER_SIGNED_OUT: &'static str = "user_signed_out";
612
613 pub const ALL: &'static [&'static str] = &[
629 Self::RUN_CREATED,
630 Self::RUN_STATUS_CHANGED,
631 Self::RUN_FAILED,
632 Self::RUN_BUDGET_EXCEEDED,
633 Self::STEP_COMPLETED,
634 Self::STEP_FAILED,
635 Self::APPROVAL_REQUESTED,
636 Self::APPROVAL_GRANTED,
637 Self::APPROVAL_REJECTED,
638 Self::LOG_LINE,
639 Self::USER_SIGNED_IN,
640 Self::USER_SIGNED_UP,
641 Self::USER_SIGNED_OUT,
642 Self::RETRY_FORCED,
643 ];
644
645 #[deny(unreachable_patterns)]
664 pub fn event_type(&self) -> &'static str {
665 match self {
666 Event::RunCreated(_) => Self::RUN_CREATED,
667 Event::RunStatusChanged(_) => Self::RUN_STATUS_CHANGED,
668 Event::RunFailed(_) => Self::RUN_FAILED,
669 Event::RunBudgetExceeded(_) => Self::RUN_BUDGET_EXCEEDED,
670 Event::RetryForced(_) => Self::RETRY_FORCED,
671 Event::StepCompleted(_) => Self::STEP_COMPLETED,
672 Event::StepFailed(_) => Self::STEP_FAILED,
673 Event::ApprovalRequested(_) => Self::APPROVAL_REQUESTED,
674 Event::ApprovalGranted(_) => Self::APPROVAL_GRANTED,
675 Event::ApprovalRejected(_) => Self::APPROVAL_REJECTED,
676 Event::LogLine(_) => Self::LOG_LINE,
677 Event::UserSignedIn(_) => Self::USER_SIGNED_IN,
678 Event::UserSignedUp(_) => Self::USER_SIGNED_UP,
679 Event::UserSignedOut(_) => Self::USER_SIGNED_OUT,
680 }
681 }
682
683 #[deny(unreachable_patterns)]
706 pub fn run_id(&self) -> Option<Uuid> {
707 match self {
708 Event::RunCreated(e) => Some(e.run_id),
709 Event::RunStatusChanged(e) => Some(e.run_id),
710 Event::RunFailed(e) => Some(e.run_id),
711 Event::RunBudgetExceeded(e) => Some(e.run_id),
712 Event::RetryForced(e) => Some(e.run_id),
713 Event::StepCompleted(e) => Some(e.run_id),
714 Event::StepFailed(e) => Some(e.run_id),
715 Event::ApprovalRequested(e) => Some(e.run_id),
716 Event::ApprovalGranted(e) => Some(e.run_id),
717 Event::ApprovalRejected(e) => Some(e.run_id),
718 Event::LogLine(e) => Some(e.run_id),
719 Event::UserSignedIn(_) | Event::UserSignedUp(_) | Event::UserSignedOut(_) => None,
720 }
721 }
722
723 #[deny(unreachable_patterns)]
750 pub fn step_id(&self) -> Option<Uuid> {
751 match self {
752 Event::StepCompleted(e) => Some(e.step_id),
753 Event::StepFailed(e) => Some(e.step_id),
754 Event::ApprovalRequested(e) => Some(e.step_id),
755 Event::RunCreated(_)
756 | Event::RunStatusChanged(_)
757 | Event::RunFailed(_)
758 | Event::RunBudgetExceeded(_)
759 | Event::RetryForced(_)
760 | Event::ApprovalGranted(_)
761 | Event::ApprovalRejected(_)
762 | Event::LogLine(_)
763 | Event::UserSignedIn(_)
764 | Event::UserSignedUp(_)
765 | Event::UserSignedOut(_) => None,
766 }
767 }
768
769 #[deny(unreachable_patterns)]
792 pub fn user_id(&self) -> Option<Uuid> {
793 match self {
794 Event::UserSignedIn(e) => Some(e.user_id),
795 Event::UserSignedUp(e) => Some(e.user_id),
796 Event::UserSignedOut(e) => Some(e.user_id),
797 Event::RunCreated(_)
798 | Event::RunStatusChanged(_)
799 | Event::RunFailed(_)
800 | Event::RunBudgetExceeded(_)
801 | Event::RetryForced(_)
802 | Event::StepCompleted(_)
803 | Event::StepFailed(_)
804 | Event::ApprovalRequested(_)
805 | Event::ApprovalGranted(_)
806 | Event::ApprovalRejected(_)
807 | Event::LogLine(_) => None,
808 }
809 }
810}
811
812#[cfg(test)]
813mod tests {
814 use super::*;
815
816 #[test]
817 fn run_status_changed_serde_roundtrip() {
818 let event = Event::RunStatusChanged(RunStatusChangedEvent {
819 run_id: Uuid::now_v7(),
820 workflow_name: "deploy".to_string(),
821 from: RunStatus::Running,
822 to: RunStatus::Completed,
823 error: None,
824 cost_usd: Decimal::new(42, 2),
825 duration_ms: 5000,
826 labels: HashMap::new(),
827 at: Utc::now(),
828 });
829
830 let json = serde_json::to_string(&event).expect("serialize");
831 let back: Event = serde_json::from_str(&json).expect("deserialize");
832
833 assert_eq!(back.event_type(), "run_status_changed");
834 assert!(json.contains("\"type\":\"run_status_changed\""));
835 }
836
837 #[test]
838 fn run_failed_serde_roundtrip() {
839 let event = Event::RunFailed(RunFailedEvent {
840 run_id: Uuid::now_v7(),
841 workflow_name: "deploy".to_string(),
842 error: Some("step crashed".to_string()),
843 cost_usd: Decimal::new(10, 2),
844 duration_ms: 3000,
845 labels: HashMap::new(),
846 at: Utc::now(),
847 });
848
849 let json = serde_json::to_string(&event).expect("serialize");
850 let back: Event = serde_json::from_str(&json).expect("deserialize");
851
852 assert_eq!(back.event_type(), "run_failed");
853 assert!(json.contains("\"type\":\"run_failed\""));
854 assert!(json.contains("step crashed"));
855 }
856
857 #[test]
858 fn run_budget_exceeded_serde_roundtrip() {
859 let event = Event::RunBudgetExceeded(RunBudgetExceededEvent {
860 run_id: Uuid::now_v7(),
861 workflow_name: "deploy".to_string(),
862 limit_usd: Decimal::new(200, 2),
863 spent_usd: Decimal::new(180, 2),
864 step_budget_usd: Decimal::new(50, 2),
865 at: Utc::now(),
866 });
867
868 let json = serde_json::to_string(&event).expect("serialize");
869 let back: Event = serde_json::from_str(&json).expect("deserialize");
870
871 assert_eq!(back.event_type(), "run_budget_exceeded");
872 assert!(json.contains("\"type\":\"run_budget_exceeded\""));
873 assert!(json.contains("limit_usd"));
874 assert!(json.contains("step_budget_usd"));
875 }
876
877 #[test]
878 fn all_contains_run_budget_exceeded() {
879 assert!(Event::ALL.contains(&Event::RUN_BUDGET_EXCEEDED));
880 }
881
882 #[test]
883 fn user_signed_in_serde_roundtrip() {
884 let event = Event::UserSignedIn(UserSignedInEvent {
885 user_id: Uuid::now_v7(),
886 username: "alice".to_string(),
887 at: Utc::now(),
888 });
889
890 let json = serde_json::to_string(&event).expect("serialize");
891 let back: Event = serde_json::from_str(&json).expect("deserialize");
892
893 assert_eq!(back.event_type(), "user_signed_in");
894 assert!(json.contains("alice"));
895 }
896
897 #[test]
898 fn step_failed_serde_roundtrip() {
899 let event = Event::StepFailed(StepFailedEvent {
900 run_id: Uuid::now_v7(),
901 step_id: Uuid::now_v7(),
902 step_name: "build".to_string(),
903 kind: StepKind::Shell,
904 error: "exit code 1".to_string(),
905 at: Utc::now(),
906 });
907
908 let json = serde_json::to_string(&event).expect("serialize");
909 let back: Event = serde_json::from_str(&json).expect("deserialize");
910
911 assert_eq!(back.event_type(), "step_failed");
912 }
913
914 #[test]
915 fn approval_requested_serde_roundtrip() {
916 let event = Event::ApprovalRequested(ApprovalRequestedEvent {
917 run_id: Uuid::now_v7(),
918 step_id: Uuid::now_v7(),
919 message: "Deploy to prod?".to_string(),
920 at: Utc::now(),
921 });
922
923 let json = serde_json::to_string(&event).expect("serialize");
924 assert!(json.contains("approval_requested"));
925 }
926
927 #[test]
928 fn log_line_serde_roundtrip() {
929 let event = Event::LogLine(LogLineEvent {
930 run_id: Uuid::now_v7(),
931 step_id: Uuid::now_v7(),
932 step_name: "build".to_string(),
933 stream: LogStream::Stdout,
934 line: "Compiling ironflow v0.1.0".to_string(),
935 at: Utc::now(),
936 });
937
938 let json = serde_json::to_string(&event).expect("serialize");
939 let back: Event = serde_json::from_str(&json).expect("deserialize");
940
941 assert_eq!(back.event_type(), "log_line");
942 assert!(json.contains("\"type\":\"log_line\""));
943 assert!(json.contains("Compiling ironflow"));
944 }
945
946 #[test]
951 fn legacy_flat_json_deserializes_into_typed_payload() {
952 let run_id: Uuid = "01890000-0000-7000-8000-000000000000"
953 .parse()
954 .expect("valid uuid");
955
956 let raw = r#"{"type":"run_created","run_id":"01890000-0000-7000-8000-000000000000","workflow_name":"deploy","at":"2026-01-01T00:00:00Z"}"#;
957 let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
958 match event {
959 Event::RunCreated(e) => {
960 assert_eq!(e.run_id, run_id);
961 assert_eq!(e.workflow_name, "deploy");
962 }
963 other => panic!("expected RunCreated, got {other:?}"),
964 }
965
966 let raw = r#"{"type":"run_status_changed","run_id":"01890000-0000-7000-8000-000000000000","workflow_name":"deploy","from":"running","to":"completed","error":null,"cost_usd":0.5,"duration_ms":5000,"labels":{"env":"prod"},"at":"2026-01-01T00:00:00Z"}"#;
967 let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
968 match event {
969 Event::RunStatusChanged(e) => {
970 assert_eq!(e.from, RunStatus::Running);
971 assert_eq!(e.to, RunStatus::Completed);
972 assert_eq!(e.cost_usd, Decimal::new(5, 1));
973 assert_eq!(e.duration_ms, 5000);
974 assert_eq!(e.labels.get("env").map(String::as_str), Some("prod"));
975 }
976 other => panic!("expected RunStatusChanged, got {other:?}"),
977 }
978
979 let raw = r#"{"type":"run_status_changed","run_id":"01890000-0000-7000-8000-000000000000","workflow_name":"deploy","from":"running","to":"failed","error":"boom","cost_usd":0,"duration_ms":0,"at":"2026-01-01T00:00:00Z"}"#;
981 let event: Event = serde_json::from_str(raw).expect("missing labels must default");
982 match event {
983 Event::RunStatusChanged(e) => {
984 assert!(e.labels.is_empty());
985 assert_eq!(e.error.as_deref(), Some("boom"));
986 }
987 other => panic!("expected RunStatusChanged, got {other:?}"),
988 }
989
990 let raw = r#"{"type":"run_failed","run_id":"01890000-0000-7000-8000-000000000000","workflow_name":"deploy","error":"boom","cost_usd":0.25,"duration_ms":3000,"at":"2026-01-01T00:00:00Z"}"#;
991 let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
992 match event {
993 Event::RunFailed(e) => {
994 assert_eq!(e.error.as_deref(), Some("boom"));
995 assert!(e.labels.is_empty());
996 }
997 other => panic!("expected RunFailed, got {other:?}"),
998 }
999
1000 let raw = r#"{"type":"step_failed","run_id":"01890000-0000-7000-8000-000000000000","step_id":"01890000-0000-7000-8000-000000000001","step_name":"build","kind":"shell","error":"exit code 1","at":"2026-01-01T00:00:00Z"}"#;
1001 let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1002 match event {
1003 Event::StepFailed(e) => {
1004 assert_eq!(e.kind, StepKind::Shell);
1005 assert_eq!(e.error, "exit code 1");
1006 }
1007 other => panic!("expected StepFailed, got {other:?}"),
1008 }
1009
1010 let raw = r#"{"type":"log_line","run_id":"01890000-0000-7000-8000-000000000000","step_id":"01890000-0000-7000-8000-000000000001","step_name":"build","stream":"stdout","line":"hello","at":"2026-01-01T00:00:00Z"}"#;
1011 let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1012 match event {
1013 Event::LogLine(e) => {
1014 assert_eq!(e.stream, LogStream::Stdout);
1015 assert_eq!(e.line, "hello");
1016 }
1017 other => panic!("expected LogLine, got {other:?}"),
1018 }
1019
1020 let raw = r#"{"type":"user_signed_in","user_id":"01890000-0000-7000-8000-000000000000","username":"alice","at":"2026-01-01T00:00:00Z"}"#;
1021 let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1022 match event {
1023 Event::UserSignedIn(e) => assert_eq!(e.username, "alice"),
1024 other => panic!("expected UserSignedIn, got {other:?}"),
1025 }
1026 }
1027
1028 #[test]
1031 fn serialized_event_is_flat_with_type_tag() {
1032 let run_id = Uuid::now_v7();
1033 let event = Event::RunCreated(RunCreatedEvent {
1034 run_id,
1035 workflow_name: "deploy".to_string(),
1036 at: Utc::now(),
1037 });
1038
1039 let value: serde_json::Value = serde_json::to_value(&event).expect("serialize");
1040 let object = value.as_object().expect("event serializes to an object");
1041
1042 assert_eq!(
1043 object.get("type").and_then(|v| v.as_str()),
1044 Some("run_created")
1045 );
1046 assert_eq!(
1047 object.get("workflow_name").and_then(|v| v.as_str()),
1048 Some("deploy")
1049 );
1050 assert_eq!(
1051 object.get("run_id").and_then(|v| v.as_str()),
1052 Some(run_id.to_string().as_str())
1053 );
1054 assert!(object.contains_key("at"));
1055 assert_eq!(object.len(), 4, "no nesting: {object:?}");
1056 assert!(!object.contains_key("RunCreated"));
1057 }
1058
1059 #[test]
1060 fn run_id_returns_some_for_run_events() {
1061 let run_id = Uuid::now_v7();
1062 let now = Utc::now();
1063
1064 let events = vec![
1065 Event::RunCreated(RunCreatedEvent {
1066 run_id,
1067 workflow_name: "w".to_string(),
1068 at: now,
1069 }),
1070 Event::RunStatusChanged(RunStatusChangedEvent {
1071 run_id,
1072 workflow_name: "w".to_string(),
1073 from: RunStatus::Pending,
1074 to: RunStatus::Running,
1075 error: None,
1076 cost_usd: Decimal::ZERO,
1077 duration_ms: 0,
1078 labels: HashMap::new(),
1079 at: now,
1080 }),
1081 Event::RunFailed(RunFailedEvent {
1082 run_id,
1083 workflow_name: "w".to_string(),
1084 error: None,
1085 cost_usd: Decimal::ZERO,
1086 duration_ms: 0,
1087 labels: HashMap::new(),
1088 at: now,
1089 }),
1090 Event::RunBudgetExceeded(RunBudgetExceededEvent {
1091 run_id,
1092 workflow_name: "w".to_string(),
1093 limit_usd: Decimal::ZERO,
1094 spent_usd: Decimal::ZERO,
1095 step_budget_usd: Decimal::ZERO,
1096 at: now,
1097 }),
1098 Event::RetryForced(RetryForcedEvent {
1099 run_id,
1100 workflow_name: "w".to_string(),
1101 original_version: "1".to_string(),
1102 current_version: "2".to_string(),
1103 at: now,
1104 }),
1105 Event::StepCompleted(StepCompletedEvent {
1106 run_id,
1107 step_id: Uuid::now_v7(),
1108 step_name: "s".to_string(),
1109 kind: StepKind::Shell,
1110 duration_ms: 0,
1111 cost_usd: Decimal::ZERO,
1112 at: now,
1113 }),
1114 Event::StepFailed(StepFailedEvent {
1115 run_id,
1116 step_id: Uuid::now_v7(),
1117 step_name: "s".to_string(),
1118 kind: StepKind::Shell,
1119 error: "e".to_string(),
1120 at: now,
1121 }),
1122 Event::ApprovalRequested(ApprovalRequestedEvent {
1123 run_id,
1124 step_id: Uuid::now_v7(),
1125 message: "ok?".to_string(),
1126 at: now,
1127 }),
1128 Event::ApprovalGranted(ApprovalGrantedEvent {
1129 run_id,
1130 approved_by: "alice".to_string(),
1131 at: now,
1132 }),
1133 Event::ApprovalRejected(ApprovalRejectedEvent {
1134 run_id,
1135 rejected_by: "bob".to_string(),
1136 at: now,
1137 }),
1138 Event::LogLine(LogLineEvent {
1139 run_id,
1140 step_id: Uuid::now_v7(),
1141 step_name: "s".to_string(),
1142 stream: LogStream::Stdout,
1143 line: "l".to_string(),
1144 at: now,
1145 }),
1146 ];
1147
1148 for event in &events {
1149 assert_eq!(
1150 event.run_id(),
1151 Some(run_id),
1152 "{} should carry a run_id",
1153 event.event_type()
1154 );
1155 }
1156 }
1157
1158 #[test]
1159 fn run_id_returns_none_for_auth_events() {
1160 let user_id = Uuid::now_v7();
1161 let now = Utc::now();
1162
1163 let events = vec![
1164 Event::UserSignedIn(UserSignedInEvent {
1165 user_id,
1166 username: "alice".to_string(),
1167 at: now,
1168 }),
1169 Event::UserSignedUp(UserSignedUpEvent {
1170 user_id,
1171 username: "alice".to_string(),
1172 at: now,
1173 }),
1174 Event::UserSignedOut(UserSignedOutEvent { user_id, at: now }),
1175 ];
1176
1177 for event in &events {
1178 assert_eq!(event.run_id(), None, "{} has no run", event.event_type());
1179 }
1180 }
1181
1182 #[test]
1183 fn step_id_returns_some_only_for_step_events() {
1184 let step_id = Uuid::now_v7();
1185 let run_id = Uuid::now_v7();
1186 let now = Utc::now();
1187
1188 let with_step = vec![
1189 Event::StepCompleted(StepCompletedEvent {
1190 run_id,
1191 step_id,
1192 step_name: "s".to_string(),
1193 kind: StepKind::Shell,
1194 duration_ms: 0,
1195 cost_usd: Decimal::ZERO,
1196 at: now,
1197 }),
1198 Event::StepFailed(StepFailedEvent {
1199 run_id,
1200 step_id,
1201 step_name: "s".to_string(),
1202 kind: StepKind::Shell,
1203 error: "e".to_string(),
1204 at: now,
1205 }),
1206 Event::ApprovalRequested(ApprovalRequestedEvent {
1207 run_id,
1208 step_id,
1209 message: "ok?".to_string(),
1210 at: now,
1211 }),
1212 ];
1213
1214 for event in &with_step {
1215 assert_eq!(
1216 event.step_id(),
1217 Some(step_id),
1218 "{} should carry a step_id",
1219 event.event_type()
1220 );
1221 }
1222
1223 let without_step = vec![
1224 Event::RunCreated(RunCreatedEvent {
1225 run_id,
1226 workflow_name: "w".to_string(),
1227 at: now,
1228 }),
1229 Event::ApprovalGranted(ApprovalGrantedEvent {
1230 run_id,
1231 approved_by: "alice".to_string(),
1232 at: now,
1233 }),
1234 Event::LogLine(LogLineEvent {
1237 run_id,
1238 step_id,
1239 step_name: "s".to_string(),
1240 stream: LogStream::Stdout,
1241 line: "l".to_string(),
1242 at: now,
1243 }),
1244 Event::UserSignedOut(UserSignedOutEvent {
1245 user_id: Uuid::now_v7(),
1246 at: now,
1247 }),
1248 ];
1249
1250 for event in &without_step {
1251 assert_eq!(
1252 event.step_id(),
1253 None,
1254 "{} should not carry a step_id",
1255 event.event_type()
1256 );
1257 }
1258 }
1259
1260 #[test]
1261 fn user_id_returns_some_only_for_auth_events() {
1262 let user_id = Uuid::now_v7();
1263 let run_id = Uuid::now_v7();
1264 let now = Utc::now();
1265
1266 let auth = vec![
1267 Event::UserSignedIn(UserSignedInEvent {
1268 user_id,
1269 username: "alice".to_string(),
1270 at: now,
1271 }),
1272 Event::UserSignedUp(UserSignedUpEvent {
1273 user_id,
1274 username: "alice".to_string(),
1275 at: now,
1276 }),
1277 Event::UserSignedOut(UserSignedOutEvent { user_id, at: now }),
1278 ];
1279
1280 for event in &auth {
1281 assert_eq!(
1282 event.user_id(),
1283 Some(user_id),
1284 "{} should carry a user_id",
1285 event.event_type()
1286 );
1287 }
1288
1289 let non_auth = vec![
1290 Event::RunCreated(RunCreatedEvent {
1291 run_id,
1292 workflow_name: "w".to_string(),
1293 at: now,
1294 }),
1295 Event::StepFailed(StepFailedEvent {
1296 run_id,
1297 step_id: Uuid::now_v7(),
1298 step_name: "s".to_string(),
1299 kind: StepKind::Shell,
1300 error: "e".to_string(),
1301 at: now,
1302 }),
1303 ];
1304
1305 for event in &non_auth {
1306 assert_eq!(
1307 event.user_id(),
1308 None,
1309 "{} should not carry a user_id",
1310 event.event_type()
1311 );
1312 }
1313 }
1314
1315 #[test]
1316 fn event_type_all_variants() {
1317 let id = Uuid::now_v7();
1318 let now = Utc::now();
1319
1320 let cases: Vec<(Event, &str)> = vec![
1321 (
1322 Event::RunCreated(RunCreatedEvent {
1323 run_id: id,
1324 workflow_name: "w".to_string(),
1325 at: now,
1326 }),
1327 "run_created",
1328 ),
1329 (
1330 Event::RunStatusChanged(RunStatusChangedEvent {
1331 run_id: id,
1332 workflow_name: "w".to_string(),
1333 from: RunStatus::Pending,
1334 to: RunStatus::Running,
1335 error: None,
1336 cost_usd: Decimal::ZERO,
1337 duration_ms: 0,
1338 labels: HashMap::new(),
1339 at: now,
1340 }),
1341 "run_status_changed",
1342 ),
1343 (
1344 Event::RunFailed(RunFailedEvent {
1345 run_id: id,
1346 workflow_name: "w".to_string(),
1347 error: Some("boom".to_string()),
1348 cost_usd: Decimal::ZERO,
1349 duration_ms: 0,
1350 labels: HashMap::new(),
1351 at: now,
1352 }),
1353 "run_failed",
1354 ),
1355 (
1356 Event::RunBudgetExceeded(RunBudgetExceededEvent {
1357 run_id: id,
1358 workflow_name: "w".to_string(),
1359 limit_usd: Decimal::new(200, 2),
1360 spent_usd: Decimal::new(180, 2),
1361 step_budget_usd: Decimal::new(50, 2),
1362 at: now,
1363 }),
1364 "run_budget_exceeded",
1365 ),
1366 (
1367 Event::RetryForced(RetryForcedEvent {
1368 run_id: id,
1369 workflow_name: "w".to_string(),
1370 original_version: "1".to_string(),
1371 current_version: "2".to_string(),
1372 at: now,
1373 }),
1374 "retry_forced",
1375 ),
1376 (
1377 Event::StepCompleted(StepCompletedEvent {
1378 run_id: id,
1379 step_id: id,
1380 step_name: "s".to_string(),
1381 kind: StepKind::Shell,
1382 duration_ms: 0,
1383 cost_usd: Decimal::ZERO,
1384 at: now,
1385 }),
1386 "step_completed",
1387 ),
1388 (
1389 Event::StepFailed(StepFailedEvent {
1390 run_id: id,
1391 step_id: id,
1392 step_name: "s".to_string(),
1393 kind: StepKind::Shell,
1394 error: "err".to_string(),
1395 at: now,
1396 }),
1397 "step_failed",
1398 ),
1399 (
1400 Event::ApprovalRequested(ApprovalRequestedEvent {
1401 run_id: id,
1402 step_id: id,
1403 message: "ok?".to_string(),
1404 at: now,
1405 }),
1406 "approval_requested",
1407 ),
1408 (
1409 Event::ApprovalGranted(ApprovalGrantedEvent {
1410 run_id: id,
1411 approved_by: "alice".to_string(),
1412 at: now,
1413 }),
1414 "approval_granted",
1415 ),
1416 (
1417 Event::ApprovalRejected(ApprovalRejectedEvent {
1418 run_id: id,
1419 rejected_by: "bob".to_string(),
1420 at: now,
1421 }),
1422 "approval_rejected",
1423 ),
1424 (
1425 Event::LogLine(LogLineEvent {
1426 run_id: id,
1427 step_id: id,
1428 step_name: "build".to_string(),
1429 stream: LogStream::Stdout,
1430 line: "Compiling ironflow v0.1.0".to_string(),
1431 at: now,
1432 }),
1433 "log_line",
1434 ),
1435 (
1436 Event::UserSignedIn(UserSignedInEvent {
1437 user_id: id,
1438 username: "u".to_string(),
1439 at: now,
1440 }),
1441 "user_signed_in",
1442 ),
1443 (
1444 Event::UserSignedUp(UserSignedUpEvent {
1445 user_id: id,
1446 username: "u".to_string(),
1447 at: now,
1448 }),
1449 "user_signed_up",
1450 ),
1451 (
1452 Event::UserSignedOut(UserSignedOutEvent {
1453 user_id: id,
1454 at: now,
1455 }),
1456 "user_signed_out",
1457 ),
1458 ];
1459
1460 assert_eq!(
1461 cases.len(),
1462 Event::ALL.len(),
1463 "every variant must be covered"
1464 );
1465
1466 for (event, expected_type) in cases {
1467 assert_eq!(event.event_type(), expected_type);
1468 }
1469 }
1470}