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::{
12 ApprovalRequirement, Assignee, ProviderAccountWindow, RunStatus, StepKind,
13};
14
15fn default_approval_count() -> u32 {
18 1
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
38#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
39pub struct RunCreatedEvent {
40 pub run_id: Uuid,
42 pub workflow_name: String,
44 pub at: DateTime<Utc>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
75#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
76pub struct RunStatusChangedEvent {
77 pub run_id: Uuid,
79 pub workflow_name: String,
81 pub from: RunStatus,
83 pub to: RunStatus,
85 pub error: Option<String>,
87 pub cost_usd: Decimal,
89 pub duration_ms: u64,
91 #[serde(default)]
93 pub labels: HashMap<String, String>,
94 pub at: DateTime<Utc>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
122#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
123pub struct RunFailedEvent {
124 pub run_id: Uuid,
126 pub workflow_name: String,
128 pub error: Option<String>,
130 pub cost_usd: Decimal,
132 pub duration_ms: u64,
134 #[serde(default)]
136 pub labels: HashMap<String, String>,
137 pub at: DateTime<Utc>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
162#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
163pub struct RunBudgetExceededEvent {
164 pub run_id: Uuid,
166 pub workflow_name: String,
168 pub limit_usd: Decimal,
170 pub spent_usd: Decimal,
172 pub step_budget_usd: Decimal,
174 pub at: DateTime<Utc>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
197#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
198pub struct RetryForcedEvent {
199 pub run_id: Uuid,
201 pub workflow_name: String,
203 pub original_version: String,
205 pub current_version: String,
207 pub at: DateTime<Utc>,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
234#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
235pub struct StepCompletedEvent {
236 pub run_id: Uuid,
238 pub step_id: Uuid,
240 pub step_name: String,
242 #[cfg_attr(feature = "openapi", schema(value_type = String))]
244 pub kind: StepKind,
245 pub duration_ms: u64,
247 pub cost_usd: Decimal,
249 pub at: DateTime<Utc>,
251}
252
253#[derive(Debug, Clone, Serialize, Deserialize)]
274#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
275pub struct StepFailedEvent {
276 pub run_id: Uuid,
278 pub step_id: Uuid,
280 pub step_name: String,
282 #[cfg_attr(feature = "openapi", schema(value_type = String))]
284 pub kind: StepKind,
285 pub error: String,
287 pub at: DateTime<Utc>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
313#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
314pub struct ApprovalRequestedEvent {
315 pub run_id: Uuid,
317 pub step_id: Uuid,
319 pub message: String,
321 #[serde(default)]
324 pub requirement: Option<ApprovalRequirement>,
325 pub at: DateTime<Utc>,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
355#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
356pub struct ApprovalGrantedEvent {
357 pub run_id: Uuid,
359 #[serde(default)]
361 pub step_id: Option<Uuid>,
362 pub approved_by: String,
364 #[serde(default = "default_approval_count")]
366 pub approvals_received: u32,
367 #[serde(default = "default_approval_count")]
369 pub approvals_required: u32,
370 #[serde(default)]
372 pub requirement: Option<ApprovalRequirement>,
373 pub at: DateTime<Utc>,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
396#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
397pub struct ApprovalRejectedEvent {
398 pub run_id: Uuid,
400 #[serde(default)]
402 pub step_id: Option<Uuid>,
403 pub rejected_by: String,
405 #[serde(default)]
407 pub requirement: Option<ApprovalRequirement>,
408 pub at: DateTime<Utc>,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize)]
440#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
441pub struct ApprovalEscalatedEvent {
442 pub run_id: Uuid,
444 pub step_id: Uuid,
446 pub step_name: String,
448 pub stage: u32,
450 pub policy: String,
452 pub action: String,
454 pub reason: String,
456 #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
458 pub assignee: Option<Assignee>,
459 pub at: DateTime<Utc>,
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
484#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
485pub struct LogLineEvent {
486 #[serde(default)]
495 pub id: Uuid,
496 pub run_id: Uuid,
498 pub step_id: Uuid,
500 pub step_name: String,
502 pub stream: LogStream,
504 pub line: String,
506 pub at: DateTime<Utc>,
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize)]
527#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
528pub struct UserSignedInEvent {
529 pub user_id: Uuid,
531 pub username: String,
533 pub at: DateTime<Utc>,
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize)]
554#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
555pub struct UserSignedUpEvent {
556 pub user_id: Uuid,
558 pub username: String,
560 pub at: DateTime<Utc>,
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize)]
581#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
582pub struct UserSignedOutEvent {
583 pub user_id: Uuid,
585 pub at: DateTime<Utc>,
587}
588
589#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
600#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
601#[serde(rename_all = "snake_case")]
602pub enum ProviderAccountChange {
603 Created,
605 Updated,
607 Deleted,
609 TokenReplaced,
611}
612
613impl ProviderAccountChange {
614 pub fn as_str(&self) -> &'static str {
616 match self {
617 Self::Created => "created",
618 Self::Updated => "updated",
619 Self::Deleted => "deleted",
620 Self::TokenReplaced => "token_replaced",
621 }
622 }
623}
624
625#[derive(Debug, Clone, Serialize, Deserialize)]
645#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
646pub struct ProviderAccountUpdatedEvent {
647 pub account_id: Uuid,
649 pub name: String,
651 pub change: ProviderAccountChange,
653 pub at: DateTime<Utc>,
655}
656
657#[derive(Debug, Clone, Serialize, Deserialize)]
675#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
676pub struct ProviderAccountUsageUpdatedEvent {
677 pub account_id: Uuid,
679 pub name: String,
681 pub windows: Vec<ProviderAccountWindow>,
683 pub at: DateTime<Utc>,
685}
686
687#[derive(Debug, Clone, Serialize, Deserialize)]
719#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
720#[serde(tag = "type", rename_all = "snake_case")]
721pub enum Event {
722 RunCreated(RunCreatedEvent),
725
726 RunStatusChanged(RunStatusChangedEvent),
728
729 RunFailed(RunFailedEvent),
735
736 RunBudgetExceeded(RunBudgetExceededEvent),
744
745 RetryForced(RetryForcedEvent),
752
753 StepCompleted(StepCompletedEvent),
756
757 StepFailed(StepFailedEvent),
759
760 ApprovalRequested(ApprovalRequestedEvent),
763
764 ApprovalGranted(ApprovalGrantedEvent),
766
767 ApprovalRejected(ApprovalRejectedEvent),
769
770 ApprovalEscalated(ApprovalEscalatedEvent),
772
773 LogLine(LogLineEvent),
779
780 UserSignedIn(UserSignedInEvent),
783
784 UserSignedUp(UserSignedUpEvent),
786
787 UserSignedOut(UserSignedOutEvent),
789
790 #[serde(rename = "provider_account.updated")]
793 ProviderAccountUpdated(ProviderAccountUpdatedEvent),
794
795 #[serde(rename = "provider_account.usage_updated")]
797 ProviderAccountUsageUpdated(ProviderAccountUsageUpdatedEvent),
798}
799
800impl Event {
801 pub const RUN_CREATED: &'static str = "run_created";
803 pub const RUN_STATUS_CHANGED: &'static str = "run_status_changed";
805 pub const RUN_FAILED: &'static str = "run_failed";
807 pub const RUN_BUDGET_EXCEEDED: &'static str = "run_budget_exceeded";
809 pub const RETRY_FORCED: &'static str = "retry_forced";
811 pub const STEP_COMPLETED: &'static str = "step_completed";
813 pub const STEP_FAILED: &'static str = "step_failed";
815 pub const APPROVAL_REQUESTED: &'static str = "approval_requested";
817 pub const APPROVAL_GRANTED: &'static str = "approval_granted";
819 pub const APPROVAL_REJECTED: &'static str = "approval_rejected";
821 pub const APPROVAL_ESCALATED: &'static str = "approval_escalated";
823 pub const LOG_LINE: &'static str = "log_line";
825 pub const USER_SIGNED_IN: &'static str = "user_signed_in";
827 pub const USER_SIGNED_UP: &'static str = "user_signed_up";
829 pub const USER_SIGNED_OUT: &'static str = "user_signed_out";
831 pub const PROVIDER_ACCOUNT_UPDATED: &'static str = "provider_account.updated";
833 pub const PROVIDER_ACCOUNT_USAGE_UPDATED: &'static str = "provider_account.usage_updated";
836
837 pub const ALL: &'static [&'static str] = &[
853 Self::RUN_CREATED,
854 Self::RUN_STATUS_CHANGED,
855 Self::RUN_FAILED,
856 Self::RUN_BUDGET_EXCEEDED,
857 Self::STEP_COMPLETED,
858 Self::STEP_FAILED,
859 Self::APPROVAL_REQUESTED,
860 Self::APPROVAL_GRANTED,
861 Self::APPROVAL_REJECTED,
862 Self::APPROVAL_ESCALATED,
863 Self::LOG_LINE,
864 Self::USER_SIGNED_IN,
865 Self::USER_SIGNED_UP,
866 Self::USER_SIGNED_OUT,
867 Self::RETRY_FORCED,
868 Self::PROVIDER_ACCOUNT_UPDATED,
869 Self::PROVIDER_ACCOUNT_USAGE_UPDATED,
870 ];
871
872 #[deny(unreachable_patterns)]
891 pub fn event_type(&self) -> &'static str {
892 match self {
893 Event::RunCreated(_) => Self::RUN_CREATED,
894 Event::RunStatusChanged(_) => Self::RUN_STATUS_CHANGED,
895 Event::RunFailed(_) => Self::RUN_FAILED,
896 Event::RunBudgetExceeded(_) => Self::RUN_BUDGET_EXCEEDED,
897 Event::RetryForced(_) => Self::RETRY_FORCED,
898 Event::StepCompleted(_) => Self::STEP_COMPLETED,
899 Event::StepFailed(_) => Self::STEP_FAILED,
900 Event::ApprovalRequested(_) => Self::APPROVAL_REQUESTED,
901 Event::ApprovalGranted(_) => Self::APPROVAL_GRANTED,
902 Event::ApprovalRejected(_) => Self::APPROVAL_REJECTED,
903 Event::ApprovalEscalated(_) => Self::APPROVAL_ESCALATED,
904 Event::LogLine(_) => Self::LOG_LINE,
905 Event::UserSignedIn(_) => Self::USER_SIGNED_IN,
906 Event::UserSignedUp(_) => Self::USER_SIGNED_UP,
907 Event::UserSignedOut(_) => Self::USER_SIGNED_OUT,
908 Event::ProviderAccountUpdated(_) => Self::PROVIDER_ACCOUNT_UPDATED,
909 Event::ProviderAccountUsageUpdated(_) => Self::PROVIDER_ACCOUNT_USAGE_UPDATED,
910 }
911 }
912
913 #[deny(unreachable_patterns)]
936 pub fn run_id(&self) -> Option<Uuid> {
937 match self {
938 Event::RunCreated(e) => Some(e.run_id),
939 Event::RunStatusChanged(e) => Some(e.run_id),
940 Event::RunFailed(e) => Some(e.run_id),
941 Event::RunBudgetExceeded(e) => Some(e.run_id),
942 Event::RetryForced(e) => Some(e.run_id),
943 Event::StepCompleted(e) => Some(e.run_id),
944 Event::StepFailed(e) => Some(e.run_id),
945 Event::ApprovalRequested(e) => Some(e.run_id),
946 Event::ApprovalGranted(e) => Some(e.run_id),
947 Event::ApprovalRejected(e) => Some(e.run_id),
948 Event::ApprovalEscalated(e) => Some(e.run_id),
949 Event::LogLine(e) => Some(e.run_id),
950 Event::UserSignedIn(_)
951 | Event::UserSignedUp(_)
952 | Event::UserSignedOut(_)
953 | Event::ProviderAccountUpdated(_)
954 | Event::ProviderAccountUsageUpdated(_) => None,
955 }
956 }
957
958 #[deny(unreachable_patterns)]
988 pub fn step_id(&self) -> Option<Uuid> {
989 match self {
990 Event::StepCompleted(e) => Some(e.step_id),
991 Event::StepFailed(e) => Some(e.step_id),
992 Event::ApprovalRequested(e) => Some(e.step_id),
993 Event::ApprovalEscalated(e) => Some(e.step_id),
994 Event::ApprovalGranted(e) => e.step_id,
995 Event::ApprovalRejected(e) => e.step_id,
996 Event::RunCreated(_)
997 | Event::RunStatusChanged(_)
998 | Event::RunFailed(_)
999 | Event::RunBudgetExceeded(_)
1000 | Event::RetryForced(_)
1001 | Event::LogLine(_)
1002 | Event::UserSignedIn(_)
1003 | Event::UserSignedUp(_)
1004 | Event::UserSignedOut(_)
1005 | Event::ProviderAccountUpdated(_)
1006 | Event::ProviderAccountUsageUpdated(_) => None,
1007 }
1008 }
1009
1010 #[deny(unreachable_patterns)]
1033 pub fn user_id(&self) -> Option<Uuid> {
1034 match self {
1035 Event::UserSignedIn(e) => Some(e.user_id),
1036 Event::UserSignedUp(e) => Some(e.user_id),
1037 Event::UserSignedOut(e) => Some(e.user_id),
1038 Event::RunCreated(_)
1039 | Event::RunStatusChanged(_)
1040 | Event::RunFailed(_)
1041 | Event::RunBudgetExceeded(_)
1042 | Event::RetryForced(_)
1043 | Event::StepCompleted(_)
1044 | Event::StepFailed(_)
1045 | Event::ApprovalRequested(_)
1046 | Event::ApprovalGranted(_)
1047 | Event::ApprovalRejected(_)
1048 | Event::ApprovalEscalated(_)
1049 | Event::LogLine(_)
1050 | Event::ProviderAccountUpdated(_)
1051 | Event::ProviderAccountUsageUpdated(_) => None,
1052 }
1053 }
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059
1060 #[test]
1061 fn run_status_changed_serde_roundtrip() {
1062 let event = Event::RunStatusChanged(RunStatusChangedEvent {
1063 run_id: Uuid::now_v7(),
1064 workflow_name: "deploy".to_string(),
1065 from: RunStatus::Running,
1066 to: RunStatus::Completed,
1067 error: None,
1068 cost_usd: Decimal::new(42, 2),
1069 duration_ms: 5000,
1070 labels: HashMap::new(),
1071 at: Utc::now(),
1072 });
1073
1074 let json = serde_json::to_string(&event).expect("serialize");
1075 let back: Event = serde_json::from_str(&json).expect("deserialize");
1076
1077 assert_eq!(back.event_type(), "run_status_changed");
1078 assert!(json.contains("\"type\":\"run_status_changed\""));
1079 }
1080
1081 #[test]
1082 fn run_failed_serde_roundtrip() {
1083 let event = Event::RunFailed(RunFailedEvent {
1084 run_id: Uuid::now_v7(),
1085 workflow_name: "deploy".to_string(),
1086 error: Some("step crashed".to_string()),
1087 cost_usd: Decimal::new(10, 2),
1088 duration_ms: 3000,
1089 labels: HashMap::new(),
1090 at: Utc::now(),
1091 });
1092
1093 let json = serde_json::to_string(&event).expect("serialize");
1094 let back: Event = serde_json::from_str(&json).expect("deserialize");
1095
1096 assert_eq!(back.event_type(), "run_failed");
1097 assert!(json.contains("\"type\":\"run_failed\""));
1098 assert!(json.contains("step crashed"));
1099 }
1100
1101 #[test]
1102 fn run_budget_exceeded_serde_roundtrip() {
1103 let event = Event::RunBudgetExceeded(RunBudgetExceededEvent {
1104 run_id: Uuid::now_v7(),
1105 workflow_name: "deploy".to_string(),
1106 limit_usd: Decimal::new(200, 2),
1107 spent_usd: Decimal::new(180, 2),
1108 step_budget_usd: Decimal::new(50, 2),
1109 at: Utc::now(),
1110 });
1111
1112 let json = serde_json::to_string(&event).expect("serialize");
1113 let back: Event = serde_json::from_str(&json).expect("deserialize");
1114
1115 assert_eq!(back.event_type(), "run_budget_exceeded");
1116 assert!(json.contains("\"type\":\"run_budget_exceeded\""));
1117 assert!(json.contains("limit_usd"));
1118 assert!(json.contains("step_budget_usd"));
1119 }
1120
1121 #[test]
1122 fn all_contains_run_budget_exceeded() {
1123 assert!(Event::ALL.contains(&Event::RUN_BUDGET_EXCEEDED));
1124 }
1125
1126 #[test]
1127 fn user_signed_in_serde_roundtrip() {
1128 let event = Event::UserSignedIn(UserSignedInEvent {
1129 user_id: Uuid::now_v7(),
1130 username: "alice".to_string(),
1131 at: Utc::now(),
1132 });
1133
1134 let json = serde_json::to_string(&event).expect("serialize");
1135 let back: Event = serde_json::from_str(&json).expect("deserialize");
1136
1137 assert_eq!(back.event_type(), "user_signed_in");
1138 assert!(json.contains("alice"));
1139 }
1140
1141 #[test]
1142 fn step_failed_serde_roundtrip() {
1143 let event = Event::StepFailed(StepFailedEvent {
1144 run_id: Uuid::now_v7(),
1145 step_id: Uuid::now_v7(),
1146 step_name: "build".to_string(),
1147 kind: StepKind::Shell,
1148 error: "exit code 1".to_string(),
1149 at: Utc::now(),
1150 });
1151
1152 let json = serde_json::to_string(&event).expect("serialize");
1153 let back: Event = serde_json::from_str(&json).expect("deserialize");
1154
1155 assert_eq!(back.event_type(), "step_failed");
1156 }
1157
1158 #[test]
1159 fn legacy_approval_granted_defaults_to_a_single_vote() {
1160 let raw = r#"{"type":"approval_granted","run_id":"01890000-0000-7000-8000-000000000000","approved_by":"alice","at":"2026-01-01T00:00:00Z"}"#;
1161 let event: Event = serde_json::from_str(raw).expect("deserialize");
1162 let Event::ApprovalGranted(event) = event else {
1163 panic!("expected approval_granted");
1164 };
1165
1166 assert_eq!(event.step_id, None);
1167 assert_eq!(event.approvals_received, 1);
1168 assert_eq!(event.approvals_required, 1);
1169 assert!(event.requirement.is_none());
1170 }
1171
1172 #[test]
1173 fn legacy_approval_requested_and_rejected_have_no_requirement() {
1174 let raw = r#"{"type":"approval_requested","run_id":"01890000-0000-7000-8000-000000000000","step_id":"01890000-0000-7000-8000-000000000001","message":"ok?","at":"2026-01-01T00:00:00Z"}"#;
1175 let requested: Event = serde_json::from_str(raw).expect("deserialize");
1176 let Event::ApprovalRequested(requested) = requested else {
1177 panic!("expected approval_requested");
1178 };
1179 assert!(requested.requirement.is_none());
1180
1181 let raw = r#"{"type":"approval_rejected","run_id":"01890000-0000-7000-8000-000000000000","rejected_by":"bob","at":"2026-01-01T00:00:00Z"}"#;
1182 let rejected: Event = serde_json::from_str(raw).expect("deserialize");
1183 let Event::ApprovalRejected(rejected) = rejected else {
1184 panic!("expected approval_rejected");
1185 };
1186 assert_eq!(rejected.step_id, None);
1187 assert!(rejected.requirement.is_none());
1188 }
1189
1190 #[test]
1191 fn approval_granted_roundtrips_the_vote_counts() {
1192 let requirement = ApprovalRequirement {
1193 reason: Some("amount > 10k".to_string()),
1194 required_approvers: 2,
1195 approver_groups: vec!["finance".to_string()],
1196 };
1197 let event = Event::ApprovalGranted(ApprovalGrantedEvent {
1198 run_id: Uuid::now_v7(),
1199 step_id: Some(Uuid::now_v7()),
1200 approved_by: "alice".to_string(),
1201 approvals_received: 1,
1202 approvals_required: 2,
1203 requirement: Some(requirement.clone()),
1204 at: Utc::now(),
1205 });
1206
1207 let json = serde_json::to_string(&event).expect("serialize");
1208 let back: Event = serde_json::from_str(&json).expect("deserialize");
1209 let Event::ApprovalGranted(back) = back else {
1210 panic!("expected approval_granted");
1211 };
1212 assert_eq!(back.approvals_received, 1);
1213 assert_eq!(back.approvals_required, 2);
1214 assert_eq!(back.requirement, Some(requirement));
1215 }
1216
1217 #[test]
1218 fn approval_requested_serde_roundtrip() {
1219 let event = Event::ApprovalRequested(ApprovalRequestedEvent {
1220 run_id: Uuid::now_v7(),
1221 step_id: Uuid::now_v7(),
1222 message: "Deploy to prod?".to_string(),
1223 requirement: None,
1224 at: Utc::now(),
1225 });
1226
1227 let json = serde_json::to_string(&event).expect("serialize");
1228 assert!(json.contains("approval_requested"));
1229 }
1230
1231 #[test]
1232 fn log_line_serde_roundtrip() {
1233 let event = Event::LogLine(LogLineEvent {
1234 id: Uuid::now_v7(),
1235 run_id: Uuid::now_v7(),
1236 step_id: Uuid::now_v7(),
1237 step_name: "build".to_string(),
1238 stream: LogStream::Stdout,
1239 line: "Compiling ironflow v0.1.0".to_string(),
1240 at: Utc::now(),
1241 });
1242
1243 let json = serde_json::to_string(&event).expect("serialize");
1244 let back: Event = serde_json::from_str(&json).expect("deserialize");
1245
1246 assert_eq!(back.event_type(), "log_line");
1247 assert!(json.contains("\"type\":\"log_line\""));
1248 assert!(json.contains("Compiling ironflow"));
1249 }
1250
1251 #[test]
1256 fn legacy_flat_json_deserializes_into_typed_payload() {
1257 let run_id: Uuid = "01890000-0000-7000-8000-000000000000"
1258 .parse()
1259 .expect("valid uuid");
1260
1261 let raw = r#"{"type":"run_created","run_id":"01890000-0000-7000-8000-000000000000","workflow_name":"deploy","at":"2026-01-01T00:00:00Z"}"#;
1262 let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1263 match event {
1264 Event::RunCreated(e) => {
1265 assert_eq!(e.run_id, run_id);
1266 assert_eq!(e.workflow_name, "deploy");
1267 }
1268 other => panic!("expected RunCreated, got {other:?}"),
1269 }
1270
1271 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"}"#;
1272 let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1273 match event {
1274 Event::RunStatusChanged(e) => {
1275 assert_eq!(e.from, RunStatus::Running);
1276 assert_eq!(e.to, RunStatus::Completed);
1277 assert_eq!(e.cost_usd, Decimal::new(5, 1));
1278 assert_eq!(e.duration_ms, 5000);
1279 assert_eq!(e.labels.get("env").map(String::as_str), Some("prod"));
1280 }
1281 other => panic!("expected RunStatusChanged, got {other:?}"),
1282 }
1283
1284 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"}"#;
1286 let event: Event = serde_json::from_str(raw).expect("missing labels must default");
1287 match event {
1288 Event::RunStatusChanged(e) => {
1289 assert!(e.labels.is_empty());
1290 assert_eq!(e.error.as_deref(), Some("boom"));
1291 }
1292 other => panic!("expected RunStatusChanged, got {other:?}"),
1293 }
1294
1295 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"}"#;
1296 let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1297 match event {
1298 Event::RunFailed(e) => {
1299 assert_eq!(e.error.as_deref(), Some("boom"));
1300 assert!(e.labels.is_empty());
1301 }
1302 other => panic!("expected RunFailed, got {other:?}"),
1303 }
1304
1305 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"}"#;
1306 let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1307 match event {
1308 Event::StepFailed(e) => {
1309 assert_eq!(e.kind, StepKind::Shell);
1310 assert_eq!(e.error, "exit code 1");
1311 }
1312 other => panic!("expected StepFailed, got {other:?}"),
1313 }
1314
1315 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"}"#;
1316 let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1317 match event {
1318 Event::LogLine(e) => {
1319 assert_eq!(e.stream, LogStream::Stdout);
1320 assert_eq!(e.line, "hello");
1321 assert_eq!(e.id, Uuid::nil());
1324 }
1325 other => panic!("expected LogLine, got {other:?}"),
1326 }
1327
1328 let raw = r#"{"type":"user_signed_in","user_id":"01890000-0000-7000-8000-000000000000","username":"alice","at":"2026-01-01T00:00:00Z"}"#;
1329 let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1330 match event {
1331 Event::UserSignedIn(e) => assert_eq!(e.username, "alice"),
1332 other => panic!("expected UserSignedIn, got {other:?}"),
1333 }
1334 }
1335
1336 #[test]
1339 fn serialized_event_is_flat_with_type_tag() {
1340 let run_id = Uuid::now_v7();
1341 let event = Event::RunCreated(RunCreatedEvent {
1342 run_id,
1343 workflow_name: "deploy".to_string(),
1344 at: Utc::now(),
1345 });
1346
1347 let value: serde_json::Value = serde_json::to_value(&event).expect("serialize");
1348 let object = value.as_object().expect("event serializes to an object");
1349
1350 assert_eq!(
1351 object.get("type").and_then(|v| v.as_str()),
1352 Some("run_created")
1353 );
1354 assert_eq!(
1355 object.get("workflow_name").and_then(|v| v.as_str()),
1356 Some("deploy")
1357 );
1358 assert_eq!(
1359 object.get("run_id").and_then(|v| v.as_str()),
1360 Some(run_id.to_string().as_str())
1361 );
1362 assert!(object.contains_key("at"));
1363 assert_eq!(object.len(), 4, "no nesting: {object:?}");
1364 assert!(!object.contains_key("RunCreated"));
1365 }
1366
1367 #[test]
1368 fn run_id_returns_some_for_run_events() {
1369 let run_id = Uuid::now_v7();
1370 let now = Utc::now();
1371
1372 let events = vec![
1373 Event::RunCreated(RunCreatedEvent {
1374 run_id,
1375 workflow_name: "w".to_string(),
1376 at: now,
1377 }),
1378 Event::RunStatusChanged(RunStatusChangedEvent {
1379 run_id,
1380 workflow_name: "w".to_string(),
1381 from: RunStatus::Pending,
1382 to: RunStatus::Running,
1383 error: None,
1384 cost_usd: Decimal::ZERO,
1385 duration_ms: 0,
1386 labels: HashMap::new(),
1387 at: now,
1388 }),
1389 Event::RunFailed(RunFailedEvent {
1390 run_id,
1391 workflow_name: "w".to_string(),
1392 error: None,
1393 cost_usd: Decimal::ZERO,
1394 duration_ms: 0,
1395 labels: HashMap::new(),
1396 at: now,
1397 }),
1398 Event::RunBudgetExceeded(RunBudgetExceededEvent {
1399 run_id,
1400 workflow_name: "w".to_string(),
1401 limit_usd: Decimal::ZERO,
1402 spent_usd: Decimal::ZERO,
1403 step_budget_usd: Decimal::ZERO,
1404 at: now,
1405 }),
1406 Event::RetryForced(RetryForcedEvent {
1407 run_id,
1408 workflow_name: "w".to_string(),
1409 original_version: "1".to_string(),
1410 current_version: "2".to_string(),
1411 at: now,
1412 }),
1413 Event::StepCompleted(StepCompletedEvent {
1414 run_id,
1415 step_id: Uuid::now_v7(),
1416 step_name: "s".to_string(),
1417 kind: StepKind::Shell,
1418 duration_ms: 0,
1419 cost_usd: Decimal::ZERO,
1420 at: now,
1421 }),
1422 Event::StepFailed(StepFailedEvent {
1423 run_id,
1424 step_id: Uuid::now_v7(),
1425 step_name: "s".to_string(),
1426 kind: StepKind::Shell,
1427 error: "e".to_string(),
1428 at: now,
1429 }),
1430 Event::ApprovalRequested(ApprovalRequestedEvent {
1431 run_id,
1432 step_id: Uuid::now_v7(),
1433 message: "ok?".to_string(),
1434 requirement: None,
1435 at: now,
1436 }),
1437 Event::ApprovalGranted(ApprovalGrantedEvent {
1438 run_id,
1439 step_id: None,
1440 approved_by: "alice".to_string(),
1441 approvals_received: 1,
1442 approvals_required: 1,
1443 requirement: None,
1444 at: now,
1445 }),
1446 Event::ApprovalRejected(ApprovalRejectedEvent {
1447 run_id,
1448 step_id: None,
1449 rejected_by: "bob".to_string(),
1450 requirement: None,
1451 at: now,
1452 }),
1453 Event::LogLine(LogLineEvent {
1454 id: Uuid::now_v7(),
1455 run_id,
1456 step_id: Uuid::now_v7(),
1457 step_name: "s".to_string(),
1458 stream: LogStream::Stdout,
1459 line: "l".to_string(),
1460 at: now,
1461 }),
1462 ];
1463
1464 for event in &events {
1465 assert_eq!(
1466 event.run_id(),
1467 Some(run_id),
1468 "{} should carry a run_id",
1469 event.event_type()
1470 );
1471 }
1472 }
1473
1474 #[test]
1475 fn run_id_returns_none_for_auth_events() {
1476 let user_id = Uuid::now_v7();
1477 let now = Utc::now();
1478
1479 let events = vec![
1480 Event::UserSignedIn(UserSignedInEvent {
1481 user_id,
1482 username: "alice".to_string(),
1483 at: now,
1484 }),
1485 Event::UserSignedUp(UserSignedUpEvent {
1486 user_id,
1487 username: "alice".to_string(),
1488 at: now,
1489 }),
1490 Event::UserSignedOut(UserSignedOutEvent { user_id, at: now }),
1491 ];
1492
1493 for event in &events {
1494 assert_eq!(event.run_id(), None, "{} has no run", event.event_type());
1495 }
1496 }
1497
1498 #[test]
1499 fn step_id_returns_some_only_for_step_events() {
1500 let step_id = Uuid::now_v7();
1501 let run_id = Uuid::now_v7();
1502 let now = Utc::now();
1503
1504 let with_step = vec![
1505 Event::StepCompleted(StepCompletedEvent {
1506 run_id,
1507 step_id,
1508 step_name: "s".to_string(),
1509 kind: StepKind::Shell,
1510 duration_ms: 0,
1511 cost_usd: Decimal::ZERO,
1512 at: now,
1513 }),
1514 Event::StepFailed(StepFailedEvent {
1515 run_id,
1516 step_id,
1517 step_name: "s".to_string(),
1518 kind: StepKind::Shell,
1519 error: "e".to_string(),
1520 at: now,
1521 }),
1522 Event::ApprovalRequested(ApprovalRequestedEvent {
1523 run_id,
1524 step_id,
1525 message: "ok?".to_string(),
1526 requirement: None,
1527 at: now,
1528 }),
1529 Event::ApprovalGranted(ApprovalGrantedEvent {
1530 run_id,
1531 step_id: Some(step_id),
1532 approved_by: "alice".to_string(),
1533 approvals_received: 1,
1534 approvals_required: 2,
1535 requirement: None,
1536 at: now,
1537 }),
1538 Event::ApprovalRejected(ApprovalRejectedEvent {
1539 run_id,
1540 step_id: Some(step_id),
1541 rejected_by: "bob".to_string(),
1542 requirement: None,
1543 at: now,
1544 }),
1545 ];
1546
1547 for event in &with_step {
1548 assert_eq!(
1549 event.step_id(),
1550 Some(step_id),
1551 "{} should carry a step_id",
1552 event.event_type()
1553 );
1554 }
1555
1556 let without_step = vec![
1557 Event::RunCreated(RunCreatedEvent {
1558 run_id,
1559 workflow_name: "w".to_string(),
1560 at: now,
1561 }),
1562 Event::ApprovalGranted(ApprovalGrantedEvent {
1564 run_id,
1565 step_id: None,
1566 approved_by: "alice".to_string(),
1567 approvals_received: 1,
1568 approvals_required: 1,
1569 requirement: None,
1570 at: now,
1571 }),
1572 Event::LogLine(LogLineEvent {
1575 id: Uuid::now_v7(),
1576 run_id,
1577 step_id,
1578 step_name: "s".to_string(),
1579 stream: LogStream::Stdout,
1580 line: "l".to_string(),
1581 at: now,
1582 }),
1583 Event::UserSignedOut(UserSignedOutEvent {
1584 user_id: Uuid::now_v7(),
1585 at: now,
1586 }),
1587 ];
1588
1589 for event in &without_step {
1590 assert_eq!(
1591 event.step_id(),
1592 None,
1593 "{} should not carry a step_id",
1594 event.event_type()
1595 );
1596 }
1597 }
1598
1599 #[test]
1600 fn user_id_returns_some_only_for_auth_events() {
1601 let user_id = Uuid::now_v7();
1602 let run_id = Uuid::now_v7();
1603 let now = Utc::now();
1604
1605 let auth = vec![
1606 Event::UserSignedIn(UserSignedInEvent {
1607 user_id,
1608 username: "alice".to_string(),
1609 at: now,
1610 }),
1611 Event::UserSignedUp(UserSignedUpEvent {
1612 user_id,
1613 username: "alice".to_string(),
1614 at: now,
1615 }),
1616 Event::UserSignedOut(UserSignedOutEvent { user_id, at: now }),
1617 ];
1618
1619 for event in &auth {
1620 assert_eq!(
1621 event.user_id(),
1622 Some(user_id),
1623 "{} should carry a user_id",
1624 event.event_type()
1625 );
1626 }
1627
1628 let non_auth = vec![
1629 Event::RunCreated(RunCreatedEvent {
1630 run_id,
1631 workflow_name: "w".to_string(),
1632 at: now,
1633 }),
1634 Event::StepFailed(StepFailedEvent {
1635 run_id,
1636 step_id: Uuid::now_v7(),
1637 step_name: "s".to_string(),
1638 kind: StepKind::Shell,
1639 error: "e".to_string(),
1640 at: now,
1641 }),
1642 ];
1643
1644 for event in &non_auth {
1645 assert_eq!(
1646 event.user_id(),
1647 None,
1648 "{} should not carry a user_id",
1649 event.event_type()
1650 );
1651 }
1652 }
1653
1654 #[test]
1655 fn event_type_all_variants() {
1656 let id = Uuid::now_v7();
1657 let now = Utc::now();
1658
1659 let cases: Vec<(Event, &str)> = vec![
1660 (
1661 Event::RunCreated(RunCreatedEvent {
1662 run_id: id,
1663 workflow_name: "w".to_string(),
1664 at: now,
1665 }),
1666 "run_created",
1667 ),
1668 (
1669 Event::RunStatusChanged(RunStatusChangedEvent {
1670 run_id: id,
1671 workflow_name: "w".to_string(),
1672 from: RunStatus::Pending,
1673 to: RunStatus::Running,
1674 error: None,
1675 cost_usd: Decimal::ZERO,
1676 duration_ms: 0,
1677 labels: HashMap::new(),
1678 at: now,
1679 }),
1680 "run_status_changed",
1681 ),
1682 (
1683 Event::RunFailed(RunFailedEvent {
1684 run_id: id,
1685 workflow_name: "w".to_string(),
1686 error: Some("boom".to_string()),
1687 cost_usd: Decimal::ZERO,
1688 duration_ms: 0,
1689 labels: HashMap::new(),
1690 at: now,
1691 }),
1692 "run_failed",
1693 ),
1694 (
1695 Event::RunBudgetExceeded(RunBudgetExceededEvent {
1696 run_id: id,
1697 workflow_name: "w".to_string(),
1698 limit_usd: Decimal::new(200, 2),
1699 spent_usd: Decimal::new(180, 2),
1700 step_budget_usd: Decimal::new(50, 2),
1701 at: now,
1702 }),
1703 "run_budget_exceeded",
1704 ),
1705 (
1706 Event::RetryForced(RetryForcedEvent {
1707 run_id: id,
1708 workflow_name: "w".to_string(),
1709 original_version: "1".to_string(),
1710 current_version: "2".to_string(),
1711 at: now,
1712 }),
1713 "retry_forced",
1714 ),
1715 (
1716 Event::StepCompleted(StepCompletedEvent {
1717 run_id: id,
1718 step_id: id,
1719 step_name: "s".to_string(),
1720 kind: StepKind::Shell,
1721 duration_ms: 0,
1722 cost_usd: Decimal::ZERO,
1723 at: now,
1724 }),
1725 "step_completed",
1726 ),
1727 (
1728 Event::StepFailed(StepFailedEvent {
1729 run_id: id,
1730 step_id: id,
1731 step_name: "s".to_string(),
1732 kind: StepKind::Shell,
1733 error: "err".to_string(),
1734 at: now,
1735 }),
1736 "step_failed",
1737 ),
1738 (
1739 Event::ApprovalRequested(ApprovalRequestedEvent {
1740 run_id: id,
1741 step_id: id,
1742 message: "ok?".to_string(),
1743 requirement: None,
1744 at: now,
1745 }),
1746 "approval_requested",
1747 ),
1748 (
1749 Event::ApprovalGranted(ApprovalGrantedEvent {
1750 run_id: id,
1751 step_id: Some(id),
1752 approved_by: "alice".to_string(),
1753 approvals_received: 1,
1754 approvals_required: 1,
1755 requirement: None,
1756 at: now,
1757 }),
1758 "approval_granted",
1759 ),
1760 (
1761 Event::ApprovalRejected(ApprovalRejectedEvent {
1762 run_id: id,
1763 step_id: Some(id),
1764 rejected_by: "bob".to_string(),
1765 requirement: None,
1766 at: now,
1767 }),
1768 "approval_rejected",
1769 ),
1770 (
1771 Event::ApprovalEscalated(ApprovalEscalatedEvent {
1772 run_id: id,
1773 step_id: id,
1774 step_name: "prod-gate".to_string(),
1775 stage: 0,
1776 policy: "auto_reject".to_string(),
1777 action: "rejected".to_string(),
1778 reason: "approval deadline of 3600s expired".to_string(),
1779 assignee: None,
1780 at: now,
1781 }),
1782 "approval_escalated",
1783 ),
1784 (
1785 Event::LogLine(LogLineEvent {
1786 id,
1787 run_id: id,
1788 step_id: id,
1789 step_name: "build".to_string(),
1790 stream: LogStream::Stdout,
1791 line: "Compiling ironflow v0.1.0".to_string(),
1792 at: now,
1793 }),
1794 "log_line",
1795 ),
1796 (
1797 Event::UserSignedIn(UserSignedInEvent {
1798 user_id: id,
1799 username: "u".to_string(),
1800 at: now,
1801 }),
1802 "user_signed_in",
1803 ),
1804 (
1805 Event::UserSignedUp(UserSignedUpEvent {
1806 user_id: id,
1807 username: "u".to_string(),
1808 at: now,
1809 }),
1810 "user_signed_up",
1811 ),
1812 (
1813 Event::UserSignedOut(UserSignedOutEvent {
1814 user_id: id,
1815 at: now,
1816 }),
1817 "user_signed_out",
1818 ),
1819 (
1820 Event::ProviderAccountUpdated(ProviderAccountUpdatedEvent {
1821 account_id: id,
1822 name: "perso".to_string(),
1823 change: ProviderAccountChange::TokenReplaced,
1824 at: now,
1825 }),
1826 "provider_account.updated",
1827 ),
1828 (
1829 Event::ProviderAccountUsageUpdated(ProviderAccountUsageUpdatedEvent {
1830 account_id: id,
1831 name: "perso".to_string(),
1832 windows: Vec::new(),
1833 at: now,
1834 }),
1835 "provider_account.usage_updated",
1836 ),
1837 ];
1838
1839 assert_eq!(
1840 cases.len(),
1841 Event::ALL.len(),
1842 "every variant must be covered"
1843 );
1844
1845 for (event, expected_type) in cases {
1846 assert_eq!(event.event_type(), expected_type);
1847 }
1848 }
1849
1850 #[test]
1851 fn approval_escalated_serde_roundtrip() {
1852 let run_id = Uuid::now_v7();
1853 let step_id = Uuid::now_v7();
1854 let event = Event::ApprovalEscalated(ApprovalEscalatedEvent {
1855 run_id,
1856 step_id,
1857 step_name: "prod-gate".to_string(),
1858 stage: 1,
1859 policy: "escalate".to_string(),
1860 action: "reassigned to sre-oncall".to_string(),
1861 reason: "approval deadline of 3600s expired".to_string(),
1862 assignee: Some(Assignee::group("sre-oncall")),
1863 at: Utc::now(),
1864 });
1865
1866 let json = serde_json::to_string(&event).expect("serialize");
1867 assert!(
1868 json.contains("\"type\":\"approval_escalated\""),
1869 "got {json}"
1870 );
1871
1872 let back: Event = serde_json::from_str(&json).expect("deserialize");
1873 let Event::ApprovalEscalated(payload) = back else {
1874 panic!("expected an approval_escalated event");
1875 };
1876 assert_eq!(payload.run_id, run_id);
1877 assert_eq!(payload.stage, 1);
1878 assert_eq!(payload.assignee, Some(Assignee::group("sre-oncall")));
1879 }
1880
1881 #[test]
1882 fn approval_escalated_carries_run_and_step_ids() {
1883 let run_id = Uuid::now_v7();
1884 let step_id = Uuid::now_v7();
1885 let event = Event::ApprovalEscalated(ApprovalEscalatedEvent {
1886 run_id,
1887 step_id,
1888 step_name: "prod-gate".to_string(),
1889 stage: 0,
1890 policy: "notify".to_string(),
1891 action: "notified 1 target".to_string(),
1892 reason: "approval deadline of 60s expired".to_string(),
1893 assignee: None,
1894 at: Utc::now(),
1895 });
1896
1897 assert_eq!(event.run_id(), Some(run_id));
1898 assert_eq!(event.step_id(), Some(step_id));
1899 assert_eq!(event.user_id(), None);
1900 }
1901}