Skip to main content

ironflow_engine/notify/
event.rs

1//! Domain events emitted throughout the ironflow lifecycle.
2
3use 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, ScheduleMissReason, StepKind,
13};
14
15/// Vote counts assumed for approval events serialized before multi-approver
16/// gates existed: one approval was always enough.
17fn default_approval_count() -> u32 {
18    1
19}
20
21/// Payload of the `Event::RunCreated` event.
22///
23/// # Examples
24///
25/// ```
26/// use chrono::Utc;
27/// use ironflow_engine::notify::RunCreatedEvent;
28/// use uuid::Uuid;
29///
30/// let payload = RunCreatedEvent {
31///     run_id: Uuid::now_v7(),
32///     workflow_name: "deploy".to_string(),
33///     at: Utc::now(),
34/// };
35/// assert_eq!(payload.workflow_name, "deploy");
36/// ```
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
39pub struct RunCreatedEvent {
40    /// Run identifier.
41    pub run_id: Uuid,
42    /// Workflow name.
43    pub workflow_name: String,
44    /// When the run was created.
45    pub at: DateTime<Utc>,
46}
47
48/// Payload of the `Event::RunStatusChanged` event.
49///
50/// # Examples
51///
52/// ```
53/// use std::collections::HashMap;
54///
55/// use chrono::Utc;
56/// use ironflow_engine::notify::RunStatusChangedEvent;
57/// use ironflow_store::models::RunStatus;
58/// use rust_decimal::Decimal;
59/// use uuid::Uuid;
60///
61/// let payload = RunStatusChangedEvent {
62///     run_id: Uuid::now_v7(),
63///     workflow_name: "deploy".to_string(),
64///     from: RunStatus::Running,
65///     to: RunStatus::Completed,
66///     error: None,
67///     cost_usd: Decimal::ZERO,
68///     duration_ms: 5000,
69///     labels: HashMap::new(),
70///     at: Utc::now(),
71/// };
72/// assert_eq!(payload.to, RunStatus::Completed);
73/// ```
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
76pub struct RunStatusChangedEvent {
77    /// Run identifier.
78    pub run_id: Uuid,
79    /// Workflow name.
80    pub workflow_name: String,
81    /// Previous status.
82    pub from: RunStatus,
83    /// New status.
84    pub to: RunStatus,
85    /// Error message (when transitioning to Failed).
86    pub error: Option<String>,
87    /// Aggregated cost in USD at the time of transition.
88    pub cost_usd: Decimal,
89    /// Aggregated duration in milliseconds at the time of transition.
90    pub duration_ms: u64,
91    /// Labels of the run at the time of the transition.
92    #[serde(default)]
93    pub labels: HashMap<String, String>,
94    /// When the transition occurred.
95    pub at: DateTime<Utc>,
96}
97
98/// Payload of the `Event::RunFailed` event.
99///
100/// # Examples
101///
102/// ```
103/// use std::collections::HashMap;
104///
105/// use chrono::Utc;
106/// use ironflow_engine::notify::RunFailedEvent;
107/// use rust_decimal::Decimal;
108/// use uuid::Uuid;
109///
110/// let payload = RunFailedEvent {
111///     run_id: Uuid::now_v7(),
112///     workflow_name: "deploy".to_string(),
113///     error: Some("step crashed".to_string()),
114///     cost_usd: Decimal::ZERO,
115///     duration_ms: 3000,
116///     labels: HashMap::new(),
117///     at: Utc::now(),
118/// };
119/// assert_eq!(payload.error.as_deref(), Some("step crashed"));
120/// ```
121#[derive(Debug, Clone, Serialize, Deserialize)]
122#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
123pub struct RunFailedEvent {
124    /// Run identifier.
125    pub run_id: Uuid,
126    /// Workflow name.
127    pub workflow_name: String,
128    /// Error message.
129    pub error: Option<String>,
130    /// Aggregated cost in USD at the time of failure.
131    pub cost_usd: Decimal,
132    /// Aggregated duration in milliseconds at the time of failure.
133    pub duration_ms: u64,
134    /// Labels of the run at the time of the failure.
135    #[serde(default)]
136    pub labels: HashMap<String, String>,
137    /// When the failure occurred.
138    pub at: DateTime<Utc>,
139}
140
141/// Payload of the `Event::RunBudgetExceeded` event.
142///
143/// # Examples
144///
145/// ```
146/// use chrono::Utc;
147/// use ironflow_engine::notify::RunBudgetExceededEvent;
148/// use rust_decimal::Decimal;
149/// use uuid::Uuid;
150///
151/// let payload = RunBudgetExceededEvent {
152///     run_id: Uuid::now_v7(),
153///     workflow_name: "deploy".to_string(),
154///     limit_usd: Decimal::new(200, 2),
155///     spent_usd: Decimal::new(180, 2),
156///     step_budget_usd: Decimal::new(50, 2),
157///     at: Utc::now(),
158/// };
159/// assert_eq!(payload.limit_usd, Decimal::new(200, 2));
160/// ```
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
163pub struct RunBudgetExceededEvent {
164    /// Run identifier.
165    pub run_id: Uuid,
166    /// Workflow name.
167    pub workflow_name: String,
168    /// The configured cost cap in USD.
169    pub limit_usd: Decimal,
170    /// Cost already consumed when the cap was reached, in USD.
171    pub spent_usd: Decimal,
172    /// Declared budget of the refused step, in USD.
173    pub step_budget_usd: Decimal,
174    /// When the refusal occurred.
175    pub at: DateTime<Utc>,
176}
177
178/// Payload of the `Event::RetryForced` event.
179///
180/// # Examples
181///
182/// ```
183/// use chrono::Utc;
184/// use ironflow_engine::notify::RetryForcedEvent;
185/// use uuid::Uuid;
186///
187/// let payload = RetryForcedEvent {
188///     run_id: Uuid::now_v7(),
189///     workflow_name: "deploy".to_string(),
190///     original_version: "1".to_string(),
191///     current_version: "2".to_string(),
192///     at: Utc::now(),
193/// };
194/// assert_eq!(payload.current_version, "2");
195/// ```
196#[derive(Debug, Clone, Serialize, Deserialize)]
197#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
198pub struct RetryForcedEvent {
199    /// The new run created by the forced retry.
200    pub run_id: Uuid,
201    /// Workflow name.
202    pub workflow_name: String,
203    /// Version stored on the original run.
204    pub original_version: String,
205    /// Current version of the handler.
206    pub current_version: String,
207    /// When the forced retry occurred.
208    pub at: DateTime<Utc>,
209}
210
211/// Payload of the `Event::StepCompleted` event.
212///
213/// # Examples
214///
215/// ```
216/// use chrono::Utc;
217/// use ironflow_engine::notify::StepCompletedEvent;
218/// use ironflow_store::models::StepKind;
219/// use rust_decimal::Decimal;
220/// use uuid::Uuid;
221///
222/// let payload = StepCompletedEvent {
223///     run_id: Uuid::now_v7(),
224///     step_id: Uuid::now_v7(),
225///     step_name: "build".to_string(),
226///     kind: StepKind::Shell,
227///     duration_ms: 1200,
228///     cost_usd: Decimal::ZERO,
229///     at: Utc::now(),
230/// };
231/// assert_eq!(payload.step_name, "build");
232/// ```
233#[derive(Debug, Clone, Serialize, Deserialize)]
234#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
235pub struct StepCompletedEvent {
236    /// Run identifier.
237    pub run_id: Uuid,
238    /// Step identifier.
239    pub step_id: Uuid,
240    /// Human-readable step name.
241    pub step_name: String,
242    /// Step operation kind.
243    #[cfg_attr(feature = "openapi", schema(value_type = String))]
244    pub kind: StepKind,
245    /// Step duration in milliseconds.
246    pub duration_ms: u64,
247    /// Step cost in USD.
248    pub cost_usd: Decimal,
249    /// When the step completed.
250    pub at: DateTime<Utc>,
251}
252
253/// Payload of the `Event::StepFailed` event.
254///
255/// # Examples
256///
257/// ```
258/// use chrono::Utc;
259/// use ironflow_engine::notify::StepFailedEvent;
260/// use ironflow_store::models::StepKind;
261/// use uuid::Uuid;
262///
263/// let payload = StepFailedEvent {
264///     run_id: Uuid::now_v7(),
265///     step_id: Uuid::now_v7(),
266///     step_name: "build".to_string(),
267///     kind: StepKind::Shell,
268///     error: "exit code 1".to_string(),
269///     at: Utc::now(),
270/// };
271/// assert_eq!(payload.error, "exit code 1");
272/// ```
273#[derive(Debug, Clone, Serialize, Deserialize)]
274#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
275pub struct StepFailedEvent {
276    /// Run identifier.
277    pub run_id: Uuid,
278    /// Step identifier.
279    pub step_id: Uuid,
280    /// Human-readable step name.
281    pub step_name: String,
282    /// Step operation kind.
283    #[cfg_attr(feature = "openapi", schema(value_type = String))]
284    pub kind: StepKind,
285    /// Error message.
286    pub error: String,
287    /// When the step failed.
288    pub at: DateTime<Utc>,
289}
290
291/// Payload of the `Event::ApprovalRequested` event.
292///
293/// Published when an approval gate opens. Carries the approvers the handler
294/// required, if it set any.
295///
296/// # Examples
297///
298/// ```
299/// use chrono::Utc;
300/// use ironflow_engine::notify::ApprovalRequestedEvent;
301/// use uuid::Uuid;
302///
303/// let payload = ApprovalRequestedEvent {
304///     run_id: Uuid::now_v7(),
305///     step_id: Uuid::now_v7(),
306///     message: "Deploy to prod?".to_string(),
307///     requirement: None,
308///     at: Utc::now(),
309/// };
310/// assert_eq!(payload.message, "Deploy to prod?");
311/// ```
312#[derive(Debug, Clone, Serialize, Deserialize)]
313#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
314pub struct ApprovalRequestedEvent {
315    /// Run identifier.
316    pub run_id: Uuid,
317    /// Approval step identifier.
318    pub step_id: Uuid,
319    /// Message displayed to reviewers.
320    pub message: String,
321    /// Approvers the handler required. `None` for a gate opened without
322    /// approvers: one approval resolves it.
323    #[serde(default)]
324    pub requirement: Option<ApprovalRequirement>,
325    /// When the approval was requested.
326    pub at: DateTime<Utc>,
327}
328
329/// Payload of the `Event::ApprovalGranted` event.
330///
331/// Published for every vote cast on a gate. A vote with
332/// `approvals_received < approvals_required` is recorded but does not resolve
333/// the gate: the run stays `AwaitingApproval` until enough distinct approvers
334/// voted.
335///
336/// # Examples
337///
338/// ```
339/// use chrono::Utc;
340/// use ironflow_engine::notify::ApprovalGrantedEvent;
341/// use uuid::Uuid;
342///
343/// let payload = ApprovalGrantedEvent {
344///     run_id: Uuid::now_v7(),
345///     step_id: Some(Uuid::now_v7()),
346///     approved_by: "alice".to_string(),
347///     user_id: Some(Uuid::now_v7()),
348///     approvals_received: 1,
349///     approvals_required: 2,
350///     requirement: None,
351///     at: Utc::now(),
352/// };
353/// assert!(payload.approvals_received < payload.approvals_required);
354/// ```
355#[derive(Debug, Clone, Serialize, Deserialize)]
356#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
357pub struct ApprovalGrantedEvent {
358    /// Run identifier.
359    pub run_id: Uuid,
360    /// Approval step identifier. `None` in events recorded before it existed.
361    #[serde(default)]
362    pub step_id: Option<Uuid>,
363    /// User who approved (ID or username).
364    pub approved_by: String,
365    /// ID of the user who approved. `None` for a system decision (timeout
366    /// escalation) and in events recorded before it existed. `approved_by`
367    /// stays the display label.
368    #[serde(default)]
369    pub user_id: Option<Uuid>,
370    /// Distinct approvals recorded on the gate, this one included.
371    #[serde(default = "default_approval_count")]
372    pub approvals_received: u32,
373    /// Distinct approvals needed to resolve the gate.
374    #[serde(default = "default_approval_count")]
375    pub approvals_required: u32,
376    /// Approvers the handler required, if any.
377    #[serde(default)]
378    pub requirement: Option<ApprovalRequirement>,
379    /// When the approval was granted.
380    pub at: DateTime<Utc>,
381}
382
383/// Payload of the `Event::ApprovalRejected` event.
384///
385/// # Examples
386///
387/// ```
388/// use chrono::Utc;
389/// use ironflow_engine::notify::ApprovalRejectedEvent;
390/// use uuid::Uuid;
391///
392/// let payload = ApprovalRejectedEvent {
393///     run_id: Uuid::now_v7(),
394///     step_id: Some(Uuid::now_v7()),
395///     rejected_by: "bob".to_string(),
396///     user_id: Some(Uuid::now_v7()),
397///     reason: None,
398///     requirement: None,
399///     at: Utc::now(),
400/// };
401/// assert_eq!(payload.rejected_by, "bob");
402/// ```
403#[derive(Debug, Clone, Serialize, Deserialize)]
404#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
405pub struct ApprovalRejectedEvent {
406    /// Run identifier.
407    pub run_id: Uuid,
408    /// Approval step identifier. `None` in events recorded before it existed.
409    #[serde(default)]
410    pub step_id: Option<Uuid>,
411    /// User who rejected (ID or username).
412    pub rejected_by: String,
413    /// ID of the user who rejected. `None` for a system decision (timeout
414    /// escalation) and in events recorded before it existed. `rejected_by`
415    /// stays the display label.
416    #[serde(default)]
417    pub user_id: Option<Uuid>,
418    /// Why the decision was refused, when the rejection carries one (human
419    /// input rejection). `None` for a gate rejection.
420    #[serde(default)]
421    pub reason: Option<String>,
422    /// Approvers the handler required, if any.
423    #[serde(default)]
424    pub requirement: Option<ApprovalRequirement>,
425    /// When the rejection occurred.
426    pub at: DateTime<Utc>,
427}
428
429/// Payload of the `Event::ApprovalEscalated` event.
430///
431/// Emitted every time an approval gate misses its SLA deadline, including the
432/// repeated firings of a bare `Notify`/`Escalate` policy and the final
433/// "chain exhausted" notice. The audit log persists it verbatim, so the whole
434/// escalation history of a gate is reconstructable from it.
435///
436/// # Examples
437///
438/// ```
439/// use chrono::Utc;
440/// use ironflow_engine::notify::ApprovalEscalatedEvent;
441/// use uuid::Uuid;
442///
443/// let payload = ApprovalEscalatedEvent {
444///     run_id: Uuid::now_v7(),
445///     step_id: Uuid::now_v7(),
446///     step_name: "prod-gate".to_string(),
447///     stage: 0,
448///     policy: "auto_reject".to_string(),
449///     action: "rejected".to_string(),
450///     reason: "approval deadline of 3600s expired".to_string(),
451///     assignee: None,
452///     at: Utc::now(),
453/// };
454/// assert_eq!(payload.policy, "auto_reject");
455/// ```
456#[derive(Debug, Clone, Serialize, Deserialize)]
457#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
458pub struct ApprovalEscalatedEvent {
459    /// Run identifier.
460    pub run_id: Uuid,
461    /// Approval step identifier.
462    pub step_id: Uuid,
463    /// Human-readable step name.
464    pub step_name: String,
465    /// Escalation stage that fired (0-based index into the policy chain).
466    pub stage: u32,
467    /// Policy applied, e.g. `"auto_reject"`, `"notify"`, `"escalate"`.
468    pub policy: String,
469    /// What the escalation did, for the audit log.
470    pub action: String,
471    /// Why it fired, e.g. `"approval deadline of 3600s expired"`.
472    pub reason: String,
473    /// Assignee after the escalation, when it reassigned the gate.
474    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>))]
475    pub assignee: Option<Assignee>,
476    /// When the escalation ran.
477    pub at: DateTime<Utc>,
478}
479
480/// Payload of the `Event::LogLine` event.
481///
482/// # Examples
483///
484/// ```
485/// use chrono::Utc;
486/// use ironflow_engine::notify::{LogLineEvent, LogStream};
487/// use uuid::Uuid;
488///
489/// let payload = LogLineEvent {
490///     id: Uuid::now_v7(),
491///     run_id: Uuid::now_v7(),
492///     step_id: Uuid::now_v7(),
493///     step_name: "build".to_string(),
494///     stream: LogStream::Stdout,
495///     line: "Compiling ironflow v0.1.0".to_string(),
496///     at: Utc::now(),
497/// };
498/// assert_eq!(payload.line, "Compiling ironflow v0.1.0");
499/// ```
500#[derive(Debug, Clone, Serialize, Deserialize)]
501#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
502pub struct LogLineEvent {
503    /// Persisted entry identifier (UUID v7, time-ordered).
504    ///
505    /// Matches the `id` of the log entry later stored for this line, so clients
506    /// can de-duplicate the live SSE stream against the persisted history
507    /// fetched from `GET /runs/:id/logs`.
508    ///
509    /// Defaults to the nil UUID when absent, so a payload emitted by an older
510    /// producer still deserializes instead of dropping the whole event.
511    #[serde(default)]
512    pub id: Uuid,
513    /// Run identifier.
514    pub run_id: Uuid,
515    /// Step identifier.
516    pub step_id: Uuid,
517    /// Human-readable step name.
518    pub step_name: String,
519    /// Output stream.
520    pub stream: LogStream,
521    /// The log line content.
522    pub line: String,
523    /// When the line was emitted.
524    pub at: DateTime<Utc>,
525}
526
527/// Payload of the `Event::UserSignedIn` event.
528///
529/// # Examples
530///
531/// ```
532/// use chrono::Utc;
533/// use ironflow_engine::notify::UserSignedInEvent;
534/// use uuid::Uuid;
535///
536/// let payload = UserSignedInEvent {
537///     user_id: Uuid::now_v7(),
538///     username: "alice".to_string(),
539///     at: Utc::now(),
540/// };
541/// assert_eq!(payload.username, "alice");
542/// ```
543#[derive(Debug, Clone, Serialize, Deserialize)]
544#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
545pub struct UserSignedInEvent {
546    /// User identifier.
547    pub user_id: Uuid,
548    /// Username.
549    pub username: String,
550    /// When the sign-in occurred.
551    pub at: DateTime<Utc>,
552}
553
554/// Payload of the `Event::UserSignedUp` event.
555///
556/// # Examples
557///
558/// ```
559/// use chrono::Utc;
560/// use ironflow_engine::notify::UserSignedUpEvent;
561/// use uuid::Uuid;
562///
563/// let payload = UserSignedUpEvent {
564///     user_id: Uuid::now_v7(),
565///     username: "alice".to_string(),
566///     at: Utc::now(),
567/// };
568/// assert_eq!(payload.username, "alice");
569/// ```
570#[derive(Debug, Clone, Serialize, Deserialize)]
571#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
572pub struct UserSignedUpEvent {
573    /// User identifier.
574    pub user_id: Uuid,
575    /// Username.
576    pub username: String,
577    /// When the sign-up occurred.
578    pub at: DateTime<Utc>,
579}
580
581/// Payload of the `Event::UserSignedOut` event.
582///
583/// # Examples
584///
585/// ```
586/// use chrono::Utc;
587/// use ironflow_engine::notify::UserSignedOutEvent;
588/// use uuid::Uuid;
589///
590/// let user_id = Uuid::now_v7();
591/// let payload = UserSignedOutEvent {
592///     user_id,
593///     at: Utc::now(),
594/// };
595/// assert_eq!(payload.user_id, user_id);
596/// ```
597#[derive(Debug, Clone, Serialize, Deserialize)]
598#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
599pub struct UserSignedOutEvent {
600    /// User identifier.
601    pub user_id: Uuid,
602    /// When the sign-out occurred.
603    pub at: DateTime<Utc>,
604}
605
606/// What happened to a Provider Account in a
607/// [`ProviderAccountUpdatedEvent`].
608///
609/// # Examples
610///
611/// ```
612/// use ironflow_engine::notify::ProviderAccountChange;
613///
614/// assert_eq!(ProviderAccountChange::TokenReplaced.as_str(), "token_replaced");
615/// ```
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
617#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
618#[serde(rename_all = "snake_case")]
619pub enum ProviderAccountChange {
620    /// The account was created.
621    Created,
622    /// Settings of the account changed.
623    Updated,
624    /// The account was deleted.
625    Deleted,
626    /// The credential was replaced.
627    TokenReplaced,
628}
629
630impl ProviderAccountChange {
631    /// Wire name of the change, as stored in audit payloads.
632    pub fn as_str(&self) -> &'static str {
633        match self {
634            Self::Created => "created",
635            Self::Updated => "updated",
636            Self::Deleted => "deleted",
637            Self::TokenReplaced => "token_replaced",
638        }
639    }
640}
641
642/// Payload of the `Event::ProviderAccountUpdated` event.
643///
644/// Never carries the credential.
645///
646/// # Examples
647///
648/// ```
649/// use chrono::Utc;
650/// use ironflow_engine::notify::{ProviderAccountChange, ProviderAccountUpdatedEvent};
651/// use uuid::Uuid;
652///
653/// let payload = ProviderAccountUpdatedEvent {
654///     account_id: Uuid::now_v7(),
655///     name: "perso".to_string(),
656///     change: ProviderAccountChange::Created,
657///     at: Utc::now(),
658/// };
659/// assert_eq!(payload.name, "perso");
660/// ```
661#[derive(Debug, Clone, Serialize, Deserialize)]
662#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
663pub struct ProviderAccountUpdatedEvent {
664    /// Account identifier.
665    pub account_id: Uuid,
666    /// Account name.
667    pub name: String,
668    /// What changed.
669    pub change: ProviderAccountChange,
670    /// When the change happened.
671    pub at: DateTime<Utc>,
672}
673
674/// Payload of the `Event::ProviderAccountUsageUpdated` event.
675///
676/// # Examples
677///
678/// ```
679/// use chrono::Utc;
680/// use ironflow_engine::notify::ProviderAccountUsageUpdatedEvent;
681/// use uuid::Uuid;
682///
683/// let payload = ProviderAccountUsageUpdatedEvent {
684///     account_id: Uuid::now_v7(),
685///     name: "perso".to_string(),
686///     windows: Vec::new(),
687///     at: Utc::now(),
688/// };
689/// assert!(payload.windows.is_empty());
690/// ```
691#[derive(Debug, Clone, Serialize, Deserialize)]
692#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
693pub struct ProviderAccountUsageUpdatedEvent {
694    /// Account identifier.
695    pub account_id: Uuid,
696    /// Account name.
697    pub name: String,
698    /// Current windows of the account.
699    pub windows: Vec<ProviderAccountWindow>,
700    /// When the windows were recorded.
701    pub at: DateTime<Utc>,
702}
703
704/// Payload of the `Event::SignalAwaited` event.
705///
706/// Emitted when a run suspends on `ctx.wait_for_signal` and goes `Sleeping`.
707///
708/// # Examples
709///
710/// ```
711/// use chrono::{TimeDelta, Utc};
712/// use ironflow_engine::notify::SignalAwaitedEvent;
713/// use uuid::Uuid;
714///
715/// let now = Utc::now();
716/// let payload = SignalAwaitedEvent {
717///     run_id: Uuid::now_v7(),
718///     step_id: Uuid::now_v7(),
719///     step_name: "wait-ci".to_string(),
720///     name: "ci.pipeline_finished".to_string(),
721///     key: "4f2a9c1".to_string(),
722///     deadline_at: now + TimeDelta::hours(1),
723///     at: now,
724/// };
725/// assert_eq!(payload.key, "4f2a9c1");
726/// ```
727#[derive(Debug, Clone, Serialize, Deserialize)]
728#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
729pub struct SignalAwaitedEvent {
730    /// Run identifier.
731    pub run_id: Uuid,
732    /// The signal step the run waits on.
733    pub step_id: Uuid,
734    /// Name of the signal step.
735    pub step_name: String,
736    /// Awaited signal name.
737    pub name: String,
738    /// Awaited occurrence key.
739    pub key: String,
740    /// When the wait times out.
741    pub deadline_at: DateTime<Utc>,
742    /// When the run started waiting.
743    pub at: DateTime<Utc>,
744}
745
746/// Payload of the `Event::SignalReceived` event.
747///
748/// Emitted once per stored signal; a duplicate (same idempotency ID) emits
749/// nothing.
750///
751/// # Examples
752///
753/// ```
754/// use chrono::Utc;
755/// use ironflow_engine::notify::SignalReceivedEvent;
756/// use uuid::Uuid;
757///
758/// let payload = SignalReceivedEvent {
759///     signal_id: Uuid::now_v7(),
760///     name: "ci.pipeline_finished".to_string(),
761///     key: "4f2a9c1".to_string(),
762///     resumed_runs: vec![Uuid::now_v7()],
763///     at: Utc::now(),
764/// };
765/// assert_eq!(payload.resumed_runs.len(), 1);
766/// ```
767#[derive(Debug, Clone, Serialize, Deserialize)]
768#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
769pub struct SignalReceivedEvent {
770    /// Stored signal identifier.
771    pub signal_id: Uuid,
772    /// Signal name.
773    pub name: String,
774    /// Occurrence key.
775    pub key: String,
776    /// Runs whose waiting step the signal resolved.
777    pub resumed_runs: Vec<Uuid>,
778    /// When the signal was received.
779    pub at: DateTime<Utc>,
780}
781
782/// Payload of the `Event::ScheduleOccurrencesMissed` event.
783///
784/// A cron schedule skipped some of its occurrences: the server was down
785/// longer than the catch-up window allows, the catch-up policy dropped them,
786/// or the previous run was still active under an overlap `skip` policy.
787///
788/// # Examples
789///
790/// ```
791/// use chrono::{TimeDelta, Utc};
792/// use ironflow_engine::notify::ScheduleOccurrencesMissedEvent;
793/// use ironflow_store::models::ScheduleMissReason;
794/// use uuid::Uuid;
795///
796/// let now = Utc::now();
797/// let payload = ScheduleOccurrencesMissedEvent {
798///     schedule_id: Uuid::now_v7(),
799///     workflow_name: "nightly-report".to_string(),
800///     reason: ScheduleMissReason::OutsideWindow,
801///     count: 3,
802///     first: now - TimeDelta::hours(3),
803///     last: now - TimeDelta::hours(1),
804///     at: now,
805/// };
806/// assert_eq!(payload.count, 3);
807/// ```
808#[derive(Debug, Clone, Serialize, Deserialize)]
809#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
810pub struct ScheduleOccurrencesMissedEvent {
811    /// Schedule that missed the occurrences.
812    pub schedule_id: Uuid,
813    /// Workflow the schedule starts.
814    pub workflow_name: String,
815    /// Why the occurrences were not run.
816    pub reason: ScheduleMissReason,
817    /// Number of missed occurrences.
818    pub count: u64,
819    /// Earliest missed occurrence.
820    pub first: DateTime<Utc>,
821    /// Latest missed occurrence.
822    pub last: DateTime<Utc>,
823    /// When the ticker noticed the miss.
824    pub at: DateTime<Utc>,
825}
826
827/// Payload of the `Event::WorkflowPaused` event.
828///
829/// Emitted on every pause request, including the pause of a workflow that
830/// was already paused: the request is a decision of its actor.
831///
832/// # Examples
833///
834/// ```
835/// use chrono::Utc;
836/// use ironflow_engine::notify::WorkflowPausedEvent;
837/// use uuid::Uuid;
838///
839/// let payload = WorkflowPausedEvent {
840///     workflow_name: "deploy".to_string(),
841///     user_id: Some(Uuid::now_v7()),
842///     at: Utc::now(),
843/// };
844/// assert_eq!(payload.workflow_name, "deploy");
845/// ```
846#[derive(Debug, Clone, Serialize, Deserialize)]
847#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
848pub struct WorkflowPausedEvent {
849    /// Paused workflow.
850    pub workflow_name: String,
851    /// ID of the user who paused the workflow. `None` when no user is behind
852    /// the call.
853    #[serde(default)]
854    pub user_id: Option<Uuid>,
855    /// When the workflow was paused.
856    pub at: DateTime<Utc>,
857}
858
859/// Payload of the `Event::WorkflowResumed` event.
860///
861/// Emitted only when the workflow was paused: resuming a workflow that is
862/// not paused changes nothing and emits nothing.
863///
864/// # Examples
865///
866/// ```
867/// use chrono::Utc;
868/// use ironflow_engine::notify::WorkflowResumedEvent;
869/// use uuid::Uuid;
870///
871/// let payload = WorkflowResumedEvent {
872///     workflow_name: "deploy".to_string(),
873///     user_id: Some(Uuid::now_v7()),
874///     at: Utc::now(),
875/// };
876/// assert_eq!(payload.workflow_name, "deploy");
877/// ```
878#[derive(Debug, Clone, Serialize, Deserialize)]
879#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
880pub struct WorkflowResumedEvent {
881    /// Resumed workflow.
882    pub workflow_name: String,
883    /// ID of the user who resumed the workflow. `None` when no user is behind
884    /// the call.
885    #[serde(default)]
886    pub user_id: Option<Uuid>,
887    /// When the workflow was resumed.
888    pub at: DateTime<Utc>,
889}
890
891/// A domain event emitted by the ironflow system.
892///
893/// Covers the full lifecycle: runs, steps, approvals, and authentication.
894/// Subscribers receive these via [`EventPublisher`](super::EventPublisher)
895/// and pattern-match on the variants they care about.
896///
897/// Each variant wraps a dedicated payload struct. The serialized form stays
898/// flat: the `type` discriminant sits next to the payload fields, so
899/// `{"type":"run_created","run_id":...}` round-trips unchanged.
900///
901/// # Examples
902///
903/// ```
904/// use std::collections::HashMap;
905/// use ironflow_engine::notify::{Event, RunStatusChangedEvent};
906/// use ironflow_store::models::RunStatus;
907/// use uuid::Uuid;
908///
909/// let event = Event::RunStatusChanged(RunStatusChangedEvent {
910///     run_id: Uuid::now_v7(),
911///     workflow_name: "deploy".to_string(),
912///     from: RunStatus::Running,
913///     to: RunStatus::Completed,
914///     error: None,
915///     cost_usd: rust_decimal::Decimal::ZERO,
916///     duration_ms: 5000,
917///     labels: HashMap::new(),
918///     at: chrono::Utc::now(),
919/// });
920/// assert_eq!(event.event_type(), "run_status_changed");
921/// ```
922#[derive(Debug, Clone, Serialize, Deserialize)]
923#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
924#[serde(tag = "type", rename_all = "snake_case")]
925pub enum Event {
926    // -- Run lifecycle --
927    /// A new run was created (status: Pending).
928    RunCreated(RunCreatedEvent),
929
930    /// A run changed status.
931    RunStatusChanged(RunStatusChangedEvent),
932
933    /// A run transitioned to [`Failed`](ironflow_store::models::RunStatus::Failed).
934    ///
935    /// This is a convenience event emitted alongside [`RunStatusChanged`](Event::RunStatusChanged)
936    /// when the target status is `Failed`. Subscribe to this instead of
937    /// `RUN_STATUS_CHANGED` when you only care about failures.
938    RunFailed(RunFailedEvent),
939
940    /// A run was stopped because it reached its cumulative cost cap.
941    ///
942    /// Emitted when the engine refuses an agent step that would cross the run's
943    /// `max_cost_usd`. The run transitions to
944    /// [`Cancelled`](ironflow_store::models::RunStatus::Cancelled) and the step
945    /// is never launched, so the reported spend is what the run had already
946    /// consumed.
947    RunBudgetExceeded(RunBudgetExceededEvent),
948
949    /// A manual retry was forced despite a handler version mismatch.
950    ///
951    /// Emitted when a caller passes `force=true` on a retry where the
952    /// handler version differs from the run's recorded version. This is
953    /// an audit event: it means the new code will execute on the old
954    /// payload without the handler explicitly declaring compatibility.
955    RetryForced(RetryForcedEvent),
956
957    // -- Step lifecycle --
958    /// A step completed successfully.
959    StepCompleted(StepCompletedEvent),
960
961    /// A step failed.
962    StepFailed(StepFailedEvent),
963
964    // -- Approval --
965    /// A run is waiting for human approval.
966    ApprovalRequested(ApprovalRequestedEvent),
967
968    /// A run was approved by a human.
969    ApprovalGranted(ApprovalGrantedEvent),
970
971    /// A run was rejected by a human.
972    ApprovalRejected(ApprovalRejectedEvent),
973
974    /// An approval gate missed its SLA deadline and an escalation policy ran.
975    ApprovalEscalated(ApprovalEscalatedEvent),
976
977    // -- Log streaming --
978    /// A log line emitted during step execution.
979    ///
980    /// Pushed by the worker in real time so that SSE clients can stream
981    /// step output as it happens, without waiting for step completion.
982    LogLine(LogLineEvent),
983
984    // -- Authentication --
985    /// A user signed in.
986    UserSignedIn(UserSignedInEvent),
987
988    /// A new user signed up.
989    UserSignedUp(UserSignedUpEvent),
990
991    /// A user signed out.
992    UserSignedOut(UserSignedOutEvent),
993
994    // -- Provider Accounts --
995    /// A Provider Account was created, updated, deleted or had its token replaced.
996    #[serde(rename = "provider_account.updated")]
997    ProviderAccountUpdated(ProviderAccountUpdatedEvent),
998
999    /// New usage windows were recorded for a Provider Account.
1000    #[serde(rename = "provider_account.usage_updated")]
1001    ProviderAccountUsageUpdated(ProviderAccountUsageUpdatedEvent),
1002
1003    // -- Signals --
1004    /// A run started waiting for a signal.
1005    SignalAwaited(SignalAwaitedEvent),
1006
1007    /// A signal was received.
1008    SignalReceived(SignalReceivedEvent),
1009
1010    // -- Schedules --
1011    /// A cron schedule dropped occurrences instead of starting a run for them.
1012    ///
1013    /// Emitted by the schedule ticker once per schedule, tick and reason.
1014    ScheduleOccurrencesMissed(ScheduleOccurrencesMissedEvent),
1015
1016    // -- Workflows --
1017    /// A workflow was paused: its new runs are held until it is resumed.
1018    WorkflowPaused(WorkflowPausedEvent),
1019
1020    /// A paused workflow was resumed.
1021    WorkflowResumed(WorkflowResumedEvent),
1022}
1023
1024impl Event {
1025    /// Event type constant for [`RunCreated`](Event::RunCreated).
1026    pub const RUN_CREATED: &'static str = "run_created";
1027    /// Event type constant for [`RunStatusChanged`](Event::RunStatusChanged).
1028    pub const RUN_STATUS_CHANGED: &'static str = "run_status_changed";
1029    /// Event type constant for [`RunFailed`](Event::RunFailed).
1030    pub const RUN_FAILED: &'static str = "run_failed";
1031    /// Event type constant for [`RunBudgetExceeded`](Event::RunBudgetExceeded).
1032    pub const RUN_BUDGET_EXCEEDED: &'static str = "run_budget_exceeded";
1033    /// Event type constant for [`RetryForced`](Event::RetryForced).
1034    pub const RETRY_FORCED: &'static str = "retry_forced";
1035    /// Event type constant for [`StepCompleted`](Event::StepCompleted).
1036    pub const STEP_COMPLETED: &'static str = "step_completed";
1037    /// Event type constant for [`StepFailed`](Event::StepFailed).
1038    pub const STEP_FAILED: &'static str = "step_failed";
1039    /// Event type constant for [`ApprovalRequested`](Event::ApprovalRequested).
1040    pub const APPROVAL_REQUESTED: &'static str = "approval_requested";
1041    /// Event type constant for [`ApprovalGranted`](Event::ApprovalGranted).
1042    pub const APPROVAL_GRANTED: &'static str = "approval_granted";
1043    /// Event type constant for [`ApprovalRejected`](Event::ApprovalRejected).
1044    pub const APPROVAL_REJECTED: &'static str = "approval_rejected";
1045    /// Event type constant for [`ApprovalEscalated`](Event::ApprovalEscalated).
1046    pub const APPROVAL_ESCALATED: &'static str = "approval_escalated";
1047    /// Event type constant for [`LogLine`](Event::LogLine).
1048    pub const LOG_LINE: &'static str = "log_line";
1049    /// Event type constant for [`UserSignedIn`](Event::UserSignedIn).
1050    pub const USER_SIGNED_IN: &'static str = "user_signed_in";
1051    /// Event type constant for [`UserSignedUp`](Event::UserSignedUp).
1052    pub const USER_SIGNED_UP: &'static str = "user_signed_up";
1053    /// Event type constant for [`UserSignedOut`](Event::UserSignedOut).
1054    pub const USER_SIGNED_OUT: &'static str = "user_signed_out";
1055    /// Event type constant for [`ProviderAccountUpdated`](Event::ProviderAccountUpdated).
1056    pub const PROVIDER_ACCOUNT_UPDATED: &'static str = "provider_account.updated";
1057    /// Event type constant for
1058    /// [`ProviderAccountUsageUpdated`](Event::ProviderAccountUsageUpdated).
1059    pub const PROVIDER_ACCOUNT_USAGE_UPDATED: &'static str = "provider_account.usage_updated";
1060    /// Event type constant for [`SignalAwaited`](Event::SignalAwaited).
1061    pub const SIGNAL_AWAITED: &'static str = "signal_awaited";
1062    /// Event type constant for [`SignalReceived`](Event::SignalReceived).
1063    pub const SIGNAL_RECEIVED: &'static str = "signal_received";
1064    /// Event type constant for
1065    /// [`ScheduleOccurrencesMissed`](Event::ScheduleOccurrencesMissed).
1066    pub const SCHEDULE_OCCURRENCES_MISSED: &'static str = "schedule_occurrences_missed";
1067    /// Event type constant for [`WorkflowPaused`](Event::WorkflowPaused).
1068    pub const WORKFLOW_PAUSED: &'static str = "workflow_paused";
1069    /// Event type constant for [`WorkflowResumed`](Event::WorkflowResumed).
1070    pub const WORKFLOW_RESUMED: &'static str = "workflow_resumed";
1071
1072    /// All event types. Pass this to
1073    /// [`EventPublisher::subscribe`](super::EventPublisher::subscribe) to
1074    /// receive every event.
1075    ///
1076    /// # Examples
1077    ///
1078    /// ```no_run
1079    /// use ironflow_engine::notify::{Event, EventPublisher, WebhookSubscriber};
1080    ///
1081    /// let mut publisher = EventPublisher::new();
1082    /// publisher.subscribe(
1083    ///     WebhookSubscriber::new("https://example.com/all"),
1084    ///     Event::ALL,
1085    /// );
1086    /// ```
1087    pub const ALL: &'static [&'static str] = &[
1088        Self::RUN_CREATED,
1089        Self::RUN_STATUS_CHANGED,
1090        Self::RUN_FAILED,
1091        Self::RUN_BUDGET_EXCEEDED,
1092        Self::STEP_COMPLETED,
1093        Self::STEP_FAILED,
1094        Self::APPROVAL_REQUESTED,
1095        Self::APPROVAL_GRANTED,
1096        Self::APPROVAL_REJECTED,
1097        Self::APPROVAL_ESCALATED,
1098        Self::LOG_LINE,
1099        Self::USER_SIGNED_IN,
1100        Self::USER_SIGNED_UP,
1101        Self::USER_SIGNED_OUT,
1102        Self::RETRY_FORCED,
1103        Self::PROVIDER_ACCOUNT_UPDATED,
1104        Self::PROVIDER_ACCOUNT_USAGE_UPDATED,
1105        Self::SIGNAL_AWAITED,
1106        Self::SIGNAL_RECEIVED,
1107        Self::SCHEDULE_OCCURRENCES_MISSED,
1108        Self::WORKFLOW_PAUSED,
1109        Self::WORKFLOW_RESUMED,
1110    ];
1111
1112    /// Returns the event type as a static string (e.g. `"run_status_changed"`).
1113    ///
1114    /// Useful for filtering and logging without deserializing.
1115    ///
1116    /// # Examples
1117    ///
1118    /// ```
1119    /// use ironflow_engine::notify::{Event, UserSignedInEvent};
1120    /// use uuid::Uuid;
1121    /// use chrono::Utc;
1122    ///
1123    /// let event = Event::UserSignedIn(UserSignedInEvent {
1124    ///     user_id: Uuid::now_v7(),
1125    ///     username: "alice".to_string(),
1126    ///     at: Utc::now(),
1127    /// });
1128    /// assert_eq!(event.event_type(), "user_signed_in");
1129    /// ```
1130    #[deny(unreachable_patterns)]
1131    pub fn event_type(&self) -> &'static str {
1132        match self {
1133            Event::RunCreated(_) => Self::RUN_CREATED,
1134            Event::RunStatusChanged(_) => Self::RUN_STATUS_CHANGED,
1135            Event::RunFailed(_) => Self::RUN_FAILED,
1136            Event::RunBudgetExceeded(_) => Self::RUN_BUDGET_EXCEEDED,
1137            Event::RetryForced(_) => Self::RETRY_FORCED,
1138            Event::StepCompleted(_) => Self::STEP_COMPLETED,
1139            Event::StepFailed(_) => Self::STEP_FAILED,
1140            Event::ApprovalRequested(_) => Self::APPROVAL_REQUESTED,
1141            Event::ApprovalGranted(_) => Self::APPROVAL_GRANTED,
1142            Event::ApprovalRejected(_) => Self::APPROVAL_REJECTED,
1143            Event::ApprovalEscalated(_) => Self::APPROVAL_ESCALATED,
1144            Event::LogLine(_) => Self::LOG_LINE,
1145            Event::UserSignedIn(_) => Self::USER_SIGNED_IN,
1146            Event::UserSignedUp(_) => Self::USER_SIGNED_UP,
1147            Event::UserSignedOut(_) => Self::USER_SIGNED_OUT,
1148            Event::ProviderAccountUpdated(_) => Self::PROVIDER_ACCOUNT_UPDATED,
1149            Event::ProviderAccountUsageUpdated(_) => Self::PROVIDER_ACCOUNT_USAGE_UPDATED,
1150            Event::SignalAwaited(_) => Self::SIGNAL_AWAITED,
1151            Event::SignalReceived(_) => Self::SIGNAL_RECEIVED,
1152            Event::ScheduleOccurrencesMissed(_) => Self::SCHEDULE_OCCURRENCES_MISSED,
1153            Event::WorkflowPaused(_) => Self::WORKFLOW_PAUSED,
1154            Event::WorkflowResumed(_) => Self::WORKFLOW_RESUMED,
1155        }
1156    }
1157
1158    /// Returns the run this event belongs to, if any.
1159    ///
1160    /// Auth events ([`UserSignedIn`](Event::UserSignedIn),
1161    /// [`UserSignedUp`](Event::UserSignedUp),
1162    /// [`UserSignedOut`](Event::UserSignedOut)) are not tied to a run and
1163    /// return `None`.
1164    ///
1165    /// # Examples
1166    ///
1167    /// ```
1168    /// use ironflow_engine::notify::{Event, RunCreatedEvent};
1169    /// use uuid::Uuid;
1170    /// use chrono::Utc;
1171    ///
1172    /// let run_id = Uuid::now_v7();
1173    /// let event = Event::RunCreated(RunCreatedEvent {
1174    ///     run_id,
1175    ///     workflow_name: "deploy".to_string(),
1176    ///     at: Utc::now(),
1177    /// });
1178    /// assert_eq!(event.run_id(), Some(run_id));
1179    /// ```
1180    #[deny(unreachable_patterns)]
1181    pub fn run_id(&self) -> Option<Uuid> {
1182        match self {
1183            Event::RunCreated(e) => Some(e.run_id),
1184            Event::RunStatusChanged(e) => Some(e.run_id),
1185            Event::RunFailed(e) => Some(e.run_id),
1186            Event::RunBudgetExceeded(e) => Some(e.run_id),
1187            Event::RetryForced(e) => Some(e.run_id),
1188            Event::StepCompleted(e) => Some(e.run_id),
1189            Event::StepFailed(e) => Some(e.run_id),
1190            Event::ApprovalRequested(e) => Some(e.run_id),
1191            Event::ApprovalGranted(e) => Some(e.run_id),
1192            Event::ApprovalRejected(e) => Some(e.run_id),
1193            Event::ApprovalEscalated(e) => Some(e.run_id),
1194            Event::LogLine(e) => Some(e.run_id),
1195            Event::SignalAwaited(e) => Some(e.run_id),
1196            Event::UserSignedIn(_)
1197            | Event::UserSignedUp(_)
1198            | Event::UserSignedOut(_)
1199            | Event::ProviderAccountUpdated(_)
1200            | Event::ProviderAccountUsageUpdated(_)
1201            | Event::SignalReceived(_)
1202            | Event::ScheduleOccurrencesMissed(_)
1203            | Event::WorkflowPaused(_)
1204            | Event::WorkflowResumed(_) => None,
1205        }
1206    }
1207
1208    /// Returns the step this event belongs to, if any.
1209    ///
1210    /// Only [`StepCompleted`](Event::StepCompleted),
1211    /// [`StepFailed`](Event::StepFailed),
1212    /// [`ApprovalRequested`](Event::ApprovalRequested) and
1213    /// [`ApprovalEscalated`](Event::ApprovalEscalated) always carry a step
1214    /// identifier; [`ApprovalGranted`](Event::ApprovalGranted) and
1215    /// [`ApprovalRejected`](Event::ApprovalRejected) carry one when it was
1216    /// recorded; every other variant returns `None`.
1217    ///
1218    /// # Examples
1219    ///
1220    /// ```
1221    /// use ironflow_engine::notify::{Event, StepFailedEvent};
1222    /// use ironflow_store::models::StepKind;
1223    /// use uuid::Uuid;
1224    /// use chrono::Utc;
1225    ///
1226    /// let step_id = Uuid::now_v7();
1227    /// let event = Event::StepFailed(StepFailedEvent {
1228    ///     run_id: Uuid::now_v7(),
1229    ///     step_id,
1230    ///     step_name: "build".to_string(),
1231    ///     kind: StepKind::Shell,
1232    ///     error: "exit code 1".to_string(),
1233    ///     at: Utc::now(),
1234    /// });
1235    /// assert_eq!(event.step_id(), Some(step_id));
1236    /// ```
1237    #[deny(unreachable_patterns)]
1238    pub fn step_id(&self) -> Option<Uuid> {
1239        match self {
1240            Event::StepCompleted(e) => Some(e.step_id),
1241            Event::StepFailed(e) => Some(e.step_id),
1242            Event::ApprovalRequested(e) => Some(e.step_id),
1243            Event::ApprovalEscalated(e) => Some(e.step_id),
1244            Event::ApprovalGranted(e) => e.step_id,
1245            Event::ApprovalRejected(e) => e.step_id,
1246            Event::SignalAwaited(e) => Some(e.step_id),
1247            Event::RunCreated(_)
1248            | Event::RunStatusChanged(_)
1249            | Event::RunFailed(_)
1250            | Event::RunBudgetExceeded(_)
1251            | Event::RetryForced(_)
1252            | Event::LogLine(_)
1253            | Event::UserSignedIn(_)
1254            | Event::UserSignedUp(_)
1255            | Event::UserSignedOut(_)
1256            | Event::ProviderAccountUpdated(_)
1257            | Event::ProviderAccountUsageUpdated(_)
1258            | Event::SignalReceived(_)
1259            | Event::ScheduleOccurrencesMissed(_)
1260            | Event::WorkflowPaused(_)
1261            | Event::WorkflowResumed(_) => None,
1262        }
1263    }
1264
1265    /// Returns the user this event belongs to, if any.
1266    ///
1267    /// Only the auth events ([`UserSignedIn`](Event::UserSignedIn),
1268    /// [`UserSignedUp`](Event::UserSignedUp),
1269    /// [`UserSignedOut`](Event::UserSignedOut)) and the decisions and pauses
1270    /// ([`ApprovalGranted`](Event::ApprovalGranted),
1271    /// [`ApprovalRejected`](Event::ApprovalRejected),
1272    /// [`WorkflowPaused`](Event::WorkflowPaused),
1273    /// [`WorkflowResumed`](Event::WorkflowResumed)) carry a user identifier.
1274    /// A decision without a human actor (timeout escalation) returns `None`,
1275    /// like every other variant.
1276    ///
1277    /// # Examples
1278    ///
1279    /// ```
1280    /// use ironflow_engine::notify::{Event, UserSignedInEvent};
1281    /// use uuid::Uuid;
1282    /// use chrono::Utc;
1283    ///
1284    /// let user_id = Uuid::now_v7();
1285    /// let event = Event::UserSignedIn(UserSignedInEvent {
1286    ///     user_id,
1287    ///     username: "alice".to_string(),
1288    ///     at: Utc::now(),
1289    /// });
1290    /// assert_eq!(event.user_id(), Some(user_id));
1291    /// ```
1292    #[deny(unreachable_patterns)]
1293    pub fn user_id(&self) -> Option<Uuid> {
1294        match self {
1295            Event::UserSignedIn(e) => Some(e.user_id),
1296            Event::UserSignedUp(e) => Some(e.user_id),
1297            Event::UserSignedOut(e) => Some(e.user_id),
1298            Event::ApprovalGranted(e) => e.user_id,
1299            Event::ApprovalRejected(e) => e.user_id,
1300            Event::WorkflowPaused(e) => e.user_id,
1301            Event::WorkflowResumed(e) => e.user_id,
1302            Event::RunCreated(_)
1303            | Event::RunStatusChanged(_)
1304            | Event::RunFailed(_)
1305            | Event::RunBudgetExceeded(_)
1306            | Event::RetryForced(_)
1307            | Event::StepCompleted(_)
1308            | Event::StepFailed(_)
1309            | Event::ApprovalRequested(_)
1310            | Event::ApprovalEscalated(_)
1311            | Event::LogLine(_)
1312            | Event::ProviderAccountUpdated(_)
1313            | Event::ProviderAccountUsageUpdated(_)
1314            | Event::SignalAwaited(_)
1315            | Event::SignalReceived(_)
1316            | Event::ScheduleOccurrencesMissed(_) => None,
1317        }
1318    }
1319}
1320
1321#[cfg(test)]
1322mod tests {
1323    use super::*;
1324
1325    #[test]
1326    fn run_status_changed_serde_roundtrip() {
1327        let event = Event::RunStatusChanged(RunStatusChangedEvent {
1328            run_id: Uuid::now_v7(),
1329            workflow_name: "deploy".to_string(),
1330            from: RunStatus::Running,
1331            to: RunStatus::Completed,
1332            error: None,
1333            cost_usd: Decimal::new(42, 2),
1334            duration_ms: 5000,
1335            labels: HashMap::new(),
1336            at: Utc::now(),
1337        });
1338
1339        let json = serde_json::to_string(&event).expect("serialize");
1340        let back: Event = serde_json::from_str(&json).expect("deserialize");
1341
1342        assert_eq!(back.event_type(), "run_status_changed");
1343        assert!(json.contains("\"type\":\"run_status_changed\""));
1344    }
1345
1346    #[test]
1347    fn run_failed_serde_roundtrip() {
1348        let event = Event::RunFailed(RunFailedEvent {
1349            run_id: Uuid::now_v7(),
1350            workflow_name: "deploy".to_string(),
1351            error: Some("step crashed".to_string()),
1352            cost_usd: Decimal::new(10, 2),
1353            duration_ms: 3000,
1354            labels: HashMap::new(),
1355            at: Utc::now(),
1356        });
1357
1358        let json = serde_json::to_string(&event).expect("serialize");
1359        let back: Event = serde_json::from_str(&json).expect("deserialize");
1360
1361        assert_eq!(back.event_type(), "run_failed");
1362        assert!(json.contains("\"type\":\"run_failed\""));
1363        assert!(json.contains("step crashed"));
1364    }
1365
1366    #[test]
1367    fn run_budget_exceeded_serde_roundtrip() {
1368        let event = Event::RunBudgetExceeded(RunBudgetExceededEvent {
1369            run_id: Uuid::now_v7(),
1370            workflow_name: "deploy".to_string(),
1371            limit_usd: Decimal::new(200, 2),
1372            spent_usd: Decimal::new(180, 2),
1373            step_budget_usd: Decimal::new(50, 2),
1374            at: Utc::now(),
1375        });
1376
1377        let json = serde_json::to_string(&event).expect("serialize");
1378        let back: Event = serde_json::from_str(&json).expect("deserialize");
1379
1380        assert_eq!(back.event_type(), "run_budget_exceeded");
1381        assert!(json.contains("\"type\":\"run_budget_exceeded\""));
1382        assert!(json.contains("limit_usd"));
1383        assert!(json.contains("step_budget_usd"));
1384    }
1385
1386    #[test]
1387    fn all_contains_run_budget_exceeded() {
1388        assert!(Event::ALL.contains(&Event::RUN_BUDGET_EXCEEDED));
1389    }
1390
1391    #[test]
1392    fn user_signed_in_serde_roundtrip() {
1393        let event = Event::UserSignedIn(UserSignedInEvent {
1394            user_id: Uuid::now_v7(),
1395            username: "alice".to_string(),
1396            at: Utc::now(),
1397        });
1398
1399        let json = serde_json::to_string(&event).expect("serialize");
1400        let back: Event = serde_json::from_str(&json).expect("deserialize");
1401
1402        assert_eq!(back.event_type(), "user_signed_in");
1403        assert!(json.contains("alice"));
1404    }
1405
1406    #[test]
1407    fn step_failed_serde_roundtrip() {
1408        let event = Event::StepFailed(StepFailedEvent {
1409            run_id: Uuid::now_v7(),
1410            step_id: Uuid::now_v7(),
1411            step_name: "build".to_string(),
1412            kind: StepKind::Shell,
1413            error: "exit code 1".to_string(),
1414            at: Utc::now(),
1415        });
1416
1417        let json = serde_json::to_string(&event).expect("serialize");
1418        let back: Event = serde_json::from_str(&json).expect("deserialize");
1419
1420        assert_eq!(back.event_type(), "step_failed");
1421    }
1422
1423    #[test]
1424    fn legacy_approval_granted_defaults_to_a_single_vote() {
1425        let raw = r#"{"type":"approval_granted","run_id":"01890000-0000-7000-8000-000000000000","approved_by":"alice","at":"2026-01-01T00:00:00Z"}"#;
1426        let event: Event = serde_json::from_str(raw).expect("deserialize");
1427        let Event::ApprovalGranted(event) = event else {
1428            panic!("expected approval_granted");
1429        };
1430
1431        assert_eq!(event.step_id, None);
1432        assert_eq!(event.approvals_received, 1);
1433        assert_eq!(event.approvals_required, 1);
1434        assert!(event.requirement.is_none());
1435    }
1436
1437    #[test]
1438    fn legacy_approval_requested_and_rejected_have_no_requirement() {
1439        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"}"#;
1440        let requested: Event = serde_json::from_str(raw).expect("deserialize");
1441        let Event::ApprovalRequested(requested) = requested else {
1442            panic!("expected approval_requested");
1443        };
1444        assert!(requested.requirement.is_none());
1445
1446        let raw = r#"{"type":"approval_rejected","run_id":"01890000-0000-7000-8000-000000000000","rejected_by":"bob","at":"2026-01-01T00:00:00Z"}"#;
1447        let rejected: Event = serde_json::from_str(raw).expect("deserialize");
1448        let Event::ApprovalRejected(rejected) = rejected else {
1449            panic!("expected approval_rejected");
1450        };
1451        assert_eq!(rejected.step_id, None);
1452        assert!(rejected.requirement.is_none());
1453    }
1454
1455    #[test]
1456    fn approval_granted_roundtrips_the_vote_counts() {
1457        let requirement = ApprovalRequirement {
1458            reason: Some("amount > 10k".to_string()),
1459            required_approvers: 2,
1460            approver_groups: vec!["finance".to_string()],
1461        };
1462        let event = Event::ApprovalGranted(ApprovalGrantedEvent {
1463            run_id: Uuid::now_v7(),
1464            step_id: Some(Uuid::now_v7()),
1465            approved_by: "alice".to_string(),
1466            user_id: None,
1467            approvals_received: 1,
1468            approvals_required: 2,
1469            requirement: Some(requirement.clone()),
1470            at: Utc::now(),
1471        });
1472
1473        let json = serde_json::to_string(&event).expect("serialize");
1474        let back: Event = serde_json::from_str(&json).expect("deserialize");
1475        let Event::ApprovalGranted(back) = back else {
1476            panic!("expected approval_granted");
1477        };
1478        assert_eq!(back.approvals_received, 1);
1479        assert_eq!(back.approvals_required, 2);
1480        assert_eq!(back.requirement, Some(requirement));
1481    }
1482
1483    #[test]
1484    fn approval_requested_serde_roundtrip() {
1485        let event = Event::ApprovalRequested(ApprovalRequestedEvent {
1486            run_id: Uuid::now_v7(),
1487            step_id: Uuid::now_v7(),
1488            message: "Deploy to prod?".to_string(),
1489            requirement: None,
1490            at: Utc::now(),
1491        });
1492
1493        let json = serde_json::to_string(&event).expect("serialize");
1494        assert!(json.contains("approval_requested"));
1495    }
1496
1497    #[test]
1498    fn log_line_serde_roundtrip() {
1499        let event = Event::LogLine(LogLineEvent {
1500            id: Uuid::now_v7(),
1501            run_id: Uuid::now_v7(),
1502            step_id: Uuid::now_v7(),
1503            step_name: "build".to_string(),
1504            stream: LogStream::Stdout,
1505            line: "Compiling ironflow v0.1.0".to_string(),
1506            at: Utc::now(),
1507        });
1508
1509        let json = serde_json::to_string(&event).expect("serialize");
1510        let back: Event = serde_json::from_str(&json).expect("deserialize");
1511
1512        assert_eq!(back.event_type(), "log_line");
1513        assert!(json.contains("\"type\":\"log_line\""));
1514        assert!(json.contains("Compiling ironflow"));
1515    }
1516
1517    /// The pre-refactor wire format used flat inline-struct variants. Newtype
1518    /// variants over named-field payloads produce and accept the exact same
1519    /// JSON, so audit rows and in-flight payloads written before the refactor
1520    /// still deserialize. No data migration is required.
1521    #[test]
1522    fn legacy_flat_json_deserializes_into_typed_payload() {
1523        let run_id: Uuid = "01890000-0000-7000-8000-000000000000"
1524            .parse()
1525            .expect("valid uuid");
1526
1527        let raw = r#"{"type":"run_created","run_id":"01890000-0000-7000-8000-000000000000","workflow_name":"deploy","at":"2026-01-01T00:00:00Z"}"#;
1528        let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1529        match event {
1530            Event::RunCreated(e) => {
1531                assert_eq!(e.run_id, run_id);
1532                assert_eq!(e.workflow_name, "deploy");
1533            }
1534            other => panic!("expected RunCreated, got {other:?}"),
1535        }
1536
1537        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"}"#;
1538        let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1539        match event {
1540            Event::RunStatusChanged(e) => {
1541                assert_eq!(e.from, RunStatus::Running);
1542                assert_eq!(e.to, RunStatus::Completed);
1543                assert_eq!(e.cost_usd, Decimal::new(5, 1));
1544                assert_eq!(e.duration_ms, 5000);
1545                assert_eq!(e.labels.get("env").map(String::as_str), Some("prod"));
1546            }
1547            other => panic!("expected RunStatusChanged, got {other:?}"),
1548        }
1549
1550        // `labels` predates no payload: omitting it must still work via `#[serde(default)]`.
1551        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"}"#;
1552        let event: Event = serde_json::from_str(raw).expect("missing labels must default");
1553        match event {
1554            Event::RunStatusChanged(e) => {
1555                assert!(e.labels.is_empty());
1556                assert_eq!(e.error.as_deref(), Some("boom"));
1557            }
1558            other => panic!("expected RunStatusChanged, got {other:?}"),
1559        }
1560
1561        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"}"#;
1562        let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1563        match event {
1564            Event::RunFailed(e) => {
1565                assert_eq!(e.error.as_deref(), Some("boom"));
1566                assert!(e.labels.is_empty());
1567            }
1568            other => panic!("expected RunFailed, got {other:?}"),
1569        }
1570
1571        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"}"#;
1572        let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1573        match event {
1574            Event::StepFailed(e) => {
1575                assert_eq!(e.kind, StepKind::Shell);
1576                assert_eq!(e.error, "exit code 1");
1577            }
1578            other => panic!("expected StepFailed, got {other:?}"),
1579        }
1580
1581        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"}"#;
1582        let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1583        match event {
1584            Event::LogLine(e) => {
1585                assert_eq!(e.stream, LogStream::Stdout);
1586                assert_eq!(e.line, "hello");
1587                // A payload predating the `id` field degrades to the nil UUID
1588                // rather than failing to deserialize.
1589                assert_eq!(e.id, Uuid::nil());
1590            }
1591            other => panic!("expected LogLine, got {other:?}"),
1592        }
1593
1594        let raw = r#"{"type":"user_signed_in","user_id":"01890000-0000-7000-8000-000000000000","username":"alice","at":"2026-01-01T00:00:00Z"}"#;
1595        let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1596        match event {
1597            Event::UserSignedIn(e) => assert_eq!(e.username, "alice"),
1598            other => panic!("expected UserSignedIn, got {other:?}"),
1599        }
1600    }
1601
1602    /// Guards the internally-tagged representation: payload fields must stay
1603    /// siblings of `type`, never nested under a variant key.
1604    #[test]
1605    fn serialized_event_is_flat_with_type_tag() {
1606        let run_id = Uuid::now_v7();
1607        let event = Event::RunCreated(RunCreatedEvent {
1608            run_id,
1609            workflow_name: "deploy".to_string(),
1610            at: Utc::now(),
1611        });
1612
1613        let value: serde_json::Value = serde_json::to_value(&event).expect("serialize");
1614        let object = value.as_object().expect("event serializes to an object");
1615
1616        assert_eq!(
1617            object.get("type").and_then(|v| v.as_str()),
1618            Some("run_created")
1619        );
1620        assert_eq!(
1621            object.get("workflow_name").and_then(|v| v.as_str()),
1622            Some("deploy")
1623        );
1624        assert_eq!(
1625            object.get("run_id").and_then(|v| v.as_str()),
1626            Some(run_id.to_string().as_str())
1627        );
1628        assert!(object.contains_key("at"));
1629        assert_eq!(object.len(), 4, "no nesting: {object:?}");
1630        assert!(!object.contains_key("RunCreated"));
1631    }
1632
1633    #[test]
1634    fn run_id_returns_some_for_run_events() {
1635        let run_id = Uuid::now_v7();
1636        let now = Utc::now();
1637
1638        let events = vec![
1639            Event::RunCreated(RunCreatedEvent {
1640                run_id,
1641                workflow_name: "w".to_string(),
1642                at: now,
1643            }),
1644            Event::RunStatusChanged(RunStatusChangedEvent {
1645                run_id,
1646                workflow_name: "w".to_string(),
1647                from: RunStatus::Pending,
1648                to: RunStatus::Running,
1649                error: None,
1650                cost_usd: Decimal::ZERO,
1651                duration_ms: 0,
1652                labels: HashMap::new(),
1653                at: now,
1654            }),
1655            Event::RunFailed(RunFailedEvent {
1656                run_id,
1657                workflow_name: "w".to_string(),
1658                error: None,
1659                cost_usd: Decimal::ZERO,
1660                duration_ms: 0,
1661                labels: HashMap::new(),
1662                at: now,
1663            }),
1664            Event::RunBudgetExceeded(RunBudgetExceededEvent {
1665                run_id,
1666                workflow_name: "w".to_string(),
1667                limit_usd: Decimal::ZERO,
1668                spent_usd: Decimal::ZERO,
1669                step_budget_usd: Decimal::ZERO,
1670                at: now,
1671            }),
1672            Event::RetryForced(RetryForcedEvent {
1673                run_id,
1674                workflow_name: "w".to_string(),
1675                original_version: "1".to_string(),
1676                current_version: "2".to_string(),
1677                at: now,
1678            }),
1679            Event::StepCompleted(StepCompletedEvent {
1680                run_id,
1681                step_id: Uuid::now_v7(),
1682                step_name: "s".to_string(),
1683                kind: StepKind::Shell,
1684                duration_ms: 0,
1685                cost_usd: Decimal::ZERO,
1686                at: now,
1687            }),
1688            Event::StepFailed(StepFailedEvent {
1689                run_id,
1690                step_id: Uuid::now_v7(),
1691                step_name: "s".to_string(),
1692                kind: StepKind::Shell,
1693                error: "e".to_string(),
1694                at: now,
1695            }),
1696            Event::ApprovalRequested(ApprovalRequestedEvent {
1697                run_id,
1698                step_id: Uuid::now_v7(),
1699                message: "ok?".to_string(),
1700                requirement: None,
1701                at: now,
1702            }),
1703            Event::ApprovalGranted(ApprovalGrantedEvent {
1704                run_id,
1705                step_id: None,
1706                approved_by: "alice".to_string(),
1707                user_id: None,
1708                approvals_received: 1,
1709                approvals_required: 1,
1710                requirement: None,
1711                at: now,
1712            }),
1713            Event::ApprovalRejected(ApprovalRejectedEvent {
1714                run_id,
1715                step_id: None,
1716                rejected_by: "bob".to_string(),
1717                user_id: None,
1718                reason: None,
1719                requirement: None,
1720                at: now,
1721            }),
1722            Event::LogLine(LogLineEvent {
1723                id: Uuid::now_v7(),
1724                run_id,
1725                step_id: Uuid::now_v7(),
1726                step_name: "s".to_string(),
1727                stream: LogStream::Stdout,
1728                line: "l".to_string(),
1729                at: now,
1730            }),
1731        ];
1732
1733        for event in &events {
1734            assert_eq!(
1735                event.run_id(),
1736                Some(run_id),
1737                "{} should carry a run_id",
1738                event.event_type()
1739            );
1740        }
1741    }
1742
1743    #[test]
1744    fn run_id_returns_none_for_auth_events() {
1745        let user_id = Uuid::now_v7();
1746        let now = Utc::now();
1747
1748        let events = vec![
1749            Event::UserSignedIn(UserSignedInEvent {
1750                user_id,
1751                username: "alice".to_string(),
1752                at: now,
1753            }),
1754            Event::UserSignedUp(UserSignedUpEvent {
1755                user_id,
1756                username: "alice".to_string(),
1757                at: now,
1758            }),
1759            Event::UserSignedOut(UserSignedOutEvent { user_id, at: now }),
1760        ];
1761
1762        for event in &events {
1763            assert_eq!(event.run_id(), None, "{} has no run", event.event_type());
1764        }
1765    }
1766
1767    #[test]
1768    fn step_id_returns_some_only_for_step_events() {
1769        let step_id = Uuid::now_v7();
1770        let run_id = Uuid::now_v7();
1771        let now = Utc::now();
1772
1773        let with_step = vec![
1774            Event::StepCompleted(StepCompletedEvent {
1775                run_id,
1776                step_id,
1777                step_name: "s".to_string(),
1778                kind: StepKind::Shell,
1779                duration_ms: 0,
1780                cost_usd: Decimal::ZERO,
1781                at: now,
1782            }),
1783            Event::StepFailed(StepFailedEvent {
1784                run_id,
1785                step_id,
1786                step_name: "s".to_string(),
1787                kind: StepKind::Shell,
1788                error: "e".to_string(),
1789                at: now,
1790            }),
1791            Event::ApprovalRequested(ApprovalRequestedEvent {
1792                run_id,
1793                step_id,
1794                message: "ok?".to_string(),
1795                requirement: None,
1796                at: now,
1797            }),
1798            Event::ApprovalGranted(ApprovalGrantedEvent {
1799                run_id,
1800                step_id: Some(step_id),
1801                approved_by: "alice".to_string(),
1802                user_id: None,
1803                approvals_received: 1,
1804                approvals_required: 2,
1805                requirement: None,
1806                at: now,
1807            }),
1808            Event::ApprovalRejected(ApprovalRejectedEvent {
1809                run_id,
1810                step_id: Some(step_id),
1811                rejected_by: "bob".to_string(),
1812                user_id: None,
1813                reason: None,
1814                requirement: None,
1815                at: now,
1816            }),
1817        ];
1818
1819        for event in &with_step {
1820            assert_eq!(
1821                event.step_id(),
1822                Some(step_id),
1823                "{} should carry a step_id",
1824                event.event_type()
1825            );
1826        }
1827
1828        let without_step = vec![
1829            Event::RunCreated(RunCreatedEvent {
1830                run_id,
1831                workflow_name: "w".to_string(),
1832                at: now,
1833            }),
1834            // A granted event recorded before step ids were carried.
1835            Event::ApprovalGranted(ApprovalGrantedEvent {
1836                run_id,
1837                step_id: None,
1838                approved_by: "alice".to_string(),
1839                user_id: None,
1840                approvals_received: 1,
1841                approvals_required: 1,
1842                requirement: None,
1843                at: now,
1844            }),
1845            // LogLine carries a step_id field but is reported as a run-level
1846            // stream event, matching the pre-refactor behaviour.
1847            Event::LogLine(LogLineEvent {
1848                id: Uuid::now_v7(),
1849                run_id,
1850                step_id,
1851                step_name: "s".to_string(),
1852                stream: LogStream::Stdout,
1853                line: "l".to_string(),
1854                at: now,
1855            }),
1856            Event::UserSignedOut(UserSignedOutEvent {
1857                user_id: Uuid::now_v7(),
1858                at: now,
1859            }),
1860        ];
1861
1862        for event in &without_step {
1863            assert_eq!(
1864                event.step_id(),
1865                None,
1866                "{} should not carry a step_id",
1867                event.event_type()
1868            );
1869        }
1870    }
1871
1872    #[test]
1873    fn user_id_returns_the_actor_of_auth_events_decisions_and_pauses() {
1874        let user_id = Uuid::now_v7();
1875        let run_id = Uuid::now_v7();
1876        let now = Utc::now();
1877
1878        let auth = vec![
1879            Event::UserSignedIn(UserSignedInEvent {
1880                user_id,
1881                username: "alice".to_string(),
1882                at: now,
1883            }),
1884            Event::UserSignedUp(UserSignedUpEvent {
1885                user_id,
1886                username: "alice".to_string(),
1887                at: now,
1888            }),
1889            Event::UserSignedOut(UserSignedOutEvent { user_id, at: now }),
1890            Event::ApprovalGranted(ApprovalGrantedEvent {
1891                run_id,
1892                step_id: None,
1893                approved_by: "alice".to_string(),
1894                user_id: Some(user_id),
1895                approvals_received: 1,
1896                approvals_required: 1,
1897                requirement: None,
1898                at: now,
1899            }),
1900            Event::ApprovalRejected(ApprovalRejectedEvent {
1901                run_id,
1902                step_id: None,
1903                rejected_by: "bob".to_string(),
1904                user_id: Some(user_id),
1905                reason: None,
1906                requirement: None,
1907                at: now,
1908            }),
1909            Event::WorkflowPaused(WorkflowPausedEvent {
1910                workflow_name: "w".to_string(),
1911                user_id: Some(user_id),
1912                at: now,
1913            }),
1914            Event::WorkflowResumed(WorkflowResumedEvent {
1915                workflow_name: "w".to_string(),
1916                user_id: Some(user_id),
1917                at: now,
1918            }),
1919        ];
1920
1921        for event in &auth {
1922            assert_eq!(
1923                event.user_id(),
1924                Some(user_id),
1925                "{} should carry a user_id",
1926                event.event_type()
1927            );
1928        }
1929
1930        let non_auth = vec![
1931            Event::RunCreated(RunCreatedEvent {
1932                run_id,
1933                workflow_name: "w".to_string(),
1934                at: now,
1935            }),
1936            Event::StepFailed(StepFailedEvent {
1937                run_id,
1938                step_id: Uuid::now_v7(),
1939                step_name: "s".to_string(),
1940                kind: StepKind::Shell,
1941                error: "e".to_string(),
1942                at: now,
1943            }),
1944            // A system decision (timeout escalation) has no human actor.
1945            Event::ApprovalRejected(ApprovalRejectedEvent {
1946                run_id,
1947                step_id: None,
1948                rejected_by: "system:timeout".to_string(),
1949                user_id: None,
1950                reason: None,
1951                requirement: None,
1952                at: now,
1953            }),
1954            Event::WorkflowPaused(WorkflowPausedEvent {
1955                workflow_name: "w".to_string(),
1956                user_id: None,
1957                at: now,
1958            }),
1959        ];
1960
1961        for event in &non_auth {
1962            assert_eq!(
1963                event.user_id(),
1964                None,
1965                "{} should not carry a user_id",
1966                event.event_type()
1967            );
1968        }
1969    }
1970
1971    #[test]
1972    fn event_type_all_variants() {
1973        let id = Uuid::now_v7();
1974        let now = Utc::now();
1975
1976        let cases: Vec<(Event, &str)> = vec![
1977            (
1978                Event::RunCreated(RunCreatedEvent {
1979                    run_id: id,
1980                    workflow_name: "w".to_string(),
1981                    at: now,
1982                }),
1983                "run_created",
1984            ),
1985            (
1986                Event::RunStatusChanged(RunStatusChangedEvent {
1987                    run_id: id,
1988                    workflow_name: "w".to_string(),
1989                    from: RunStatus::Pending,
1990                    to: RunStatus::Running,
1991                    error: None,
1992                    cost_usd: Decimal::ZERO,
1993                    duration_ms: 0,
1994                    labels: HashMap::new(),
1995                    at: now,
1996                }),
1997                "run_status_changed",
1998            ),
1999            (
2000                Event::RunFailed(RunFailedEvent {
2001                    run_id: id,
2002                    workflow_name: "w".to_string(),
2003                    error: Some("boom".to_string()),
2004                    cost_usd: Decimal::ZERO,
2005                    duration_ms: 0,
2006                    labels: HashMap::new(),
2007                    at: now,
2008                }),
2009                "run_failed",
2010            ),
2011            (
2012                Event::RunBudgetExceeded(RunBudgetExceededEvent {
2013                    run_id: id,
2014                    workflow_name: "w".to_string(),
2015                    limit_usd: Decimal::new(200, 2),
2016                    spent_usd: Decimal::new(180, 2),
2017                    step_budget_usd: Decimal::new(50, 2),
2018                    at: now,
2019                }),
2020                "run_budget_exceeded",
2021            ),
2022            (
2023                Event::RetryForced(RetryForcedEvent {
2024                    run_id: id,
2025                    workflow_name: "w".to_string(),
2026                    original_version: "1".to_string(),
2027                    current_version: "2".to_string(),
2028                    at: now,
2029                }),
2030                "retry_forced",
2031            ),
2032            (
2033                Event::StepCompleted(StepCompletedEvent {
2034                    run_id: id,
2035                    step_id: id,
2036                    step_name: "s".to_string(),
2037                    kind: StepKind::Shell,
2038                    duration_ms: 0,
2039                    cost_usd: Decimal::ZERO,
2040                    at: now,
2041                }),
2042                "step_completed",
2043            ),
2044            (
2045                Event::StepFailed(StepFailedEvent {
2046                    run_id: id,
2047                    step_id: id,
2048                    step_name: "s".to_string(),
2049                    kind: StepKind::Shell,
2050                    error: "err".to_string(),
2051                    at: now,
2052                }),
2053                "step_failed",
2054            ),
2055            (
2056                Event::ApprovalRequested(ApprovalRequestedEvent {
2057                    run_id: id,
2058                    step_id: id,
2059                    message: "ok?".to_string(),
2060                    requirement: None,
2061                    at: now,
2062                }),
2063                "approval_requested",
2064            ),
2065            (
2066                Event::ApprovalGranted(ApprovalGrantedEvent {
2067                    run_id: id,
2068                    step_id: Some(id),
2069                    approved_by: "alice".to_string(),
2070                    user_id: None,
2071                    approvals_received: 1,
2072                    approvals_required: 1,
2073                    requirement: None,
2074                    at: now,
2075                }),
2076                "approval_granted",
2077            ),
2078            (
2079                Event::ApprovalRejected(ApprovalRejectedEvent {
2080                    run_id: id,
2081                    step_id: Some(id),
2082                    rejected_by: "bob".to_string(),
2083                    user_id: None,
2084                    reason: None,
2085                    requirement: None,
2086                    at: now,
2087                }),
2088                "approval_rejected",
2089            ),
2090            (
2091                Event::ApprovalEscalated(ApprovalEscalatedEvent {
2092                    run_id: id,
2093                    step_id: id,
2094                    step_name: "prod-gate".to_string(),
2095                    stage: 0,
2096                    policy: "auto_reject".to_string(),
2097                    action: "rejected".to_string(),
2098                    reason: "approval deadline of 3600s expired".to_string(),
2099                    assignee: None,
2100                    at: now,
2101                }),
2102                "approval_escalated",
2103            ),
2104            (
2105                Event::LogLine(LogLineEvent {
2106                    id,
2107                    run_id: id,
2108                    step_id: id,
2109                    step_name: "build".to_string(),
2110                    stream: LogStream::Stdout,
2111                    line: "Compiling ironflow v0.1.0".to_string(),
2112                    at: now,
2113                }),
2114                "log_line",
2115            ),
2116            (
2117                Event::UserSignedIn(UserSignedInEvent {
2118                    user_id: id,
2119                    username: "u".to_string(),
2120                    at: now,
2121                }),
2122                "user_signed_in",
2123            ),
2124            (
2125                Event::UserSignedUp(UserSignedUpEvent {
2126                    user_id: id,
2127                    username: "u".to_string(),
2128                    at: now,
2129                }),
2130                "user_signed_up",
2131            ),
2132            (
2133                Event::UserSignedOut(UserSignedOutEvent {
2134                    user_id: id,
2135                    at: now,
2136                }),
2137                "user_signed_out",
2138            ),
2139            (
2140                Event::ProviderAccountUpdated(ProviderAccountUpdatedEvent {
2141                    account_id: id,
2142                    name: "perso".to_string(),
2143                    change: ProviderAccountChange::TokenReplaced,
2144                    at: now,
2145                }),
2146                "provider_account.updated",
2147            ),
2148            (
2149                Event::ProviderAccountUsageUpdated(ProviderAccountUsageUpdatedEvent {
2150                    account_id: id,
2151                    name: "perso".to_string(),
2152                    windows: Vec::new(),
2153                    at: now,
2154                }),
2155                "provider_account.usage_updated",
2156            ),
2157            (
2158                Event::SignalAwaited(SignalAwaitedEvent {
2159                    run_id: id,
2160                    step_id: id,
2161                    step_name: "wait-ci".to_string(),
2162                    name: "ci.done".to_string(),
2163                    key: "abc".to_string(),
2164                    deadline_at: now,
2165                    at: now,
2166                }),
2167                "signal_awaited",
2168            ),
2169            (
2170                Event::SignalReceived(SignalReceivedEvent {
2171                    signal_id: id,
2172                    name: "ci.done".to_string(),
2173                    key: "abc".to_string(),
2174                    resumed_runs: vec![id],
2175                    at: now,
2176                }),
2177                "signal_received",
2178            ),
2179            (
2180                Event::ScheduleOccurrencesMissed(ScheduleOccurrencesMissedEvent {
2181                    schedule_id: id,
2182                    workflow_name: "nightly".to_string(),
2183                    reason: ScheduleMissReason::Overlap,
2184                    count: 1,
2185                    first: now,
2186                    last: now,
2187                    at: now,
2188                }),
2189                "schedule_occurrences_missed",
2190            ),
2191            (
2192                Event::WorkflowPaused(WorkflowPausedEvent {
2193                    workflow_name: "w".to_string(),
2194                    user_id: Some(id),
2195                    at: now,
2196                }),
2197                "workflow_paused",
2198            ),
2199            (
2200                Event::WorkflowResumed(WorkflowResumedEvent {
2201                    workflow_name: "w".to_string(),
2202                    user_id: Some(id),
2203                    at: now,
2204                }),
2205                "workflow_resumed",
2206            ),
2207        ];
2208
2209        assert_eq!(
2210            cases.len(),
2211            Event::ALL.len(),
2212            "every variant must be covered"
2213        );
2214
2215        for (event, expected_type) in cases {
2216            assert_eq!(event.event_type(), expected_type);
2217        }
2218    }
2219
2220    #[test]
2221    fn approval_escalated_serde_roundtrip() {
2222        let run_id = Uuid::now_v7();
2223        let step_id = Uuid::now_v7();
2224        let event = Event::ApprovalEscalated(ApprovalEscalatedEvent {
2225            run_id,
2226            step_id,
2227            step_name: "prod-gate".to_string(),
2228            stage: 1,
2229            policy: "escalate".to_string(),
2230            action: "reassigned to sre-oncall".to_string(),
2231            reason: "approval deadline of 3600s expired".to_string(),
2232            assignee: Some(Assignee::group("sre-oncall")),
2233            at: Utc::now(),
2234        });
2235
2236        let json = serde_json::to_string(&event).expect("serialize");
2237        assert!(
2238            json.contains("\"type\":\"approval_escalated\""),
2239            "got {json}"
2240        );
2241
2242        let back: Event = serde_json::from_str(&json).expect("deserialize");
2243        let Event::ApprovalEscalated(payload) = back else {
2244            panic!("expected an approval_escalated event");
2245        };
2246        assert_eq!(payload.run_id, run_id);
2247        assert_eq!(payload.stage, 1);
2248        assert_eq!(payload.assignee, Some(Assignee::group("sre-oncall")));
2249    }
2250
2251    #[test]
2252    fn approval_escalated_carries_run_and_step_ids() {
2253        let run_id = Uuid::now_v7();
2254        let step_id = Uuid::now_v7();
2255        let event = Event::ApprovalEscalated(ApprovalEscalatedEvent {
2256            run_id,
2257            step_id,
2258            step_name: "prod-gate".to_string(),
2259            stage: 0,
2260            policy: "notify".to_string(),
2261            action: "notified 1 target".to_string(),
2262            reason: "approval deadline of 60s expired".to_string(),
2263            assignee: None,
2264            at: Utc::now(),
2265        });
2266
2267        assert_eq!(event.run_id(), Some(run_id));
2268        assert_eq!(event.step_id(), Some(step_id));
2269        assert_eq!(event.user_id(), None);
2270    }
2271
2272    #[test]
2273    fn signal_awaited_serde_roundtrip() {
2274        let run_id = Uuid::now_v7();
2275        let step_id = Uuid::now_v7();
2276        let event = Event::SignalAwaited(SignalAwaitedEvent {
2277            run_id,
2278            step_id,
2279            step_name: "wait-ci".to_string(),
2280            name: "ci.done".to_string(),
2281            key: "abc".to_string(),
2282            deadline_at: Utc::now(),
2283            at: Utc::now(),
2284        });
2285
2286        let json = serde_json::to_string(&event).expect("serialize");
2287        assert!(json.contains("\"type\":\"signal_awaited\""), "got {json}");
2288        let back: Event = serde_json::from_str(&json).expect("deserialize");
2289        assert_eq!(back.event_type(), Event::SIGNAL_AWAITED);
2290        assert_eq!(back.run_id(), Some(run_id));
2291        assert_eq!(back.step_id(), Some(step_id));
2292        assert_eq!(back.user_id(), None);
2293    }
2294
2295    #[test]
2296    fn signal_received_serde_roundtrip() {
2297        let resumed = Uuid::now_v7();
2298        let event = Event::SignalReceived(SignalReceivedEvent {
2299            signal_id: Uuid::now_v7(),
2300            name: "ci.done".to_string(),
2301            key: "abc".to_string(),
2302            resumed_runs: vec![resumed],
2303            at: Utc::now(),
2304        });
2305
2306        let json = serde_json::to_string(&event).expect("serialize");
2307        assert!(json.contains("\"type\":\"signal_received\""), "got {json}");
2308        let back: Event = serde_json::from_str(&json).expect("deserialize");
2309        let Event::SignalReceived(payload) = &back else {
2310            panic!("expected a signal_received event, got {back:?}");
2311        };
2312        assert_eq!(payload.resumed_runs, vec![resumed]);
2313        assert_eq!(back.run_id(), None);
2314        assert_eq!(back.step_id(), None);
2315        assert_eq!(back.user_id(), None);
2316    }
2317
2318    #[test]
2319    fn schedule_occurrences_missed_serde_roundtrip() {
2320        let schedule_id = Uuid::now_v7();
2321        let first = Utc::now();
2322        let event = Event::ScheduleOccurrencesMissed(ScheduleOccurrencesMissedEvent {
2323            schedule_id,
2324            workflow_name: "nightly".to_string(),
2325            reason: ScheduleMissReason::OutsideWindow,
2326            count: 4,
2327            first,
2328            last: first,
2329            at: first,
2330        });
2331
2332        let json = serde_json::to_string(&event).expect("serialize");
2333        assert!(
2334            json.contains("\"type\":\"schedule_occurrences_missed\""),
2335            "got {json}"
2336        );
2337        assert!(json.contains("\"reason\":\"outside_window\""), "got {json}");
2338        let back: Event = serde_json::from_str(&json).expect("deserialize");
2339        let Event::ScheduleOccurrencesMissed(payload) = &back else {
2340            panic!("expected a schedule_occurrences_missed event, got {back:?}");
2341        };
2342        assert_eq!(payload.schedule_id, schedule_id);
2343        assert_eq!(payload.reason, ScheduleMissReason::OutsideWindow);
2344        assert_eq!(payload.count, 4);
2345        assert_eq!(payload.first, first);
2346        assert!(Event::ALL.contains(&Event::SCHEDULE_OCCURRENCES_MISSED));
2347        assert_eq!(back.run_id(), None);
2348        assert_eq!(back.step_id(), None);
2349        assert_eq!(back.user_id(), None);
2350    }
2351
2352    #[test]
2353    fn legacy_approval_decisions_have_no_user_id_nor_reason() {
2354        let raw = r#"{"type":"approval_granted","run_id":"01890000-0000-7000-8000-000000000000","approved_by":"alice","at":"2026-01-01T00:00:00Z"}"#;
2355        let granted: Event = serde_json::from_str(raw).expect("deserialize");
2356        assert_eq!(granted.user_id(), None);
2357
2358        let raw = r#"{"type":"approval_rejected","run_id":"01890000-0000-7000-8000-000000000000","rejected_by":"bob","at":"2026-01-01T00:00:00Z"}"#;
2359        let rejected: Event = serde_json::from_str(raw).expect("deserialize");
2360        let Event::ApprovalRejected(rejected) = rejected else {
2361            panic!("expected approval_rejected");
2362        };
2363        assert_eq!(rejected.user_id, None);
2364        assert_eq!(rejected.reason, None);
2365    }
2366
2367    #[test]
2368    fn approval_rejected_roundtrips_the_actor_and_reason() {
2369        let user_id = Uuid::now_v7();
2370        let event = Event::ApprovalRejected(ApprovalRejectedEvent {
2371            run_id: Uuid::now_v7(),
2372            step_id: Some(Uuid::now_v7()),
2373            rejected_by: "bob".to_string(),
2374            user_id: Some(user_id),
2375            reason: Some("wrong environment".to_string()),
2376            requirement: None,
2377            at: Utc::now(),
2378        });
2379
2380        let json = serde_json::to_string(&event).expect("serialize");
2381        let back: Event = serde_json::from_str(&json).expect("deserialize");
2382        assert_eq!(back.user_id(), Some(user_id));
2383        let Event::ApprovalRejected(back) = back else {
2384            panic!("expected approval_rejected");
2385        };
2386        assert_eq!(back.reason.as_deref(), Some("wrong environment"));
2387    }
2388
2389    #[test]
2390    fn workflow_paused_and_resumed_serde_roundtrip() {
2391        let user_id = Uuid::now_v7();
2392        let now = Utc::now();
2393        let events = vec![
2394            Event::WorkflowPaused(WorkflowPausedEvent {
2395                workflow_name: "deploy".to_string(),
2396                user_id: Some(user_id),
2397                at: now,
2398            }),
2399            Event::WorkflowResumed(WorkflowResumedEvent {
2400                workflow_name: "deploy".to_string(),
2401                user_id: Some(user_id),
2402                at: now,
2403            }),
2404        ];
2405
2406        for event in events {
2407            let json = serde_json::to_string(&event).expect("serialize");
2408            assert!(
2409                json.contains(&format!("\"type\":\"{}\"", event.event_type())),
2410                "got {json}"
2411            );
2412            assert!(json.contains("\"workflow_name\":\"deploy\""), "got {json}");
2413            let back: Event = serde_json::from_str(&json).expect("deserialize");
2414            assert_eq!(back.event_type(), event.event_type());
2415            assert_eq!(back.user_id(), Some(user_id));
2416            assert_eq!(back.run_id(), None);
2417            assert_eq!(back.step_id(), None);
2418            assert!(Event::ALL.contains(&back.event_type()));
2419        }
2420    }
2421
2422    #[test]
2423    fn workflow_paused_without_user_id_deserializes() {
2424        let raw =
2425            r#"{"type":"workflow_paused","workflow_name":"deploy","at":"2026-01-01T00:00:00Z"}"#;
2426        let event: Event = serde_json::from_str(raw).expect("deserialize");
2427        assert_eq!(event.event_type(), Event::WORKFLOW_PAUSED);
2428        assert_eq!(event.user_id(), None);
2429    }
2430}