Skip to main content

ironflow_engine/notify/
event.rs

1//! Domain events emitted throughout the ironflow lifecycle.
2
3use chrono::{DateTime, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8use ironflow_store::models::{RunStatus, StepKind};
9
10/// A domain event emitted by the ironflow system.
11///
12/// Covers the full lifecycle: runs, steps, approvals, and authentication.
13/// Subscribers receive these via [`EventPublisher`](super::EventPublisher)
14/// and pattern-match on the variants they care about.
15///
16/// # Examples
17///
18/// ```
19/// use ironflow_engine::notify::Event;
20/// use ironflow_store::models::RunStatus;
21/// use uuid::Uuid;
22///
23/// let event = Event::RunStatusChanged {
24///     run_id: Uuid::now_v7(),
25///     workflow_name: "deploy".to_string(),
26///     from: RunStatus::Running,
27///     to: RunStatus::Completed,
28///     error: None,
29///     cost_usd: rust_decimal::Decimal::ZERO,
30///     duration_ms: 5000,
31///     at: chrono::Utc::now(),
32/// };
33/// ```
34#[derive(Debug, Clone, Serialize, Deserialize)]
35#[serde(tag = "type", rename_all = "snake_case")]
36pub enum Event {
37    // -- Run lifecycle --
38    /// A new run was created (status: Pending).
39    RunCreated {
40        /// Run identifier.
41        run_id: Uuid,
42        /// Workflow name.
43        workflow_name: String,
44        /// When the run was created.
45        at: DateTime<Utc>,
46    },
47
48    /// A run changed status.
49    RunStatusChanged {
50        /// Run identifier.
51        run_id: Uuid,
52        /// Workflow name.
53        workflow_name: String,
54        /// Previous status.
55        from: RunStatus,
56        /// New status.
57        to: RunStatus,
58        /// Error message (when transitioning to Failed).
59        error: Option<String>,
60        /// Aggregated cost in USD at the time of transition.
61        cost_usd: Decimal,
62        /// Aggregated duration in milliseconds at the time of transition.
63        duration_ms: u64,
64        /// When the transition occurred.
65        at: DateTime<Utc>,
66    },
67
68    /// A run transitioned to [`Failed`](ironflow_store::models::RunStatus::Failed).
69    ///
70    /// This is a convenience event emitted alongside [`RunStatusChanged`](Event::RunStatusChanged)
71    /// when the target status is `Failed`. Subscribe to this instead of
72    /// `RUN_STATUS_CHANGED` when you only care about failures.
73    RunFailed {
74        /// Run identifier.
75        run_id: Uuid,
76        /// Workflow name.
77        workflow_name: String,
78        /// Error message.
79        error: Option<String>,
80        /// Aggregated cost in USD at the time of failure.
81        cost_usd: Decimal,
82        /// Aggregated duration in milliseconds at the time of failure.
83        duration_ms: u64,
84        /// When the failure occurred.
85        at: DateTime<Utc>,
86    },
87
88    // -- Step lifecycle --
89    /// A step completed successfully.
90    StepCompleted {
91        /// Run identifier.
92        run_id: Uuid,
93        /// Step identifier.
94        step_id: Uuid,
95        /// Human-readable step name.
96        step_name: String,
97        /// Step operation kind.
98        kind: StepKind,
99        /// Step duration in milliseconds.
100        duration_ms: u64,
101        /// Step cost in USD.
102        cost_usd: Decimal,
103        /// When the step completed.
104        at: DateTime<Utc>,
105    },
106
107    /// A step failed.
108    StepFailed {
109        /// Run identifier.
110        run_id: Uuid,
111        /// Step identifier.
112        step_id: Uuid,
113        /// Human-readable step name.
114        step_name: String,
115        /// Step operation kind.
116        kind: StepKind,
117        /// Error message.
118        error: String,
119        /// When the step failed.
120        at: DateTime<Utc>,
121    },
122
123    // -- Approval --
124    /// A run is waiting for human approval.
125    ApprovalRequested {
126        /// Run identifier.
127        run_id: Uuid,
128        /// Approval step identifier.
129        step_id: Uuid,
130        /// Message displayed to reviewers.
131        message: String,
132        /// When the approval was requested.
133        at: DateTime<Utc>,
134    },
135
136    /// A run was approved by a human.
137    ApprovalGranted {
138        /// Run identifier.
139        run_id: Uuid,
140        /// User who approved (ID or username).
141        approved_by: String,
142        /// When the approval was granted.
143        at: DateTime<Utc>,
144    },
145
146    /// A run was rejected by a human.
147    ApprovalRejected {
148        /// Run identifier.
149        run_id: Uuid,
150        /// User who rejected (ID or username).
151        rejected_by: String,
152        /// When the rejection occurred.
153        at: DateTime<Utc>,
154    },
155
156    // -- Authentication --
157    /// A user signed in.
158    UserSignedIn {
159        /// User identifier.
160        user_id: Uuid,
161        /// Username.
162        username: String,
163        /// When the sign-in occurred.
164        at: DateTime<Utc>,
165    },
166
167    /// A new user signed up.
168    UserSignedUp {
169        /// User identifier.
170        user_id: Uuid,
171        /// Username.
172        username: String,
173        /// When the sign-up occurred.
174        at: DateTime<Utc>,
175    },
176
177    /// A user signed out.
178    UserSignedOut {
179        /// User identifier.
180        user_id: Uuid,
181        /// When the sign-out occurred.
182        at: DateTime<Utc>,
183    },
184}
185
186impl Event {
187    /// Event type constant for [`RunCreated`](Event::RunCreated).
188    pub const RUN_CREATED: &'static str = "run_created";
189    /// Event type constant for [`RunStatusChanged`](Event::RunStatusChanged).
190    pub const RUN_STATUS_CHANGED: &'static str = "run_status_changed";
191    /// Event type constant for [`RunFailed`](Event::RunFailed).
192    pub const RUN_FAILED: &'static str = "run_failed";
193    /// Event type constant for [`StepCompleted`](Event::StepCompleted).
194    pub const STEP_COMPLETED: &'static str = "step_completed";
195    /// Event type constant for [`StepFailed`](Event::StepFailed).
196    pub const STEP_FAILED: &'static str = "step_failed";
197    /// Event type constant for [`ApprovalRequested`](Event::ApprovalRequested).
198    pub const APPROVAL_REQUESTED: &'static str = "approval_requested";
199    /// Event type constant for [`ApprovalGranted`](Event::ApprovalGranted).
200    pub const APPROVAL_GRANTED: &'static str = "approval_granted";
201    /// Event type constant for [`ApprovalRejected`](Event::ApprovalRejected).
202    pub const APPROVAL_REJECTED: &'static str = "approval_rejected";
203    /// Event type constant for [`UserSignedIn`](Event::UserSignedIn).
204    pub const USER_SIGNED_IN: &'static str = "user_signed_in";
205    /// Event type constant for [`UserSignedUp`](Event::UserSignedUp).
206    pub const USER_SIGNED_UP: &'static str = "user_signed_up";
207    /// Event type constant for [`UserSignedOut`](Event::UserSignedOut).
208    pub const USER_SIGNED_OUT: &'static str = "user_signed_out";
209
210    /// All event types. Pass this to
211    /// [`EventPublisher::subscribe`](super::EventPublisher::subscribe) to
212    /// receive every event.
213    ///
214    /// # Examples
215    ///
216    /// ```no_run
217    /// use ironflow_engine::notify::{Event, EventPublisher, WebhookSubscriber};
218    ///
219    /// let mut publisher = EventPublisher::new();
220    /// publisher.subscribe(
221    ///     WebhookSubscriber::new("https://example.com/all"),
222    ///     Event::ALL,
223    /// );
224    /// ```
225    pub const ALL: &'static [&'static str] = &[
226        Self::RUN_CREATED,
227        Self::RUN_STATUS_CHANGED,
228        Self::RUN_FAILED,
229        Self::STEP_COMPLETED,
230        Self::STEP_FAILED,
231        Self::APPROVAL_REQUESTED,
232        Self::APPROVAL_GRANTED,
233        Self::APPROVAL_REJECTED,
234        Self::USER_SIGNED_IN,
235        Self::USER_SIGNED_UP,
236        Self::USER_SIGNED_OUT,
237    ];
238
239    /// Returns the event type as a static string (e.g. `"run_status_changed"`).
240    ///
241    /// Useful for filtering and logging without deserializing.
242    ///
243    /// # Examples
244    ///
245    /// ```
246    /// use ironflow_engine::notify::Event;
247    /// use uuid::Uuid;
248    /// use chrono::Utc;
249    ///
250    /// let event = Event::UserSignedIn {
251    ///     user_id: Uuid::now_v7(),
252    ///     username: "alice".to_string(),
253    ///     at: Utc::now(),
254    /// };
255    /// assert_eq!(event.event_type(), "user_signed_in");
256    /// ```
257    pub fn event_type(&self) -> &'static str {
258        match self {
259            Event::RunCreated { .. } => Self::RUN_CREATED,
260            Event::RunStatusChanged { .. } => Self::RUN_STATUS_CHANGED,
261            Event::RunFailed { .. } => Self::RUN_FAILED,
262            Event::StepCompleted { .. } => Self::STEP_COMPLETED,
263            Event::StepFailed { .. } => Self::STEP_FAILED,
264            Event::ApprovalRequested { .. } => Self::APPROVAL_REQUESTED,
265            Event::ApprovalGranted { .. } => Self::APPROVAL_GRANTED,
266            Event::ApprovalRejected { .. } => Self::APPROVAL_REJECTED,
267            Event::UserSignedIn { .. } => Self::USER_SIGNED_IN,
268            Event::UserSignedUp { .. } => Self::USER_SIGNED_UP,
269            Event::UserSignedOut { .. } => Self::USER_SIGNED_OUT,
270        }
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[test]
279    fn run_status_changed_serde_roundtrip() {
280        let event = Event::RunStatusChanged {
281            run_id: Uuid::now_v7(),
282            workflow_name: "deploy".to_string(),
283            from: RunStatus::Running,
284            to: RunStatus::Completed,
285            error: None,
286            cost_usd: Decimal::new(42, 2),
287            duration_ms: 5000,
288            at: Utc::now(),
289        };
290
291        let json = serde_json::to_string(&event).expect("serialize");
292        let back: Event = serde_json::from_str(&json).expect("deserialize");
293
294        assert_eq!(back.event_type(), "run_status_changed");
295        assert!(json.contains("\"type\":\"run_status_changed\""));
296    }
297
298    #[test]
299    fn run_failed_serde_roundtrip() {
300        let event = Event::RunFailed {
301            run_id: Uuid::now_v7(),
302            workflow_name: "deploy".to_string(),
303            error: Some("step crashed".to_string()),
304            cost_usd: Decimal::new(10, 2),
305            duration_ms: 3000,
306            at: Utc::now(),
307        };
308
309        let json = serde_json::to_string(&event).expect("serialize");
310        let back: Event = serde_json::from_str(&json).expect("deserialize");
311
312        assert_eq!(back.event_type(), "run_failed");
313        assert!(json.contains("\"type\":\"run_failed\""));
314        assert!(json.contains("step crashed"));
315    }
316
317    #[test]
318    fn user_signed_in_serde_roundtrip() {
319        let event = Event::UserSignedIn {
320            user_id: Uuid::now_v7(),
321            username: "alice".to_string(),
322            at: Utc::now(),
323        };
324
325        let json = serde_json::to_string(&event).expect("serialize");
326        let back: Event = serde_json::from_str(&json).expect("deserialize");
327
328        assert_eq!(back.event_type(), "user_signed_in");
329        assert!(json.contains("alice"));
330    }
331
332    #[test]
333    fn step_failed_serde_roundtrip() {
334        let event = Event::StepFailed {
335            run_id: Uuid::now_v7(),
336            step_id: Uuid::now_v7(),
337            step_name: "build".to_string(),
338            kind: StepKind::Shell,
339            error: "exit code 1".to_string(),
340            at: Utc::now(),
341        };
342
343        let json = serde_json::to_string(&event).expect("serialize");
344        let back: Event = serde_json::from_str(&json).expect("deserialize");
345
346        assert_eq!(back.event_type(), "step_failed");
347    }
348
349    #[test]
350    fn approval_requested_serde_roundtrip() {
351        let event = Event::ApprovalRequested {
352            run_id: Uuid::now_v7(),
353            step_id: Uuid::now_v7(),
354            message: "Deploy to prod?".to_string(),
355            at: Utc::now(),
356        };
357
358        let json = serde_json::to_string(&event).expect("serialize");
359        assert!(json.contains("approval_requested"));
360    }
361
362    #[test]
363    fn event_type_all_variants() {
364        let id = Uuid::now_v7();
365        let now = Utc::now();
366
367        let cases: Vec<(Event, &str)> = vec![
368            (
369                Event::RunCreated {
370                    run_id: id,
371                    workflow_name: "w".to_string(),
372                    at: now,
373                },
374                "run_created",
375            ),
376            (
377                Event::RunStatusChanged {
378                    run_id: id,
379                    workflow_name: "w".to_string(),
380                    from: RunStatus::Pending,
381                    to: RunStatus::Running,
382                    error: None,
383                    cost_usd: Decimal::ZERO,
384                    duration_ms: 0,
385                    at: now,
386                },
387                "run_status_changed",
388            ),
389            (
390                Event::RunFailed {
391                    run_id: id,
392                    workflow_name: "w".to_string(),
393                    error: Some("boom".to_string()),
394                    cost_usd: Decimal::ZERO,
395                    duration_ms: 0,
396                    at: now,
397                },
398                "run_failed",
399            ),
400            (
401                Event::StepCompleted {
402                    run_id: id,
403                    step_id: id,
404                    step_name: "s".to_string(),
405                    kind: StepKind::Shell,
406                    duration_ms: 0,
407                    cost_usd: Decimal::ZERO,
408                    at: now,
409                },
410                "step_completed",
411            ),
412            (
413                Event::StepFailed {
414                    run_id: id,
415                    step_id: id,
416                    step_name: "s".to_string(),
417                    kind: StepKind::Shell,
418                    error: "err".to_string(),
419                    at: now,
420                },
421                "step_failed",
422            ),
423            (
424                Event::ApprovalRequested {
425                    run_id: id,
426                    step_id: id,
427                    message: "ok?".to_string(),
428                    at: now,
429                },
430                "approval_requested",
431            ),
432            (
433                Event::ApprovalGranted {
434                    run_id: id,
435                    approved_by: "alice".to_string(),
436                    at: now,
437                },
438                "approval_granted",
439            ),
440            (
441                Event::ApprovalRejected {
442                    run_id: id,
443                    rejected_by: "bob".to_string(),
444                    at: now,
445                },
446                "approval_rejected",
447            ),
448            (
449                Event::UserSignedIn {
450                    user_id: id,
451                    username: "u".to_string(),
452                    at: now,
453                },
454                "user_signed_in",
455            ),
456            (
457                Event::UserSignedUp {
458                    user_id: id,
459                    username: "u".to_string(),
460                    at: now,
461                },
462                "user_signed_up",
463            ),
464            (
465                Event::UserSignedOut {
466                    user_id: id,
467                    at: now,
468                },
469                "user_signed_out",
470            ),
471        ];
472
473        for (event, expected_type) in cases {
474            assert_eq!(event.event_type(), expected_type);
475        }
476    }
477}