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::{RunStatus, StepKind};
12
13/// Payload of the `Event::RunCreated` event.
14///
15/// # Examples
16///
17/// ```
18/// use chrono::Utc;
19/// use ironflow_engine::notify::RunCreatedEvent;
20/// use uuid::Uuid;
21///
22/// let payload = RunCreatedEvent {
23///     run_id: Uuid::now_v7(),
24///     workflow_name: "deploy".to_string(),
25///     at: Utc::now(),
26/// };
27/// assert_eq!(payload.workflow_name, "deploy");
28/// ```
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
31pub struct RunCreatedEvent {
32    /// Run identifier.
33    pub run_id: Uuid,
34    /// Workflow name.
35    pub workflow_name: String,
36    /// When the run was created.
37    pub at: DateTime<Utc>,
38}
39
40/// Payload of the `Event::RunStatusChanged` event.
41///
42/// # Examples
43///
44/// ```
45/// use std::collections::HashMap;
46///
47/// use chrono::Utc;
48/// use ironflow_engine::notify::RunStatusChangedEvent;
49/// use ironflow_store::models::RunStatus;
50/// use rust_decimal::Decimal;
51/// use uuid::Uuid;
52///
53/// let payload = RunStatusChangedEvent {
54///     run_id: Uuid::now_v7(),
55///     workflow_name: "deploy".to_string(),
56///     from: RunStatus::Running,
57///     to: RunStatus::Completed,
58///     error: None,
59///     cost_usd: Decimal::ZERO,
60///     duration_ms: 5000,
61///     labels: HashMap::new(),
62///     at: Utc::now(),
63/// };
64/// assert_eq!(payload.to, RunStatus::Completed);
65/// ```
66#[derive(Debug, Clone, Serialize, Deserialize)]
67#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
68pub struct RunStatusChangedEvent {
69    /// Run identifier.
70    pub run_id: Uuid,
71    /// Workflow name.
72    pub workflow_name: String,
73    /// Previous status.
74    pub from: RunStatus,
75    /// New status.
76    pub to: RunStatus,
77    /// Error message (when transitioning to Failed).
78    pub error: Option<String>,
79    /// Aggregated cost in USD at the time of transition.
80    pub cost_usd: Decimal,
81    /// Aggregated duration in milliseconds at the time of transition.
82    pub duration_ms: u64,
83    /// Labels of the run at the time of the transition.
84    #[serde(default)]
85    pub labels: HashMap<String, String>,
86    /// When the transition occurred.
87    pub at: DateTime<Utc>,
88}
89
90/// Payload of the `Event::RunFailed` event.
91///
92/// # Examples
93///
94/// ```
95/// use std::collections::HashMap;
96///
97/// use chrono::Utc;
98/// use ironflow_engine::notify::RunFailedEvent;
99/// use rust_decimal::Decimal;
100/// use uuid::Uuid;
101///
102/// let payload = RunFailedEvent {
103///     run_id: Uuid::now_v7(),
104///     workflow_name: "deploy".to_string(),
105///     error: Some("step crashed".to_string()),
106///     cost_usd: Decimal::ZERO,
107///     duration_ms: 3000,
108///     labels: HashMap::new(),
109///     at: Utc::now(),
110/// };
111/// assert_eq!(payload.error.as_deref(), Some("step crashed"));
112/// ```
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
115pub struct RunFailedEvent {
116    /// Run identifier.
117    pub run_id: Uuid,
118    /// Workflow name.
119    pub workflow_name: String,
120    /// Error message.
121    pub error: Option<String>,
122    /// Aggregated cost in USD at the time of failure.
123    pub cost_usd: Decimal,
124    /// Aggregated duration in milliseconds at the time of failure.
125    pub duration_ms: u64,
126    /// Labels of the run at the time of the failure.
127    #[serde(default)]
128    pub labels: HashMap<String, String>,
129    /// When the failure occurred.
130    pub at: DateTime<Utc>,
131}
132
133/// Payload of the `Event::RunBudgetExceeded` event.
134///
135/// # Examples
136///
137/// ```
138/// use chrono::Utc;
139/// use ironflow_engine::notify::RunBudgetExceededEvent;
140/// use rust_decimal::Decimal;
141/// use uuid::Uuid;
142///
143/// let payload = RunBudgetExceededEvent {
144///     run_id: Uuid::now_v7(),
145///     workflow_name: "deploy".to_string(),
146///     limit_usd: Decimal::new(200, 2),
147///     spent_usd: Decimal::new(180, 2),
148///     step_budget_usd: Decimal::new(50, 2),
149///     at: Utc::now(),
150/// };
151/// assert_eq!(payload.limit_usd, Decimal::new(200, 2));
152/// ```
153#[derive(Debug, Clone, Serialize, Deserialize)]
154#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
155pub struct RunBudgetExceededEvent {
156    /// Run identifier.
157    pub run_id: Uuid,
158    /// Workflow name.
159    pub workflow_name: String,
160    /// The configured cost cap in USD.
161    pub limit_usd: Decimal,
162    /// Cost already consumed when the cap was reached, in USD.
163    pub spent_usd: Decimal,
164    /// Declared budget of the refused step, in USD.
165    pub step_budget_usd: Decimal,
166    /// When the refusal occurred.
167    pub at: DateTime<Utc>,
168}
169
170/// Payload of the `Event::RetryForced` event.
171///
172/// # Examples
173///
174/// ```
175/// use chrono::Utc;
176/// use ironflow_engine::notify::RetryForcedEvent;
177/// use uuid::Uuid;
178///
179/// let payload = RetryForcedEvent {
180///     run_id: Uuid::now_v7(),
181///     workflow_name: "deploy".to_string(),
182///     original_version: "1".to_string(),
183///     current_version: "2".to_string(),
184///     at: Utc::now(),
185/// };
186/// assert_eq!(payload.current_version, "2");
187/// ```
188#[derive(Debug, Clone, Serialize, Deserialize)]
189#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
190pub struct RetryForcedEvent {
191    /// The new run created by the forced retry.
192    pub run_id: Uuid,
193    /// Workflow name.
194    pub workflow_name: String,
195    /// Version stored on the original run.
196    pub original_version: String,
197    /// Current version of the handler.
198    pub current_version: String,
199    /// When the forced retry occurred.
200    pub at: DateTime<Utc>,
201}
202
203/// Payload of the `Event::StepCompleted` event.
204///
205/// # Examples
206///
207/// ```
208/// use chrono::Utc;
209/// use ironflow_engine::notify::StepCompletedEvent;
210/// use ironflow_store::models::StepKind;
211/// use rust_decimal::Decimal;
212/// use uuid::Uuid;
213///
214/// let payload = StepCompletedEvent {
215///     run_id: Uuid::now_v7(),
216///     step_id: Uuid::now_v7(),
217///     step_name: "build".to_string(),
218///     kind: StepKind::Shell,
219///     duration_ms: 1200,
220///     cost_usd: Decimal::ZERO,
221///     at: Utc::now(),
222/// };
223/// assert_eq!(payload.step_name, "build");
224/// ```
225#[derive(Debug, Clone, Serialize, Deserialize)]
226#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
227pub struct StepCompletedEvent {
228    /// Run identifier.
229    pub run_id: Uuid,
230    /// Step identifier.
231    pub step_id: Uuid,
232    /// Human-readable step name.
233    pub step_name: String,
234    /// Step operation kind.
235    #[cfg_attr(feature = "openapi", schema(value_type = String))]
236    pub kind: StepKind,
237    /// Step duration in milliseconds.
238    pub duration_ms: u64,
239    /// Step cost in USD.
240    pub cost_usd: Decimal,
241    /// When the step completed.
242    pub at: DateTime<Utc>,
243}
244
245/// Payload of the `Event::StepFailed` event.
246///
247/// # Examples
248///
249/// ```
250/// use chrono::Utc;
251/// use ironflow_engine::notify::StepFailedEvent;
252/// use ironflow_store::models::StepKind;
253/// use uuid::Uuid;
254///
255/// let payload = StepFailedEvent {
256///     run_id: Uuid::now_v7(),
257///     step_id: Uuid::now_v7(),
258///     step_name: "build".to_string(),
259///     kind: StepKind::Shell,
260///     error: "exit code 1".to_string(),
261///     at: Utc::now(),
262/// };
263/// assert_eq!(payload.error, "exit code 1");
264/// ```
265#[derive(Debug, Clone, Serialize, Deserialize)]
266#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
267pub struct StepFailedEvent {
268    /// Run identifier.
269    pub run_id: Uuid,
270    /// Step identifier.
271    pub step_id: Uuid,
272    /// Human-readable step name.
273    pub step_name: String,
274    /// Step operation kind.
275    #[cfg_attr(feature = "openapi", schema(value_type = String))]
276    pub kind: StepKind,
277    /// Error message.
278    pub error: String,
279    /// When the step failed.
280    pub at: DateTime<Utc>,
281}
282
283/// Payload of the `Event::ApprovalRequested` event.
284///
285/// # Examples
286///
287/// ```
288/// use chrono::Utc;
289/// use ironflow_engine::notify::ApprovalRequestedEvent;
290/// use uuid::Uuid;
291///
292/// let payload = ApprovalRequestedEvent {
293///     run_id: Uuid::now_v7(),
294///     step_id: Uuid::now_v7(),
295///     message: "Deploy to prod?".to_string(),
296///     at: Utc::now(),
297/// };
298/// assert_eq!(payload.message, "Deploy to prod?");
299/// ```
300#[derive(Debug, Clone, Serialize, Deserialize)]
301#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
302pub struct ApprovalRequestedEvent {
303    /// Run identifier.
304    pub run_id: Uuid,
305    /// Approval step identifier.
306    pub step_id: Uuid,
307    /// Message displayed to reviewers.
308    pub message: String,
309    /// When the approval was requested.
310    pub at: DateTime<Utc>,
311}
312
313/// Payload of the `Event::ApprovalGranted` event.
314///
315/// # Examples
316///
317/// ```
318/// use chrono::Utc;
319/// use ironflow_engine::notify::ApprovalGrantedEvent;
320/// use uuid::Uuid;
321///
322/// let payload = ApprovalGrantedEvent {
323///     run_id: Uuid::now_v7(),
324///     approved_by: "alice".to_string(),
325///     at: Utc::now(),
326/// };
327/// assert_eq!(payload.approved_by, "alice");
328/// ```
329#[derive(Debug, Clone, Serialize, Deserialize)]
330#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
331pub struct ApprovalGrantedEvent {
332    /// Run identifier.
333    pub run_id: Uuid,
334    /// User who approved (ID or username).
335    pub approved_by: String,
336    /// When the approval was granted.
337    pub at: DateTime<Utc>,
338}
339
340/// Payload of the `Event::ApprovalRejected` event.
341///
342/// # Examples
343///
344/// ```
345/// use chrono::Utc;
346/// use ironflow_engine::notify::ApprovalRejectedEvent;
347/// use uuid::Uuid;
348///
349/// let payload = ApprovalRejectedEvent {
350///     run_id: Uuid::now_v7(),
351///     rejected_by: "bob".to_string(),
352///     at: Utc::now(),
353/// };
354/// assert_eq!(payload.rejected_by, "bob");
355/// ```
356#[derive(Debug, Clone, Serialize, Deserialize)]
357#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
358pub struct ApprovalRejectedEvent {
359    /// Run identifier.
360    pub run_id: Uuid,
361    /// User who rejected (ID or username).
362    pub rejected_by: String,
363    /// When the rejection occurred.
364    pub at: DateTime<Utc>,
365}
366
367/// Payload of the `Event::LogLine` event.
368///
369/// # Examples
370///
371/// ```
372/// use chrono::Utc;
373/// use ironflow_engine::notify::{LogLineEvent, LogStream};
374/// use uuid::Uuid;
375///
376/// let payload = LogLineEvent {
377///     run_id: Uuid::now_v7(),
378///     step_id: Uuid::now_v7(),
379///     step_name: "build".to_string(),
380///     stream: LogStream::Stdout,
381///     line: "Compiling ironflow v0.1.0".to_string(),
382///     at: Utc::now(),
383/// };
384/// assert_eq!(payload.line, "Compiling ironflow v0.1.0");
385/// ```
386#[derive(Debug, Clone, Serialize, Deserialize)]
387#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
388pub struct LogLineEvent {
389    /// Run identifier.
390    pub run_id: Uuid,
391    /// Step identifier.
392    pub step_id: Uuid,
393    /// Human-readable step name.
394    pub step_name: String,
395    /// Output stream.
396    pub stream: LogStream,
397    /// The log line content.
398    pub line: String,
399    /// When the line was emitted.
400    pub at: DateTime<Utc>,
401}
402
403/// Payload of the `Event::UserSignedIn` event.
404///
405/// # Examples
406///
407/// ```
408/// use chrono::Utc;
409/// use ironflow_engine::notify::UserSignedInEvent;
410/// use uuid::Uuid;
411///
412/// let payload = UserSignedInEvent {
413///     user_id: Uuid::now_v7(),
414///     username: "alice".to_string(),
415///     at: Utc::now(),
416/// };
417/// assert_eq!(payload.username, "alice");
418/// ```
419#[derive(Debug, Clone, Serialize, Deserialize)]
420#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
421pub struct UserSignedInEvent {
422    /// User identifier.
423    pub user_id: Uuid,
424    /// Username.
425    pub username: String,
426    /// When the sign-in occurred.
427    pub at: DateTime<Utc>,
428}
429
430/// Payload of the `Event::UserSignedUp` event.
431///
432/// # Examples
433///
434/// ```
435/// use chrono::Utc;
436/// use ironflow_engine::notify::UserSignedUpEvent;
437/// use uuid::Uuid;
438///
439/// let payload = UserSignedUpEvent {
440///     user_id: Uuid::now_v7(),
441///     username: "alice".to_string(),
442///     at: Utc::now(),
443/// };
444/// assert_eq!(payload.username, "alice");
445/// ```
446#[derive(Debug, Clone, Serialize, Deserialize)]
447#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
448pub struct UserSignedUpEvent {
449    /// User identifier.
450    pub user_id: Uuid,
451    /// Username.
452    pub username: String,
453    /// When the sign-up occurred.
454    pub at: DateTime<Utc>,
455}
456
457/// Payload of the `Event::UserSignedOut` event.
458///
459/// # Examples
460///
461/// ```
462/// use chrono::Utc;
463/// use ironflow_engine::notify::UserSignedOutEvent;
464/// use uuid::Uuid;
465///
466/// let user_id = Uuid::now_v7();
467/// let payload = UserSignedOutEvent {
468///     user_id,
469///     at: Utc::now(),
470/// };
471/// assert_eq!(payload.user_id, user_id);
472/// ```
473#[derive(Debug, Clone, Serialize, Deserialize)]
474#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
475pub struct UserSignedOutEvent {
476    /// User identifier.
477    pub user_id: Uuid,
478    /// When the sign-out occurred.
479    pub at: DateTime<Utc>,
480}
481
482/// A domain event emitted by the ironflow system.
483///
484/// Covers the full lifecycle: runs, steps, approvals, and authentication.
485/// Subscribers receive these via [`EventPublisher`](super::EventPublisher)
486/// and pattern-match on the variants they care about.
487///
488/// Each variant wraps a dedicated payload struct. The serialized form stays
489/// flat: the `type` discriminant sits next to the payload fields, so
490/// `{"type":"run_created","run_id":...}` round-trips unchanged.
491///
492/// # Examples
493///
494/// ```
495/// use std::collections::HashMap;
496/// use ironflow_engine::notify::{Event, RunStatusChangedEvent};
497/// use ironflow_store::models::RunStatus;
498/// use uuid::Uuid;
499///
500/// let event = Event::RunStatusChanged(RunStatusChangedEvent {
501///     run_id: Uuid::now_v7(),
502///     workflow_name: "deploy".to_string(),
503///     from: RunStatus::Running,
504///     to: RunStatus::Completed,
505///     error: None,
506///     cost_usd: rust_decimal::Decimal::ZERO,
507///     duration_ms: 5000,
508///     labels: HashMap::new(),
509///     at: chrono::Utc::now(),
510/// });
511/// assert_eq!(event.event_type(), "run_status_changed");
512/// ```
513#[derive(Debug, Clone, Serialize, Deserialize)]
514#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
515#[serde(tag = "type", rename_all = "snake_case")]
516pub enum Event {
517    // -- Run lifecycle --
518    /// A new run was created (status: Pending).
519    RunCreated(RunCreatedEvent),
520
521    /// A run changed status.
522    RunStatusChanged(RunStatusChangedEvent),
523
524    /// A run transitioned to [`Failed`](ironflow_store::models::RunStatus::Failed).
525    ///
526    /// This is a convenience event emitted alongside [`RunStatusChanged`](Event::RunStatusChanged)
527    /// when the target status is `Failed`. Subscribe to this instead of
528    /// `RUN_STATUS_CHANGED` when you only care about failures.
529    RunFailed(RunFailedEvent),
530
531    /// A run was stopped because it reached its cumulative cost cap.
532    ///
533    /// Emitted when the engine refuses an agent step that would cross the run's
534    /// `max_cost_usd`. The run transitions to
535    /// [`Cancelled`](ironflow_store::models::RunStatus::Cancelled) and the step
536    /// is never launched, so the reported spend is what the run had already
537    /// consumed.
538    RunBudgetExceeded(RunBudgetExceededEvent),
539
540    /// A manual retry was forced despite a handler version mismatch.
541    ///
542    /// Emitted when a caller passes `force=true` on a retry where the
543    /// handler version differs from the run's recorded version. This is
544    /// an audit event: it means the new code will execute on the old
545    /// payload without the handler explicitly declaring compatibility.
546    RetryForced(RetryForcedEvent),
547
548    // -- Step lifecycle --
549    /// A step completed successfully.
550    StepCompleted(StepCompletedEvent),
551
552    /// A step failed.
553    StepFailed(StepFailedEvent),
554
555    // -- Approval --
556    /// A run is waiting for human approval.
557    ApprovalRequested(ApprovalRequestedEvent),
558
559    /// A run was approved by a human.
560    ApprovalGranted(ApprovalGrantedEvent),
561
562    /// A run was rejected by a human.
563    ApprovalRejected(ApprovalRejectedEvent),
564
565    // -- Log streaming --
566    /// A log line emitted during step execution.
567    ///
568    /// Pushed by the worker in real time so that SSE clients can stream
569    /// step output as it happens, without waiting for step completion.
570    LogLine(LogLineEvent),
571
572    // -- Authentication --
573    /// A user signed in.
574    UserSignedIn(UserSignedInEvent),
575
576    /// A new user signed up.
577    UserSignedUp(UserSignedUpEvent),
578
579    /// A user signed out.
580    UserSignedOut(UserSignedOutEvent),
581}
582
583impl Event {
584    /// Event type constant for [`RunCreated`](Event::RunCreated).
585    pub const RUN_CREATED: &'static str = "run_created";
586    /// Event type constant for [`RunStatusChanged`](Event::RunStatusChanged).
587    pub const RUN_STATUS_CHANGED: &'static str = "run_status_changed";
588    /// Event type constant for [`RunFailed`](Event::RunFailed).
589    pub const RUN_FAILED: &'static str = "run_failed";
590    /// Event type constant for [`RunBudgetExceeded`](Event::RunBudgetExceeded).
591    pub const RUN_BUDGET_EXCEEDED: &'static str = "run_budget_exceeded";
592    /// Event type constant for [`RetryForced`](Event::RetryForced).
593    pub const RETRY_FORCED: &'static str = "retry_forced";
594    /// Event type constant for [`StepCompleted`](Event::StepCompleted).
595    pub const STEP_COMPLETED: &'static str = "step_completed";
596    /// Event type constant for [`StepFailed`](Event::StepFailed).
597    pub const STEP_FAILED: &'static str = "step_failed";
598    /// Event type constant for [`ApprovalRequested`](Event::ApprovalRequested).
599    pub const APPROVAL_REQUESTED: &'static str = "approval_requested";
600    /// Event type constant for [`ApprovalGranted`](Event::ApprovalGranted).
601    pub const APPROVAL_GRANTED: &'static str = "approval_granted";
602    /// Event type constant for [`ApprovalRejected`](Event::ApprovalRejected).
603    pub const APPROVAL_REJECTED: &'static str = "approval_rejected";
604    /// Event type constant for [`LogLine`](Event::LogLine).
605    pub const LOG_LINE: &'static str = "log_line";
606    /// Event type constant for [`UserSignedIn`](Event::UserSignedIn).
607    pub const USER_SIGNED_IN: &'static str = "user_signed_in";
608    /// Event type constant for [`UserSignedUp`](Event::UserSignedUp).
609    pub const USER_SIGNED_UP: &'static str = "user_signed_up";
610    /// Event type constant for [`UserSignedOut`](Event::UserSignedOut).
611    pub const USER_SIGNED_OUT: &'static str = "user_signed_out";
612
613    /// All event types. Pass this to
614    /// [`EventPublisher::subscribe`](super::EventPublisher::subscribe) to
615    /// receive every event.
616    ///
617    /// # Examples
618    ///
619    /// ```no_run
620    /// use ironflow_engine::notify::{Event, EventPublisher, WebhookSubscriber};
621    ///
622    /// let mut publisher = EventPublisher::new();
623    /// publisher.subscribe(
624    ///     WebhookSubscriber::new("https://example.com/all"),
625    ///     Event::ALL,
626    /// );
627    /// ```
628    pub const ALL: &'static [&'static str] = &[
629        Self::RUN_CREATED,
630        Self::RUN_STATUS_CHANGED,
631        Self::RUN_FAILED,
632        Self::RUN_BUDGET_EXCEEDED,
633        Self::STEP_COMPLETED,
634        Self::STEP_FAILED,
635        Self::APPROVAL_REQUESTED,
636        Self::APPROVAL_GRANTED,
637        Self::APPROVAL_REJECTED,
638        Self::LOG_LINE,
639        Self::USER_SIGNED_IN,
640        Self::USER_SIGNED_UP,
641        Self::USER_SIGNED_OUT,
642        Self::RETRY_FORCED,
643    ];
644
645    /// Returns the event type as a static string (e.g. `"run_status_changed"`).
646    ///
647    /// Useful for filtering and logging without deserializing.
648    ///
649    /// # Examples
650    ///
651    /// ```
652    /// use ironflow_engine::notify::{Event, UserSignedInEvent};
653    /// use uuid::Uuid;
654    /// use chrono::Utc;
655    ///
656    /// let event = Event::UserSignedIn(UserSignedInEvent {
657    ///     user_id: Uuid::now_v7(),
658    ///     username: "alice".to_string(),
659    ///     at: Utc::now(),
660    /// });
661    /// assert_eq!(event.event_type(), "user_signed_in");
662    /// ```
663    #[deny(unreachable_patterns)]
664    pub fn event_type(&self) -> &'static str {
665        match self {
666            Event::RunCreated(_) => Self::RUN_CREATED,
667            Event::RunStatusChanged(_) => Self::RUN_STATUS_CHANGED,
668            Event::RunFailed(_) => Self::RUN_FAILED,
669            Event::RunBudgetExceeded(_) => Self::RUN_BUDGET_EXCEEDED,
670            Event::RetryForced(_) => Self::RETRY_FORCED,
671            Event::StepCompleted(_) => Self::STEP_COMPLETED,
672            Event::StepFailed(_) => Self::STEP_FAILED,
673            Event::ApprovalRequested(_) => Self::APPROVAL_REQUESTED,
674            Event::ApprovalGranted(_) => Self::APPROVAL_GRANTED,
675            Event::ApprovalRejected(_) => Self::APPROVAL_REJECTED,
676            Event::LogLine(_) => Self::LOG_LINE,
677            Event::UserSignedIn(_) => Self::USER_SIGNED_IN,
678            Event::UserSignedUp(_) => Self::USER_SIGNED_UP,
679            Event::UserSignedOut(_) => Self::USER_SIGNED_OUT,
680        }
681    }
682
683    /// Returns the run this event belongs to, if any.
684    ///
685    /// Auth events ([`UserSignedIn`](Event::UserSignedIn),
686    /// [`UserSignedUp`](Event::UserSignedUp),
687    /// [`UserSignedOut`](Event::UserSignedOut)) are not tied to a run and
688    /// return `None`.
689    ///
690    /// # Examples
691    ///
692    /// ```
693    /// use ironflow_engine::notify::{Event, RunCreatedEvent};
694    /// use uuid::Uuid;
695    /// use chrono::Utc;
696    ///
697    /// let run_id = Uuid::now_v7();
698    /// let event = Event::RunCreated(RunCreatedEvent {
699    ///     run_id,
700    ///     workflow_name: "deploy".to_string(),
701    ///     at: Utc::now(),
702    /// });
703    /// assert_eq!(event.run_id(), Some(run_id));
704    /// ```
705    #[deny(unreachable_patterns)]
706    pub fn run_id(&self) -> Option<Uuid> {
707        match self {
708            Event::RunCreated(e) => Some(e.run_id),
709            Event::RunStatusChanged(e) => Some(e.run_id),
710            Event::RunFailed(e) => Some(e.run_id),
711            Event::RunBudgetExceeded(e) => Some(e.run_id),
712            Event::RetryForced(e) => Some(e.run_id),
713            Event::StepCompleted(e) => Some(e.run_id),
714            Event::StepFailed(e) => Some(e.run_id),
715            Event::ApprovalRequested(e) => Some(e.run_id),
716            Event::ApprovalGranted(e) => Some(e.run_id),
717            Event::ApprovalRejected(e) => Some(e.run_id),
718            Event::LogLine(e) => Some(e.run_id),
719            Event::UserSignedIn(_) | Event::UserSignedUp(_) | Event::UserSignedOut(_) => None,
720        }
721    }
722
723    /// Returns the step this event belongs to, if any.
724    ///
725    /// Only [`StepCompleted`](Event::StepCompleted),
726    /// [`StepFailed`](Event::StepFailed) and
727    /// [`ApprovalRequested`](Event::ApprovalRequested) carry a step
728    /// identifier; every other variant returns `None`.
729    ///
730    /// # Examples
731    ///
732    /// ```
733    /// use ironflow_engine::notify::{Event, StepFailedEvent};
734    /// use ironflow_store::models::StepKind;
735    /// use uuid::Uuid;
736    /// use chrono::Utc;
737    ///
738    /// let step_id = Uuid::now_v7();
739    /// let event = Event::StepFailed(StepFailedEvent {
740    ///     run_id: Uuid::now_v7(),
741    ///     step_id,
742    ///     step_name: "build".to_string(),
743    ///     kind: StepKind::Shell,
744    ///     error: "exit code 1".to_string(),
745    ///     at: Utc::now(),
746    /// });
747    /// assert_eq!(event.step_id(), Some(step_id));
748    /// ```
749    #[deny(unreachable_patterns)]
750    pub fn step_id(&self) -> Option<Uuid> {
751        match self {
752            Event::StepCompleted(e) => Some(e.step_id),
753            Event::StepFailed(e) => Some(e.step_id),
754            Event::ApprovalRequested(e) => Some(e.step_id),
755            Event::RunCreated(_)
756            | Event::RunStatusChanged(_)
757            | Event::RunFailed(_)
758            | Event::RunBudgetExceeded(_)
759            | Event::RetryForced(_)
760            | Event::ApprovalGranted(_)
761            | Event::ApprovalRejected(_)
762            | Event::LogLine(_)
763            | Event::UserSignedIn(_)
764            | Event::UserSignedUp(_)
765            | Event::UserSignedOut(_) => None,
766        }
767    }
768
769    /// Returns the user this event belongs to, if any.
770    ///
771    /// Only the auth events ([`UserSignedIn`](Event::UserSignedIn),
772    /// [`UserSignedUp`](Event::UserSignedUp),
773    /// [`UserSignedOut`](Event::UserSignedOut)) carry a user identifier;
774    /// every other variant returns `None`.
775    ///
776    /// # Examples
777    ///
778    /// ```
779    /// use ironflow_engine::notify::{Event, UserSignedInEvent};
780    /// use uuid::Uuid;
781    /// use chrono::Utc;
782    ///
783    /// let user_id = Uuid::now_v7();
784    /// let event = Event::UserSignedIn(UserSignedInEvent {
785    ///     user_id,
786    ///     username: "alice".to_string(),
787    ///     at: Utc::now(),
788    /// });
789    /// assert_eq!(event.user_id(), Some(user_id));
790    /// ```
791    #[deny(unreachable_patterns)]
792    pub fn user_id(&self) -> Option<Uuid> {
793        match self {
794            Event::UserSignedIn(e) => Some(e.user_id),
795            Event::UserSignedUp(e) => Some(e.user_id),
796            Event::UserSignedOut(e) => Some(e.user_id),
797            Event::RunCreated(_)
798            | Event::RunStatusChanged(_)
799            | Event::RunFailed(_)
800            | Event::RunBudgetExceeded(_)
801            | Event::RetryForced(_)
802            | Event::StepCompleted(_)
803            | Event::StepFailed(_)
804            | Event::ApprovalRequested(_)
805            | Event::ApprovalGranted(_)
806            | Event::ApprovalRejected(_)
807            | Event::LogLine(_) => None,
808        }
809    }
810}
811
812#[cfg(test)]
813mod tests {
814    use super::*;
815
816    #[test]
817    fn run_status_changed_serde_roundtrip() {
818        let event = Event::RunStatusChanged(RunStatusChangedEvent {
819            run_id: Uuid::now_v7(),
820            workflow_name: "deploy".to_string(),
821            from: RunStatus::Running,
822            to: RunStatus::Completed,
823            error: None,
824            cost_usd: Decimal::new(42, 2),
825            duration_ms: 5000,
826            labels: HashMap::new(),
827            at: Utc::now(),
828        });
829
830        let json = serde_json::to_string(&event).expect("serialize");
831        let back: Event = serde_json::from_str(&json).expect("deserialize");
832
833        assert_eq!(back.event_type(), "run_status_changed");
834        assert!(json.contains("\"type\":\"run_status_changed\""));
835    }
836
837    #[test]
838    fn run_failed_serde_roundtrip() {
839        let event = Event::RunFailed(RunFailedEvent {
840            run_id: Uuid::now_v7(),
841            workflow_name: "deploy".to_string(),
842            error: Some("step crashed".to_string()),
843            cost_usd: Decimal::new(10, 2),
844            duration_ms: 3000,
845            labels: HashMap::new(),
846            at: Utc::now(),
847        });
848
849        let json = serde_json::to_string(&event).expect("serialize");
850        let back: Event = serde_json::from_str(&json).expect("deserialize");
851
852        assert_eq!(back.event_type(), "run_failed");
853        assert!(json.contains("\"type\":\"run_failed\""));
854        assert!(json.contains("step crashed"));
855    }
856
857    #[test]
858    fn run_budget_exceeded_serde_roundtrip() {
859        let event = Event::RunBudgetExceeded(RunBudgetExceededEvent {
860            run_id: Uuid::now_v7(),
861            workflow_name: "deploy".to_string(),
862            limit_usd: Decimal::new(200, 2),
863            spent_usd: Decimal::new(180, 2),
864            step_budget_usd: Decimal::new(50, 2),
865            at: Utc::now(),
866        });
867
868        let json = serde_json::to_string(&event).expect("serialize");
869        let back: Event = serde_json::from_str(&json).expect("deserialize");
870
871        assert_eq!(back.event_type(), "run_budget_exceeded");
872        assert!(json.contains("\"type\":\"run_budget_exceeded\""));
873        assert!(json.contains("limit_usd"));
874        assert!(json.contains("step_budget_usd"));
875    }
876
877    #[test]
878    fn all_contains_run_budget_exceeded() {
879        assert!(Event::ALL.contains(&Event::RUN_BUDGET_EXCEEDED));
880    }
881
882    #[test]
883    fn user_signed_in_serde_roundtrip() {
884        let event = Event::UserSignedIn(UserSignedInEvent {
885            user_id: Uuid::now_v7(),
886            username: "alice".to_string(),
887            at: Utc::now(),
888        });
889
890        let json = serde_json::to_string(&event).expect("serialize");
891        let back: Event = serde_json::from_str(&json).expect("deserialize");
892
893        assert_eq!(back.event_type(), "user_signed_in");
894        assert!(json.contains("alice"));
895    }
896
897    #[test]
898    fn step_failed_serde_roundtrip() {
899        let event = Event::StepFailed(StepFailedEvent {
900            run_id: Uuid::now_v7(),
901            step_id: Uuid::now_v7(),
902            step_name: "build".to_string(),
903            kind: StepKind::Shell,
904            error: "exit code 1".to_string(),
905            at: Utc::now(),
906        });
907
908        let json = serde_json::to_string(&event).expect("serialize");
909        let back: Event = serde_json::from_str(&json).expect("deserialize");
910
911        assert_eq!(back.event_type(), "step_failed");
912    }
913
914    #[test]
915    fn approval_requested_serde_roundtrip() {
916        let event = Event::ApprovalRequested(ApprovalRequestedEvent {
917            run_id: Uuid::now_v7(),
918            step_id: Uuid::now_v7(),
919            message: "Deploy to prod?".to_string(),
920            at: Utc::now(),
921        });
922
923        let json = serde_json::to_string(&event).expect("serialize");
924        assert!(json.contains("approval_requested"));
925    }
926
927    #[test]
928    fn log_line_serde_roundtrip() {
929        let event = Event::LogLine(LogLineEvent {
930            run_id: Uuid::now_v7(),
931            step_id: Uuid::now_v7(),
932            step_name: "build".to_string(),
933            stream: LogStream::Stdout,
934            line: "Compiling ironflow v0.1.0".to_string(),
935            at: Utc::now(),
936        });
937
938        let json = serde_json::to_string(&event).expect("serialize");
939        let back: Event = serde_json::from_str(&json).expect("deserialize");
940
941        assert_eq!(back.event_type(), "log_line");
942        assert!(json.contains("\"type\":\"log_line\""));
943        assert!(json.contains("Compiling ironflow"));
944    }
945
946    /// The pre-refactor wire format used flat inline-struct variants. Newtype
947    /// variants over named-field payloads produce and accept the exact same
948    /// JSON, so audit rows and in-flight payloads written before the refactor
949    /// still deserialize. No data migration is required.
950    #[test]
951    fn legacy_flat_json_deserializes_into_typed_payload() {
952        let run_id: Uuid = "01890000-0000-7000-8000-000000000000"
953            .parse()
954            .expect("valid uuid");
955
956        let raw = r#"{"type":"run_created","run_id":"01890000-0000-7000-8000-000000000000","workflow_name":"deploy","at":"2026-01-01T00:00:00Z"}"#;
957        let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
958        match event {
959            Event::RunCreated(e) => {
960                assert_eq!(e.run_id, run_id);
961                assert_eq!(e.workflow_name, "deploy");
962            }
963            other => panic!("expected RunCreated, got {other:?}"),
964        }
965
966        let raw = r#"{"type":"run_status_changed","run_id":"01890000-0000-7000-8000-000000000000","workflow_name":"deploy","from":"running","to":"completed","error":null,"cost_usd":0.5,"duration_ms":5000,"labels":{"env":"prod"},"at":"2026-01-01T00:00:00Z"}"#;
967        let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
968        match event {
969            Event::RunStatusChanged(e) => {
970                assert_eq!(e.from, RunStatus::Running);
971                assert_eq!(e.to, RunStatus::Completed);
972                assert_eq!(e.cost_usd, Decimal::new(5, 1));
973                assert_eq!(e.duration_ms, 5000);
974                assert_eq!(e.labels.get("env").map(String::as_str), Some("prod"));
975            }
976            other => panic!("expected RunStatusChanged, got {other:?}"),
977        }
978
979        // `labels` predates no payload: omitting it must still work via `#[serde(default)]`.
980        let raw = r#"{"type":"run_status_changed","run_id":"01890000-0000-7000-8000-000000000000","workflow_name":"deploy","from":"running","to":"failed","error":"boom","cost_usd":0,"duration_ms":0,"at":"2026-01-01T00:00:00Z"}"#;
981        let event: Event = serde_json::from_str(raw).expect("missing labels must default");
982        match event {
983            Event::RunStatusChanged(e) => {
984                assert!(e.labels.is_empty());
985                assert_eq!(e.error.as_deref(), Some("boom"));
986            }
987            other => panic!("expected RunStatusChanged, got {other:?}"),
988        }
989
990        let raw = r#"{"type":"run_failed","run_id":"01890000-0000-7000-8000-000000000000","workflow_name":"deploy","error":"boom","cost_usd":0.25,"duration_ms":3000,"at":"2026-01-01T00:00:00Z"}"#;
991        let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
992        match event {
993            Event::RunFailed(e) => {
994                assert_eq!(e.error.as_deref(), Some("boom"));
995                assert!(e.labels.is_empty());
996            }
997            other => panic!("expected RunFailed, got {other:?}"),
998        }
999
1000        let raw = r#"{"type":"step_failed","run_id":"01890000-0000-7000-8000-000000000000","step_id":"01890000-0000-7000-8000-000000000001","step_name":"build","kind":"shell","error":"exit code 1","at":"2026-01-01T00:00:00Z"}"#;
1001        let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1002        match event {
1003            Event::StepFailed(e) => {
1004                assert_eq!(e.kind, StepKind::Shell);
1005                assert_eq!(e.error, "exit code 1");
1006            }
1007            other => panic!("expected StepFailed, got {other:?}"),
1008        }
1009
1010        let raw = r#"{"type":"log_line","run_id":"01890000-0000-7000-8000-000000000000","step_id":"01890000-0000-7000-8000-000000000001","step_name":"build","stream":"stdout","line":"hello","at":"2026-01-01T00:00:00Z"}"#;
1011        let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1012        match event {
1013            Event::LogLine(e) => {
1014                assert_eq!(e.stream, LogStream::Stdout);
1015                assert_eq!(e.line, "hello");
1016            }
1017            other => panic!("expected LogLine, got {other:?}"),
1018        }
1019
1020        let raw = r#"{"type":"user_signed_in","user_id":"01890000-0000-7000-8000-000000000000","username":"alice","at":"2026-01-01T00:00:00Z"}"#;
1021        let event: Event = serde_json::from_str(raw).expect("legacy payload must deserialize");
1022        match event {
1023            Event::UserSignedIn(e) => assert_eq!(e.username, "alice"),
1024            other => panic!("expected UserSignedIn, got {other:?}"),
1025        }
1026    }
1027
1028    /// Guards the internally-tagged representation: payload fields must stay
1029    /// siblings of `type`, never nested under a variant key.
1030    #[test]
1031    fn serialized_event_is_flat_with_type_tag() {
1032        let run_id = Uuid::now_v7();
1033        let event = Event::RunCreated(RunCreatedEvent {
1034            run_id,
1035            workflow_name: "deploy".to_string(),
1036            at: Utc::now(),
1037        });
1038
1039        let value: serde_json::Value = serde_json::to_value(&event).expect("serialize");
1040        let object = value.as_object().expect("event serializes to an object");
1041
1042        assert_eq!(
1043            object.get("type").and_then(|v| v.as_str()),
1044            Some("run_created")
1045        );
1046        assert_eq!(
1047            object.get("workflow_name").and_then(|v| v.as_str()),
1048            Some("deploy")
1049        );
1050        assert_eq!(
1051            object.get("run_id").and_then(|v| v.as_str()),
1052            Some(run_id.to_string().as_str())
1053        );
1054        assert!(object.contains_key("at"));
1055        assert_eq!(object.len(), 4, "no nesting: {object:?}");
1056        assert!(!object.contains_key("RunCreated"));
1057    }
1058
1059    #[test]
1060    fn run_id_returns_some_for_run_events() {
1061        let run_id = Uuid::now_v7();
1062        let now = Utc::now();
1063
1064        let events = vec![
1065            Event::RunCreated(RunCreatedEvent {
1066                run_id,
1067                workflow_name: "w".to_string(),
1068                at: now,
1069            }),
1070            Event::RunStatusChanged(RunStatusChangedEvent {
1071                run_id,
1072                workflow_name: "w".to_string(),
1073                from: RunStatus::Pending,
1074                to: RunStatus::Running,
1075                error: None,
1076                cost_usd: Decimal::ZERO,
1077                duration_ms: 0,
1078                labels: HashMap::new(),
1079                at: now,
1080            }),
1081            Event::RunFailed(RunFailedEvent {
1082                run_id,
1083                workflow_name: "w".to_string(),
1084                error: None,
1085                cost_usd: Decimal::ZERO,
1086                duration_ms: 0,
1087                labels: HashMap::new(),
1088                at: now,
1089            }),
1090            Event::RunBudgetExceeded(RunBudgetExceededEvent {
1091                run_id,
1092                workflow_name: "w".to_string(),
1093                limit_usd: Decimal::ZERO,
1094                spent_usd: Decimal::ZERO,
1095                step_budget_usd: Decimal::ZERO,
1096                at: now,
1097            }),
1098            Event::RetryForced(RetryForcedEvent {
1099                run_id,
1100                workflow_name: "w".to_string(),
1101                original_version: "1".to_string(),
1102                current_version: "2".to_string(),
1103                at: now,
1104            }),
1105            Event::StepCompleted(StepCompletedEvent {
1106                run_id,
1107                step_id: Uuid::now_v7(),
1108                step_name: "s".to_string(),
1109                kind: StepKind::Shell,
1110                duration_ms: 0,
1111                cost_usd: Decimal::ZERO,
1112                at: now,
1113            }),
1114            Event::StepFailed(StepFailedEvent {
1115                run_id,
1116                step_id: Uuid::now_v7(),
1117                step_name: "s".to_string(),
1118                kind: StepKind::Shell,
1119                error: "e".to_string(),
1120                at: now,
1121            }),
1122            Event::ApprovalRequested(ApprovalRequestedEvent {
1123                run_id,
1124                step_id: Uuid::now_v7(),
1125                message: "ok?".to_string(),
1126                at: now,
1127            }),
1128            Event::ApprovalGranted(ApprovalGrantedEvent {
1129                run_id,
1130                approved_by: "alice".to_string(),
1131                at: now,
1132            }),
1133            Event::ApprovalRejected(ApprovalRejectedEvent {
1134                run_id,
1135                rejected_by: "bob".to_string(),
1136                at: now,
1137            }),
1138            Event::LogLine(LogLineEvent {
1139                run_id,
1140                step_id: Uuid::now_v7(),
1141                step_name: "s".to_string(),
1142                stream: LogStream::Stdout,
1143                line: "l".to_string(),
1144                at: now,
1145            }),
1146        ];
1147
1148        for event in &events {
1149            assert_eq!(
1150                event.run_id(),
1151                Some(run_id),
1152                "{} should carry a run_id",
1153                event.event_type()
1154            );
1155        }
1156    }
1157
1158    #[test]
1159    fn run_id_returns_none_for_auth_events() {
1160        let user_id = Uuid::now_v7();
1161        let now = Utc::now();
1162
1163        let events = vec![
1164            Event::UserSignedIn(UserSignedInEvent {
1165                user_id,
1166                username: "alice".to_string(),
1167                at: now,
1168            }),
1169            Event::UserSignedUp(UserSignedUpEvent {
1170                user_id,
1171                username: "alice".to_string(),
1172                at: now,
1173            }),
1174            Event::UserSignedOut(UserSignedOutEvent { user_id, at: now }),
1175        ];
1176
1177        for event in &events {
1178            assert_eq!(event.run_id(), None, "{} has no run", event.event_type());
1179        }
1180    }
1181
1182    #[test]
1183    fn step_id_returns_some_only_for_step_events() {
1184        let step_id = Uuid::now_v7();
1185        let run_id = Uuid::now_v7();
1186        let now = Utc::now();
1187
1188        let with_step = vec![
1189            Event::StepCompleted(StepCompletedEvent {
1190                run_id,
1191                step_id,
1192                step_name: "s".to_string(),
1193                kind: StepKind::Shell,
1194                duration_ms: 0,
1195                cost_usd: Decimal::ZERO,
1196                at: now,
1197            }),
1198            Event::StepFailed(StepFailedEvent {
1199                run_id,
1200                step_id,
1201                step_name: "s".to_string(),
1202                kind: StepKind::Shell,
1203                error: "e".to_string(),
1204                at: now,
1205            }),
1206            Event::ApprovalRequested(ApprovalRequestedEvent {
1207                run_id,
1208                step_id,
1209                message: "ok?".to_string(),
1210                at: now,
1211            }),
1212        ];
1213
1214        for event in &with_step {
1215            assert_eq!(
1216                event.step_id(),
1217                Some(step_id),
1218                "{} should carry a step_id",
1219                event.event_type()
1220            );
1221        }
1222
1223        let without_step = vec![
1224            Event::RunCreated(RunCreatedEvent {
1225                run_id,
1226                workflow_name: "w".to_string(),
1227                at: now,
1228            }),
1229            Event::ApprovalGranted(ApprovalGrantedEvent {
1230                run_id,
1231                approved_by: "alice".to_string(),
1232                at: now,
1233            }),
1234            // LogLine carries a step_id field but is reported as a run-level
1235            // stream event, matching the pre-refactor behaviour.
1236            Event::LogLine(LogLineEvent {
1237                run_id,
1238                step_id,
1239                step_name: "s".to_string(),
1240                stream: LogStream::Stdout,
1241                line: "l".to_string(),
1242                at: now,
1243            }),
1244            Event::UserSignedOut(UserSignedOutEvent {
1245                user_id: Uuid::now_v7(),
1246                at: now,
1247            }),
1248        ];
1249
1250        for event in &without_step {
1251            assert_eq!(
1252                event.step_id(),
1253                None,
1254                "{} should not carry a step_id",
1255                event.event_type()
1256            );
1257        }
1258    }
1259
1260    #[test]
1261    fn user_id_returns_some_only_for_auth_events() {
1262        let user_id = Uuid::now_v7();
1263        let run_id = Uuid::now_v7();
1264        let now = Utc::now();
1265
1266        let auth = vec![
1267            Event::UserSignedIn(UserSignedInEvent {
1268                user_id,
1269                username: "alice".to_string(),
1270                at: now,
1271            }),
1272            Event::UserSignedUp(UserSignedUpEvent {
1273                user_id,
1274                username: "alice".to_string(),
1275                at: now,
1276            }),
1277            Event::UserSignedOut(UserSignedOutEvent { user_id, at: now }),
1278        ];
1279
1280        for event in &auth {
1281            assert_eq!(
1282                event.user_id(),
1283                Some(user_id),
1284                "{} should carry a user_id",
1285                event.event_type()
1286            );
1287        }
1288
1289        let non_auth = vec![
1290            Event::RunCreated(RunCreatedEvent {
1291                run_id,
1292                workflow_name: "w".to_string(),
1293                at: now,
1294            }),
1295            Event::StepFailed(StepFailedEvent {
1296                run_id,
1297                step_id: Uuid::now_v7(),
1298                step_name: "s".to_string(),
1299                kind: StepKind::Shell,
1300                error: "e".to_string(),
1301                at: now,
1302            }),
1303        ];
1304
1305        for event in &non_auth {
1306            assert_eq!(
1307                event.user_id(),
1308                None,
1309                "{} should not carry a user_id",
1310                event.event_type()
1311            );
1312        }
1313    }
1314
1315    #[test]
1316    fn event_type_all_variants() {
1317        let id = Uuid::now_v7();
1318        let now = Utc::now();
1319
1320        let cases: Vec<(Event, &str)> = vec![
1321            (
1322                Event::RunCreated(RunCreatedEvent {
1323                    run_id: id,
1324                    workflow_name: "w".to_string(),
1325                    at: now,
1326                }),
1327                "run_created",
1328            ),
1329            (
1330                Event::RunStatusChanged(RunStatusChangedEvent {
1331                    run_id: id,
1332                    workflow_name: "w".to_string(),
1333                    from: RunStatus::Pending,
1334                    to: RunStatus::Running,
1335                    error: None,
1336                    cost_usd: Decimal::ZERO,
1337                    duration_ms: 0,
1338                    labels: HashMap::new(),
1339                    at: now,
1340                }),
1341                "run_status_changed",
1342            ),
1343            (
1344                Event::RunFailed(RunFailedEvent {
1345                    run_id: id,
1346                    workflow_name: "w".to_string(),
1347                    error: Some("boom".to_string()),
1348                    cost_usd: Decimal::ZERO,
1349                    duration_ms: 0,
1350                    labels: HashMap::new(),
1351                    at: now,
1352                }),
1353                "run_failed",
1354            ),
1355            (
1356                Event::RunBudgetExceeded(RunBudgetExceededEvent {
1357                    run_id: id,
1358                    workflow_name: "w".to_string(),
1359                    limit_usd: Decimal::new(200, 2),
1360                    spent_usd: Decimal::new(180, 2),
1361                    step_budget_usd: Decimal::new(50, 2),
1362                    at: now,
1363                }),
1364                "run_budget_exceeded",
1365            ),
1366            (
1367                Event::RetryForced(RetryForcedEvent {
1368                    run_id: id,
1369                    workflow_name: "w".to_string(),
1370                    original_version: "1".to_string(),
1371                    current_version: "2".to_string(),
1372                    at: now,
1373                }),
1374                "retry_forced",
1375            ),
1376            (
1377                Event::StepCompleted(StepCompletedEvent {
1378                    run_id: id,
1379                    step_id: id,
1380                    step_name: "s".to_string(),
1381                    kind: StepKind::Shell,
1382                    duration_ms: 0,
1383                    cost_usd: Decimal::ZERO,
1384                    at: now,
1385                }),
1386                "step_completed",
1387            ),
1388            (
1389                Event::StepFailed(StepFailedEvent {
1390                    run_id: id,
1391                    step_id: id,
1392                    step_name: "s".to_string(),
1393                    kind: StepKind::Shell,
1394                    error: "err".to_string(),
1395                    at: now,
1396                }),
1397                "step_failed",
1398            ),
1399            (
1400                Event::ApprovalRequested(ApprovalRequestedEvent {
1401                    run_id: id,
1402                    step_id: id,
1403                    message: "ok?".to_string(),
1404                    at: now,
1405                }),
1406                "approval_requested",
1407            ),
1408            (
1409                Event::ApprovalGranted(ApprovalGrantedEvent {
1410                    run_id: id,
1411                    approved_by: "alice".to_string(),
1412                    at: now,
1413                }),
1414                "approval_granted",
1415            ),
1416            (
1417                Event::ApprovalRejected(ApprovalRejectedEvent {
1418                    run_id: id,
1419                    rejected_by: "bob".to_string(),
1420                    at: now,
1421                }),
1422                "approval_rejected",
1423            ),
1424            (
1425                Event::LogLine(LogLineEvent {
1426                    run_id: id,
1427                    step_id: id,
1428                    step_name: "build".to_string(),
1429                    stream: LogStream::Stdout,
1430                    line: "Compiling ironflow v0.1.0".to_string(),
1431                    at: now,
1432                }),
1433                "log_line",
1434            ),
1435            (
1436                Event::UserSignedIn(UserSignedInEvent {
1437                    user_id: id,
1438                    username: "u".to_string(),
1439                    at: now,
1440                }),
1441                "user_signed_in",
1442            ),
1443            (
1444                Event::UserSignedUp(UserSignedUpEvent {
1445                    user_id: id,
1446                    username: "u".to_string(),
1447                    at: now,
1448                }),
1449                "user_signed_up",
1450            ),
1451            (
1452                Event::UserSignedOut(UserSignedOutEvent {
1453                    user_id: id,
1454                    at: now,
1455                }),
1456                "user_signed_out",
1457            ),
1458        ];
1459
1460        assert_eq!(
1461            cases.len(),
1462            Event::ALL.len(),
1463            "every variant must be covered"
1464        );
1465
1466        for (event, expected_type) in cases {
1467            assert_eq!(event.event_type(), expected_type);
1468        }
1469    }
1470}