//! A process-wide push bus for live taxonomy events. //! //! `/api/world/live` is a 2-second database poll. That is the right shape for //! state you can query — statuses, phases, telemetry — and the wrong shape for a //! token stream: an agent's reasoning only becomes visible after the step //! finishes and its text is persisted, so the REASONING STREAM card showed //! completed paragraphs rather than an agent thinking. //! //! This carries the frames that cannot wait for a round trip through Postgres. //! `topology_exec` publishes as the runtime's WebSocket delivers them; the SSE //! handler subscribes and forwards, so a chunk reaches the browser in one hop. //! //! **Why a global rather than a field on `AppState`.** The publisher is //! `topology_exec`, reached through `phase_runner` → `topology_worker` → //! `MissionTap`, none of which hold `AppState`. Threading a handle through all //! of them would put a UI concern into four layers that have no other reason to //! know about one. There is exactly one bus per process and it holds no //! per-request state, so a `OnceLock` is the honest representation. //! //! **Lossy on purpose.** A slow reader lags and skips rather than applying //! backpressure to the agent that is producing. Dropping frames degrades a live //! view; blocking would slow the mission to the speed of the slowest open tab. //! The durable record is `mission_events` — this bus is the fast path, never the //! source of truth. use std::sync::{Arc, OnceLock}; use serde_json::Value; use tokio::sync::broadcast; use uuid::Uuid; /// Bounded so a stalled subscriber costs memory once, not unboundedly. At /// token granularity a busy mission produces a few hundred frames a second; /// this is roughly a couple of seconds of slack before a slow reader starts /// skipping. const CAPACITY: usize = 2048; #[derive(Debug, Clone)] pub struct LiveEvent { /// Every subscriber is workspace-scoped; the bus is not. pub workspace_id: Uuid, /// A taxonomy type, e.g. `agent.reasoning.delta`. pub kind: String, pub data: Value, } pub struct LiveBus { tx: broadcast::Sender, } impl LiveBus { fn new() -> LiveBus { let (tx, _rx) = broadcast::channel(CAPACITY); LiveBus { tx } } /// Publish. Returns immediately, and succeeds even with no subscribers — /// nobody watching is the normal case, not an error. pub fn publish(&self, workspace_id: Uuid, kind: &str, data: Value) { let _ = self.tx.send(LiveEvent { workspace_id, kind: kind.to_string(), data, }); } pub fn subscribe(&self) -> broadcast::Receiver { self.tx.subscribe() } } static BUS: OnceLock> = OnceLock::new(); pub fn global() -> &'static Arc { BUS.get_or_init(|| Arc::new(LiveBus::new())) } /// The claw alias the runtime dispatches on (`claw_`) → the agent id the /// UI keys on. Returns `None` for any other alias — the governor, the door and /// the evaluator all drive turns under names that are not claws, and attributing /// their output to an agent would put words in someone's mouth. pub fn agent_id_from_alias(alias: &str) -> Option { Uuid::parse_str(alias.strip_prefix("claw_")?).ok() } #[cfg(test)] mod tests { use super::*; #[test] fn only_claw_aliases_resolve_to_an_agent() { let id = Uuid::now_v7(); assert_eq!( agent_id_from_alias(&format!("claw_{id}")), Some(id), "the runtime's own alias form must resolve" ); // These drive real turns and must NOT be attributed to an agent. for other in ["scout", "coordinator", "door", "evaluator", "claw_nonsense"] { assert_eq!(agent_id_from_alias(other), None, "{other}"); } } #[tokio::test] async fn a_subscriber_receives_what_is_published() { let bus = LiveBus::new(); let mut rx = bus.subscribe(); let ws = Uuid::now_v7(); bus.publish( ws, "agent.reasoning.delta", serde_json::json!({"text": "hi"}), ); let ev = rx.recv().await.expect("delivered"); assert_eq!(ev.workspace_id, ws); assert_eq!(ev.kind, "agent.reasoning.delta"); } /// Publishing with nobody listening must not error — that is the common /// case (no browser open) and it must never disturb the mission. #[test] fn publishing_into_the_void_is_fine() { let bus = LiveBus::new(); bus.publish(Uuid::now_v7(), "agent.tool.call", serde_json::json!({})); } }