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}