1use std::collections::HashMap;
34use std::sync::RwLock;
35
36use chrono::{DateTime, Utc};
37use rust_decimal::Decimal;
38use serde::{Deserialize, Serialize};
39use serde_json::Value;
40use tokio::sync::broadcast;
41use uuid::Uuid;
42
43const DEFAULT_BUFFER_SIZE: usize = 64;
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
62#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
63pub struct WorkflowStepStartedEvent {
64 pub step_name: String,
66 pub step_index: u32,
68 pub timestamp: DateTime<Utc>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
88#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
89pub struct WorkflowStepCompletedEvent {
90 pub step_name: String,
92 pub step_index: u32,
94 pub duration_ms: u64,
96 pub output_summary: Option<String>,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
116#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
117pub struct WorkflowStepFailedEvent {
118 pub step_name: String,
120 pub step_index: u32,
122 pub error: String,
124 pub duration_ms: u64,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
144#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
145pub struct WorkflowApprovalRequiredEvent {
146 pub step_name: String,
148 pub step_index: u32,
150 pub approval_id: Uuid,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
174#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
175pub struct WorkflowInputRequiredEvent {
176 pub run_id: Uuid,
178 pub step_id: Uuid,
180 pub step_name: String,
182 pub step_index: u32,
184 pub message: String,
186 #[cfg_attr(feature = "openapi", schema(value_type = Object))]
188 pub schema: Value,
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
207#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
208pub struct WorkflowAgentStepTokensUsedEvent {
209 pub step_name: String,
211 pub tokens: u64,
213 pub cost_usd: Decimal,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
245#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
246#[serde(tag = "type", rename_all = "snake_case")]
247pub enum WorkflowEvent {
248 StepStarted(WorkflowStepStartedEvent),
250
251 StepCompleted(WorkflowStepCompletedEvent),
253
254 StepFailed(WorkflowStepFailedEvent),
256
257 ApprovalRequired(WorkflowApprovalRequiredEvent),
259
260 InputRequired(WorkflowInputRequiredEvent),
262
263 AgentStepTokensUsed(WorkflowAgentStepTokensUsedEvent),
265}
266
267impl WorkflowEvent {
268 pub const STEP_STARTED: &'static str = "step_started";
270 pub const STEP_COMPLETED: &'static str = "step_completed";
272 pub const STEP_FAILED: &'static str = "step_failed";
274 pub const APPROVAL_REQUIRED: &'static str = "approval_required";
276 pub const INPUT_REQUIRED: &'static str = "input_required";
278 pub const AGENT_STEP_TOKENS_USED: &'static str = "agent_step_tokens_used";
280
281 #[deny(unreachable_patterns)]
297 pub fn event_type(&self) -> &'static str {
298 match self {
299 WorkflowEvent::StepStarted(_) => Self::STEP_STARTED,
300 WorkflowEvent::StepCompleted(_) => Self::STEP_COMPLETED,
301 WorkflowEvent::StepFailed(_) => Self::STEP_FAILED,
302 WorkflowEvent::ApprovalRequired(_) => Self::APPROVAL_REQUIRED,
303 WorkflowEvent::InputRequired(_) => Self::INPUT_REQUIRED,
304 WorkflowEvent::AgentStepTokensUsed(_) => Self::AGENT_STEP_TOKENS_USED,
305 }
306 }
307}
308
309#[derive(Clone)]
336pub struct WorkflowEventBus {
337 channels: std::sync::Arc<RwLock<HashMap<Uuid, broadcast::Sender<WorkflowEvent>>>>,
338}
339
340impl WorkflowEventBus {
341 pub fn new() -> Self {
351 Self {
352 channels: std::sync::Arc::new(RwLock::new(HashMap::new())),
353 }
354 }
355
356 pub fn subscribe(&self, run_id: Uuid) -> broadcast::Receiver<WorkflowEvent> {
373 let mut channels = self.channels.write().expect("event bus lock poisoned");
374 let sender = channels
375 .entry(run_id)
376 .or_insert_with(|| broadcast::channel(DEFAULT_BUFFER_SIZE).0);
377 sender.subscribe()
378 }
379
380 pub fn publish(&self, run_id: Uuid, event: WorkflowEvent) {
405 let channels = self.channels.read().expect("event bus lock poisoned");
406 if let Some(sender) = channels.get(&run_id) {
407 let _ = sender.send(event);
408 }
409 }
410
411 pub fn remove(&self, run_id: Uuid) {
428 let mut channels = self.channels.write().expect("event bus lock poisoned");
429 channels.remove(&run_id);
430 }
431}
432
433impl Default for WorkflowEventBus {
434 fn default() -> Self {
435 Self::new()
436 }
437}
438
439impl std::fmt::Debug for WorkflowEventBus {
440 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441 let count = self.channels.read().map(|c| c.len()).unwrap_or(0);
442 f.debug_struct("WorkflowEventBus")
443 .field("active_channels", &count)
444 .finish()
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use serde_json::json;
451
452 use super::*;
453
454 fn step_started(step_name: &str) -> WorkflowEvent {
455 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
456 step_name: step_name.to_string(),
457 step_index: 0,
458 timestamp: Utc::now(),
459 })
460 }
461
462 #[tokio::test]
463 async fn subscribe_receives_published_events() {
464 let bus = WorkflowEventBus::new();
465 let run_id = Uuid::now_v7();
466
467 let mut rx = bus.subscribe(run_id);
468
469 bus.publish(run_id, step_started("build"));
470
471 let received = rx.recv().await.expect("should receive event");
472 assert_eq!(received.event_type(), "step_started");
473 match received {
474 WorkflowEvent::StepStarted(e) => {
475 assert_eq!(e.step_name, "build");
476 assert_eq!(e.step_index, 0);
477 }
478 _ => panic!("expected StepStarted"),
479 }
480 }
481
482 #[test]
483 fn subscribe_creates_channel_on_demand() {
484 let bus = WorkflowEventBus::new();
485 let run_id = Uuid::now_v7();
486
487 let count_before = bus.channels.read().unwrap().len();
488 assert_eq!(count_before, 0);
489
490 let _rx = bus.subscribe(run_id);
491
492 let count_after = bus.channels.read().unwrap().len();
493 assert_eq!(count_after, 1);
494 }
495
496 #[test]
497 fn publish_unknown_run_is_noop() {
498 let bus = WorkflowEventBus::new();
499 let unknown_run = Uuid::now_v7();
500
501 bus.publish(unknown_run, step_started("build"));
502 }
503
504 #[test]
505 fn remove_cleans_up_channel() {
506 let bus = WorkflowEventBus::new();
507 let run_id = Uuid::now_v7();
508
509 let _rx = bus.subscribe(run_id);
510 assert_eq!(bus.channels.read().unwrap().len(), 1);
511
512 bus.remove(run_id);
513 assert_eq!(bus.channels.read().unwrap().len(), 0);
514 }
515
516 #[test]
517 fn remove_unknown_is_noop() {
518 let bus = WorkflowEventBus::new();
519 bus.remove(Uuid::now_v7());
520 }
521
522 #[test]
523 fn workflow_event_serde_roundtrip() {
524 let cases: Vec<WorkflowEvent> = vec![
525 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
526 step_name: "build".to_string(),
527 step_index: 0,
528 timestamp: Utc::now(),
529 }),
530 WorkflowEvent::StepCompleted(WorkflowStepCompletedEvent {
531 step_name: "deploy".to_string(),
532 step_index: 1,
533 duration_ms: 5000,
534 output_summary: Some("deployed v1.2.3".to_string()),
535 }),
536 WorkflowEvent::StepFailed(WorkflowStepFailedEvent {
537 step_name: "test".to_string(),
538 step_index: 2,
539 error: "exit code 1".to_string(),
540 duration_ms: 3000,
541 }),
542 WorkflowEvent::ApprovalRequired(WorkflowApprovalRequiredEvent {
543 step_name: "prod-gate".to_string(),
544 step_index: 3,
545 approval_id: Uuid::now_v7(),
546 }),
547 WorkflowEvent::InputRequired(WorkflowInputRequiredEvent {
548 run_id: Uuid::now_v7(),
549 step_id: Uuid::now_v7(),
550 step_name: "clarify".to_string(),
551 step_index: 4,
552 message: "Answer the questions".to_string(),
553 schema: json!({"type": "object"}),
554 }),
555 WorkflowEvent::AgentStepTokensUsed(WorkflowAgentStepTokensUsedEvent {
556 step_name: "review".to_string(),
557 tokens: 15000,
558 cost_usd: Decimal::new(42, 4),
559 }),
560 ];
561
562 for event in &cases {
563 let json = serde_json::to_string(event).expect("serialize");
564 let back: WorkflowEvent = serde_json::from_str(&json).expect("deserialize");
565
566 assert_eq!(back.event_type(), event.event_type());
567 assert!(json.contains(&format!("\"type\":\"{}\"", event.event_type())));
568 }
569 }
570
571 #[test]
575 fn workflow_event_legacy_flat_json_deserializes() {
576 let approval_id: Uuid = "01890000-0000-7000-8000-000000000002"
577 .parse()
578 .expect("valid uuid");
579
580 let raw = r#"{"type":"step_started","step_name":"build","step_index":0,"timestamp":"2026-01-01T00:00:00Z"}"#;
581 match serde_json::from_str::<WorkflowEvent>(raw).expect("legacy payload") {
582 WorkflowEvent::StepStarted(e) => {
583 assert_eq!(e.step_name, "build");
584 assert_eq!(e.step_index, 0);
585 }
586 other => panic!("expected StepStarted, got {other:?}"),
587 }
588
589 let raw = r#"{"type":"step_completed","step_name":"deploy","step_index":1,"duration_ms":5000,"output_summary":"deployed v1.2.3"}"#;
590 match serde_json::from_str::<WorkflowEvent>(raw).expect("legacy payload") {
591 WorkflowEvent::StepCompleted(e) => {
592 assert_eq!(e.duration_ms, 5000);
593 assert_eq!(e.output_summary.as_deref(), Some("deployed v1.2.3"));
594 }
595 other => panic!("expected StepCompleted, got {other:?}"),
596 }
597
598 let raw = r#"{"type":"step_failed","step_name":"test","step_index":2,"error":"exit code 1","duration_ms":3000}"#;
599 match serde_json::from_str::<WorkflowEvent>(raw).expect("legacy payload") {
600 WorkflowEvent::StepFailed(e) => {
601 assert_eq!(e.error, "exit code 1");
602 assert_eq!(e.duration_ms, 3000);
603 }
604 other => panic!("expected StepFailed, got {other:?}"),
605 }
606
607 let raw = r#"{"type":"approval_required","step_name":"prod-gate","step_index":3,"approval_id":"01890000-0000-7000-8000-000000000002"}"#;
608 match serde_json::from_str::<WorkflowEvent>(raw).expect("legacy payload") {
609 WorkflowEvent::ApprovalRequired(e) => {
610 assert_eq!(e.approval_id, approval_id);
611 }
612 other => panic!("expected ApprovalRequired, got {other:?}"),
613 }
614
615 let raw = r#"{"type":"agent_step_tokens_used","step_name":"review","tokens":15000,"cost_usd":0.5}"#;
616 match serde_json::from_str::<WorkflowEvent>(raw).expect("legacy payload") {
617 WorkflowEvent::AgentStepTokensUsed(e) => {
618 assert_eq!(e.tokens, 15000);
619 assert_eq!(e.cost_usd, Decimal::new(5, 1));
620 }
621 other => panic!("expected AgentStepTokensUsed, got {other:?}"),
622 }
623 }
624
625 #[test]
628 fn serialized_workflow_event_is_flat_with_type_tag() {
629 let event = WorkflowEvent::StepFailed(WorkflowStepFailedEvent {
630 step_name: "test".to_string(),
631 step_index: 2,
632 error: "exit code 1".to_string(),
633 duration_ms: 3000,
634 });
635
636 let value: serde_json::Value = serde_json::to_value(&event).expect("serialize");
637 let object = value.as_object().expect("event serializes to an object");
638
639 assert_eq!(
640 object.get("type").and_then(|v| v.as_str()),
641 Some("step_failed")
642 );
643 assert_eq!(
644 object.get("step_name").and_then(|v| v.as_str()),
645 Some("test")
646 );
647 assert_eq!(object.get("step_index").and_then(|v| v.as_u64()), Some(2));
648 assert_eq!(
649 object.get("error").and_then(|v| v.as_str()),
650 Some("exit code 1")
651 );
652 assert_eq!(
653 object.get("duration_ms").and_then(|v| v.as_u64()),
654 Some(3000)
655 );
656 assert_eq!(object.len(), 5, "no nesting: {object:?}");
657 }
658
659 #[test]
660 fn input_required_event_serializes_flat_with_its_schema() {
661 let step_id = Uuid::now_v7();
662 let event = WorkflowEvent::InputRequired(WorkflowInputRequiredEvent {
663 run_id: Uuid::now_v7(),
664 step_id,
665 step_name: "clarify".to_string(),
666 step_index: 1,
667 message: "Answer the questions".to_string(),
668 schema: json!({"type": "object", "required": ["answers"]}),
669 });
670
671 let value = serde_json::to_value(&event).expect("serialize");
672 assert_eq!(value["type"], "input_required");
673 assert_eq!(value["step_id"], step_id.to_string());
674 assert_eq!(value["message"], "Answer the questions");
675 assert_eq!(value["schema"]["required"][0], "answers");
676 assert_eq!(event.event_type(), WorkflowEvent::INPUT_REQUIRED);
677 }
678
679 #[test]
680 fn event_type_all_variants() {
681 let cases: Vec<(WorkflowEvent, &str)> = vec![
682 (
683 WorkflowEvent::StepStarted(WorkflowStepStartedEvent {
684 step_name: "s".to_string(),
685 step_index: 0,
686 timestamp: Utc::now(),
687 }),
688 "step_started",
689 ),
690 (
691 WorkflowEvent::StepCompleted(WorkflowStepCompletedEvent {
692 step_name: "s".to_string(),
693 step_index: 0,
694 duration_ms: 0,
695 output_summary: None,
696 }),
697 "step_completed",
698 ),
699 (
700 WorkflowEvent::StepFailed(WorkflowStepFailedEvent {
701 step_name: "s".to_string(),
702 step_index: 0,
703 error: "e".to_string(),
704 duration_ms: 0,
705 }),
706 "step_failed",
707 ),
708 (
709 WorkflowEvent::ApprovalRequired(WorkflowApprovalRequiredEvent {
710 step_name: "s".to_string(),
711 step_index: 0,
712 approval_id: Uuid::now_v7(),
713 }),
714 "approval_required",
715 ),
716 (
717 WorkflowEvent::InputRequired(WorkflowInputRequiredEvent {
718 run_id: Uuid::now_v7(),
719 step_id: Uuid::now_v7(),
720 step_name: "s".to_string(),
721 step_index: 0,
722 message: "m".to_string(),
723 schema: Value::Null,
724 }),
725 "input_required",
726 ),
727 (
728 WorkflowEvent::AgentStepTokensUsed(WorkflowAgentStepTokensUsedEvent {
729 step_name: "s".to_string(),
730 tokens: 0,
731 cost_usd: Decimal::ZERO,
732 }),
733 "agent_step_tokens_used",
734 ),
735 ];
736
737 for (event, expected) in cases {
738 assert_eq!(event.event_type(), expected);
739 }
740 }
741
742 #[tokio::test]
743 async fn multiple_subscribers_receive_same_event() {
744 let bus = WorkflowEventBus::new();
745 let run_id = Uuid::now_v7();
746
747 let mut rx1 = bus.subscribe(run_id);
748 let mut rx2 = bus.subscribe(run_id);
749
750 bus.publish(run_id, step_started("build"));
751
752 let e1 = rx1.recv().await.expect("rx1 should receive");
753 let e2 = rx2.recv().await.expect("rx2 should receive");
754
755 assert_eq!(e1.event_type(), "step_started");
756 assert_eq!(e2.event_type(), "step_started");
757 }
758
759 #[tokio::test]
760 async fn events_isolated_between_runs() {
761 let bus = WorkflowEventBus::new();
762 let run_a = Uuid::now_v7();
763 let run_b = Uuid::now_v7();
764
765 let mut rx_a = bus.subscribe(run_a);
766 let mut rx_b = bus.subscribe(run_b);
767
768 bus.publish(run_a, step_started("only-for-a"));
769
770 let received = rx_a.recv().await.expect("rx_a should receive");
771 match received {
772 WorkflowEvent::StepStarted(e) => {
773 assert_eq!(e.step_name, "only-for-a");
774 }
775 _ => panic!("expected StepStarted"),
776 }
777
778 assert!(rx_b.try_recv().is_err());
780 }
781}