//! Broadcast projection for durable Agent run/event updates. //! //! The hub follows the WorkManager plan-change broadcast pattern: the //! Coordinator publishes a bounded view after each commit; the gateway //! relays it to WebSocket clients. A lost broadcast is never fatal — the //! client recalibrates with `GetAgentRuns`, whose `revision` comes from //! `agent_session_state`. use crate::protocol::{AgentEventView, AgentRunView}; /// One projected change for a root session. Exactly one of `run`/`event` is /// present. #[derive(Debug, Clone)] pub struct AgentProjection { pub session_id: String, pub revision: i64, pub run: Option, pub event: Option, } #[derive(Debug, Clone)] pub struct AgentProjectionHub { tx: tokio::sync::broadcast::Sender, } impl Default for AgentProjectionHub { fn default() -> Self { Self::new() } } impl AgentProjectionHub { pub fn new() -> Self { let (tx, _) = tokio::sync::broadcast::channel(256); Self { tx } } pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { self.tx.subscribe() } /// Publish a run update. Best-effort: relays lag and drop the event, /// clients recalibrate with a full query. pub fn publish(&self, projection: AgentProjection) { let _ = self.tx.send(projection); } } #[cfg(test)] mod tests { use super::*; #[test] fn publish_reaches_subscribers_and_drop_is_nonfatal() { let hub = AgentProjectionHub::new(); let mut rx = hub.subscribe(); hub.publish(AgentProjection { session_id: "cli:test:d".to_string(), revision: 1, run: None, event: None, }); let received = rx.try_recv().unwrap(); assert_eq!(received.session_id, "cli:test:d"); // No subscribers after the first is dropped; publish must not panic. drop(rx); hub.publish(AgentProjection { session_id: "cli:test:d".to_string(), revision: 2, run: None, event: None, }); } }