1use std::collections::HashMap;
34use std::sync::RwLock;
35
36use chrono::{DateTime, Utc};
37use rust_decimal::Decimal;
38use serde::{Deserialize, Serialize};
39use tokio::sync::broadcast;
40use uuid::Uuid;
41
42const DEFAULT_BUFFER_SIZE: usize = 64;
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
61#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
62pub struct WorkflowStepStartedEvent {
63 pub step_name: String,
65 pub step_index: u32,
67 pub timestamp: DateTime<Utc>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
87#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
88pub struct WorkflowStepCompletedEvent {
89 pub step_name: String,
91 pub step_index: u32,
93 pub duration_ms: u64,
95 pub output_summary: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
115#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
116pub struct WorkflowStepFailedEvent {
117 pub step_name: String,
119 pub step_index: u32,
121 pub error: String,
123 pub duration_ms: u64,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
143#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
144pub struct WorkflowApprovalRequiredEvent {
145 pub step_name: String,
147 pub step_index: u32,
149 pub approval_id: Uuid,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
169#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
170pub struct WorkflowAgentStepTokensUsedEvent {
171 pub step_name: String,
173 pub tokens: u64,
175 pub cost_usd: Decimal,
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
207#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
208#[serde(tag = "type", rename_all = "snake_case")]
209pub enum WorkflowEvent {
210 StepStarted(WorkflowStepStartedEvent),
212
213 StepCompleted(WorkflowStepCompletedEvent),
215
216 StepFailed(WorkflowStepFailedEvent),
218
219 ApprovalRequired(WorkflowApprovalRequiredEvent),
221
222 AgentStepTokensUsed(WorkflowAgentStepTokensUsedEvent),
224}
225
226impl WorkflowEvent {
227 pub const STEP_STARTED: &'static str = "step_started";
229 pub const STEP_COMPLETED: &'static str = "step_completed";
231 pub const STEP_FAILED: &'static str = "step_failed";
233 pub const APPROVAL_REQUIRED: &'static str = "approval_required";
235 pub const AGENT_STEP_TOKENS_USED: &'static str = "agent_step_tokens_used";
237
238 #[deny(unreachable_patterns)]
254 pub fn event_type(&self) -> &'static str {
255 match self {
256 WorkflowEvent::StepStarted(_) => Self::STEP_STARTED,
257 WorkflowEvent::StepCompleted(_) => Self::STEP_COMPLETED,
258 WorkflowEvent::StepFailed(_) => Self::STEP_FAILED,
259 WorkflowEvent::ApprovalRequired(_) => Self::APPROVAL_REQUIRED,
260 WorkflowEvent::AgentStepTokensUsed(_) => Self::AGENT_STEP_TOKENS_USED,
261 }
262 }
263}
264
265#[derive(Clone)]
292pub struct WorkflowEventBus {
293 channels: std::sync::Arc<RwLock<HashMap<Uuid, broadcast::Sender<WorkflowEvent>>>>,
294}
295
296impl WorkflowEventBus {
297 pub fn new() -> Self {
307 Self {
308 channels: std::sync::Arc::new(RwLock::new(HashMap::new())),
309 }
310 }
311
312 pub fn subscribe(&self, run_id: Uuid) -> broadcast::Receiver<WorkflowEvent> {
329 let mut channels = self.channels.write().expect("event bus lock poisoned");
330 let sender = channels
331 .entry(run_id)
332 .or_insert_with(|| broadcast::channel(DEFAULT_BUFFER_SIZE).0);
333 sender.subscribe()
334 }
335
336 pub fn publish(&self, run_id: Uuid, event: WorkflowEvent) {
361 let channels = self.channels.read().expect("event bus lock poisoned");
362 if let Some(sender) = channels.get(&run_id) {
363 let _ = sender.send(event);
364 }
365 }
366
367 pub fn remove(&self, run_id: Uuid) {
384 let mut channels = self.channels.write().expect("event bus lock poisoned");
385 channels.remove(&run_id);
386 }
387}
388
389impl Default for WorkflowEventBus {
390 fn default() -> Self {
391 Self::new()
392 }
393}
394
395impl std::fmt::Debug for WorkflowEventBus {
396 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397 let count = self.channels.read().map(|c| c.len()).unwrap_or(0);
398 f.debug_struct("WorkflowEventBus")
399 .field("active_channels", &count)
400 .finish()
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 fn step_started(step_name: &str) -> WorkflowEvent {
409 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
410 step_name: step_name.to_string(),
411 step_index: 0,
412 timestamp: Utc::now(),
413 })
414 }
415
416 #[tokio::test]
417 async fn subscribe_receives_published_events() {
418 let bus = WorkflowEventBus::new();
419 let run_id = Uuid::now_v7();
420
421 let mut rx = bus.subscribe(run_id);
422
423 bus.publish(run_id, step_started("build"));
424
425 let received = rx.recv().await.expect("should receive event");
426 assert_eq!(received.event_type(), "step_started");
427 match received {
428 WorkflowEvent::StepStarted(e) => {
429 assert_eq!(e.step_name, "build");
430 assert_eq!(e.step_index, 0);
431 }
432 _ => panic!("expected StepStarted"),
433 }
434 }
435
436 #[test]
437 fn subscribe_creates_channel_on_demand() {
438 let bus = WorkflowEventBus::new();
439 let run_id = Uuid::now_v7();
440
441 let count_before = bus.channels.read().unwrap().len();
442 assert_eq!(count_before, 0);
443
444 let _rx = bus.subscribe(run_id);
445
446 let count_after = bus.channels.read().unwrap().len();
447 assert_eq!(count_after, 1);
448 }
449
450 #[test]
451 fn publish_unknown_run_is_noop() {
452 let bus = WorkflowEventBus::new();
453 let unknown_run = Uuid::now_v7();
454
455 bus.publish(unknown_run, step_started("build"));
456 }
457
458 #[test]
459 fn remove_cleans_up_channel() {
460 let bus = WorkflowEventBus::new();
461 let run_id = Uuid::now_v7();
462
463 let _rx = bus.subscribe(run_id);
464 assert_eq!(bus.channels.read().unwrap().len(), 1);
465
466 bus.remove(run_id);
467 assert_eq!(bus.channels.read().unwrap().len(), 0);
468 }
469
470 #[test]
471 fn remove_unknown_is_noop() {
472 let bus = WorkflowEventBus::new();
473 bus.remove(Uuid::now_v7());
474 }
475
476 #[test]
477 fn workflow_event_serde_roundtrip() {
478 let cases: Vec<WorkflowEvent> = vec![
479 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
480 step_name: "build".to_string(),
481 step_index: 0,
482 timestamp: Utc::now(),
483 }),
484 WorkflowEvent::StepCompleted(WorkflowStepCompletedEvent {
485 step_name: "deploy".to_string(),
486 step_index: 1,
487 duration_ms: 5000,
488 output_summary: Some("deployed v1.2.3".to_string()),
489 }),
490 WorkflowEvent::StepFailed(WorkflowStepFailedEvent {
491 step_name: "test".to_string(),
492 step_index: 2,
493 error: "exit code 1".to_string(),
494 duration_ms: 3000,
495 }),
496 WorkflowEvent::ApprovalRequired(WorkflowApprovalRequiredEvent {
497 step_name: "prod-gate".to_string(),
498 step_index: 3,
499 approval_id: Uuid::now_v7(),
500 }),
501 WorkflowEvent::AgentStepTokensUsed(WorkflowAgentStepTokensUsedEvent {
502 step_name: "review".to_string(),
503 tokens: 15000,
504 cost_usd: Decimal::new(42, 4),
505 }),
506 ];
507
508 for event in &cases {
509 let json = serde_json::to_string(event).expect("serialize");
510 let back: WorkflowEvent = serde_json::from_str(&json).expect("deserialize");
511
512 assert_eq!(back.event_type(), event.event_type());
513 assert!(json.contains(&format!("\"type\":\"{}\"", event.event_type())));
514 }
515 }
516
517 #[test]
521 fn workflow_event_legacy_flat_json_deserializes() {
522 let approval_id: Uuid = "01890000-0000-7000-8000-000000000002"
523 .parse()
524 .expect("valid uuid");
525
526 let raw = r#"{"type":"step_started","step_name":"build","step_index":0,"timestamp":"2026-01-01T00:00:00Z"}"#;
527 match serde_json::from_str::<WorkflowEvent>(raw).expect("legacy payload") {
528 WorkflowEvent::StepStarted(e) => {
529 assert_eq!(e.step_name, "build");
530 assert_eq!(e.step_index, 0);
531 }
532 other => panic!("expected StepStarted, got {other:?}"),
533 }
534
535 let raw = r#"{"type":"step_completed","step_name":"deploy","step_index":1,"duration_ms":5000,"output_summary":"deployed v1.2.3"}"#;
536 match serde_json::from_str::<WorkflowEvent>(raw).expect("legacy payload") {
537 WorkflowEvent::StepCompleted(e) => {
538 assert_eq!(e.duration_ms, 5000);
539 assert_eq!(e.output_summary.as_deref(), Some("deployed v1.2.3"));
540 }
541 other => panic!("expected StepCompleted, got {other:?}"),
542 }
543
544 let raw = r#"{"type":"step_failed","step_name":"test","step_index":2,"error":"exit code 1","duration_ms":3000}"#;
545 match serde_json::from_str::<WorkflowEvent>(raw).expect("legacy payload") {
546 WorkflowEvent::StepFailed(e) => {
547 assert_eq!(e.error, "exit code 1");
548 assert_eq!(e.duration_ms, 3000);
549 }
550 other => panic!("expected StepFailed, got {other:?}"),
551 }
552
553 let raw = r#"{"type":"approval_required","step_name":"prod-gate","step_index":3,"approval_id":"01890000-0000-7000-8000-000000000002"}"#;
554 match serde_json::from_str::<WorkflowEvent>(raw).expect("legacy payload") {
555 WorkflowEvent::ApprovalRequired(e) => {
556 assert_eq!(e.approval_id, approval_id);
557 }
558 other => panic!("expected ApprovalRequired, got {other:?}"),
559 }
560
561 let raw = r#"{"type":"agent_step_tokens_used","step_name":"review","tokens":15000,"cost_usd":0.5}"#;
562 match serde_json::from_str::<WorkflowEvent>(raw).expect("legacy payload") {
563 WorkflowEvent::AgentStepTokensUsed(e) => {
564 assert_eq!(e.tokens, 15000);
565 assert_eq!(e.cost_usd, Decimal::new(5, 1));
566 }
567 other => panic!("expected AgentStepTokensUsed, got {other:?}"),
568 }
569 }
570
571 #[test]
574 fn serialized_workflow_event_is_flat_with_type_tag() {
575 let event = WorkflowEvent::StepFailed(WorkflowStepFailedEvent {
576 step_name: "test".to_string(),
577 step_index: 2,
578 error: "exit code 1".to_string(),
579 duration_ms: 3000,
580 });
581
582 let value: serde_json::Value = serde_json::to_value(&event).expect("serialize");
583 let object = value.as_object().expect("event serializes to an object");
584
585 assert_eq!(
586 object.get("type").and_then(|v| v.as_str()),
587 Some("step_failed")
588 );
589 assert_eq!(
590 object.get("step_name").and_then(|v| v.as_str()),
591 Some("test")
592 );
593 assert_eq!(object.get("step_index").and_then(|v| v.as_u64()), Some(2));
594 assert_eq!(
595 object.get("error").and_then(|v| v.as_str()),
596 Some("exit code 1")
597 );
598 assert_eq!(
599 object.get("duration_ms").and_then(|v| v.as_u64()),
600 Some(3000)
601 );
602 assert_eq!(object.len(), 5, "no nesting: {object:?}");
603 }
604
605 #[test]
606 fn event_type_all_variants() {
607 let cases: Vec<(WorkflowEvent, &str)> = vec![
608 (
609 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
610 step_name: "s".to_string(),
611 step_index: 0,
612 timestamp: Utc::now(),
613 }),
614 "step_started",
615 ),
616 (
617 WorkflowEvent::StepCompleted(WorkflowStepCompletedEvent {
618 step_name: "s".to_string(),
619 step_index: 0,
620 duration_ms: 0,
621 output_summary: None,
622 }),
623 "step_completed",
624 ),
625 (
626 WorkflowEvent::StepFailed(WorkflowStepFailedEvent {
627 step_name: "s".to_string(),
628 step_index: 0,
629 error: "e".to_string(),
630 duration_ms: 0,
631 }),
632 "step_failed",
633 ),
634 (
635 WorkflowEvent::ApprovalRequired(WorkflowApprovalRequiredEvent {
636 step_name: "s".to_string(),
637 step_index: 0,
638 approval_id: Uuid::now_v7(),
639 }),
640 "approval_required",
641 ),
642 (
643 WorkflowEvent::AgentStepTokensUsed(WorkflowAgentStepTokensUsedEvent {
644 step_name: "s".to_string(),
645 tokens: 0,
646 cost_usd: Decimal::ZERO,
647 }),
648 "agent_step_tokens_used",
649 ),
650 ];
651
652 for (event, expected) in cases {
653 assert_eq!(event.event_type(), expected);
654 }
655 }
656
657 #[tokio::test]
658 async fn multiple_subscribers_receive_same_event() {
659 let bus = WorkflowEventBus::new();
660 let run_id = Uuid::now_v7();
661
662 let mut rx1 = bus.subscribe(run_id);
663 let mut rx2 = bus.subscribe(run_id);
664
665 bus.publish(run_id, step_started("build"));
666
667 let e1 = rx1.recv().await.expect("rx1 should receive");
668 let e2 = rx2.recv().await.expect("rx2 should receive");
669
670 assert_eq!(e1.event_type(), "step_started");
671 assert_eq!(e2.event_type(), "step_started");
672 }
673
674 #[tokio::test]
675 async fn events_isolated_between_runs() {
676 let bus = WorkflowEventBus::new();
677 let run_a = Uuid::now_v7();
678 let run_b = Uuid::now_v7();
679
680 let mut rx_a = bus.subscribe(run_a);
681 let mut rx_b = bus.subscribe(run_b);
682
683 bus.publish(run_a, step_started("only-for-a"));
684
685 let received = rx_a.recv().await.expect("rx_a should receive");
686 match received {
687 WorkflowEvent::StepStarted(e) => {
688 assert_eq!(e.step_name, "only-for-a");
689 }
690 _ => panic!("expected StepStarted"),
691 }
692
693 assert!(rx_b.try_recv().is_err());
695 }
696}