Skip to main content

agentmux_srv/backend/
mps.rs

1// Copyright 2025, Command Line Inc.
2// Copyright 2025-2026, AgentMux Corp.
3// SPDX-License-Identifier: Apache-2.0
4
5//! AgentMux Pub/Sub system: event brokering with scoped subscriptions.
6//! Port of Go's pkg/wps/wps.go + wpstypes.go (Wave Terminal's original names).
7
8//!
9//! The Broker supports:
10//! - All-scope subscriptions (receive all events of a type)
11//! - Exact-scope subscriptions (e.g., "block:uuid")
12//! - Star-scope subscriptions (e.g., "block:*")
13//! - Event persistence (history/replay)
14
15use std::collections::{HashMap, HashSet};
16use std::sync::Mutex;
17
18use serde::{Deserialize, Serialize};
19
20// ---- Event type constants (match Go) ----
21
22#[allow(dead_code)]
23pub const EVENT_BLOCK_CLOSE: &str = "blockclose";
24#[allow(dead_code)]
25pub const EVENT_CONN_CHANGE: &str = "connchange";
26/// The Connectors → Remotes list changed in a way `connchange` doesn't cover: a
27/// remote's settings, or a recent one forgotten (SPEC_REMOTES_PANE_2026_10_05.md §4.6).
28pub const EVENT_REMOTES_CHANGE: &str = "remoteschange";
29/// The widget packages changed: installed, approved, edited, removed
30/// (SPEC_USER_WIDGETS_AND_WIDGET_API_2026_10_09.md §8). Payload `{ packages }`.
31pub const EVENT_WIDGET_PACKAGES: &str = "widgetpackages";
32/// A widget package's storage changed: `{ id, keys }`, never the values.
33pub const EVENT_WIDGET_STORAGE: &str = "widgetstorage";
34/// Agents' widget install requests waiting for the user: `{ requests }`.
35pub const EVENT_WIDGET_REQUESTS: &str = "widgetrequests";
36/// A call to action srv holds open started or ended:
37/// `{ key, block_id, kind, text, window_ids, active }` (`backend/user_attention.rs`).
38pub const EVENT_USER_ATTENTION: &str = "userattention";
39pub const EVENT_SYS_INFO: &str = "sysinfo";
40pub const EVENT_CONTROLLER_STATUS: &str = "controllerstatus";
41/// What an agent process was actually spawned with. See `agent_runtime.rs`.
42pub const EVENT_AGENT_RUNTIME: &str = "agentruntime";
43/// Where an agent process will auto-compact, as its CLI reports it. See
44/// `agent_context_usage.rs`.
45pub const EVENT_AGENT_CONTEXT_USAGE: &str = "agentcontextusage";
46/// The agent's turn as the user sees it, over however many CLI passes. See
47/// `blockcontroller::health::TurnLedger`.
48pub const EVENT_AGENT_TURN: &str = "agentturn";
49pub const EVENT_MUX_OBJ_UPDATE: &str = "waveobj:update";
50pub const EVENT_BLOCK_FILE: &str = "blockfile";
51pub const EVENT_INSTALL_PROGRESS: &str = "install_progress";
52#[allow(dead_code)]
53pub const EVENT_CONFIG: &str = "config";
54#[allow(dead_code)]
55pub const EVENT_USER_INPUT: &str = "userinput";
56/// Fired by `SubprocessController::spawn_turn` when a user message is
57/// picked up (either direct-spawn or queue drain). Frontend uses this to
58/// promote pending `PendingMessage` entries into the conversation
59/// document. Payload: `{ block_id, message_id }`.
60pub const EVENT_AGENT_MESSAGE_ACCEPTED: &str = "agent-message-accepted";
61/// Image attachment processing (SPEC_AGENT_PANE_IMAGE_ATTACHMENTS_2026_09_26.md
62/// §6.5). Scoped `block:<id>` of the pane that started the batch. Payloads are
63/// `AttachmentProgressEvent` / `AttachmentReadyEvent` / `AttachmentFailedEvent`
64/// / `AttachmentBatchDoneEvent` in `rpc_types::attachments`.
65pub const EVENT_ATTACHMENT_PROGRESS: &str = "attachment:progress";
66pub const EVENT_ATTACHMENT_READY: &str = "attachment:ready";
67pub const EVENT_ATTACHMENT_FAILED: &str = "attachment:failed";
68pub const EVENT_ATTACHMENT_BATCH_DONE: &str = "attachment:batch-done";
69#[allow(dead_code)]
70pub const EVENT_ROUTE_GONE: &str = "route:gone";
71pub const EVENT_BLOCK_STATS: &str = "blockstats";
72/// Fired when an agent subprocess exits non-zero (or reports an error on its
73/// terminal `result` frame). Carries the classified `AgentFailure` so the pane
74/// shows the real cause instead of a bare exit code.
75pub const EVENT_AGENT_FAILURE: &str = "agentfailure";
76/// Fired by the persistent controller's stale-`--resume` recovery path
77/// (`retry_after_resume_failure` / `publish_resume_retry_status`,
78/// `docs/status/STATUS_STALE_RESUME_LIVE_REPRO_AND_FIX_PLAN_2026_08_23.md` §6.2)
79/// so the pane can show a "Reconnecting…" readout instead of going silent for
80/// the ~seconds-to-tens-of-seconds it can take the controller to detect a
81/// stale registry `session_id` and respawn against a recovered one.
82/// Payload: `{ "status": "retrying", "startedAt": "<rfc3339>" }` or
83/// `{ "status": "resolved" }`. `persist: 2` (unlike `compaction_started`'s
84/// `persist: 0`) is deliberate: both ends of this signal travel over this
85/// same MPS channel (there's no separate out-of-band completion marker the
86/// way `compact_boundary` is for compaction), so replaying the latest
87/// retrying→resolved pair to a freshly (re)subscribed pane is always the
88/// *correct* current state, not a stale echo — see
89/// `publish_resume_retry_status`'s own doc comment for why 2, not 1.
90pub const EVENT_AGENT_RESUME_RETRY: &str = "agent-resume-retry";
91/// Fired by `SubagentWatcher::scan_session_subagents` (the pane-reopen
92/// cold-backfill entry point, `subagent_watcher/scan.rs`) so a pane can
93/// show its BrainSpinner overlay (`block.tsx`'s `ready()` gate) until its
94/// own subagent/dispatch history has actually finished backfilling, instead
95/// of exposing the Activity Dock's genuinely-changing intermediate states —
96/// see `docs/retro/retro-activity-dock-flicker-survives-debounce-fix-2026-08-24.md`
97/// §5 option 1/2. Payload: `{ "status": "started" | "done" }`. `persist: 2`,
98/// same rationale as `EVENT_AGENT_RESUME_RETRY` above — both ends travel
99/// over this one channel, so a mount-time `EventReadHistoryCommand` read
100/// (guarding the same "pane subscribed after the backend already finished"
101/// race that event's own hook already handles) always recovers the correct
102/// current status rather than a stale one.
103pub const EVENT_SUBAGENT_BACKFILL_STATUS: &str = "subagent:backfill_status";
104/// Fired by `handle_shell_create` when a persistent shell is launched.
105/// Frontend creates the ShellNode row on receipt.
106/// Payload: `{ shell_id, cmd, cwd?, title, timestamp }`.
107pub const EVENT_SHELL_NODE_CREATE: &str = "shell_node_create";
108/// Fired per stdout/stderr line and on process exit by `ShellNodeRunner`.
109/// `op: "chunk"` carries `{ shell_id, kind, content, timestamp }`;
110/// `op: "exit"` carries `{ shell_id, exit_code, timestamp }`.
111pub const EVENT_SHELL_CHUNK: &str = "shell_chunk";
112/// Fired by the agent-pane PTY read loop when an OSC 0/2 window-title sequence
113/// from Claude Code is extracted. Carries the normalised conversation-topic string
114/// so the frontend can surface it as a `term:osc_title` tab label.
115/// Payload: `{ "blockId": "...", "activity": "auth refactor" }`.
116pub const EVENT_BLOCK_ACTIVITY: &str = "block:activity";
117/// Fired whenever a cron job is created, fires, is paused/resumed, or
118/// deleted (SPEC_SWARM_LONG_RUNNING_PROCESS_ROWS_2026_07_20 Phase 2).
119/// Payload-free, unscoped — the Swarm pane just reloads the full list via
120/// `cron.ListActive` on receipt, the same "any event of this type ⇒ refetch"
121/// pattern `shell_node_create`/`shell_chunk(op:"exit")` already use.
122pub const EVENT_CRON_CHANGED: &str = "cron_changed";
123/// Fired by `handle_muxspect_dock_clear` when a `muxspect dock clear`
124/// request is served. Scoped `block:<id>`, same convention as
125/// `shell_node_create` — only a renderer currently displaying that block
126/// receives it. Payload: `{ node_id }`. The receiving renderer flips that
127/// one `ToolNode` to `status: "canceled"` via the `ForceCancelToolNode`
128/// document-reducer command; a renderer without that node (already
129/// resolved, or a different pane's block) no-ops.
130/// See `docs/specs/SPEC_MUXSPECT_DOCK_DIAGNOSIS_AND_REMEDIATION_2026_08_06.md` §3.2.
131pub const EVENT_DOCK_CLEAR: &str = "dock:clear";
132/// Fired by the `/btw` slash command's one-shot side-question turn
133/// (`server/agent_handlers/side_question.rs`, `AskSideQuestionCommand`) —
134/// one event per `AgentEvent` translated from the throwaway turn's
135/// stream-json output (same `ClaudeTranslator` the drone Agent block runner
136/// uses, `agents/translator/claude.rs`), plus a final event with
137/// `"done": true` when the turn completes (successfully, with an error, or
138/// via a timeout/no-response fallback).
139///
140/// Scoped `block:<source_block_id>:btw:<request_id>` — the block id of the
141/// PANE THAT ASKED, not the throwaway block the turn actually ran on (that
142/// one is never surfaced, is deleted when the turn ends, and nothing is
143/// ever subscribed to its scope). `request_id` is the UUID
144/// `AskSideQuestionCommand` mints and returns, so two concurrent `/btw`
145/// asks from the same pane get independently-scoped streams. Payload:
146/// `{ "blockId": "<source_block_id>", "requestId": "<request_id>",
147/// "event": <AgentEvent JSON>, "done": bool }`. `persist: 0` — a `/btw`
148/// overlay is transient UI, not conversation history, so a pane that
149/// wasn't subscribed while it ran has nothing worth replaying.
150pub const EVENT_BTW_ANSWER_CHUNK: &str = "btw_answer_chunk";
151
152// File operation constants
153#[allow(dead_code)]
154pub const FILE_OP_CREATE: &str = "create";
155#[allow(dead_code)]
156pub const FILE_OP_DELETE: &str = "delete";
157pub const FILE_OP_APPEND: &str = "append";
158pub const FILE_OP_TRUNCATE: &str = "truncate";
159/// A transcript `output` replaced by other content (restore): `pos` names the
160/// new generation and its line count (Phase 5a-3).
161pub const FILE_OP_REPLACE: &str = "replace";
162#[allow(dead_code)]
163pub const FILE_OP_INVALIDATE: &str = "invalidate";
164
165const MAX_PERSIST: usize = 4096;
166const REMAKE_ARR_THRESHOLD: usize = 10 * 1024;
167
168// ---- Types ----
169
170/// One event on the broker. The envelope is typed; `data` is each event's own
171/// payload, still untyped (`any`) on the frontend: srv builds most payloads with
172/// `json!`, so there is no Rust type per event to generate yet.
173#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
174#[ts(export, export_to = "../../../frontend/types/rpc/")]
175pub struct MuxEvent {
176    pub event: String,
177    // The skipped-when-empty fields are absent on the wire, so optional in TS.
178    #[serde(skip_serializing_if = "Vec::is_empty", default)]
179    #[ts(as = "Option<Vec<String>>", optional)]
180    pub scopes: Vec<String>,
181    #[serde(skip_serializing_if = "String::is_empty", default)]
182    #[ts(as = "Option<String>", optional)]
183    pub sender: String,
184    #[serde(skip_serializing_if = "is_zero", default)]
185    #[ts(as = "Option<u32>", optional)]
186    pub persist: usize,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    #[ts(optional, type = "any")]
189    pub data: Option<serde_json::Value>,
190}
191
192fn is_zero(v: &usize) -> bool {
193    *v == 0
194}
195
196impl MuxEvent {
197    #[allow(dead_code)]
198    pub fn has_scope(&self, scope: &str) -> bool {
199        self.scopes.iter().any(|s| s == scope)
200    }
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
204#[ts(export, export_to = "../../../frontend/types/rpc/")]
205pub struct SubscriptionRequest {
206    pub event: String,
207    #[serde(skip_serializing_if = "Vec::is_empty", default)]
208    pub scopes: Vec<String>,
209    #[serde(default)]
210    pub allscopes: bool,
211}
212
213#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
214#[ts(export, export_to = "../../../frontend/types/rpc/")]
215pub struct WSFileEventData {
216    pub zoneid: String,
217    pub filename: String,
218    pub fileop: String,
219    #[serde(skip_serializing_if = "String::is_empty", default)]
220    #[ts(as = "Option<String>", optional)]
221    pub data64: String,
222    /// File size immediately before this append (the chunk spans
223    /// `[offset, offset + data.len())`). Only populated for filestore-
224    /// write-through-backed appends (`handle_append_block_file`); absent
225    /// means "no offset info available" — consumers should treat that as
226    /// "always new" (the pre-existing, always-write behavior).
227    #[serde(skip_serializing_if = "Option::is_none", default)]
228    #[ts(optional, type = "number")]
229    pub offset: Option<u64>,
230    /// Where this append landed, per transcript stream, for agent `output`
231    /// appends to a counted file (Phase 5a-3,
232    /// SPEC_AGENT_PANE_BOUNDED_LIVE_WINDOW_MIGRATION_2026_09_23.md §6.3.7).
233    /// Empty for anything else (terminal data, uncounted files). Consumers
234    /// pick the entry for the stream they read from.
235    #[serde(skip_serializing_if = "Vec::is_empty", default)]
236    #[ts(as = "Option<Vec<StreamPos>>", optional)]
237    pub pos: Vec<StreamPos>,
238    /// Set when the records are an echo of something the pane already shows:
239    /// `"stdin"` for a user message the controller wrote to the agent's stdin
240    /// (the pane has its optimistic node). Written to the transcript like any
241    /// record, with positions, so the stream has no gap; the pane doesn't add
242    /// a second node for it (Phase 5a-3c).
243    #[serde(skip_serializing_if = "Option::is_none", default)]
244    #[ts(optional)]
245    pub echo: Option<String>,
246}
247
248/// One transcript stream's position for an append: the records it wrote are
249/// lines `line .. lines` of generation `gen` of `stream`.
250#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)]
251#[ts(export, export_to = "../../../frontend/types/rpc/")]
252pub struct StreamPos {
253    /// `b:<blockId>` for the block's own `output`, `g:<zone>` for the
254    /// agent's global transcript zone.
255    pub stream: String,
256    pub gen: String,
257    /// Index of the first record this append wrote.
258    #[ts(type = "number")]
259    pub line: u64,
260    /// The stream's line count after this append.
261    #[ts(type = "number")]
262    pub lines: u64,
263}
264
265// ---- Client trait ----
266
267/// Trait for event delivery to connected clients.
268pub trait WpsClient: Send + Sync {
269    fn send_event(&self, route_id: &str, event: MuxEvent);
270}
271
272// ---- Subscription internals ----
273
274#[derive(Default)]
275struct BrokerSubscription {
276    /// Route IDs subscribed to all scopes for this event.
277    all_subs: Vec<String>,
278    /// Exact scope → route IDs.
279    scope_subs: HashMap<String, Vec<String>>,
280    /// Star/wildcard scope → route IDs.
281    star_subs: HashMap<String, Vec<String>>,
282}
283
284impl BrokerSubscription {
285    fn is_empty(&self) -> bool {
286        self.all_subs.is_empty() && self.scope_subs.is_empty() && self.star_subs.is_empty()
287    }
288}
289
290#[derive(Hash, Eq, PartialEq, Clone)]
291struct PersistKey {
292    event: String,
293    scope: String,
294}
295
296struct PersistEventWrap {
297    arr_total_adds: usize,
298    events: Vec<MuxEvent>,
299}
300
301// ---- Broker ----
302
303/// The central pub/sub broker for MuxEvents.
304pub struct Broker {
305    inner: Mutex<BrokerInner>,
306    /// In-process observers, called on every `publish` BEFORE the inner lock
307    /// is taken. They must be cheap and must not publish synchronously — the
308    /// notification Router's observer only parses and enqueues.
309    observers: std::sync::RwLock<Vec<BrokerObserver>>,
310}
311
312/// See [`Broker::add_observer`].
313pub type BrokerObserver = std::sync::Arc<dyn Fn(&MuxEvent) + Send + Sync>;
314
315/// Tracks `(route_id, event_name, scope)` tuples whose persisted
316/// history has already been replayed to a given route. Skipping
317/// replay on resubscribe prevents the frontend's `eventsub`
318/// flushes (sent on every listener add/remove, once per conn_id)
319/// from re-emitting completed bash logs on every pane mount or
320/// tab switch. Codex P2 on PR #817; route keying updated PR #1418.
321type ReplayKey = (String, String, String);
322
323struct BrokerInner {
324    client: Option<Box<dyn WpsClient>>,
325    sub_map: HashMap<String, BrokerSubscription>,
326    persist_map: HashMap<PersistKey, PersistEventWrap>,
327    replayed: HashSet<ReplayKey>,
328}
329
330impl Broker {
331    pub fn new() -> Self {
332        Self {
333            inner: Mutex::new(BrokerInner {
334                client: None,
335                sub_map: HashMap::new(),
336                persist_map: HashMap::new(),
337                replayed: HashSet::new(),
338            }),
339            observers: std::sync::RwLock::new(Vec::new()),
340        }
341    }
342
343    /// Observe every published event in-process (the broker otherwise has a
344    /// single fan-out client — the WS bridge). Used by the notification
345    /// Router to watch `agentfailure` without touching its publish sites.
346    pub fn add_observer(&self, f: BrokerObserver) {
347        self.observers.write().unwrap_or_else(|e| e.into_inner()).push(f);
348    }
349
350    pub fn set_client(&self, client: Box<dyn WpsClient>) {
351        let mut inner = self.inner.lock().unwrap();
352        inner.client = Some(client);
353    }
354
355    /// Subscribe a route to an event, optionally scoped.
356    ///
357    /// **Replay-on-subscribe**: after registering the route, immediately
358    /// deliver any persisted events that match the subscription. Lets
359    /// late subscribers catch up on the most recent state without
360    /// waiting for the next publish — closes the race for live-log
361    /// streaming where the frontend learns the tool_use_id only after
362    /// the wrapper has already finished publishing.
363    pub fn subscribe(&self, route_id: &str, sub: SubscriptionRequest) {
364        if sub.event.is_empty() {
365            return;
366        }
367        let mut inner = self.inner.lock().unwrap();
368        // Remove existing subscription first (re-subscribe)
369        Self::unsubscribe_nolock(&mut inner, route_id, &sub.event);
370
371        let bs = inner
372            .sub_map
373            .entry(sub.event.clone())
374            .or_default();
375
376        if sub.allscopes {
377            add_unique(&mut bs.all_subs, route_id);
378        } else {
379            for scope in &sub.scopes {
380                if scope_has_star(scope) {
381                    add_to_scope_map(&mut bs.star_subs, scope, route_id);
382                } else {
383                    add_to_scope_map(&mut bs.scope_subs, scope, route_id);
384                }
385            }
386        }
387
388        Self::replay_to_route(&mut inner, route_id, &sub);
389    }
390
391    /// Deliver any persisted events matching `sub` to `route_id`.
392    /// Called inside `subscribe` so replay happens atomically under
393    /// the broker lock — no live event published mid-replay can
394    /// interleave.
395    ///
396    /// **Once-per-(route, event, scope).** The frontend
397    /// (`frontend/app/store/mps.ts`) flushes `eventsub` on every
398    /// listener add/remove; each WebSocket connection has its own
399    /// `conn_id` as the route key (PR #1418). Replaying persisted
400    /// history on each of those flushes would re-emit completed bash
401    /// logs every pane mount / tab switch / sibling subscription. The
402    /// `replayed` set tracks tuples that already received their backfill
403    /// and short-circuits subsequent resubscribes. Cleared per-route in
404    /// `unsubscribe_all` so a true reconnect (route dropped +
405    /// re-registered) gets a fresh replay.
406    ///
407    /// Star-scope replay is intentionally not implemented (rare,
408    /// requires scanning every persist key; can add later if needed).
409    fn replay_to_route(
410        inner: &mut BrokerInner,
411        route_id: &str,
412        sub: &SubscriptionRequest,
413    ) {
414        let client = match &inner.client {
415            Some(c) => c,
416            None => return,
417        };
418
419        let mut scopes_to_deliver: Vec<String> = Vec::new();
420        if sub.allscopes {
421            // "" key holds the global history per persist_event's scope_set.
422            scopes_to_deliver.push(String::new());
423        } else {
424            for scope in &sub.scopes {
425                if !scope_has_star(scope) {
426                    scopes_to_deliver.push(scope.clone());
427                }
428            }
429        }
430
431        let mut to_send: Vec<MuxEvent> = Vec::new();
432        for scope in scopes_to_deliver {
433            let key = (route_id.to_string(), sub.event.clone(), scope.clone());
434            if inner.replayed.contains(&key) {
435                continue;
436            }
437            let pkey = PersistKey {
438                event: sub.event.clone(),
439                scope: scope.clone(),
440            };
441            if let Some(pe) = inner.persist_map.get(&pkey) {
442                for event in &pe.events {
443                    to_send.push(event.clone());
444                }
445            }
446            inner.replayed.insert(key);
447        }
448        for event in to_send {
449            client.send_event(route_id, event);
450        }
451    }
452
453    /// Whether any route is subscribed to `event_name`, in any scope. For a
454    /// publisher that can skip measuring what nobody reads.
455    pub fn has_subscribers(&self, event_name: &str) -> bool {
456        let inner = self.inner.lock().unwrap();
457        inner.sub_map.get(event_name).is_some_and(|bs| !bs.is_empty())
458    }
459
460    /// Unsubscribe a route from a specific event.
461    pub fn unsubscribe(&self, route_id: &str, event_name: &str) {
462        let mut inner = self.inner.lock().unwrap();
463        Self::unsubscribe_nolock(&mut inner, route_id, event_name);
464    }
465
466    /// Unsubscribe a route from all events.
467    ///
468    /// Also clears the `replayed` tracker for this route so a future
469    /// reconnect (route registers again from scratch) gets a fresh
470    /// replay of persisted history. Without this, a transient
471    /// WebSocket drop would silently lose all subsequent replay.
472    pub fn unsubscribe_all(&self, route_id: &str) {
473        let mut inner = self.inner.lock().unwrap();
474        let events: Vec<String> = inner.sub_map.keys().cloned().collect();
475        for event in events {
476            Self::unsubscribe_nolock(&mut inner, route_id, &event);
477        }
478        inner.replayed.retain(|(r, _, _)| r != route_id);
479    }
480
481    /// Purge all persisted history for a scope (e.g. `block:<id>` or
482    /// `shell:<id>`), regardless of event name. Call when the underlying
483    /// block/shell is deleted — `persist_map`'s key set is otherwise never
484    /// pruned (each key's event Vec is capped, but the map only grows over
485    /// a session's cumulative block/shell count). Also drops matching
486    /// `replayed` entries so a stale scope can't linger across route
487    /// reconnects.
488    pub fn purge_scope(&self, scope: &str) {
489        let mut inner = self.inner.lock().unwrap();
490        inner.persist_map.retain(|k, _| k.scope != scope);
491        inner.replayed.retain(|(_, _, s)| s != scope);
492    }
493
494    fn unsubscribe_nolock(inner: &mut BrokerInner, route_id: &str, event_name: &str) {
495        let bs = match inner.sub_map.get_mut(event_name) {
496            Some(bs) => bs,
497            None => return,
498        };
499        bs.all_subs.retain(|s| s != route_id);
500        remove_from_all_scopes(&mut bs.scope_subs, route_id);
501        remove_from_all_scopes(&mut bs.star_subs, route_id);
502        if bs.is_empty() {
503            inner.sub_map.remove(event_name);
504        }
505    }
506
507    /// Publish a typed event (`mps_events`): `payload` is `E::Payload`, so its
508    /// frontend subscribers get it as that type.
509    pub fn publish_event<E: super::mps_events::Event>(&self, payload: &E::Payload, scopes: Vec<String>, persist: usize) {
510        // typed-helper: builds the event from a declared event and its payload type.
511        self.publish(MuxEvent {
512            event: E::NAME.to_string(),
513            scopes,
514            sender: String::new(),
515            persist,
516            data: super::mps_events::payload_data(payload),
517        });
518    }
519
520    /// Publishes a per-id event (`mps_events::PerIdEvent`) under its prefix plus `id`.
521    pub fn publish_event_for<E: super::mps_events::PerIdEvent>(&self, id: &str, payload: &E::Payload, scopes: Vec<String>, persist: usize) {
522        // typed-helper: builds the event from a declared per-id event and its payload type.
523        self.publish(MuxEvent {
524            event: format!("{}{id}", E::PREFIX),
525            scopes,
526            sender: String::new(),
527            persist,
528            data: super::mps_events::payload_data(payload),
529        });
530    }
531
532    /// Publish an event to all matching subscribers.
533    pub fn publish(&self, event: MuxEvent) {
534        for o in self.observers.read().unwrap_or_else(|e| e.into_inner()).iter() {
535            o(&event);
536        }
537        let mut inner = self.inner.lock().unwrap();
538
539        // Persist if requested
540        if event.persist > 0 {
541            Self::persist_event(&mut inner, &event);
542        }
543
544        let client = match &inner.client {
545            Some(c) => c,
546            None => return,
547        };
548
549        let route_ids = Self::get_matching_routes(&inner, &event);
550        for route_id in route_ids {
551            client.send_event(&route_id, event.clone());
552        }
553    }
554
555    /// Read persisted event history.
556    pub fn read_event_history(
557        &self,
558        event_type: &str,
559        scope: &str,
560        max_items: usize,
561    ) -> Vec<MuxEvent> {
562        if max_items == 0 {
563            return Vec::new();
564        }
565        let inner = self.inner.lock().unwrap();
566        let key = PersistKey {
567            event: event_type.to_string(),
568            scope: scope.to_string(),
569        };
570        match inner.persist_map.get(&key) {
571            Some(pe) if !pe.events.is_empty() => {
572                let n = max_items.min(pe.events.len());
573                pe.events[pe.events.len() - n..].to_vec()
574            }
575            _ => Vec::new(),
576        }
577    }
578
579    fn persist_event(inner: &mut BrokerInner, event: &MuxEvent) {
580        let num_persist = event.persist.min(MAX_PERSIST);
581        let mut scope_set: Vec<String> = event.scopes.clone();
582        scope_set.push(String::new()); // "" scope for global persistence
583
584        for scope in scope_set {
585            let key = PersistKey {
586                event: event.event.clone(),
587                scope,
588            };
589            let pe = inner.persist_map.entry(key).or_insert_with(|| {
590                PersistEventWrap {
591                    arr_total_adds: 0,
592                    events: Vec::with_capacity(num_persist),
593                }
594            });
595            pe.events.push(event.clone());
596            pe.arr_total_adds += 1;
597            // Trim to max persist
598            if pe.events.len() > num_persist {
599                pe.events.drain(..pe.events.len() - num_persist);
600            }
601            // Compact if too many additions (reduce memory fragmentation)
602            if pe.arr_total_adds > REMAKE_ARR_THRESHOLD {
603                let compacted: Vec<MuxEvent> = pe.events.drain(..).collect();
604                pe.events = compacted;
605                pe.arr_total_adds = pe.events.len();
606            }
607        }
608    }
609
610    fn get_matching_routes(inner: &BrokerInner, event: &MuxEvent) -> Vec<String> {
611        let bs = match inner.sub_map.get(&event.event) {
612            Some(bs) => bs,
613            None => return Vec::new(),
614        };
615
616        let mut route_ids: HashMap<&str, ()> = HashMap::new();
617
618        // All-scope subscribers
619        for route_id in &bs.all_subs {
620            route_ids.insert(route_id, ());
621        }
622
623        // Exact-scope subscribers
624        for scope in &event.scopes {
625            if let Some(routes) = bs.scope_subs.get(scope) {
626                for route_id in routes {
627                    route_ids.insert(route_id, ());
628                }
629            }
630            // Star-scope subscribers
631            for (star_scope, routes) in &bs.star_subs {
632                if star_match(star_scope, scope, ":") {
633                    for route_id in routes {
634                        route_ids.insert(route_id, ());
635                    }
636                }
637            }
638        }
639
640        route_ids.keys().map(|s| s.to_string()).collect()
641    }
642}
643
644impl Default for Broker {
645    fn default() -> Self {
646        Self::new()
647    }
648}
649
650// ---- Helpers ----
651
652fn scope_has_star(scope: &str) -> bool {
653    scope.split(':').any(|part| part == "*" || part == "**")
654}
655
656/// Simple star matching: each segment separated by `sep` is compared.
657/// "*" matches any single segment, "**" matches any remaining segments.
658fn star_match(pattern: &str, value: &str, sep: &str) -> bool {
659    let pat_parts: Vec<&str> = pattern.split(sep).collect();
660    let val_parts: Vec<&str> = value.split(sep).collect();
661
662    let mut pi = 0;
663    let mut vi = 0;
664    while pi < pat_parts.len() && vi < val_parts.len() {
665        if pat_parts[pi] == "**" {
666            return true; // matches everything remaining
667        }
668        if pat_parts[pi] != "*" && pat_parts[pi] != val_parts[vi] {
669            return false;
670        }
671        pi += 1;
672        vi += 1;
673    }
674    pi == pat_parts.len() && vi == val_parts.len()
675}
676
677fn add_unique(vec: &mut Vec<String>, val: &str) {
678    if !vec.iter().any(|s| s == val) {
679        vec.push(val.to_string());
680    }
681}
682
683fn add_to_scope_map(map: &mut HashMap<String, Vec<String>>, scope: &str, route_id: &str) {
684    let entry = map.entry(scope.to_string()).or_default();
685    add_unique(entry, route_id);
686}
687
688fn remove_from_all_scopes(map: &mut HashMap<String, Vec<String>>, route_id: &str) {
689    let empty_scopes: Vec<String> = map
690        .iter_mut()
691        .filter_map(|(scope, routes)| {
692            routes.retain(|r| r != route_id);
693            if routes.is_empty() {
694                Some(scope.clone())
695            } else {
696                None
697            }
698        })
699        .collect();
700    for scope in empty_scopes {
701        map.remove(&scope);
702    }
703}
704
705/// Publish a single install-progress line to the frontend for a given block.
706/// The frontend subscribes to `install_progress` events scoped to `block:{block_id}`
707/// and displays each message as a log line in the agent presentation view.
708pub fn publish_install_progress(broker: &Broker, block_id: &str, message: &str) {
709    let scope = format!("block:{}", block_id);
710    let line = super::mps_events::InstallProgressEvent { message: message.to_string() };
711    broker.publish_event::<super::mps_events::InstallProgress>(&line, vec![scope], 0);
712}
713
714/// Publish a Claude Code OSC window-title activity string to the frontend
715/// for a given agent-pane block. Frontend subscribes to `block:activity`
716/// events scoped to `block:{block_id}` and writes the payload to
717/// `term:osc_title` block metadata, which the tab label reads.
718pub fn publish_block_activity(broker: &Broker, block_id: &str, activity: &str) {
719    let scope = format!("block:{}", block_id);
720    let event = super::mps_events::BlockActivityEvent { block_id: block_id.to_string(), activity: activity.to_string() };
721    broker.publish_event::<super::mps_events::BlockActivity>(&event, vec![scope], 0);
722}
723
724// ====================================================================
725// Tests
726// ====================================================================
727
728#[cfg(test)]
729mod tests {
730    #[test]
731    fn observers_see_every_publish_even_without_a_client() {
732        let b = Broker::new();
733        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
734        let s2 = seen.clone();
735        b.add_observer(std::sync::Arc::new(move |e: &MuxEvent| s2.lock().unwrap().push(e.event.clone())));
736        for name in ["agentfailure", "config"] {
737            b.publish(MuxEvent { event: name.into(), scopes: vec![], sender: String::new(), persist: 0, data: None });
738        }
739        assert_eq!(*seen.lock().unwrap(), vec!["agentfailure".to_string(), "config".to_string()]);
740    }
741
742    #[test]
743    fn a_signal_event_goes_out_with_no_data_and_a_payload_event_with_its_payload() {
744        use crate::backend::mps_events::{AgentsChanged, FilesChanged, FilesChangedEvent};
745        let b = Broker::new();
746        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::<MuxEvent>::new()));
747        let s2 = seen.clone();
748        b.add_observer(std::sync::Arc::new(move |e: &MuxEvent| s2.lock().unwrap().push(e.clone())));
749        b.publish_event::<AgentsChanged>(&(), vec![], 0);
750        b.publish_event::<FilesChanged>(&FilesChangedEvent { dir: "/tmp".into() }, vec![], 0);
751        let seen = seen.lock().unwrap();
752        // As the hand-built `data: None` it replaces: no `data` key on the wire at all.
753        let signal = serde_json::to_value(&seen[0]).unwrap();
754        assert_eq!(signal["event"], "agents:changed");
755        assert!(signal.get("data").is_none(), "{signal}");
756        assert_eq!(seen[1].data, Some(serde_json::json!({ "dir": "/tmp" })));
757    }
758
759    #[test]
760    fn a_per_id_event_goes_out_under_its_prefix_and_the_id() {
761        use crate::backend::mps_events::{AgentCredentialsRevoked, AgentMemoryChanged, CredentialsRevokedEvent};
762        let b = Broker::new();
763        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::<MuxEvent>::new()));
764        let s2 = seen.clone();
765        b.add_observer(std::sync::Arc::new(move |e: &MuxEvent| s2.lock().unwrap().push(e.clone())));
766        let revoked = CredentialsRevokedEvent { credentials_revoked: true, provider: "claude".into(), account_id: None };
767        b.publish_event_for::<AgentCredentialsRevoked>("agent-1", &revoked, vec![], 0);
768        b.publish_event_for::<AgentMemoryChanged>("agent-2", &(), vec![], 0);
769        let seen = seen.lock().unwrap();
770        assert_eq!(seen[0].event, "agentcredentials:revoked:agent-1");
771        // As the json! it replaces: no accountId when no one account was unlinked.
772        assert_eq!(seen[0].data, Some(serde_json::json!({ "credentialsRevoked": true, "provider": "claude" })));
773        assert_eq!(seen[1].event, "agent:memory:changed:agent-2");
774        assert_eq!(seen[1].data, None);
775    }
776
777    use super::*;
778    use std::sync::Arc;
779
780    struct TestClient {
781        events: Mutex<Vec<(String, MuxEvent)>>,
782    }
783
784    impl TestClient {
785        fn new() -> Self {
786            Self {
787                events: Mutex::new(Vec::new()),
788            }
789        }
790
791        fn received_events(&self) -> Vec<(String, MuxEvent)> {
792            self.events.lock().unwrap().clone()
793        }
794    }
795
796    impl WpsClient for TestClient {
797        fn send_event(&self, route_id: &str, event: MuxEvent) {
798            self.events
799                .lock()
800                .unwrap()
801                .push((route_id.to_string(), event));
802        }
803    }
804
805    impl WpsClient for Arc<TestClient> {
806        fn send_event(&self, route_id: &str, event: MuxEvent) {
807            self.events
808                .lock()
809                .unwrap()
810                .push((route_id.to_string(), event));
811        }
812    }
813
814    #[test]
815    fn test_subscribe_all_scopes() {
816        let broker = Broker::new();
817        let client = Arc::new(TestClient::new());
818        broker.set_client(Box::new(Arc::clone(&client)));
819
820        broker.subscribe(
821            "route-1",
822            SubscriptionRequest {
823                event: EVENT_MUX_OBJ_UPDATE.to_string(),
824                scopes: vec![],
825                allscopes: true,
826            },
827        );
828
829        broker.publish(MuxEvent {
830            event: EVENT_MUX_OBJ_UPDATE.to_string(),
831            scopes: vec!["block:abc".to_string()],
832            sender: String::new(),
833            persist: 0,
834            data: None,
835        });
836
837        let events = client.received_events();
838        assert_eq!(events.len(), 1);
839        assert_eq!(events[0].0, "route-1");
840    }
841
842    #[test]
843    fn test_subscribe_exact_scope() {
844        let broker = Broker::new();
845        let client = Arc::new(TestClient::new());
846        broker.set_client(Box::new(Arc::clone(&client)));
847
848        broker.subscribe(
849            "route-1",
850            SubscriptionRequest {
851                event: EVENT_MUX_OBJ_UPDATE.to_string(),
852                scopes: vec!["block:abc".to_string()],
853                allscopes: false,
854            },
855        );
856
857        // Should match
858        broker.publish(MuxEvent {
859            event: EVENT_MUX_OBJ_UPDATE.to_string(),
860            scopes: vec!["block:abc".to_string()],
861            sender: String::new(),
862            persist: 0,
863            data: None,
864        });
865
866        // Should NOT match
867        broker.publish(MuxEvent {
868            event: EVENT_MUX_OBJ_UPDATE.to_string(),
869            scopes: vec!["block:xyz".to_string()],
870            sender: String::new(),
871            persist: 0,
872            data: None,
873        });
874
875        let events = client.received_events();
876        assert_eq!(events.len(), 1);
877    }
878
879    #[test]
880    fn test_subscribe_star_scope() {
881        let broker = Broker::new();
882        let client = Arc::new(TestClient::new());
883        broker.set_client(Box::new(Arc::clone(&client)));
884
885        broker.subscribe(
886            "route-1",
887            SubscriptionRequest {
888                event: EVENT_MUX_OBJ_UPDATE.to_string(),
889                scopes: vec!["block:*".to_string()],
890                allscopes: false,
891            },
892        );
893
894        broker.publish(MuxEvent {
895            event: EVENT_MUX_OBJ_UPDATE.to_string(),
896            scopes: vec!["block:abc".to_string()],
897            sender: String::new(),
898            persist: 0,
899            data: None,
900        });
901
902        broker.publish(MuxEvent {
903            event: EVENT_MUX_OBJ_UPDATE.to_string(),
904            scopes: vec!["tab:xyz".to_string()],
905            sender: String::new(),
906            persist: 0,
907            data: None,
908        });
909
910        let events = client.received_events();
911        assert_eq!(events.len(), 1); // only block:* matches block:abc
912    }
913
914    #[test]
915    fn test_unsubscribe() {
916        let broker = Broker::new();
917        let client = Arc::new(TestClient::new());
918        broker.set_client(Box::new(Arc::clone(&client)));
919
920        broker.subscribe(
921            "route-1",
922            SubscriptionRequest {
923                event: EVENT_BLOCK_CLOSE.to_string(),
924                scopes: vec![],
925                allscopes: true,
926            },
927        );
928
929        broker.unsubscribe("route-1", EVENT_BLOCK_CLOSE);
930
931        broker.publish(MuxEvent {
932            event: EVENT_BLOCK_CLOSE.to_string(),
933            scopes: vec![],
934            sender: String::new(),
935            persist: 0,
936            data: None,
937        });
938
939        assert!(client.received_events().is_empty());
940    }
941
942    #[test]
943    fn test_unsubscribe_all() {
944        let broker = Broker::new();
945        let client = Arc::new(TestClient::new());
946        broker.set_client(Box::new(Arc::clone(&client)));
947
948        broker.subscribe(
949            "route-1",
950            SubscriptionRequest {
951                event: EVENT_BLOCK_CLOSE.to_string(),
952                scopes: vec![],
953                allscopes: true,
954            },
955        );
956        broker.subscribe(
957            "route-1",
958            SubscriptionRequest {
959                event: EVENT_CONFIG.to_string(),
960                scopes: vec![],
961                allscopes: true,
962            },
963        );
964
965        broker.unsubscribe_all("route-1");
966
967        broker.publish(MuxEvent {
968            event: EVENT_BLOCK_CLOSE.to_string(),
969            scopes: vec![],
970            sender: String::new(),
971            persist: 0,
972            data: None,
973        });
974        broker.publish(MuxEvent {
975            event: EVENT_CONFIG.to_string(),
976            scopes: vec![],
977            sender: String::new(),
978            persist: 0,
979            data: None,
980        });
981
982        assert!(client.received_events().is_empty());
983    }
984
985    /// A publisher that measures something for a subscriber asks this before
986    /// doing the work: any route, any scope, and gone again once the last
987    /// route unsubscribes.
988    #[test]
989    fn test_has_subscribers_follows_any_subscription_of_the_event() {
990        let broker = Broker::new();
991        assert!(!broker.has_subscribers(EVENT_BLOCK_STATS));
992        let sub = |event: &str, scopes: Vec<&str>, allscopes: bool| SubscriptionRequest {
993            event: event.to_string(),
994            scopes: scopes.into_iter().map(String::from).collect(),
995            allscopes,
996        };
997        broker.subscribe("route-1", sub(EVENT_BLOCK_STATS, vec!["block:abc"], false));
998        assert!(broker.has_subscribers(EVENT_BLOCK_STATS), "an exact scope counts");
999        assert!(!broker.has_subscribers(EVENT_SYS_INFO), "another event doesn't");
1000        broker.subscribe("route-2", sub(EVENT_BLOCK_STATS, vec![], true));
1001        broker.unsubscribe("route-1", EVENT_BLOCK_STATS);
1002        assert!(broker.has_subscribers(EVENT_BLOCK_STATS), "route-2 still watches every scope");
1003        broker.subscribe("route-3", sub(EVENT_BLOCK_STATS, vec!["block:*"], false));
1004        broker.unsubscribe_all("route-2");
1005        assert!(broker.has_subscribers(EVENT_BLOCK_STATS), "a wildcard scope counts");
1006        broker.unsubscribe_all("route-3");
1007        assert!(!broker.has_subscribers(EVENT_BLOCK_STATS));
1008    }
1009
1010    /// Regression: replay-on-subscribe delivers persisted events that
1011    /// were published BEFORE the route subscribed. This closes the
1012    /// late-subscribe race for tool_chunk streaming.
1013    #[test]
1014    fn test_replay_on_subscribe_exact_scope() {
1015        let broker = Broker::new();
1016        let client = Arc::new(TestClient::new());
1017        broker.set_client(Box::new(Arc::clone(&client)));
1018
1019        // Publish 5 persisted events BEFORE any subscriber exists.
1020        for i in 0..5 {
1021            broker.publish(MuxEvent {
1022                event: "tool_chunk".to_string(),
1023                scopes: vec!["block:abc".to_string()],
1024                sender: String::new(),
1025                persist: 10,
1026                data: Some(serde_json::json!({"tool_id": "t1", "n": i})),
1027            });
1028        }
1029        assert!(
1030            client.received_events().is_empty(),
1031            "no subscriber yet, no delivery"
1032        );
1033
1034        // Subscribe to the matching scope — replay should fire.
1035        broker.subscribe(
1036            "route-1",
1037            SubscriptionRequest {
1038                event: "tool_chunk".to_string(),
1039                scopes: vec!["block:abc".to_string()],
1040                allscopes: false,
1041            },
1042        );
1043
1044        let events = client.received_events();
1045        assert_eq!(events.len(), 5, "all 5 persisted events replayed");
1046        assert_eq!(events[0].1.data, Some(serde_json::json!({"tool_id": "t1", "n": 0})));
1047        assert_eq!(events[4].1.data, Some(serde_json::json!({"tool_id": "t1", "n": 4})));
1048    }
1049
1050    /// Regression: re-subscribing the SAME route to the SAME
1051    /// (event, scope) does NOT replay again. The frontend's
1052    /// `eventsub` flushes happen on every listener add/remove, so
1053    /// the broker has to be idempotent across resubscribe calls.
1054    /// Codex P2 on PR #817.
1055    #[test]
1056    fn test_replay_on_resubscribe_is_idempotent() {
1057        let broker = Broker::new();
1058        let client = Arc::new(TestClient::new());
1059        broker.set_client(Box::new(Arc::clone(&client)));
1060
1061        for i in 0..3 {
1062            broker.publish(MuxEvent {
1063                event: "tool_chunk".to_string(),
1064                scopes: vec!["block:abc".to_string()],
1065                sender: String::new(),
1066                persist: 10,
1067                data: Some(serde_json::json!({"n": i})),
1068            });
1069        }
1070
1071        let sub = SubscriptionRequest {
1072            event: "tool_chunk".to_string(),
1073            scopes: vec!["block:abc".to_string()],
1074            allscopes: false,
1075        };
1076
1077        broker.subscribe("route-1", sub.clone());
1078        assert_eq!(
1079            client.received_events().len(),
1080            3,
1081            "first subscribe replays all 3 persisted events"
1082        );
1083
1084        // Re-subscribe (same route, same event+scope) — must not
1085        // replay a second time.
1086        broker.subscribe("route-1", sub.clone());
1087        assert_eq!(
1088            client.received_events().len(),
1089            3,
1090            "re-subscribe is a no-op for replay; received count stays at 3"
1091        );
1092
1093        // Live publish after re-subscribe still delivers.
1094        broker.publish(MuxEvent {
1095            event: "tool_chunk".to_string(),
1096            scopes: vec!["block:abc".to_string()],
1097            sender: String::new(),
1098            persist: 10,
1099            data: Some(serde_json::json!({"n": "live"})),
1100        });
1101        assert_eq!(
1102            client.received_events().len(),
1103            4,
1104            "live publish after resubscribe is delivered exactly once"
1105        );
1106
1107        // Disconnect (unsubscribe_all) + reconnect — fresh replay.
1108        broker.unsubscribe_all("route-1");
1109        broker.subscribe("route-1", sub.clone());
1110        assert_eq!(
1111            client.received_events().len(),
1112            8,
1113            "reconnect after unsubscribe_all clears the replayed tracker; \
1114             gets all 4 persisted events again"
1115        );
1116    }
1117
1118    /// Regression: replay does NOT cross-pollute scopes. Subscriber to
1119    /// `block:abc` must not receive events persisted for `block:xyz`.
1120    #[test]
1121    fn test_replay_on_subscribe_scope_isolation() {
1122        let broker = Broker::new();
1123        let client = Arc::new(TestClient::new());
1124        broker.set_client(Box::new(Arc::clone(&client)));
1125
1126        broker.publish(MuxEvent {
1127            event: "tool_chunk".to_string(),
1128            scopes: vec!["block:xyz".to_string()],
1129            sender: String::new(),
1130            persist: 10,
1131            data: Some(serde_json::json!({"tool_id": "other"})),
1132        });
1133
1134        broker.subscribe(
1135            "route-1",
1136            SubscriptionRequest {
1137                event: "tool_chunk".to_string(),
1138                scopes: vec!["block:abc".to_string()],
1139                allscopes: false,
1140            },
1141        );
1142
1143        let events = client.received_events();
1144        assert_eq!(events.len(), 0, "scope:abc must not get block:xyz events");
1145    }
1146
1147    #[test]
1148    fn test_event_persistence() {
1149        let broker = Broker::new();
1150        let client = Arc::new(TestClient::new());
1151        broker.set_client(Box::new(Arc::clone(&client)));
1152
1153        // Subscribe so events are dispatched
1154        broker.subscribe(
1155            "route-1",
1156            SubscriptionRequest {
1157                event: EVENT_SYS_INFO.to_string(),
1158                scopes: vec![],
1159                allscopes: true,
1160            },
1161        );
1162
1163        // Publish persistent events
1164        for i in 0..5 {
1165            broker.publish(MuxEvent {
1166                event: EVENT_SYS_INFO.to_string(),
1167                scopes: vec!["cpu".to_string()],
1168                sender: String::new(),
1169                persist: 3, // keep last 3
1170                data: Some(serde_json::json!(i)),
1171            });
1172        }
1173
1174        // Read history (global scope "")
1175        let history = broker.read_event_history(EVENT_SYS_INFO, "", 10);
1176        assert_eq!(history.len(), 3);
1177        assert_eq!(history[0].data, Some(serde_json::json!(2)));
1178        assert_eq!(history[2].data, Some(serde_json::json!(4)));
1179
1180        // Read scoped history
1181        let scoped = broker.read_event_history(EVENT_SYS_INFO, "cpu", 2);
1182        assert_eq!(scoped.len(), 2);
1183    }
1184
1185    #[test]
1186    fn test_purge_scope_removes_only_matching_scope() {
1187        let broker = Broker::new();
1188
1189        // Two different event names persisted under the same scope
1190        // (block:abc), plus one persisted under a different scope
1191        // (block:xyz) that must survive the purge.
1192        broker.publish(MuxEvent {
1193            event: "install_progress".to_string(),
1194            scopes: vec!["block:abc".to_string()],
1195            sender: String::new(),
1196            persist: 5,
1197            data: Some(serde_json::json!("a")),
1198        });
1199        broker.publish(MuxEvent {
1200            event: EVENT_BLOCK_ACTIVITY.to_string(),
1201            scopes: vec!["block:abc".to_string()],
1202            sender: String::new(),
1203            persist: 5,
1204            data: Some(serde_json::json!("b")),
1205        });
1206        broker.publish(MuxEvent {
1207            event: "install_progress".to_string(),
1208            scopes: vec!["block:xyz".to_string()],
1209            sender: String::new(),
1210            persist: 5,
1211            data: Some(serde_json::json!("c")),
1212        });
1213
1214        assert_eq!(broker.read_event_history("install_progress", "block:abc", 10).len(), 1);
1215        assert_eq!(broker.read_event_history(EVENT_BLOCK_ACTIVITY, "block:abc", 10).len(), 1);
1216        assert_eq!(broker.read_event_history("install_progress", "block:xyz", 10).len(), 1);
1217
1218        broker.purge_scope("block:abc");
1219
1220        // Both event names under the purged scope are gone...
1221        assert_eq!(broker.read_event_history("install_progress", "block:abc", 10).len(), 0);
1222        assert_eq!(broker.read_event_history(EVENT_BLOCK_ACTIVITY, "block:abc", 10).len(), 0);
1223        // ...but the other scope is untouched.
1224        assert_eq!(broker.read_event_history("install_progress", "block:xyz", 10).len(), 1);
1225    }
1226
1227    #[test]
1228    fn test_purge_scope_on_unknown_scope_is_noop() {
1229        // Purging a scope with no persisted entries must not panic.
1230        let broker = Broker::new();
1231        broker.purge_scope("block:never-existed");
1232    }
1233
1234    #[test]
1235    fn test_star_match() {
1236        assert!(star_match("block:*", "block:abc", ":"));
1237        assert!(star_match("*:abc", "block:abc", ":"));
1238        assert!(!star_match("block:*", "tab:abc", ":"));
1239        assert!(star_match("**", "block:abc:xyz", ":"));
1240        assert!(!star_match("block:*", "block:abc:xyz", ":")); // * matches one segment only
1241    }
1242
1243    #[test]
1244    fn test_mux_event_serialization() {
1245        let event = MuxEvent {
1246            event: "test".to_string(),
1247            scopes: vec!["scope1".to_string()],
1248            sender: String::new(),
1249            persist: 0,
1250            data: Some(serde_json::json!({"key": "value"})),
1251        };
1252        let json = serde_json::to_string(&event).unwrap();
1253        let parsed: MuxEvent = serde_json::from_str(&json).unwrap();
1254        assert_eq!(parsed.event, "test");
1255        assert_eq!(parsed.scopes, vec!["scope1"]);
1256        // Empty sender and zero persist should be omitted
1257        assert!(!json.contains("\"sender\""));
1258        assert!(!json.contains("\"persist\""));
1259    }
1260
1261    #[test]
1262    fn test_subscription_request_serialization() {
1263        let req = SubscriptionRequest {
1264            event: "blockclose".to_string(),
1265            scopes: vec!["block:123".to_string()],
1266            allscopes: false,
1267        };
1268        let json = serde_json::to_string(&req).unwrap();
1269        let parsed: SubscriptionRequest = serde_json::from_str(&json).unwrap();
1270        assert_eq!(parsed.event, "blockclose");
1271    }
1272
1273    #[test]
1274    fn test_no_client_publish_does_not_panic() {
1275        let broker = Broker::new();
1276        // No client set — should not panic
1277        broker.publish(MuxEvent {
1278            event: "test".to_string(),
1279            scopes: vec![],
1280            sender: String::new(),
1281            persist: 0,
1282            data: None,
1283        });
1284    }
1285
1286    #[test]
1287    fn test_double_star_scope() {
1288        let broker = Broker::new();
1289        let client = Arc::new(TestClient::new());
1290        broker.set_client(Box::new(Arc::clone(&client)));
1291
1292        broker.subscribe(
1293            "route-1",
1294            SubscriptionRequest {
1295                event: EVENT_MUX_OBJ_UPDATE.to_string(),
1296                scopes: vec!["**".to_string()],
1297                allscopes: false,
1298            },
1299        );
1300
1301        broker.publish(MuxEvent {
1302            event: EVENT_MUX_OBJ_UPDATE.to_string(),
1303            scopes: vec!["block:abc:def".to_string()],
1304            sender: String::new(),
1305            persist: 0,
1306            data: None,
1307        });
1308
1309        assert_eq!(client.received_events().len(), 1);
1310    }
1311}