1use chrono::{DateTime, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8use ironflow_store::models::{RunStatus, StepKind};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
22#[serde(rename_all = "snake_case")]
23pub enum LogStream {
24 Stdout,
26 Stderr,
28 System,
30}
31
32impl LogStream {
33 pub fn as_str(&self) -> &'static str {
35 match self {
36 Self::Stdout => "stdout",
37 Self::Stderr => "stderr",
38 Self::System => "system",
39 }
40 }
41}
42
43impl std::fmt::Display for LogStream {
44 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45 f.write_str(self.as_str())
46 }
47}
48
49impl std::str::FromStr for LogStream {
50 type Err = String;
51
52 fn from_str(s: &str) -> Result<Self, Self::Err> {
53 match s {
54 "stdout" => Ok(Self::Stdout),
55 "stderr" => Ok(Self::Stderr),
56 "system" => Ok(Self::System),
57 _ => Err(format!("unknown log stream: {s}")),
58 }
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
87#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
88#[serde(tag = "type", rename_all = "snake_case")]
89pub enum Event {
90 RunCreated {
93 run_id: Uuid,
95 workflow_name: String,
97 at: DateTime<Utc>,
99 },
100
101 RunStatusChanged {
103 run_id: Uuid,
105 workflow_name: String,
107 from: RunStatus,
109 to: RunStatus,
111 error: Option<String>,
113 cost_usd: Decimal,
115 duration_ms: u64,
117 at: DateTime<Utc>,
119 },
120
121 RunFailed {
127 run_id: Uuid,
129 workflow_name: String,
131 error: Option<String>,
133 cost_usd: Decimal,
135 duration_ms: u64,
137 at: DateTime<Utc>,
139 },
140
141 RunBudgetExceeded {
149 run_id: Uuid,
151 workflow_name: String,
153 limit_usd: Decimal,
155 spent_usd: Decimal,
157 step_budget_usd: Decimal,
159 at: DateTime<Utc>,
161 },
162
163 StepCompleted {
166 run_id: Uuid,
168 step_id: Uuid,
170 step_name: String,
172 #[cfg_attr(feature = "openapi", schema(value_type = String))]
174 kind: StepKind,
175 duration_ms: u64,
177 cost_usd: Decimal,
179 at: DateTime<Utc>,
181 },
182
183 StepFailed {
185 run_id: Uuid,
187 step_id: Uuid,
189 step_name: String,
191 #[cfg_attr(feature = "openapi", schema(value_type = String))]
193 kind: StepKind,
194 error: String,
196 at: DateTime<Utc>,
198 },
199
200 ApprovalRequested {
203 run_id: Uuid,
205 step_id: Uuid,
207 message: String,
209 at: DateTime<Utc>,
211 },
212
213 ApprovalGranted {
215 run_id: Uuid,
217 approved_by: String,
219 at: DateTime<Utc>,
221 },
222
223 ApprovalRejected {
225 run_id: Uuid,
227 rejected_by: String,
229 at: DateTime<Utc>,
231 },
232
233 LogLine {
239 run_id: Uuid,
241 step_id: Uuid,
243 step_name: String,
245 stream: LogStream,
247 line: String,
249 at: DateTime<Utc>,
251 },
252
253 UserSignedIn {
256 user_id: Uuid,
258 username: String,
260 at: DateTime<Utc>,
262 },
263
264 UserSignedUp {
266 user_id: Uuid,
268 username: String,
270 at: DateTime<Utc>,
272 },
273
274 UserSignedOut {
276 user_id: Uuid,
278 at: DateTime<Utc>,
280 },
281}
282
283impl Event {
284 pub const RUN_CREATED: &'static str = "run_created";
286 pub const RUN_STATUS_CHANGED: &'static str = "run_status_changed";
288 pub const RUN_FAILED: &'static str = "run_failed";
290 pub const RUN_BUDGET_EXCEEDED: &'static str = "run_budget_exceeded";
292 pub const STEP_COMPLETED: &'static str = "step_completed";
294 pub const STEP_FAILED: &'static str = "step_failed";
296 pub const APPROVAL_REQUESTED: &'static str = "approval_requested";
298 pub const APPROVAL_GRANTED: &'static str = "approval_granted";
300 pub const APPROVAL_REJECTED: &'static str = "approval_rejected";
302 pub const LOG_LINE: &'static str = "log_line";
304 pub const USER_SIGNED_IN: &'static str = "user_signed_in";
306 pub const USER_SIGNED_UP: &'static str = "user_signed_up";
308 pub const USER_SIGNED_OUT: &'static str = "user_signed_out";
310
311 pub const ALL: &'static [&'static str] = &[
327 Self::RUN_CREATED,
328 Self::RUN_STATUS_CHANGED,
329 Self::RUN_FAILED,
330 Self::RUN_BUDGET_EXCEEDED,
331 Self::STEP_COMPLETED,
332 Self::STEP_FAILED,
333 Self::APPROVAL_REQUESTED,
334 Self::APPROVAL_GRANTED,
335 Self::APPROVAL_REJECTED,
336 Self::LOG_LINE,
337 Self::USER_SIGNED_IN,
338 Self::USER_SIGNED_UP,
339 Self::USER_SIGNED_OUT,
340 ];
341
342 pub fn event_type(&self) -> &'static str {
361 match self {
362 Event::RunCreated { .. } => Self::RUN_CREATED,
363 Event::RunStatusChanged { .. } => Self::RUN_STATUS_CHANGED,
364 Event::RunFailed { .. } => Self::RUN_FAILED,
365 Event::RunBudgetExceeded { .. } => Self::RUN_BUDGET_EXCEEDED,
366 Event::StepCompleted { .. } => Self::STEP_COMPLETED,
367 Event::StepFailed { .. } => Self::STEP_FAILED,
368 Event::ApprovalRequested { .. } => Self::APPROVAL_REQUESTED,
369 Event::ApprovalGranted { .. } => Self::APPROVAL_GRANTED,
370 Event::ApprovalRejected { .. } => Self::APPROVAL_REJECTED,
371 Event::LogLine { .. } => Self::LOG_LINE,
372 Event::UserSignedIn { .. } => Self::USER_SIGNED_IN,
373 Event::UserSignedUp { .. } => Self::USER_SIGNED_UP,
374 Event::UserSignedOut { .. } => Self::USER_SIGNED_OUT,
375 }
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn run_status_changed_serde_roundtrip() {
385 let event = Event::RunStatusChanged {
386 run_id: Uuid::now_v7(),
387 workflow_name: "deploy".to_string(),
388 from: RunStatus::Running,
389 to: RunStatus::Completed,
390 error: None,
391 cost_usd: Decimal::new(42, 2),
392 duration_ms: 5000,
393 at: Utc::now(),
394 };
395
396 let json = serde_json::to_string(&event).expect("serialize");
397 let back: Event = serde_json::from_str(&json).expect("deserialize");
398
399 assert_eq!(back.event_type(), "run_status_changed");
400 assert!(json.contains("\"type\":\"run_status_changed\""));
401 }
402
403 #[test]
404 fn run_failed_serde_roundtrip() {
405 let event = Event::RunFailed {
406 run_id: Uuid::now_v7(),
407 workflow_name: "deploy".to_string(),
408 error: Some("step crashed".to_string()),
409 cost_usd: Decimal::new(10, 2),
410 duration_ms: 3000,
411 at: Utc::now(),
412 };
413
414 let json = serde_json::to_string(&event).expect("serialize");
415 let back: Event = serde_json::from_str(&json).expect("deserialize");
416
417 assert_eq!(back.event_type(), "run_failed");
418 assert!(json.contains("\"type\":\"run_failed\""));
419 assert!(json.contains("step crashed"));
420 }
421
422 #[test]
423 fn run_budget_exceeded_serde_roundtrip() {
424 let event = Event::RunBudgetExceeded {
425 run_id: Uuid::now_v7(),
426 workflow_name: "deploy".to_string(),
427 limit_usd: Decimal::new(200, 2),
428 spent_usd: Decimal::new(180, 2),
429 step_budget_usd: Decimal::new(50, 2),
430 at: Utc::now(),
431 };
432
433 let json = serde_json::to_string(&event).expect("serialize");
434 let back: Event = serde_json::from_str(&json).expect("deserialize");
435
436 assert_eq!(back.event_type(), "run_budget_exceeded");
437 assert!(json.contains("\"type\":\"run_budget_exceeded\""));
438 assert!(json.contains("limit_usd"));
439 assert!(json.contains("step_budget_usd"));
440 }
441
442 #[test]
443 fn all_contains_run_budget_exceeded() {
444 assert!(Event::ALL.contains(&Event::RUN_BUDGET_EXCEEDED));
445 }
446
447 #[test]
448 fn user_signed_in_serde_roundtrip() {
449 let event = Event::UserSignedIn {
450 user_id: Uuid::now_v7(),
451 username: "alice".to_string(),
452 at: Utc::now(),
453 };
454
455 let json = serde_json::to_string(&event).expect("serialize");
456 let back: Event = serde_json::from_str(&json).expect("deserialize");
457
458 assert_eq!(back.event_type(), "user_signed_in");
459 assert!(json.contains("alice"));
460 }
461
462 #[test]
463 fn step_failed_serde_roundtrip() {
464 let event = Event::StepFailed {
465 run_id: Uuid::now_v7(),
466 step_id: Uuid::now_v7(),
467 step_name: "build".to_string(),
468 kind: StepKind::Shell,
469 error: "exit code 1".to_string(),
470 at: Utc::now(),
471 };
472
473 let json = serde_json::to_string(&event).expect("serialize");
474 let back: Event = serde_json::from_str(&json).expect("deserialize");
475
476 assert_eq!(back.event_type(), "step_failed");
477 }
478
479 #[test]
480 fn approval_requested_serde_roundtrip() {
481 let event = Event::ApprovalRequested {
482 run_id: Uuid::now_v7(),
483 step_id: Uuid::now_v7(),
484 message: "Deploy to prod?".to_string(),
485 at: Utc::now(),
486 };
487
488 let json = serde_json::to_string(&event).expect("serialize");
489 assert!(json.contains("approval_requested"));
490 }
491
492 #[test]
493 fn log_line_serde_roundtrip() {
494 let event = Event::LogLine {
495 run_id: Uuid::now_v7(),
496 step_id: Uuid::now_v7(),
497 step_name: "build".to_string(),
498 stream: LogStream::Stdout,
499 line: "Compiling ironflow v0.1.0".to_string(),
500 at: Utc::now(),
501 };
502
503 let json = serde_json::to_string(&event).expect("serialize");
504 let back: Event = serde_json::from_str(&json).expect("deserialize");
505
506 assert_eq!(back.event_type(), "log_line");
507 assert!(json.contains("\"type\":\"log_line\""));
508 assert!(json.contains("Compiling ironflow"));
509 }
510
511 #[test]
512 fn event_type_all_variants() {
513 let id = Uuid::now_v7();
514 let now = Utc::now();
515
516 let cases: Vec<(Event, &str)> = vec![
517 (
518 Event::RunCreated {
519 run_id: id,
520 workflow_name: "w".to_string(),
521 at: now,
522 },
523 "run_created",
524 ),
525 (
526 Event::RunStatusChanged {
527 run_id: id,
528 workflow_name: "w".to_string(),
529 from: RunStatus::Pending,
530 to: RunStatus::Running,
531 error: None,
532 cost_usd: Decimal::ZERO,
533 duration_ms: 0,
534 at: now,
535 },
536 "run_status_changed",
537 ),
538 (
539 Event::RunFailed {
540 run_id: id,
541 workflow_name: "w".to_string(),
542 error: Some("boom".to_string()),
543 cost_usd: Decimal::ZERO,
544 duration_ms: 0,
545 at: now,
546 },
547 "run_failed",
548 ),
549 (
550 Event::StepCompleted {
551 run_id: id,
552 step_id: id,
553 step_name: "s".to_string(),
554 kind: StepKind::Shell,
555 duration_ms: 0,
556 cost_usd: Decimal::ZERO,
557 at: now,
558 },
559 "step_completed",
560 ),
561 (
562 Event::StepFailed {
563 run_id: id,
564 step_id: id,
565 step_name: "s".to_string(),
566 kind: StepKind::Shell,
567 error: "err".to_string(),
568 at: now,
569 },
570 "step_failed",
571 ),
572 (
573 Event::ApprovalRequested {
574 run_id: id,
575 step_id: id,
576 message: "ok?".to_string(),
577 at: now,
578 },
579 "approval_requested",
580 ),
581 (
582 Event::ApprovalGranted {
583 run_id: id,
584 approved_by: "alice".to_string(),
585 at: now,
586 },
587 "approval_granted",
588 ),
589 (
590 Event::ApprovalRejected {
591 run_id: id,
592 rejected_by: "bob".to_string(),
593 at: now,
594 },
595 "approval_rejected",
596 ),
597 (
598 Event::LogLine {
599 run_id: id,
600 step_id: id,
601 step_name: "build".to_string(),
602 stream: LogStream::Stdout,
603 line: "Compiling ironflow v0.1.0".to_string(),
604 at: now,
605 },
606 "log_line",
607 ),
608 (
609 Event::UserSignedIn {
610 user_id: id,
611 username: "u".to_string(),
612 at: now,
613 },
614 "user_signed_in",
615 ),
616 (
617 Event::UserSignedUp {
618 user_id: id,
619 username: "u".to_string(),
620 at: now,
621 },
622 "user_signed_up",
623 ),
624 (
625 Event::UserSignedOut {
626 user_id: id,
627 at: now,
628 },
629 "user_signed_out",
630 ),
631 ];
632
633 for (event, expected_type) in cases {
634 assert_eq!(event.event_type(), expected_type);
635 }
636 }
637}