agentmux_cef/state.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Shared application state for the CEF host.
5
6use std::collections::HashMap;
7use parking_lot::Mutex;
8
9use cef::Browser;
10
11// ── Cross-window drag types (ported from src-tauri/src/state.rs) ─────────
12
13#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum DragType {
16 Pane,
17 Tab,
18}
19
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct DragPayload {
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub block_id: Option<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub tab_id: Option<String>,
27}
28
29#[derive(Debug, Clone, serde::Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct DragSession {
32 pub drag_id: String,
33 pub drag_type: DragType,
34 pub source_window: String,
35 pub source_workspace_id: String,
36 pub source_tab_id: String,
37 pub payload: DragPayload,
38 pub started_at: u64,
39}
40
41// Phase B.5e — `WindowInstanceRegistry` struct deleted. Sequential
42// instance numbers are now owned by the launcher's reducer
43// (`agentmux-launcher::state::State.instance_registry`). Host's
44// projection lives in `AppState.shadow_instance_registry`, fed by
45// `Event::WindowInstanceAssigned` / `WindowInstanceReleased`. See
46// docs/retro/migration-pattern.md for the a→b→c→d→e ratchet.
47
48// Phase B.1: removed `JobHandle` wrapper. Host no longer owns a Job
49// Object on srv; the launcher's J0 covers srv directly via
50// AssignProcessToJobObject. The same RAII pattern lives in
51// agentmux-launcher/src/main.rs::JobHandle.
52
53/// Backend (agentmux-srv) connection endpoints.
54#[derive(Default, Clone, serde::Serialize)]
55pub struct BackendEndpoints {
56 pub ws_endpoint: String,
57 pub web_endpoint: String,
58}
59
60/// Window role in the AgentMux multi-window model.
61///
62/// Two distinct types with different taskbar treatment:
63/// - `FullInstance`: independent AgentMux window (like Chrome/VS Code new window).
64/// Appears in the Windows taskbar. All user-facing "new window" paths (status-bar
65/// version click, second `agentmux.exe` launch, `Ctrl+Shift+N`) create one.
66/// - `Subwindow`: hidden from the taskbar via `ITaskbarList::DeleteTab`. Only
67/// reachable through the backend `open_subwindow` API — reserved for agent /
68/// internal use cases (transient auxiliary views, tool-spawned panels). Closes
69/// when its parent full instance closes.
70#[derive(Copy, Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
71#[serde(rename_all = "snake_case")]
72pub enum WindowKind {
73 FullInstance,
74 Subwindow,
75}
76
77/// Per-window metadata held alongside the CEF `Browser`. See `WindowKind` for
78/// the semantics of `kind` and `parent_instance_id`.
79#[derive(Clone, Debug, PartialEq, Eq)]
80pub struct WindowMeta {
81 pub label: String,
82 pub kind: WindowKind,
83 /// For `Subwindow` only: label of the `FullInstance` that owns this window.
84 /// `None` for `FullInstance`.
85 pub parent_instance_id: Option<String>,
86}
87
88/// Phase B.5 (window_meta step d) — pre-create handoff. Caller
89/// (`drag.rs::tear_off`, `commands/window.rs::open_new_window`,
90/// `window_pool.rs::spawn_pool_window`, `pane/creation.rs`) pushes
91/// one entry per window CEF is about to create; `client.rs::on_after_created`
92/// pops the head entry and uses `kind` for the Subwindow
93/// taskbar-hide branch + as the payload for `ReportWindowOpened`.
94///
95/// Replaces the previous `pending_window_labels: VecDeque<String>`
96/// queue + parallel caller-side `window_meta` writes that used to
97/// act as the kind/parent channel. Collapsing them into a single
98/// tuple eliminates the parallel-write race; on_after_created
99/// performs the single canonical `window_meta.insert` from the
100/// popped entry (kept as a synchronous host-side cache for
101/// open_subwindow's parent liveness check + cascade-close
102/// enumeration in `task dev` mode where launcher IPC is absent).
103#[derive(Clone, Debug)]
104pub struct PendingWindowCreation {
105 pub label: String,
106 pub kind: WindowKind,
107 pub parent_instance_id: Option<String>,
108}
109
110// ── Phase H — host reducer buildout ──────────────────────────────────────
111//
112// All types below are reducer-only state. PR #1 (h1-foundations) declares
113// them; subsequent PRs (#2-#5) wire callers through the reducer per the
114// a→b→c→d→e migration ratchet. See:
115// docs/specs/SPEC_HOST_REDUCER_5PR_PLAN_2026-05-02.md
116// docs/specs/SPEC_HOST_REDUCER_PHASE_H_2026-05-02.md
117//
118// These types intentionally have `#[allow(dead_code)]` because PR #1 ships
119// the scaffolding without callers — fields are populated by reducer arms but
120// no production code reads them yet. Subsequent PRs lift the allow as they
121// wire each migration.
122
123// ── Pane lifecycle (H.1) ─────────────────────────────────────────────────
124
125/// Lifecycle state of a browser pane (the `defwidget@browser` widget). Held
126/// inside `HostState.browser_panes` keyed by `block_id`. Mirrors the existing
127/// `PaneStateMachine::BrowserPaneLifecycle` (pane/lifecycle.rs:28); the existing
128/// type stays during PR #2's a→e migration. PR #2 step e deletes the
129/// pane/lifecycle.rs version and migrates all readers to this one.
130#[derive(Clone, Debug, PartialEq, Eq)]
131#[allow(dead_code)]
132pub enum BrowserPaneLifecycle {
133 /// Pane is alive and accepting operations (focus, resize, navigate).
134 Live,
135 /// Close requested; awaiting CEF on_before_close to fully tear down.
136 /// `since` carries the request timestamp for diagnostic purposes only;
137 /// nothing in the reducer is timer-driven.
138 Closing { since: std::time::Instant },
139}
140
141/// Per-pane reducer-managed entry. Replaces `pane::lifecycle::BrowserPaneEntry`
142/// (lifecycle.rs:42) at PR #2 step e.
143#[derive(Clone, Debug)]
144#[allow(dead_code)]
145pub struct BrowserPaneEntry {
146 pub block_id: String,
147 pub label: String,
148 pub lifecycle: BrowserPaneLifecycle,
149}
150
151// ── Browser handle registry (H.2) ────────────────────────────────────────
152
153/// Wrapped CEF Browser handle stored in `HostState.browsers`. Replaces the
154/// raw `Mutex<HashMap<String, Browser>>` at `state.rs::AppState.browsers`
155/// at PR #2 step e.
156///
157/// `cef::Browser` is `Clone` (refcounted FFI handle) and safe to store
158/// inside the reducer's mutex-guarded state. Doesn't impl Debug, hence
159/// the manual `impl Debug` below for `BrowserHandle`.
160#[derive(Clone)]
161#[allow(dead_code)]
162pub struct BrowserHandle {
163 pub label: String,
164 pub browser: Browser,
165 pub kind: BrowserKind,
166 pub registered_at: std::time::Instant,
167}
168
169impl std::fmt::Debug for BrowserHandle {
170 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171 f.debug_struct("BrowserHandle")
172 .field("label", &self.label)
173 .field("kind", &self.kind)
174 .field("registered_at", &self.registered_at)
175 .field("browser", &"<cef::Browser>")
176 .finish()
177 }
178}
179
180/// Distinguishes top-level CEF Browsers (full-instance windows + pool
181/// windows) from pane CEF Browsers (children of a top-level). Determines
182/// taskbar treatment, lifecycle ownership, etc.
183#[derive(Clone, Debug)]
184#[allow(dead_code)]
185pub enum BrowserKind {
186 /// Top-level window. `is_pool=true` while the window is in the warm
187 /// pool; cleared on promote.
188 TopLevel { is_pool: bool },
189 /// Browser pane child window. `block_id` correlates with the
190 /// `HostState.browser_panes` entry.
191 Pane { block_id: String },
192}
193
194// ── Pool state (H.4) ─────────────────────────────────────────────────────
195
196/// Pre-warmed window pool state. Replaces three separate fields on
197/// `AppState`: `window_pool: Mutex<VecDeque<String>>`,
198/// `unpromoted_pool_labels: Mutex<HashSet<String>>`, and
199/// `window_pool_respawn_in_flight: AtomicBool`. PR #3 migrates each
200/// caller through the a→e ratchet.
201#[derive(Default, Clone, Debug)]
202#[allow(dead_code)]
203pub struct PoolState {
204 /// Labels of pool windows whose renderer signaled ready (eligible
205 /// for promotion).
206 pub queue: std::collections::VecDeque<String>,
207 /// Labels spawned but not yet renderer-ready (and therefore not yet
208 /// in `queue`). Used for taskbar/exclusion filters during the spawn
209 /// → ready window.
210 pub unpromoted: std::collections::HashSet<String>,
211 /// Single-flight semaphore: true while a respawn task is in flight,
212 /// preventing stacked refills.
213 pub respawn_in_flight: bool,
214}
215
216// ── Quit state (H.5) ─────────────────────────────────────────────────────
217
218/// Host process quit lifecycle. Replaces `is_quitting: AtomicBool` at
219/// `state.rs::AppState`. Three states; transitions are monotonic
220/// (Running → Draining → Quit, no regression).
221#[derive(Clone, Debug, PartialEq, Eq)]
222#[allow(dead_code)]
223pub enum QuitState {
224 /// Normal operation. All commands accepted (subject to per-arm rules).
225 Running,
226 /// `BeginDrain` dispatched. Pool refills suppressed; awaiting pool +
227 /// browsers to drain.
228 Draining { reason: QuitReason },
229 /// `ConfirmDrained` dispatched. Host quitting; no further commands.
230 Quit,
231}
232
233#[derive(Clone, Debug, PartialEq, Eq)]
234#[allow(dead_code)]
235pub enum QuitReason {
236 /// User closed the last user-visible top-level window. Standard exit.
237 LastWindowClosed,
238 /// Launcher signaled HostShouldQuit (cross-process shutdown).
239 LauncherRequested,
240 /// External force-quit (Win32 WM_QUIT, signal, etc.).
241 External,
242}
243
244impl Default for QuitState {
245 fn default() -> Self { QuitState::Running }
246}
247
248// ── Top-level window creation runner (H.6) ───────────────────────────────
249
250/// A request to create a top-level window. Pushed to
251/// `HostState.top_level_creation.queue` via `EnqueueTopLevelWindow`.
252/// Carries the full spec the effect handler needs to call
253/// `ui_tasks::post_create_window`.
254#[derive(Clone, Debug)]
255#[allow(dead_code)]
256pub struct TopLevelCreationRequest {
257 pub label: String,
258 pub kind: WindowKind,
259 pub parent_instance_id: Option<String>,
260 pub url: String,
261 pub pos: (i32, i32),
262 pub size: (i32, i32),
263 pub frameless: bool,
264 /// `User`-initiated (fail-fast on contention) vs `Background` (pool
265 /// refill — may queue silently).
266 pub source: TopLevelSource,
267}
268
269/// Distinguishes user-facing creation requests (which fail-fast on
270/// contention) from background ones (pool refill — may queue indefinitely).
271#[derive(Clone, Debug, PartialEq, Eq)]
272#[allow(dead_code)]
273pub enum TopLevelSource {
274 /// Triggered by a user action (click "open new window", tear off a
275 /// tab, etc.). Reducer rejects with error if in-flight slot is
276 /// occupied — caller propagates a visible error to the frontend.
277 User,
278 /// Triggered by the runner itself (pool refill, recovery). Queues
279 /// behind in-flight; no caller waiting on completion.
280 Background,
281}
282
283/// The single in-flight top-level creation. Singleton invariant enforced
284/// by the reducer (at most one Some across all action sequences).
285///
286/// **No `deadline` field. No watchdog.** The reducer reacts only to
287/// observable CEF callbacks: `on_after_created` (success),
288/// `on_render_process_terminated` (renderer crash), `on_before_close`
289/// (cancel mid-create). If none fire, the slot stays occupied
290/// permanently — user-initiated creates fail-fast with visible error
291/// per the no-timer directive.
292#[derive(Clone, Debug)]
293#[allow(dead_code)]
294pub struct InFlightCreation {
295 pub creation_id: u64,
296 pub label: String,
297 pub started_at: std::time::Instant,
298 pub phase: CreationPhase,
299}
300
301/// Phase progression of an in-flight top-level creation. Monotonic;
302/// `AdvanceCreationPhase` (if added later) refuses regression.
303#[derive(Clone, Debug, Copy, PartialEq, Eq, PartialOrd, Ord)]
304#[repr(u8)]
305#[allow(dead_code)]
306pub enum CreationPhase {
307 /// Effect handler has dispatched `post_create_window`; CEF has not
308 /// yet fired any callback.
309 Started = 0,
310 /// CEF `on_after_created` fired — Browser exists, renderer alive.
311 BrowserCallbackFired = 1,
312}
313
314/// Archived completion record for the runner's history ring buffer.
315/// Bounded at `TOP_LEVEL_CREATION_HISTORY_CAP` (50); oldest evicted FIFO.
316#[derive(Clone, Debug)]
317#[allow(dead_code)]
318pub struct CompletedCreation {
319 pub creation_id: u64,
320 pub label: String,
321 pub outcome: TopLevelCreationOutcome,
322 pub started_at: std::time::Instant,
323 pub finished_at: std::time::Instant,
324 pub last_phase: CreationPhase,
325}
326
327/// Why a top-level creation completed. `Completed` is happy-path; the
328/// other variants are observable failure modes.
329#[derive(Clone, Debug)]
330#[allow(dead_code)]
331pub enum TopLevelCreationOutcome {
332 /// CEF `on_after_created` fired; browser registered. Normal completion.
333 Completed,
334 /// CEF `on_render_process_terminated` fired during creation. Renderer
335 /// process died (crash, OOM, killed).
336 RendererTerminated { status: String },
337 /// CEF `on_before_close` fired during creation. Browser closed
338 /// externally (user action, parent close, etc.).
339 ExternallyClosed,
340}
341
342/// Reducer-managed runner state. Owns the queue, in-flight slot, history,
343/// and id allocator.
344#[derive(Default, Clone, Debug)]
345#[allow(dead_code)]
346pub struct TopLevelCreationState {
347 pub queue: std::collections::VecDeque<TopLevelCreationRequest>,
348 pub in_flight: Option<InFlightCreation>,
349 pub history: std::collections::VecDeque<CompletedCreation>,
350 pub next_creation_id: u64,
351}
352
353// ── Effects (carrier for side-effect-bearing events) ─────────────────────
354
355/// Side-effect descriptor emitted by reducer arms. Carried inside
356/// `HostEvent::Effect(EffectKind)`. The effects executor in
357/// `AppState::host_dispatch_with_effects` dispatches each kind to the
358/// appropriate imperative handler (e.g., posting a CEF UI task).
359///
360/// Reducer arms emit effects but never execute them; this preserves the
361/// pure-functional discipline of `update()`. Manual Debug impl below
362/// because `cef::Browser` doesn't impl Debug.
363#[derive(Clone)]
364#[allow(dead_code)]
365pub enum EffectKind {
366 /// Begin top-level window creation by posting `ui_tasks::post_create_window`.
367 /// Carried by `HostEvent::TopLevelCreationStarted`'s effect path.
368 PostCreateWindow { request: TopLevelCreationRequest, creation_id: u64 },
369 /// Spawn a pool window (PR #3 wires this when pool drops below TARGET_SIZE).
370 SpawnPoolWindow,
371 /// Close an orphan CEF browser whose label doesn't match any in-flight
372 /// or registered entry. Used by H.6's mismatched-callback handler to
373 /// prevent label collision.
374 CloseOrphanBrowser { browser: Browser },
375}
376
377impl std::fmt::Debug for EffectKind {
378 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379 match self {
380 EffectKind::PostCreateWindow { request, creation_id } => f
381 .debug_struct("PostCreateWindow")
382 .field("creation_id", creation_id)
383 .field("label", &request.label)
384 .finish(),
385 EffectKind::SpawnPoolWindow => f.write_str("SpawnPoolWindow"),
386 EffectKind::CloseOrphanBrowser { .. } => f
387 .debug_struct("CloseOrphanBrowser")
388 .field("browser", &"<cef::Browser>")
389 .finish(),
390 }
391 }
392}
393
394/// Shared application state for the CEF host.
395///
396/// Unlike the Tauri version, this uses `Arc<AppState>` directly instead of
397/// `tauri::State<AppState>`. The sidecar child is `std::process::Child` instead
398/// of `tauri_plugin_shell::process::CommandChild`.
399pub struct AppState {
400 // Phase B.5 (window_id_map step e) — `window_id_map` field
401 // deleted. Authoritative copy lives in the launcher's
402 // `state.backend_window_ids`. Host's projection is in
403 // `shadow_backend_window_ids` (below).
404
405 /// Auth key for backend communication
406 pub auth_key: Mutex<String>,
407
408 /// Backend (agentmux-srv) connection endpoints
409 pub backend_endpoints: Mutex<BackendEndpoints>,
410
411 /// Handle to the sidecar child process for graceful shutdown
412 pub sidecar_child: Mutex<Option<std::process::Child>>,
413
414 /// Backend process PID (set after spawn)
415 pub backend_pid: Mutex<Option<u32>>,
416
417 /// Backend process start time as ISO 8601 string
418 pub backend_started_at: Mutex<Option<String>>,
419
420 /// Current zoom factor
421 pub zoom_factor: Mutex<f64>,
422
423 /// Client ID (set after querying backend on startup)
424 pub client_id: Mutex<Option<String>>,
425
426 /// Window ID (set after querying/creating window via backend)
427 pub window_id: Mutex<Option<String>>,
428
429 /// Active tab ID (set after querying/creating default tab via backend)
430 pub active_tab_id: Mutex<Option<String>>,
431
432 /// Window initialization status ("ready" or "wave-ready")
433 pub window_init_status: Mutex<String>,
434
435 /// Phase B.5e — host's projection of the launcher's
436 /// authoritative `state.instance_registry`. Fed by
437 /// `Event::WindowInstanceAssigned` /
438 /// `Event::WindowInstanceReleased` via
439 /// `launcher_ipc::apply_event_to_shadow`. Host code never
440 /// mutates this directly — reads only. Pre-seeded with
441 /// `{"main": 1}` to mirror the launcher's pre-seed (avoids a
442 /// spurious first-event mismatch during startup before the
443 /// `WindowInstanceAssigned { label: "main", num: 1 }` event
444 /// arrives).
445 pub shadow_instance_registry: Mutex<HashMap<String, u32>>,
446
447 /// Phase B.5 (window_id_map) — host's projection of the
448 /// launcher's authoritative `state.backend_window_ids`. Fed by
449 /// `Event::BackendWindowIdRegistered` /
450 /// `Event::BackendWindowIdUnregistered` via
451 /// `apply_event_to_shadow`. Sole source of truth post-step-e
452 /// (host's `window_id_map` was deleted). Read via
453 /// `Self::backend_window_id`; never mutated directly by host
454 /// code.
455 pub shadow_backend_window_ids: Mutex<HashMap<String, String>>,
456
457 /// Phase B.5 (window_meta step b) — host's projection of the
458 /// launcher's authoritative `state.windows: HashMap<String,
459 /// WindowMirror>` (B.4). The launcher already mirrors all
460 /// `WindowMeta` data via `Event::WindowOpened`/`WindowClosed`
461 /// (carrying `{label, kind, parent_label}` since B.4); this
462 /// host-side cache makes it readable without a launcher
463 /// round-trip. Maintained in parallel to `window_meta` until
464 /// step c flips reads, step d drops mutations, step e deletes
465 /// the host field. Drift logged via
466 /// `target = "launcher-ipc:drift"`.
467 pub shadow_window_meta: Mutex<HashMap<String, WindowMeta>>,
468
469 /// Cancellation channel for an in-progress CLI login process
470 pub cli_login_cancel: Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
471
472 /// PID of an in-progress PTY-backed CLI login child. The pipe
473 /// path uses `cli_login_cancel` + `tokio::select!` + Tokio's
474 /// `kill_on_drop` Child to terminate, but the PTY path moves
475 /// the `portable_pty` child into a `spawn_blocking` task that
476 /// outlives the outer abort — so cancel needs a PID + platform
477 /// kill to actually stop the subprocess. Populated by
478 /// `run_cli_login_pty`, cleared when the child exits naturally.
479 pub cli_login_pty_pid: Mutex<Option<u32>>,
480
481 /// Stdin handle for the running CLI login child process. Two
482 /// variants because some providers (OpenClaw) require an
483 /// interactive TTY for their auth subcommand and we spawn them via
484 /// `portable_pty` instead of `tokio::process::Command`. Written to
485 /// by `set_provider_auth` to deliver an OAuth device code or
486 /// pasted token. See `commands::platform::CliLoginStdin`.
487 pub cli_login_stdin: Mutex<Option<crate::commands::platform::CliLoginStdin>>,
488
489 /// IPC HTTP server port
490 pub ipc_port: Mutex<u16>,
491
492 /// IPC bearer token — injected into the page alongside the port.
493 /// Verified on every IPC request to prevent unauthorized local access.
494 pub ipc_token: String,
495
496 // Phase H.2.e (PR #4) — `pub browsers: Mutex<HashMap<String, Browser>>`
497 // deleted. Authoritative storage is now `HostState.browsers` (the host
498 // reducer's map). Read access goes through `AppState::get_browser`,
499 // `list_browsers`, etc. (state.rs:704-742). The H.2 ratchet
500 // (a → parallel writes, b → reads with fallback, c → flip reads,
501 // d → drop legacy writes, e → delete) is complete for browsers.
502 // Pane lifecycle (`PaneStateMachine`) follows in PR #5.
503
504 /// Per-window metadata (kind, parent linkage).
505 ///
506 /// **Phase B status**: synchronous host-side cache mirroring the
507 /// launcher's `state.windows` mirror. Single canonical mutation site:
508 /// inserted in `client.rs::on_after_created` from the popped
509 /// `PendingWindowCreation` entry, removed in `on_before_close`.
510 /// Required for synchronous lookups that can't tolerate the launcher
511 /// round-trip lag (`open_subwindow` parent-liveness check; cascade-close
512 /// child enumeration). See `docs/retro/migration-pattern.md` for the
513 /// sync-cache exception and `b5-migration-architecture-2026-04-28.md`
514 /// for why step e ≠ delete here.
515 pub window_meta: Mutex<HashMap<String, WindowMeta>>,
516
517 /// Phase F.1 — host reducer state.
518 ///
519 /// Owns `pending_window_creations` (formerly a top-level
520 /// `Mutex<VecDeque<PendingWindowCreation>>` field on AppState).
521 /// All mutations go through `host_dispatch`; reads use the
522 /// `peek_back_pending_window_creation` snapshot helper.
523 /// Future PRs will migrate `active_drag` and tear-off-hook state
524 /// here too. See `agentmux-cef/src/reducer.rs` and
525 /// `docs/specs/SPEC_PHASE_F_HOST_REDUCER_2026-05-01.md`.
526 pub host_state: Mutex<crate::reducer::HostState>,
527
528 /// Phase F.7 host-bridge dedup. Keyed by `"{event_kind}|{label}|{hwnd}"`,
529 /// value is the highest launcher-event version dispatched to renderers
530 /// for that key. The bridge skips dispatch if the incoming event's
531 /// version is `<=` the cached version — preserving subscriber
532 /// idempotency under §8.14 even if the renderer-side guard fails
533 /// (multiple V8 contexts, fresh-renderer post-crash, race during
534 /// init, etc.).
535 ///
536 /// Originally proposed in `ANALYSIS_DRIFT_STORM_RENDERER_CRASH_2026-05-06.md`
537 /// §4.4 / master spec §9.8 as Phase F.7. Implementation forced when
538 /// v0.33.688 smoke surfaced a 164× amplification of a single
539 /// `HiddenSinceOpen` drift event v=78 — launcher cap (PR #721)
540 /// prevented multi-emit but the bridge fanned the one event into
541 /// many V8 contexts, exhausting the renderer.
542 ///
543 /// Bounded at 4096 keys with FIFO eviction (insertion order). A
544 /// re-arrival for an evicted key bypasses dedup once but the
545 /// renderer guard still catches it.
546 pub launcher_bridge_dedup: Mutex<crate::launcher_event_bridge::DedupCache>,
547
548 /// Linux/macOS only — registry of live top-level `CefWindow` handles,
549 /// keyed by window label ("main" for the primary, otherwise the label
550 /// CreateWindowTask assigns to a sub-window). Populated by
551 /// `AgentMuxWindowDelegate::on_window_created` and cleared by
552 /// `on_window_destroyed`. Used by the browser-pane Views path to find
553 /// the right parent Window when attaching a pane via
554 /// `Window::add_overlay_view` — without this map, panes opened from a
555 /// non-main window were silently routed to the main window (PR #682
556 /// follow-up: "the browser opens in the wrong window" bug).
557 /// The Windows pane path uses native HWND lookup instead and never
558 /// reads this field, so it is cfg-gated to avoid carrying unused
559 /// state on Windows.
560 #[cfg(not(target_os = "windows"))]
561 pub windows: Mutex<std::collections::HashMap<String, cef::Window>>,
562
563 /// Linux/macOS only — `CefOverlayController` handles for live browser
564 /// panes, keyed by pane label (the same label used in `host_state.browsers`).
565 /// Each entry pairs the controller with the `window_label` of the
566 /// parent CefWindow it was attached to (looked up from `state.windows`
567 /// at create time), so resize / detach / overlay-clip calls can find
568 /// the right Window for `layout()` / `remove_child_view`.
569 ///
570 /// Populated by `browser_pane/creation_views.rs` after the BrowserView
571 /// has been added to its parent Window via `add_overlay_view`. We use
572 /// AddOverlayView (not AddChildView) because AddChildView cohabitates
573 /// poorly with the host UI's BrowserView under the Window's default
574 /// FillLayout — both children get full-parent-size bounds and the pane
575 /// stacks on top of the host UI at full size with per-frame redraw
576 /// flashing (verified empirically during the spike). OverlayController
577 /// has its own explicit `set_size` / `set_position` / `set_visible`
578 /// /`destroy` that aren't subject to the parent's auto-layout.
579 /// Windows panes use HWND ops instead and never touch this map.
580 #[cfg(not(target_os = "windows"))]
581 pub browser_pane_overlays:
582 Mutex<std::collections::HashMap<String, (String, cef::OverlayController)>>,
583
584 /// Linux/macOS only — latest overlay-clip rects per window_label.
585 ///
586 /// `browser_panes_set_overlay_clip` IPC publishes here so that BOTH the
587 /// pane-airspace task (`SetPaneOverlayClipViewsTask`) and the per-pane
588 /// resize task (`resize_browser_pane_view`) can compute pane visibility
589 /// from the same authoritative state. Without this, resize would be the
590 /// sole visibility authority on its path and `set_visible(1)` on a
591 /// non-zero resize (e.g. user dragging a splitter while a DOM modal is
592 /// open) would clobber the airspace's `set_visible(0)` — the
593 /// "borderless DOM appears above modal" repro Codex caught on PR #881.
594 ///
595 /// Empty `Vec` for a key means "no overlays open in this window" →
596 /// panes are visible (subject to their own rect being non-zero).
597 /// Stale entries for closed windows are harmless: they only affect
598 /// panes still attached to that window.
599 #[cfg(not(target_os = "windows"))]
600 pub pane_overlay_rects:
601 Mutex<std::collections::HashMap<String, Vec<(i32, i32, i32, i32)>>>,
602
603 /// Linux/macOS only — OverlayControllers awaiting deferred destroy.
604 ///
605 /// Calling `OverlayController::destroy()` synchronously on the same UI
606 /// tick as `BrowserHost::close_browser(force=1)` races CEF/Chromium's
607 /// internal Views focus traversal — pending tasks hold
608 /// `base::WeakPtr<View>` to the BrowserView, and yanking the view out
609 /// of the Window's hierarchy before those tasks drain trips
610 /// `weak_ptr.h:250 Check failed: ref_.IsValid()` and FATALs the host.
611 /// Two confirmed reproducers: closing a pane while a pool window is
612 /// being spawned (PR #743 follow-up) and tearing off a tab (a tear-off
613 /// closes the pane in the source workspace then immediately creates a
614 /// new top-level window for the torn tab).
615 ///
616 /// Fix: detach moves the controller out of `browser_pane_overlays`
617 /// (so future resize/clip calls miss), calls `close_browser(force=1)`,
618 /// and stashes the controller here. `on_before_close_browser_pane`
619 /// then drains this map and runs `destroy()` — by that point the
620 /// Browser is fully torn down and Chromium has drained the queued
621 /// tasks that referenced its View, so destroy can't race anything.
622 #[cfg(not(target_os = "windows"))]
623 pub pending_overlay_destroy:
624 Mutex<std::collections::HashMap<String, cef::OverlayController>>,
625
626 /// Tear-off Phase 6 — pre-warmed pool of hidden CEF windows ready for
627 /// instant promotion on tear-off. Each entry is a label of a window
628 /// that's already painted, has its renderer connected, and is sitting
629 /// in pool-mode (`?pool=1` URL flag) waiting to be assigned a workspace.
630 /// On tear-off: pop a label, reposition + show + emit `pool:promote`.
631 // PR #5 H.4 — `window_pool`, `unpromoted_pool_labels`, and
632 // `window_pool_respawn_in_flight` deleted; the host reducer's
633 // `HostState.pool: PoolState` is the sole source of truth. Reads
634 // go through `AppState::pool_queue_size`,
635 // `unpromoted_pool_labels_snapshot`, `is_unpromoted_pool_label`;
636 // mutations through `HostCommand::PoolWindowSpawnStart`,
637 // `PoolWindowReady`, `PoolWindowDestroyedBeforePromote`,
638 // `PopAndPromoteFrontPoolWindow`, `PoolDrainAll`.
639
640 // PR #5 H.5 — `is_quitting` AtomicBool deleted; the host reducer's
641 // `HostState.quit_state: QuitState` is the sole source of truth.
642 // Reads go through `AppState::is_quitting()`; transitions through
643 // `HostCommand::BeginDrain` and `ConfirmDrained`.
644
645 /// Version-specific data directory (e.g. ai.agentmux.cef.v0-32-111/)
646 pub version_data_dir: Mutex<Option<String>>,
647
648 /// Version-specific config directory
649 pub version_config_dir: Mutex<Option<String>>,
650
651 /// User data home used by the frontend's `agentmuxHome()` helper to
652 /// construct per-agent paths (working dir, `GH_CONFIG_DIR`, etc.).
653 ///
654 /// - Portable: `<portable>/data/` — keeps agent state inside the portable
655 /// folder so two coexisting portables don't clobber each other on the
656 /// same `~/.agentmux/agents/<slug>/` path (slugs are unique per Forge
657 /// DB, not globally; see `docs/specs/portable-agent-working-dirs.md`).
658 /// - Installed: `~/.agentmux/` — preserves existing behavior.
659 ///
660 /// `AGENTMUX_DATA_HOME` env var, if set, overrides both.
661 pub user_home_dir: Mutex<Option<String>>,
662
663 // PR #5 H.3 — `active_drag` field deleted; the host reducer's
664 // `HostState.active_drag` is the sole source of truth. Reads go
665 // through `AppState::get_drag_session`; mutations through
666 // `HostCommand::StartDrag` / `EndDrag`.
667
668 /// Embedded browser panes (native CefBrowserView per pane).
669 pub browser_panes: crate::browser_panes::BrowserPaneManager,
670
671 /// Browser DOM API state — CDP target cache + future connection
672 /// pool. See `crate::browser_api`.
673 pub browser_api: crate::browser_api::BrowserApiState,
674
675 /// CEF remote debugging port (9223 dev / 9222 release). Populated
676 /// by `main.rs` from the same `is_dev` branch that sets
677 /// `Settings.remote_debugging_port`. Used by the browser DOM API
678 /// (`/agentmux/browser/*`) to open CDP WebSocket connections to
679 /// pane targets. See `docs/specs/SPEC_BROWSER_DOM_API.md` §6.
680 pub debug_port: Mutex<u16>,
681
682 // Phase B.1 removed `job_handle` (was Windows-only). Launcher
683 // owns J0 wrapping srv now; host no longer needs its own job.
684
685 /// Per-window opacity HWND registry. Populated by `set_window_init_status`
686 /// once the window is fully shown (CEF Views returns NULL at on_after_created
687 /// time). Stored as `isize` (the raw HWND value) so the map is `Send`.
688 /// Read by `set_window_opacity` to target exactly one HWND instead of
689 /// enumerating all process windows. See SPEC_PER_WINDOW_OPACITY_2026-05-14.md §5.
690 #[cfg(target_os = "windows")]
691 pub window_hwnds: Mutex<HashMap<String, isize>>,
692}
693
694impl Default for AppState {
695 fn default() -> Self {
696 Self {
697 auth_key: Mutex::new(uuid::Uuid::new_v4().to_string()),
698 backend_endpoints: Mutex::new(BackendEndpoints::default()),
699 sidecar_child: Mutex::new(None),
700 backend_pid: Mutex::new(None),
701 backend_started_at: Mutex::new(None),
702 zoom_factor: Mutex::new(1.0),
703 client_id: Mutex::new(None),
704 window_id: Mutex::new(None),
705 active_tab_id: Mutex::new(None),
706 window_init_status: Mutex::new(String::new()),
707 shadow_backend_window_ids: Mutex::new(HashMap::new()),
708 shadow_window_meta: Mutex::new(HashMap::new()),
709 shadow_instance_registry: Mutex::new({
710 // Pre-seed with main=1 to mirror the launcher's
711 // pre-seeded `instance_registry` (B.5a). Without
712 // this, the very first drift compare would log a
713 // spurious mismatch for "main" before any event
714 // arrives.
715 let mut m = HashMap::new();
716 m.insert("main".to_string(), 1);
717 m
718 }),
719 cli_login_cancel: Mutex::new(None),
720 cli_login_pty_pid: Mutex::new(None),
721 cli_login_stdin: Mutex::new(None),
722 ipc_port: Mutex::new(0),
723 ipc_token: uuid::Uuid::new_v4().to_string(),
724 // browsers field removed in H.2.e — see comment near struct decl.
725 window_meta: Mutex::new(HashMap::new()),
726 host_state: Mutex::new(crate::reducer::HostState::default()),
727 launcher_bridge_dedup: Mutex::new(crate::launcher_event_bridge::DedupCache::new()),
728 #[cfg(not(target_os = "windows"))]
729 windows: Mutex::new(HashMap::new()),
730 #[cfg(not(target_os = "windows"))]
731 browser_pane_overlays: Mutex::new(HashMap::new()),
732 #[cfg(not(target_os = "windows"))]
733 pane_overlay_rects: Mutex::new(HashMap::new()),
734 #[cfg(not(target_os = "windows"))]
735 pending_overlay_destroy: Mutex::new(HashMap::new()),
736 // window_pool / unpromoted_pool_labels / window_pool_respawn_in_flight
737 // deleted (PR #5 H.4) — see HostState.pool.
738 // is_quitting deleted (PR #5 H.5) — see HostState.quit_state.
739 version_data_dir: Mutex::new(None),
740 version_config_dir: Mutex::new(None),
741 user_home_dir: Mutex::new(None),
742 // active_drag deleted (PR #5 H.3) — see HostState.active_drag.
743 browser_panes: crate::browser_panes::BrowserPaneManager::new(),
744 browser_api: crate::browser_api::BrowserApiState::new(),
745 debug_port: Mutex::new(0),
746 #[cfg(target_os = "windows")]
747 window_hwnds: Mutex::new(HashMap::new()),
748 }
749 }
750}
751
752impl AppState {
753 /// Phase F.1 — dispatch a command through the host reducer.
754 ///
755 /// Locks `host_state`, applies the command via `reducer::update`,
756 /// logs emitted events via tracing, and returns the dispatch
757 /// output (which contains the events plus the dequeued entry for
758 /// `DequeuePendingWindowCreation`).
759 ///
760 /// Lock-hold time: pure-function reducer call, no I/O — typically
761 /// sub-microsecond. Never held across a `SendMessage`, CEF
762 /// callback, or any blocking call (snapshot-and-drop discipline,
763 /// see `docs/specs/SPEC_PHASE_F_HOST_REDUCER_2026-05-01.md` §6).
764 pub fn host_dispatch(&self, cmd: crate::reducer::HostCommand) -> crate::reducer::DispatchOutput {
765 let out = {
766 let mut state = self.host_state.lock();
767 crate::reducer::update(&mut state, cmd)
768 };
769 for ev in &out.events {
770 log_host_event(ev);
771 }
772 out
773 }
774
775 /// Phase F.1 — non-mutating peek at the back of the
776 /// `pending_window_creations` queue.
777 ///
778 /// Used by `wrr/win_event.rs::handle_event` to label OS-level
779 /// `WM_CREATE` events with the upcoming window's label. CEF's
780 /// `OnAfterCreated` (which becomes the dequeue) fires AFTER this
781 /// OS event, but the host pushed the entry BEFORE calling
782 /// `post_create_window`, so back-of-queue is the right answer at
783 /// this moment.
784 ///
785 /// Snapshot-and-drop: takes the lock, clones the entry, drops
786 /// the lock. Callers never hold the lock past this call.
787 pub fn peek_back_pending_window_creation(&self) -> Option<PendingWindowCreation> {
788 self.host_state.lock().pending_window_creations.back().cloned()
789 }
790
791 // ── Phase H.2 — browser read helpers (reducer-only, post-flip) ──────
792 //
793 // After PR #4's flip step (H.1.c + H.2.c), `HostState.browsers` /
794 // `HostState.browser_panes` are the sole source of truth. Legacy reads,
795 // fallback paths, and drift logging removed — production smoke
796 // verified zero drift across 50 reducer events with a balanced
797 // BrowserRegistered/Unregistered count (18/18) and BrowserPaneCreate/
798 // BrowserPaneClosed count (7/7) before this flip.
799 //
800 // Snapshot-and-drop: each helper takes the relevant lock briefly,
801 // clones what's needed, drops the lock. Callers never hold the
802 // lock across CEF FFI calls.
803
804 /// Get a browser handle by label.
805 pub fn get_browser(&self, label: &str) -> Option<Browser> {
806 self.host_state
807 .lock()
808 .browsers
809 .get(label)
810 .map(|h| h.browser.clone())
811 }
812
813 /// Check whether a browser is registered under the given label.
814 pub fn has_browser(&self, label: &str) -> bool {
815 self.host_state.lock().browsers.contains_key(label)
816 }
817
818 /// Are there any registered browsers?
819 pub fn browsers_is_empty(&self) -> bool {
820 self.host_state.lock().browsers.is_empty()
821 }
822
823 /// Snapshot of all registered browser labels (HashMap iteration order;
824 /// stable per-HashMap-instance, same characteristics as the original
825 /// `state.browsers.lock().keys()` pattern these helpers replaced).
826 pub fn list_browser_labels(&self) -> Vec<String> {
827 self.host_state
828 .lock()
829 .browsers
830 .keys()
831 .cloned()
832 .collect()
833 }
834
835 /// Snapshot of all registered browsers as (label, Browser) pairs.
836 /// Ordering is HashMap iteration order; callers must sort if order
837 /// matters.
838 pub fn list_browsers(&self) -> Vec<(String, Browser)> {
839 self.host_state
840 .lock()
841 .browsers
842 .iter()
843 .map(|(k, h)| (k.clone(), h.browser.clone()))
844 .collect()
845 }
846
847 /// Snapshot of TOP-LEVEL browsers only — excludes `BrowserKind::Pane`
848 /// child browsers whose main frame is loading untrusted remote
849 /// content. Callers emitting JS-injected host events must use this
850 /// (or `emit_event_to_window`) so a hostile page in one pane can't
851 /// observe events meant for the host frontend.
852 pub fn list_top_level_browsers(&self) -> Vec<(String, Browser)> {
853 self.host_state
854 .lock()
855 .browsers
856 .iter()
857 .filter(|(_, h)| matches!(h.kind, BrowserKind::TopLevel { .. }))
858 .map(|(k, h)| (k.clone(), h.browser.clone()))
859 .collect()
860 }
861
862 /// First registered browser (for "any browser" callers like command
863 /// palette routing). Returns the label + Browser pair, or None if
864 /// the registry is empty.
865 pub fn first_browser(&self) -> Option<(String, Browser)> {
866 self.host_state
867 .lock()
868 .browsers
869 .iter()
870 .next()
871 .map(|(k, h)| (k.clone(), h.browser.clone()))
872 }
873
874 // ── Phase H.1 — pane lifecycle read helpers (reducer-only, post-flip)
875
876 /// Returns the pane's label iff entry is `Live`. Used by op gates
877 /// (focus/resize/navigate) — `None` indicates the pane is missing or
878 /// in `Closing`, in which case the caller must short-circuit rather
879 /// than touch the (possibly destroyed) HWND.
880 pub fn live_browser_pane_label(&self, block_id: &str) -> Option<String> {
881 self.host_state
882 .lock()
883 .browser_panes
884 .get(block_id)
885 .filter(|e| e.lifecycle == BrowserPaneLifecycle::Live)
886 .map(|e| e.label.clone())
887 }
888
889 /// Snapshot of all `Live` pane labels. Used by `defocus_all` etc.
890 pub fn live_browser_pane_labels(&self) -> Vec<String> {
891 self.host_state
892 .lock()
893 .browser_panes
894 .values()
895 .filter(|e| e.lifecycle == BrowserPaneLifecycle::Live)
896 .map(|e| e.label.clone())
897 .collect()
898 }
899
900 /// PR #5 H.3 — read-side helper for `commands::drag::update_cross_drag`.
901 /// Returns the active drag session iff its drag_id matches `drag_id`.
902 /// Snapshot-and-drop: clones under lock, drops the lock.
903 pub fn get_drag_session(&self, drag_id: &str) -> Option<DragSession> {
904 self.host_state
905 .lock()
906 .active_drag
907 .as_ref()
908 .filter(|s| s.drag_id == drag_id)
909 .cloned()
910 }
911
912 // ── PR #5 H.4 — pool read helpers ───────────────────────────────────
913 //
914 // Replace the legacy `state.unpromoted_pool_labels: Mutex<HashSet>` /
915 // `state.window_pool: Mutex<VecDeque>` reads. All under one
916 // `host_state` lock, snapshot-and-drop discipline.
917
918 /// Atomic snapshot of (user_window_count, unpromoted_pool_count)
919 /// for the launcher drift-detection report. Both reads taken
920 /// under ONE `host_state` lock; a two-lock variant races
921 /// against `promote_pool_window` and would let queued pool
922 /// windows leak into the user-window count.
923 ///
924 /// Filter rules (mirror `list_windows` / `dispatch_to_renderers`):
925 /// - exclude pool inventory (`pool.unpromoted` ∪ `pool.queue`)
926 /// - exclude `browser-pane-*` child HWNDs
927 ///
928 /// `pool_count` is the size of the pool **inventory** (unpromoted
929 /// ∪ queue) — NOT just unpromoted. The launcher's `state.pool`
930 /// mirror is built from `ReportPoolWindowAdded` / `Removed` /
931 /// `Promoted` events. On the host's unpromoted→queue transition
932 /// (when `pool_ready` fires) NO event is emitted, so the
933 /// launcher mirror retains the queued label. Reporting just
934 /// `unpromoted.len()` would under-count and trigger spurious
935 /// pool drift while the warm pool is idle and ready.
936 pub fn host_counts_snapshot(&self) -> (u32, u32) {
937 let st = self.host_state.lock();
938 let pool_inventory: std::collections::HashSet<&str> = st
939 .pool
940 .unpromoted
941 .iter()
942 .map(String::as_str)
943 .chain(st.pool.queue.iter().map(String::as_str))
944 .collect();
945 let pool = pool_inventory.len() as u32;
946 let windows = st
947 .browsers
948 .keys()
949 .filter(|k| !k.starts_with("browser-pane-") && !pool_inventory.contains(k.as_str()))
950 .count() as u32;
951 (windows, pool)
952 }
953
954 /// Snapshot of unpromoted pool labels. Used by orphan
955 /// reconciliation and the pool-count report after spawning a
956 /// new pool slot. Caller can `.contains()` against the set or
957 /// iterate.
958 pub fn unpromoted_pool_labels_snapshot(&self) -> std::collections::HashSet<String> {
959 self.host_state
960 .lock()
961 .pool
962 .unpromoted
963 .iter()
964 .cloned()
965 .collect()
966 }
967
968 /// Single-label check against unpromoted pool set. Used by
969 /// `client.rs::on_after_created` BrowserKind classification.
970 pub fn is_unpromoted_pool_label(&self, label: &str) -> bool {
971 self.host_state.lock().pool.unpromoted.contains(label)
972 }
973
974 /// Number of pool windows currently in the renderer-ready queue
975 /// (NOT including unpromoted). Used by `init_pool` to decide
976 /// whether to bootstrap more pool windows.
977 pub fn pool_queue_size(&self) -> usize {
978 self.host_state.lock().pool.queue.len()
979 }
980
981 /// Atomic snapshot for user-visibility filtering: pool inventory
982 /// (`pool.unpromoted` ∪ `pool.queue`) AND the browser registry,
983 /// taken under ONE `host_state` lock acquisition.
984 ///
985 /// Two-lock variants (one snapshot for the pool, one for
986 /// `list_browsers()`) race against `promote_pool_window`:
987 /// between the reads, a label can move from pool.queue to
988 /// promoted, leaving the stale inventory excluding a now-real
989 /// user window. Atomic snapshot eliminates the gap.
990 ///
991 /// Returns:
992 /// - `pool_inventory`: labels in `pool.unpromoted` ∪ `pool.queue`
993 /// (host-internal, no user UI yet — exclude from user-visible
994 /// filters and from launcher-event dispatch).
995 /// - `browsers`: every label → Browser pair currently registered
996 /// (cheap clone — `Browser` is a CEF refcounted wrapper).
997 pub fn user_visibility_snapshot(&self) -> (
998 std::collections::HashSet<String>,
999 Vec<(String, Browser)>,
1000 ) {
1001 let st = self.host_state.lock();
1002 let pool_inventory: std::collections::HashSet<String> = st
1003 .pool
1004 .unpromoted
1005 .iter()
1006 .cloned()
1007 .chain(st.pool.queue.iter().cloned())
1008 .collect();
1009 let browsers: Vec<(String, Browser)> = st
1010 .browsers
1011 .iter()
1012 .map(|(k, h)| (k.clone(), h.browser.clone()))
1013 .collect();
1014 (pool_inventory, browsers)
1015 }
1016
1017 /// PR #5 H.5 — read-side helper for the legacy `is_quitting` check.
1018 /// Returns true iff the host has begun draining (BeginDrain
1019 /// dispatched) OR has fully quit. Replaces the AtomicBool.
1020 pub fn is_quitting(&self) -> bool {
1021 !matches!(
1022 self.host_state.lock().quit_state,
1023 crate::state::QuitState::Running
1024 )
1025 }
1026
1027 /// PR #6 H.7 — cross-state invariant for the 2026-05-02 freeze.
1028 ///
1029 /// Returns true iff ANY pane is in `Closing`. Top-level window
1030 /// creation paths (open_new_window, open_window_at_position,
1031 /// spawn_pool_window) MUST refuse while this is true: empirically
1032 /// (`SPEC_WINDOW_FLEET_REDUCER_2026-05-02.md`), creating a CEF
1033 /// top-level mid-pane-close hits a Chromium v146 deadlock that
1034 /// wedges the message loop with HiddenSinceOpen + IPC backpressure
1035 /// (`pending=N` rising) and never recovers.
1036 ///
1037 /// The check is small enough to inline at each call site with no
1038 /// async surface. If it turns out the gate needs to widen
1039 /// ("any pane present" rather than "any pane Closing"), that's a
1040 /// one-line edit. Spec §5 escape hatch.
1041 pub fn any_browser_pane_closing(&self) -> bool {
1042 self.host_state
1043 .lock()
1044 .browser_panes
1045 .values()
1046 .any(|e| matches!(e.lifecycle, BrowserPaneLifecycle::Closing { .. }))
1047 }
1048
1049 /// Phase B.5e — authoritative instance-number lookup. Reads
1050 /// the launcher-fed `shadow_instance_registry`, which is the
1051 /// sole source of truth post-B.5e (host's
1052 /// `WindowInstanceRegistry` was deleted). The shadow is
1053 /// pre-seeded with `{"main": 1}` so the very first lookup
1054 /// during startup resolves before the launcher's first
1055 /// `WindowInstanceAssigned` event arrives.
1056 pub fn instance_num(&self, label: &str) -> Option<u32> {
1057 self.shadow_instance_registry.lock().get(label).copied()
1058 }
1059
1060 /// Phase B.5 (window_id_map step e) — authoritative
1061 /// label→backend_window_id lookup. Reads from the
1062 /// launcher-fed `shadow_backend_window_ids`. Sole source of
1063 /// truth post-step-e (host's `window_id_map` was deleted).
1064 pub fn backend_window_id(&self, label: &str) -> Option<String> {
1065 self.shadow_backend_window_ids.lock().get(label).cloned()
1066 }
1067
1068 /// Phase B.5 (window_meta step c) — authoritative WindowMeta
1069 /// lookup. Prefers the launcher-fed `shadow_window_meta`; falls
1070 /// back to host's local `window_meta` for the race window
1071 /// where host has just inserted the pre-create handoff but
1072 /// the launcher's `WindowOpened` event hasn't returned yet.
1073 /// Same prefer-shadow pattern as `instance_num` and
1074 /// `backend_window_id`.
1075 pub fn window_meta(&self, label: &str) -> Option<WindowMeta> {
1076 if let Some(meta) = self.shadow_window_meta.lock().get(label).cloned() {
1077 return Some(meta);
1078 }
1079 let fallback = self.window_meta.lock().get(label).cloned();
1080 if fallback.is_some() {
1081 tracing::debug!(
1082 target: "launcher-ipc:fallback",
1083 label = %label,
1084 "[window_meta] shadow miss — falling back to host's window_meta (B.5c transitional)"
1085 );
1086 }
1087 fallback
1088 }
1089
1090 /// Phase B.5 (window_meta step c) — collect labels of Subwindows
1091 /// whose `parent_instance_id` points to `parent_label`. Used by
1092 /// `on_before_close`'s cascade-close logic.
1093 ///
1094 /// Returns the **union** of matches from `shadow_window_meta`
1095 /// (the launcher-fed projection) and host's `window_meta` (the
1096 /// eager pre-create handoff). The union covers a critical race:
1097 /// a parent may already have one mirrored subwindow AND a newly
1098 /// opened sibling whose `WindowOpened` event hasn't returned to
1099 /// host yet. The newer sibling lives in host's `window_meta`
1100 /// only; the mirrored one lives in shadow only (post-step-d
1101 /// the shadow becomes the sole source). Cascade-close MUST
1102 /// catch both — short-circuiting on shadow-non-empty would
1103 /// leave the race-window sibling orphaned. (codex P1 PR #591
1104 /// round-1.)
1105 ///
1106 /// Dedup is by label; if a label is in both, it's reported
1107 /// once. Labels collected via a HashSet to dedup, returned as
1108 /// a Vec.
1109 pub fn subwindow_children_of(&self, parent_label: &str) -> Vec<String> {
1110 let mut out: std::collections::HashSet<String> = std::collections::HashSet::new();
1111 for m in self.shadow_window_meta.lock().values() {
1112 if m.kind == WindowKind::Subwindow
1113 && m.parent_instance_id.as_deref() == Some(parent_label)
1114 {
1115 out.insert(m.label.clone());
1116 }
1117 }
1118 for m in self.window_meta.lock().values() {
1119 if m.kind == WindowKind::Subwindow
1120 && m.parent_instance_id.as_deref() == Some(parent_label)
1121 {
1122 out.insert(m.label.clone());
1123 }
1124 }
1125 out.into_iter().collect()
1126 }
1127}
1128
1129/// Phase F.1 — observability hook for host-reducer events.
1130///
1131/// Called by `AppState::host_dispatch` after every `update()` call.
1132/// F.1 logs via tracing only; future PRs may add a broadcast channel
1133/// here when an event consumer (cross-process saga, frontend
1134/// dispatcher) appears.
1135fn log_host_event(ev: &crate::reducer::HostEvent) {
1136 use crate::reducer::HostEvent;
1137 match ev {
1138 HostEvent::PendingWindowEnqueued {
1139 label,
1140 queue_len_after,
1141 version,
1142 } => tracing::debug!(
1143 target: "host-reducer",
1144 event = "PendingWindowEnqueued",
1145 label = %label,
1146 queue_len_after,
1147 version,
1148 ),
1149 HostEvent::PendingWindowDequeued {
1150 label,
1151 queue_len_after,
1152 version,
1153 } => tracing::debug!(
1154 target: "host-reducer",
1155 event = "PendingWindowDequeued",
1156 label = %label,
1157 queue_len_after,
1158 version,
1159 ),
1160 HostEvent::PendingWindowQueueEmpty { version } => tracing::warn!(
1161 target: "host-reducer",
1162 event = "PendingWindowQueueEmpty",
1163 version,
1164 "[host-reducer] dequeue on empty queue — caller will fall back",
1165 ),
1166 // ── H.1 panes ────────────────────────────────────────────────────
1167 HostEvent::BrowserPaneCreateRequested { block_id, label, version } => tracing::info!(
1168 target: "host-reducer",
1169 event = "BrowserPaneCreateRequested",
1170 block_id = %block_id, label = %label, version,
1171 ),
1172 HostEvent::BrowserPaneLive { block_id, label, version } => tracing::info!(
1173 target: "host-reducer",
1174 event = "BrowserPaneLive",
1175 block_id = %block_id, label = %label, version,
1176 ),
1177 HostEvent::BrowserPaneClosing { block_id, version } => tracing::info!(
1178 target: "host-reducer",
1179 event = "BrowserPaneClosing",
1180 block_id = %block_id, version,
1181 ),
1182 HostEvent::BrowserPaneClosed { block_id, version } => tracing::info!(
1183 target: "host-reducer",
1184 event = "BrowserPaneClosed",
1185 block_id = %block_id, version,
1186 ),
1187 HostEvent::BrowserPaneCreationFailed { block_id, reason, version } => tracing::warn!(
1188 target: "host-reducer",
1189 event = "BrowserPaneCreationFailed",
1190 block_id = %block_id, reason = %reason, version,
1191 ),
1192 // ── H.2 browsers ─────────────────────────────────────────────────
1193 HostEvent::BrowserRegistered { label, kind, version } => tracing::info!(
1194 target: "host-reducer",
1195 event = "BrowserRegistered",
1196 label = %label, kind = ?kind, version,
1197 ),
1198 HostEvent::BrowserUnregistered { label, version } => tracing::info!(
1199 target: "host-reducer",
1200 event = "BrowserUnregistered",
1201 label = %label, version,
1202 ),
1203 // ── H.3 drag ─────────────────────────────────────────────────────
1204 HostEvent::DragStarted { drag_id, source_window, version } => tracing::info!(
1205 target: "host-reducer",
1206 event = "DragStarted",
1207 drag_id = %drag_id, source_window = %source_window, version,
1208 ),
1209 HostEvent::DragEnded { drag_id, outcome, version } => tracing::info!(
1210 target: "host-reducer",
1211 event = "DragEnded",
1212 drag_id = %drag_id, outcome = ?outcome, version,
1213 ),
1214 // ── H.4 pool ─────────────────────────────────────────────────────
1215 HostEvent::PoolWindowEntered { label, queue_len_after, version } => tracing::info!(
1216 target: "host-reducer",
1217 event = "PoolWindowEntered",
1218 label = %label, queue_len_after, version,
1219 ),
1220 HostEvent::PoolWindowLeft { label, queue_len_after, reason, version } => tracing::info!(
1221 target: "host-reducer",
1222 event = "PoolWindowLeft",
1223 label = %label, queue_len_after, reason = ?reason, version,
1224 ),
1225 HostEvent::PoolEmpty { version } => tracing::info!(
1226 target: "host-reducer",
1227 event = "PoolEmpty",
1228 version,
1229 ),
1230 // ── H.5 quit ─────────────────────────────────────────────────────
1231 HostEvent::QuitDraining { reason, version } => tracing::warn!(
1232 target: "host-reducer",
1233 event = "QuitDraining",
1234 reason = ?reason, version,
1235 "[host-reducer] entering drain mode",
1236 ),
1237 HostEvent::QuitReady { version } => tracing::warn!(
1238 target: "host-reducer",
1239 event = "QuitReady",
1240 version,
1241 "[host-reducer] drain complete; host quitting",
1242 ),
1243 // ── H.6 top-level runner ─────────────────────────────────────────
1244 HostEvent::TopLevelCreationRequested {
1245 creation_id, source, label, version,
1246 } => tracing::info!(
1247 target: "host-reducer",
1248 event = "TopLevelCreationRequested",
1249 creation_id, source = ?source, label = %label, version,
1250 ),
1251 HostEvent::TopLevelCreationStarted { creation_id, label, version } => tracing::info!(
1252 target: "host-reducer",
1253 event = "TopLevelCreationStarted",
1254 creation_id, label = %label, version,
1255 ),
1256 HostEvent::TopLevelCreationCompleted {
1257 creation_id, label, latency_ms, version,
1258 } => tracing::info!(
1259 target: "host-reducer",
1260 event = "TopLevelCreationCompleted",
1261 creation_id, label = %label, latency_ms, version,
1262 ),
1263 HostEvent::TopLevelCreationFailed {
1264 creation_id, label, outcome, version,
1265 } => tracing::error!(
1266 target: "host-reducer",
1267 event = "TopLevelCreationFailed",
1268 creation_id, label = %label, outcome = ?outcome, version,
1269 ),
1270 HostEvent::TopLevelQueueLengthChanged { len, version } => tracing::debug!(
1271 target: "host-reducer",
1272 event = "TopLevelQueueLengthChanged",
1273 len, version,
1274 ),
1275 // ── Opacity ──────────────────────────────────────────────────────
1276 HostEvent::WindowOpacityApplied { label, opacity, version } => tracing::debug!(
1277 target: "host-reducer",
1278 event = "WindowOpacityApplied",
1279 label = %label, opacity, version,
1280 ),
1281 HostEvent::WindowOpacityCleared { label, version } => tracing::debug!(
1282 target: "host-reducer",
1283 event = "WindowOpacityCleared",
1284 label = %label, version,
1285 ),
1286 // ── Effect carrier ───────────────────────────────────────────────
1287 HostEvent::Effect { effect, version } => tracing::debug!(
1288 target: "host-reducer",
1289 event = "Effect",
1290 effect = ?effect, version,
1291 ),
1292 HostEvent::Error { message, version } => tracing::warn!(
1293 target: "host-reducer",
1294 event = "Error",
1295 version,
1296 "[host-reducer] {}", message,
1297 ),
1298 }
1299}