agentmux_cef/launcher_event_bridge.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase B.7.3.1 — host outbound JS bridge for launcher typed events.
5//
6// Single function `dispatch_to_renderers(state, event)` called from
7// `launcher_ipc::apply_event_to_shadow` after each event is applied
8// to host shadows. Iterates `state.browsers`, calls
9// `Frame::ExecuteJavaScript` per top-level browser to invoke
10// `window.__agentmux_launcher_event(<json>)` in the renderer.
11//
12// Filtering: pool windows (`window-pool-*`) and browser-pane child
13// HWNDs (`browser-pane-*`) are skipped. They have no UI to react.
14//
15// Cross-platform: `Frame::ExecuteJavaScript` is portable across
16// CEF's Windows / macOS / Linux backends. No platform specifics.
17//
18// Phase B.7.3.3 — typed events are the SOLE channel for
19// InstancePanel state. The bespoke `window-instances-changed`
20// event and its 4 sync emit sites in the host are retired.
21//
22// See `docs/specs/SPEC_B_7_3_LAUNCHER_EVENTS_TO_RENDERER_2026_04_29.md`.
23
24use std::collections::{HashMap, VecDeque};
25use std::sync::Arc;
26
27use agentmux_common::ipc::Event;
28use cef::{CefString, ImplBrowser, ImplFrame};
29
30/// Phase F.7 host-bridge dedup cache. Bounded FIFO map keyed by
31/// `"{event_kind}|{label}|{hwnd}"` → max version dispatched.
32///
33/// FIFO insertion order is tracked explicitly via `insertion_order`
34/// because std `HashMap::keys().next()` iteration order is undefined
35/// per-rebuild — the previous implementation could evict the
36/// just-inserted key (reagent P1 PR #722 round 1).
37///
38/// Reset on launcher restart sentinel (codex P1 PR #722 round 1):
39/// when the launcher's `event_version` resets to 1, any cached key
40/// holding a higher version blocks the v=1 event. Mirror the
41/// renderer-side guard's heuristic — clear the cache when we see a
42/// v=1 event AND any cached entry has a version > 0.
43#[derive(Default)]
44pub struct DedupCache {
45 seen: HashMap<String, u64>,
46 insertion_order: VecDeque<String>,
47}
48
49impl DedupCache {
50 pub fn new() -> Self {
51 Self {
52 seen: HashMap::new(),
53 insertion_order: VecDeque::new(),
54 }
55 }
56
57 /// Returns true if the event should be dispatched (strictly newer
58 /// for its key). Updates the cache as a side-effect when admitted.
59 /// Bounded at `cap`; on overflow, evicts the oldest insertion in
60 /// FIFO order.
61 pub fn check_and_record(&mut self, key: String, version: u64, cap: usize) -> bool {
62 if let Some(&seen) = self.seen.get(&key) {
63 if version <= seen {
64 return false;
65 }
66 // Update existing entry — don't reorder for this case;
67 // FIFO ordering is "first inserted, first evicted" not
68 // "least recently used".
69 self.seen.insert(key, version);
70 return true;
71 }
72 self.seen.insert(key.clone(), version);
73 self.insertion_order.push_back(key);
74 if self.seen.len() > cap {
75 if let Some(victim) = self.insertion_order.pop_front() {
76 self.seen.remove(&victim);
77 }
78 }
79 true
80 }
81
82 /// Clear the cache. Called on launcher-restart sentinel.
83 pub fn clear(&mut self) {
84 self.seen.clear();
85 self.insertion_order.clear();
86 }
87
88 /// True if any cached entry has a version above 0 — used as the
89 /// guard for the v=1 restart sentinel.
90 pub fn has_any_versioned_entry(&self) -> bool {
91 self.seen.values().any(|&v| v > 0)
92 }
93
94 pub fn len(&self) -> usize {
95 self.seen.len()
96 }
97}
98
99/// Forward a launcher event to every top-level renderer.
100///
101/// Excluded:
102/// - Pool **inventory** labels (`window-pool-*` in
103/// `pool.unpromoted` OR `pool.queue`): no user UI yet. Two
104/// sub-states:
105/// * `pool.unpromoted` — spawning, renderer not ready.
106/// * `pool.queue` — renderer ready, waiting for promote.
107/// Both are hidden off-screen and would build stale InstancePanel
108/// state from launcher events the user never sees. The bridge
109/// uses `state.user_visibility_snapshot()` which atomically
110/// reads the pool inventory (unpromoted ∪ queue) and the
111/// browser registry under one host_state lock — a two-lock
112/// variant would race against `promote_pool_window` and let a
113/// just-promoted window slip through (or, worse, count a
114/// real user window in the close-cascade gate's exclusion).
115/// - Browser-pane labels (`browser-pane-*`): not top-level
116/// windows; have no InstancePanel.
117///
118/// Promoted pool windows (label still has the `window-pool-*`
119/// prefix but the entry is in NEITHER pool set) ARE included —
120/// they're the user-visible torn-off windows. Pre-fix, a
121/// label-prefix-only check excluded them too, so torn-off windows
122/// stopped receiving launcher events post-promotion (InstancePanel
123/// drift, plus anything else listening to launcher events).
124///
125/// JSON payload uses `serde_json::to_string`, so any string content
126/// from the Event is escaped against quote / backtick injection at
127/// the JS-string boundary.
128pub fn dispatch_to_renderers(state: &Arc<crate::state::AppState>, event: &Event) {
129 // Phase F.7 host-bridge dedup. Mirror of the renderer-side guard
130 // (`shouldDispatchLauncherEvent` in launcher-events.ts), but at
131 // the host so a fresh V8 context post-crash, multi-context fan-
132 // out, or any renderer-side guard failure mode can't amplify the
133 // launcher's single emit. Skip the entire dispatch if the event's
134 // version is not strictly higher than the per-key max we've
135 // already sent.
136 if !should_dispatch(state, event) {
137 return;
138 }
139
140 let json = match serde_json::to_string(event) {
141 Ok(s) => s,
142 Err(e) => {
143 tracing::warn!(
144 target: "launcher-event-bridge",
145 "[launcher-event-bridge] serialize failed: {}",
146 e
147 );
148 return;
149 }
150 };
151
152 let script = format!(
153 "if (window.__agentmux_launcher_event) {{ try {{ window.__agentmux_launcher_event({}) }} catch(e) {{ console.error('[launcher-event] dispatch failed', e) }} }}",
154 json
155 );
156 let code = CefString::from(script.as_str());
157 let url = CefString::from("");
158
159 // Atomic snapshot — pool inventory + browsers under ONE lock.
160 // Two-lock variants race against `promote_pool_window` between
161 // the reads.
162 let (pool_inventory, browsers) = state.user_visibility_snapshot();
163
164 for (label, browser) in browsers {
165 if label.starts_with("browser-pane-") {
166 continue;
167 }
168 if pool_inventory.contains(label.as_str()) {
169 // Pool inventory (unpromoted or ready-queued) — no user
170 // UI yet, skip.
171 continue;
172 }
173 if let Some(frame) = browser.main_frame() {
174 frame.execute_java_script(Some(&code), Some(&url), 0);
175 }
176 }
177}
178
179/// Phase F.7 dedup gate. Returns `true` if the event is strictly
180/// newer for its key and should be dispatched; `false` if a
181/// higher-or-equal version was already sent.
182///
183/// Mirrors the renderer-side `shouldDispatchLauncherEvent`. Bounded
184/// at 4096 keys with FIFO eviction so a long-running host can't
185/// leak unbounded state. Re-arrival of an evicted key bypasses the
186/// host gate but the renderer guard still catches it.
187///
188/// Restart sentinel (codex P1 PR #722 round 1): clear the cache
189/// when the launcher's event_version resets to 1 and any cached
190/// entry holds a higher version. Mirrors the renderer guard.
191fn should_dispatch(state: &Arc<crate::state::AppState>, event: &Event) -> bool {
192 const MAX_DEDUP_KEYS: usize = 4096;
193 let (key, version) = dedup_key(event);
194 let mut cache = state.launcher_bridge_dedup.lock();
195 if version == 1 && cache.has_any_versioned_entry() {
196 cache.clear();
197 }
198 cache.check_and_record(key, version, MAX_DEDUP_KEYS)
199}
200
201/// Build the dedup cache key + extract the version for an event.
202/// Returns `("{kind}|{label}|{hwnd}", version)`.
203///
204/// `kind` for `HwndDriftDetected` is `"hwnd_drift_detected:{drift_kind}"`
205/// so HiddenSinceOpen and OffMonitor for the same `(label, hwnd)`
206/// don't collide (reagent P2 PR #722 round 2).
207///
208/// Unhandled variants are tagged with their serde discriminant
209/// (the `event` field of the JSON tagged-union) so different
210/// variants don't share the same `__catchall__` key (reagent P1
211/// PR #722 round 1).
212fn dedup_key(event: &Event) -> (String, u64) {
213 use Event::*;
214 let (kind, label, hwnd, version) = match event {
215 WindowOpened { label, version, .. } => ("window_opened".to_string(), label.as_str(), 0u64, *version),
216 WindowClosed { label, version, .. } => ("window_closed".to_string(), label.as_str(), 0, *version),
217 WindowInstanceAssigned { label, version, .. } => ("window_instance_assigned".to_string(), label.as_str(), 0, *version),
218 WindowInstanceReleased { label, version, .. } => ("window_instance_released".to_string(), label.as_str(), 0, *version),
219 BackendWindowIdRegistered { label, version, .. } => ("backend_window_id_registered".to_string(), label.as_str(), 0, *version),
220 BackendWindowIdUnregistered { label, version, .. } => ("backend_window_id_unregistered".to_string(), label.as_str(), 0, *version),
221 PoolWindowAdded { label, version, .. } => ("pool_window_added".to_string(), label.as_str(), 0, *version),
222 PoolWindowRemoved { label, version, .. } => ("pool_window_removed".to_string(), label.as_str(), 0, *version),
223 PoolWindowPromoted { label, version, .. } => ("pool_window_promoted".to_string(), label.as_str(), 0, *version),
224 HwndDriftDetected { kind: drift_kind, label, hwnd, version, .. } => (
225 format!("hwnd_drift_detected:{:?}", drift_kind),
226 label.as_deref().unwrap_or(""),
227 hwnd.unwrap_or(0),
228 *version,
229 ),
230 DriftDetected { kind: drift_kind, version, .. } => (
231 format!("drift_detected:{:?}", drift_kind),
232 "",
233 0,
234 *version,
235 ),
236 CorrectiveWindowMove { hwnd, version, .. } => ("corrective_window_move".to_string(), "", *hwnd, *version),
237 HostShouldQuit { version, .. } => ("host_should_quit".to_string(), "", 0, *version),
238 // Catchall: extract the serde discriminant ("event" tag in
239 // the JSON tagged-union) so different unhandled variants
240 // don't collide on the same `__catchall__` key.
241 other => {
242 let value = serde_json::to_value(other).ok();
243 let event_tag = value
244 .as_ref()
245 .and_then(|v| v.get("event"))
246 .and_then(|v| v.as_str())
247 .unwrap_or("__unknown__")
248 .to_string();
249 let v = value
250 .as_ref()
251 .and_then(|v| v.get("version"))
252 .and_then(|x| x.as_u64())
253 .unwrap_or(0);
254 (event_tag, "", 0, v)
255 }
256 };
257 (format!("{}|{}|{}", kind, label, hwnd), version)
258}
259
260#[cfg(test)]
261mod bridge_dedup_tests {
262 //! Phase F.7 host-bridge dedup. The bridge's job is to amplify-zero —
263 //! one launcher event per `(kind, label, hwnd)` key, monotonic by
264 //! version. v0.33.688 smoke surfaced a 164× amplification (single
265 //! launcher emit at v=78, 164 lines logged by the renderer);
266 //! these tests pin the cap.
267 use super::*;
268 use agentmux_common::ipc::{HwndDriftKind, Severity};
269 use std::sync::Arc;
270
271 fn fresh_state() -> Arc<crate::state::AppState> {
272 Arc::new(crate::state::AppState::default())
273 }
274
275 fn drift_event(version: u64, label: &str, hwnd: u64) -> Event {
276 Event::HwndDriftDetected {
277 kind: HwndDriftKind::HiddenSinceOpen,
278 label: Some(label.to_string()),
279 hwnd: Some(hwnd),
280 detail: "test".to_string(),
281 severity: Severity::Warn,
282 version,
283 }
284 }
285
286 #[test]
287 fn first_dispatch_passes_gate() {
288 let state = fresh_state();
289 assert!(should_dispatch(&state, &drift_event(1, "window-x", 100)));
290 }
291
292 #[test]
293 fn duplicate_same_version_drops() {
294 let state = fresh_state();
295 assert!(should_dispatch(&state, &drift_event(78, "window-x", 100)));
296 for _ in 0..200 {
297 assert!(
298 !should_dispatch(&state, &drift_event(78, "window-x", 100)),
299 "same (kind,label,hwnd,version) must drop"
300 );
301 }
302 }
303
304 #[test]
305 fn higher_version_for_same_key_passes() {
306 let state = fresh_state();
307 assert!(should_dispatch(&state, &drift_event(5, "window-x", 100)));
308 assert!(should_dispatch(&state, &drift_event(6, "window-x", 100)));
309 }
310
311 #[test]
312 fn lower_version_for_same_key_drops() {
313 let state = fresh_state();
314 assert!(should_dispatch(&state, &drift_event(10, "window-x", 100)));
315 assert!(!should_dispatch(&state, &drift_event(9, "window-x", 100)));
316 }
317
318 #[test]
319 fn different_labels_dont_collide() {
320 let state = fresh_state();
321 assert!(should_dispatch(&state, &drift_event(78, "window-a", 100)));
322 assert!(should_dispatch(&state, &drift_event(78, "window-b", 200)));
323 assert!(should_dispatch(&state, &drift_event(78, "window-c", 300)));
324 }
325
326 #[test]
327 fn different_hwnds_dont_collide() {
328 let state = fresh_state();
329 assert!(should_dispatch(&state, &drift_event(78, "window-x", 100)));
330 // Same label, different hwnd (e.g. mid-promote re-link).
331 assert!(should_dispatch(&state, &drift_event(78, "window-x", 200)));
332 }
333
334 #[test]
335 fn drift_storm_replay_collapses_to_one() {
336 // Reproduce the v0.33.688 smoke pattern: 164 dispatches of
337 // an identical HiddenSinceOpen event. The bridge must emit
338 // at most ONE through `should_dispatch`.
339 let state = fresh_state();
340 let evt = drift_event(78, "window-ee4504a143984a4db9a1559f5b66ac21", 6162460);
341 let mut admitted = 0;
342 for _ in 0..164 {
343 if should_dispatch(&state, &evt) {
344 admitted += 1;
345 }
346 }
347 assert_eq!(admitted, 1, "bridge must amplify-zero same-version drift");
348 }
349
350 #[test]
351 fn dedup_cache_bounded() {
352 let state = fresh_state();
353 // 5000 unique labels — cache must not exceed MAX_DEDUP_KEYS (4096).
354 for i in 0..5000 {
355 let label = format!("window-{}", i);
356 let _ = should_dispatch(&state, &drift_event(1, &label, i as u64));
357 }
358 let len = state.launcher_bridge_dedup.lock().len();
359 assert!(
360 len <= 4096,
361 "cache size {} exceeds bound 4096 — eviction broken",
362 len
363 );
364 }
365
366 #[test]
367 fn distinct_event_kinds_dont_collide() {
368 let state = fresh_state();
369 let label = "window-x";
370 // Both at v=10, different kinds, same label — must both pass.
371 assert!(should_dispatch(
372 &state,
373 &Event::WindowOpened {
374 label: label.to_string(),
375 kind: agentmux_common::ipc::WindowKind::FullInstance,
376 parent_label: None,
377 version: 10,
378 }
379 ));
380 assert!(should_dispatch(&state, &drift_event(10, label, 100)));
381 }
382
383 #[test]
384 fn distinct_drift_kinds_dont_collide() {
385 // Reagent P2 PR #722 round 2: HwndDriftDetected key must
386 // include the drift kind, otherwise HiddenSinceOpen and
387 // OffMonitor for the same (label, hwnd) collide.
388 let state = fresh_state();
389 let label = "window-x";
390 let hwnd = 100u64;
391 let hidden = Event::HwndDriftDetected {
392 kind: HwndDriftKind::HiddenSinceOpen,
393 label: Some(label.to_string()),
394 hwnd: Some(hwnd),
395 detail: "h".into(),
396 severity: Severity::Warn,
397 version: 10,
398 };
399 let off = Event::HwndDriftDetected {
400 kind: HwndDriftKind::OffMonitor,
401 label: Some(label.to_string()),
402 hwnd: Some(hwnd),
403 detail: "o".into(),
404 severity: Severity::Warn,
405 version: 10,
406 };
407 assert!(should_dispatch(&state, &hidden));
408 assert!(
409 should_dispatch(&state, &off),
410 "different drift kinds at same (label, hwnd, version) must NOT collide"
411 );
412 }
413
414 #[test]
415 fn launcher_restart_sentinel_clears_cache() {
416 // Codex P1 PR #722 round 1: when launcher restarts and
417 // event_version resets to 1, prior cached entries with
418 // higher versions block the v=1 sentinel and subsequent
419 // low-version events. Cache must clear on the sentinel.
420 let state = fresh_state();
421 // Establish some prior-incarnation cache entries.
422 assert!(should_dispatch(&state, &drift_event(10, "window-a", 100)));
423 assert!(should_dispatch(&state, &drift_event(15, "window-b", 200)));
424 assert!(state.launcher_bridge_dedup.lock().has_any_versioned_entry());
425
426 // Launcher restarts: emits v=1 for some new window. Without
427 // the cache reset, the v=1 wouldn't necessarily collide
428 // (different label) but SUBSEQUENT low-version events for
429 // pre-existing labels would block. The reset is keyed off
430 // "v=1 with prior versions cached" — fires once, clears all.
431 let sentinel = Event::WindowOpened {
432 label: "main".into(),
433 kind: agentmux_common::ipc::WindowKind::FullInstance,
434 parent_label: None,
435 version: 1,
436 };
437 assert!(should_dispatch(&state, &sentinel));
438 // Cache should now contain only the sentinel + nothing else.
439 assert_eq!(
440 state.launcher_bridge_dedup.lock().len(),
441 1,
442 "restart sentinel clears cache before recording new entry"
443 );
444
445 // Subsequent low-version events for the same key must admit
446 // (cache no longer holds the stale higher version).
447 assert!(should_dispatch(
448 &state,
449 &Event::WindowOpened {
450 label: "window-a".into(),
451 kind: agentmux_common::ipc::WindowKind::FullInstance,
452 parent_label: None,
453 version: 2,
454 }
455 ));
456 }
457
458 #[test]
459 fn cold_v1_event_into_empty_cache_admits() {
460 // Anti-vacuity guard: a cold v=1 event with no prior cache
461 // is the first event of a fresh launcher and admits cleanly.
462 // The sentinel logic only fires when v=1 arrives WITH a
463 // pre-existing cache entry (renderer-side mirror), so a
464 // truly-empty-cache v=1 just admits without ceremony.
465 let state = fresh_state();
466 let evt = Event::WindowOpened {
467 label: "main".into(),
468 kind: agentmux_common::ipc::WindowKind::FullInstance,
469 parent_label: None,
470 version: 1,
471 };
472 assert!(should_dispatch(&state, &evt));
473 assert_eq!(state.launcher_bridge_dedup.lock().len(), 1);
474 }
475
476 #[test]
477 fn fifo_eviction_drops_oldest_not_newest() {
478 // Reagent P1 PR #722 round 1: HashMap iteration order is
479 // not insertion order, so the previous implementation could
480 // evict the just-inserted key. Now: VecDeque tracks insert
481 // order; pop_front drops the oldest.
482 //
483 // Use version=2 (NOT 1) for every event so the v=1 restart
484 // sentinel doesn't fire each iteration and clear the cache
485 // (reagent P2 PR #722 round 3 anti-vacuity guard).
486 let state = fresh_state();
487 for i in 0..5000 {
488 let label = format!("window-{}", i);
489 assert!(should_dispatch(&state, &drift_event(2, &label, i as u64)));
490 }
491 let cache = state.launcher_bridge_dedup.lock();
492 assert!(cache.len() <= 4096, "cache bounded at 4096");
493 // First 904 keys (5000 - 4096) should have been evicted.
494 assert!(!cache.seen.contains_key("hwnd_drift_detected:HiddenSinceOpen|window-0|0"));
495 assert!(!cache.seen.contains_key("hwnd_drift_detected:HiddenSinceOpen|window-100|100"));
496 // Recent keys retained.
497 assert!(cache.seen.contains_key("hwnd_drift_detected:HiddenSinceOpen|window-4999|4999"));
498 }
499}