agentmux_cef\reducer/
mod.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase F.1 — host reducer.
5//
6// Third reducer in the multi-reducer architecture, after the launcher
7// (Phase B) and srv (Phase E). Same pure-functional shape as the
8// other two: `update(&mut HostState, HostCommand) -> Vec<HostEvent>`,
9// no I/O, no async, sub-millisecond mutex hold time.
10//
11// **Scope of F.1 (this PR):** skeleton + the `pending_window_creations`
12// arm. Per `docs/specs/SPEC_PHASE_F_HOST_REDUCER_2026-05-01.md` §3.1,
13// pending_window_creations is the lowest-risk migration:
14// single-producer/single-consumer queue with a clean enqueue/dequeue
15// lifecycle and no FFI handles inside.
16//
17// **NOT in F.1:** drag arms (F.3), tear-off hook arms (F.4 — folds
18// into the tear-off spec). The CEF `browsers` map and warm pool stay
19// outside the reducer indefinitely (snapshot-and-drop discipline at
20// every read site, see spec §3.2 / §6).
21//
22// **Wire protocol:** F.1's `HostCommand` and `HostEvent` are
23// host-internal. They do NOT cross IPC. When a future PR adds a
24// command that needs frontend or launcher access, that PR promotes
25// the relevant variants to `agentmux-common::ipc::Command` /
26// `agentmux-common::ipc::Event` and adds the IPC plumbing. Keeping
27// F.1 in-process avoids serializing `PendingWindowCreation` over a
28// pipe just to satisfy a pattern that has no current consumer.
29
30use std::collections::{HashMap, VecDeque};
31use std::sync::atomic::{AtomicU64, Ordering};
32
33use cef::Browser;
34
35use crate::state::{
36    BrowserHandle, BrowserKind, CompletedCreation, CreationPhase, DragSession, EffectKind,
37    InFlightCreation, BrowserPaneEntry, BrowserPaneLifecycle, PendingWindowCreation, PoolState, QuitReason,
38    QuitState, TopLevelCreationOutcome, TopLevelCreationRequest, TopLevelCreationState,
39    TopLevelSource,
40};
41
42/// Capacity of `TopLevelCreationState.history` ring buffer. Configurable
43/// via `~/.agentmux/config.toml [host.reducer]` once H.5 (config) lands;
44/// hard-coded for PR #1.
45pub(crate) const TOP_LEVEL_CREATION_HISTORY_CAP: usize = 50;
46
47/// Lifecycle phase of the host reducer. Mirrors the launcher and srv
48/// reducers' phase enum.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum HostLifecyclePhase {
51    /// Pre-init: AppState exists, but no commands accepted yet.
52    Bootstrapping,
53    /// Normal operation: all commands accepted.
54    Running,
55    /// Shutting down: only cleanup commands accepted; producers
56    /// short-circuit with no-op events.
57    ShuttingDown,
58}
59
60/// State owned by the host reducer.
61///
62/// Held inside `AppState.host_state: parking_lot::Mutex<HostState>`.
63/// Locked briefly by `host_dispatch`; never held across CEF callbacks
64/// or `SendMessage` (snapshot-and-drop discipline — see spec §6).
65pub struct HostState {
66    /// FIFO queue of pre-create handoffs. Pushed by callers
67    /// (`pane/creation.rs`, `commands/window.rs::open_new_window`,
68    /// `commands/drag.rs::tear_off`, `commands/window_pool.rs::spawn_pool_window`)
69    /// before `post_create_window`. Popped by `client.rs::on_after_created`
70    /// when CEF reports a new browser. Peeked at the back by
71    /// `wrr/win_event.rs::handle_event` to label OS-level WM_CREATE
72    /// events with the upcoming label.
73    ///
74    /// Invariants:
75    /// - At most one entry per (in-flight) browser create.
76    /// - `on_after_created` always pops the head it expects to find.
77    /// - The "main" window is special-cased in `on_after_created` and
78    ///   never has a corresponding entry here.
79    pub pending_window_creations: VecDeque<PendingWindowCreation>,
80
81    // ── Phase H — added in PR #1 (h1-foundations); populated by reducer
82    // arms below; no production callers yet. PRs #2-#5 wire each through
83    // the a→e migration ratchet. See SPEC_HOST_REDUCER_5PR_PLAN_2026-05-02.md.
84
85    /// H.1 — pane lifecycle map. Replaces the deleted
86    /// `pane::lifecycle::PaneStateMachine`. Keyed by `block_id`.
87    /// Authoritative; `BrowserPaneManager` (browser_panes.rs) is now a
88    /// zero-sized handle that delegates all mutations through
89    /// `host_dispatch`.
90    pub browser_panes: HashMap<String, BrowserPaneEntry>,
91
92    /// H.2 — browser handle registry. Replaces the deleted
93    /// `AppState.browsers: Mutex<HashMap<String, Browser>>`. Keyed by
94    /// label (e.g., `window-...`, `browser-pane-...`, `window-pool-...`).
95    /// Authoritative; read via `AppState::get_browser`, `list_browsers`, etc.
96    pub browsers: HashMap<String, BrowserHandle>,
97
98    /// H.3 — active drag session (singleton). Replaces the deleted
99    /// `AppState.active_drag: Mutex<Option<DragSession>>`.
100    pub active_drag: Option<DragSession>,
101
102    /// H.4 — pool state (queue + unpromoted + in-flight semaphore +
103    /// just_promoted_labels bridge from PR #708). Replaces the deleted
104    /// `window_pool` / `unpromoted_pool_labels` fields on AppState.
105    pub pool: PoolState,
106
107    /// H.5 — quit lifecycle. Replaces the deleted
108    /// `AppState.is_quitting: AtomicBool`.
109    pub quit_state: QuitState,
110
111    /// H.6 — top-level window creation runner state (queue, in-flight,
112    /// history). Event-driven; no watchdog. **Currently DORMANT** — the
113    /// reducer arms (`EnqueueTopLevelWindow`, `TopLevelCallbackFired`,
114    /// etc.) exist but no production code dispatches to them. The
115    /// `ui_tasks::post_create_window` direct-call path is still
116    /// authoritative. Wire-up is a low-priority structural improvement;
117    /// see master spec §4.3 and discussion #707.
118    #[allow(dead_code)]
119    pub top_level_creation: TopLevelCreationState,
120
121    /// Per-window opacity state. Keyed by label, value is clamped [0.0, 1.0].
122    /// Absent means fully opaque (1.0). Mutated by `SetWindowOpacity`; read by
123    /// `get_window_opacity` and the restore path in app-init. Win32 side-effect
124    /// (SetLayeredWindowAttributes) is applied by the IPC handler AFTER dispatch,
125    /// not inside the reducer. See SPEC_PER_WINDOW_OPACITY_2026-05-14.md §7.1.
126    pub window_opacities: HashMap<String, f32>,
127
128    /// Lifecycle phase. `Running` is the operating state; the others
129    /// gate command acceptance.
130    pub lifecycle: HostLifecyclePhase,
131
132    /// Monotonic event-version counter (per host-process run). Same
133    /// invariant as launcher / srv reducers.
134    pub event_version: u64,
135}
136
137impl Default for HostState {
138    fn default() -> Self {
139        Self {
140            pending_window_creations: VecDeque::new(),
141            // Phase H foundations (PR #1) — empty defaults; populated as
142            // PRs #2-#5 wire callers through the reducer.
143            browser_panes: HashMap::new(),
144            browsers: HashMap::new(),
145            active_drag: None,
146            pool: PoolState::default(),
147            quit_state: QuitState::default(),
148            top_level_creation: TopLevelCreationState::default(),
149            window_opacities: HashMap::new(),
150            // Boot directly into Running — nothing in F.1 needs the
151            // pre-init guard yet. Future PRs (drag, tear-off hooks)
152            // will move boot through Bootstrapping → Running.
153            lifecycle: HostLifecyclePhase::Running,
154            event_version: 0,
155        }
156    }
157}
158
159impl HostState {
160    /// Allocate the next event version. Called inside reducer arms
161    /// when emitting an event.
162    fn bump_version(&mut self) -> u64 {
163        self.event_version += 1;
164        self.event_version
165    }
166}
167
168// ── Pane label generator (replaces pane/lifecycle.rs::BROWSER_PANE_LABEL_SEQ) ──────
169//
170// Monotonic counter appended to every pane label so a close-then-recreate of
171// the same block_id doesn't collide: if the old browser's `on_before_close`
172// fires after the new pane's create has already run, `DrainBrowserPaneByLabel`
173// would otherwise find and wipe the NEW entry.
174pub(super) static BROWSER_PANE_LABEL_SEQ: AtomicU64 = AtomicU64::new(1);
175
176pub(super) fn next_browser_pane_label(block_id: &str) -> String {
177    let seq = BROWSER_PANE_LABEL_SEQ.fetch_add(1, Ordering::Relaxed);
178    format!("browser-pane-{}-{}", block_id, seq)
179}
180
181/// Outcome of `TryRegisterBrowserPaneLive`. Returned via
182/// `DispatchOutput::browser_pane_register_result`. Same three-way semantics as the
183/// pre-Phase-H `pane::lifecycle::PaneStateMachine::try_register_live` returned
184/// — caller decides whether to start a fresh CEF create, re-navigate the
185/// existing browser, or reject.
186#[derive(Clone, Debug, PartialEq, Eq)]
187pub enum RegisterResult {
188    /// No prior entry; reducer inserted a new `Live` pane under `label`.
189    /// Caller should post `CreateBrowserPaneTask` for this label.
190    Fresh(String),
191    /// Entry already existed and is `Live`; caller should re-navigate the
192    /// existing browser at `label`.
193    AlreadyLive(String),
194    /// Entry exists and is `Closing`; caller must reject the re-create
195    /// because the old browser's `on_before_close` will drain the entry,
196    /// and overwriting now would lose the new entry instead of the old.
197    Closing,
198}
199
200/// Commands handled by the host reducer.
201///
202/// Manual `Debug` impl below because `RegisterBrowser` carries a
203/// `cef::Browser` which doesn't impl Debug.
204#[derive(Clone)]
205pub enum HostCommand {
206    /// Append a pending-window-creation handoff. Producer side of the
207    /// `pending_window_creations` queue (replaces the four direct
208    /// `state.pending_window_creations.lock().push_back(...)` sites).
209    EnqueuePendingWindowCreation { entry: PendingWindowCreation },
210
211    /// Pop the head of `pending_window_creations`. Returns the popped
212    /// entry via `HostEvent::PendingWindowDequeued`, or
213    /// `HostEvent::PendingWindowQueueEmpty` if the queue was empty.
214    /// Consumer side of the queue (replaces
215    /// `client.rs::on_after_created`'s direct `pop_front`).
216    DequeuePendingWindowCreation,
217
218    // ── H.1 — pane lifecycle ────────────────────────────────────────────
219
220    /// Caller (pane create code) requests a new pane lifecycle entry.
221    /// Reducer inserts with `Live`. Reject if `block_id` already present.
222    EnqueueBrowserPaneCreate { block_id: String, label: String },
223
224    /// PR #5 — sole pane registration entry point post-H.1.d.
225    ///
226    /// Replaces `pane::lifecycle::PaneStateMachine::try_register_live`.
227    /// Reducer generates the label internally (via `next_browser_pane_label`) so
228    /// label assignment is atomic with the entry insert. Returns the
229    /// outcome via `DispatchOutput::browser_pane_register_result`:
230    ///   - `Fresh(label)`: new `Live` entry inserted; caller posts CreateBrowserPaneTask
231    ///   - `AlreadyLive(label)`: caller should re-navigate existing browser
232    ///   - `Closing`: caller must reject (old teardown still in flight)
233    TryRegisterBrowserPaneLive { block_id: String },
234
235    /// CEF on_after_created fired for a pane browser; confirm it's Live.
236    /// No-op if already Live or absent (idempotent against late callbacks).
237    CompleteBrowserPaneCreate { block_id: String },
238
239    /// Caller requests pane close. Reducer flips entry to `Closing` and
240    /// returns the entry's label via `DispatchOutput::closed_browser_pane_label`
241    /// iff the transition actually fired (was `Live`). Returns `None` for
242    /// missing or already-Closing entries (idempotent).
243    EnqueueBrowserPaneClose { block_id: String },
244
245    /// CEF on_before_close fired for a pane; remove entry from map.
246    CompleteBrowserPaneClose { block_id: String },
247
248    /// PR #5 — sole label-keyed drain entry point post-H.1.d.
249    ///
250    /// Replaces `pane::lifecycle::PaneStateMachine::drain_by_label`. Used
251    /// by `BrowserPaneManager::drain_closed_label` when CEF's
252    /// `on_before_close` fires for a pane. Removes the entry whose `label`
253    /// matches; returns the drained `block_id` via
254    /// `DispatchOutput::drained_browser_pane_block_id` so the caller can also dispatch
255    /// any block_id-keyed cleanup. Idempotent (None if no match).
256    DrainBrowserPaneByLabel { label: String },
257
258    /// Pane creation failed before reaching Live (e.g., CEF callback
259    /// never fired, browser host returned 0). Reducer removes entry.
260    AbortBrowserPaneCreate { block_id: String, reason: String },
261
262    // ── H.2 — browser handle registry ───────────────────────────────────
263
264    /// Insert browser into `browsers` map. Caller is on the CEF UI thread
265    /// (e.g., client.rs::on_after_created). Reject (with Error) if label
266    /// already present (collision indicates a bug).
267    RegisterBrowser {
268        label: String,
269        browser: Browser,
270        kind: BrowserKind,
271    },
272
273    /// Remove browser from `browsers` map. Idempotent; no-op if absent.
274    UnregisterBrowser { label: String },
275
276    // ── H.3 — drag state ────────────────────────────────────────────────
277
278    /// Begin a cross-window drag session. Reject if one is already
279    /// active (singleton invariant).
280    StartDrag { session: DragSession },
281
282    /// End the active drag. `drag_id` must match the current session.
283    EndDrag { drag_id: String, outcome: DragOutcome },
284
285    // ── H.4 — pool state ────────────────────────────────────────────────
286
287    /// Pool window spawn started. Adds label to `unpromoted` set; sets
288    /// `respawn_in_flight = true` if not already.
289    PoolWindowSpawnStart { label: String },
290
291    /// Frontend signaled the pool window's renderer is fully initialized.
292    /// Move from `unpromoted` to `queue`; clear `respawn_in_flight`.
293    PoolWindowReady { label: String },
294
295    /// Pool window was destroyed before renderer-ready (e.g., user
296    /// closed it externally during pre-warm). Remove from `unpromoted`,
297    /// clear `respawn_in_flight`. Reducer may emit a refill effect.
298    PoolWindowDestroyedBeforePromote { label: String },
299
300    /// Promote a pool window into a user-visible top-level. Removes from
301    /// `queue` and `unpromoted`, marks the corresponding `BrowserHandle`
302    /// as `is_pool: false`.
303    PromotePoolWindow { label: String },
304
305    /// PR #5 H.4 — atomic pop+promote front of pool queue. Returns the
306    /// popped label via `DispatchOutput::promoted_pool_label`, or None
307    /// if the queue is empty. Replaces the legacy
308    /// `state.window_pool.lock().pop_front() + state.unpromoted_pool_labels.lock().remove`
309    /// pair in `promote_pool_window`.
310    PopAndPromoteFrontPoolWindow,
311
312    /// Drain all pool windows on shutdown. Idempotent.
313    PoolDrainAll,
314
315    // ── H.5 — quit lifecycle ────────────────────────────────────────────
316
317    /// Transition Running → Draining. Suppresses pool refills, awaits
318    /// drain completion.
319    BeginDrain { reason: QuitReason },
320
321    /// All drainable resources are gone (pool empty, browsers empty).
322    /// Transition Draining → Quit.
323    ConfirmDrained,
324
325    // ── H.6 — top-level window creation runner ──────────────────────────
326
327    /// Caller requests a top-level window. Reducer either:
328    /// - rejects (User-initiated + busy) with Error; caller propagates
329    ///   visible error to frontend.
330    /// - queues (Background) for later auto-advance.
331    /// - starts immediately (idle slot) and emits `Effect::PostCreateWindow`.
332    EnqueueTopLevelWindow { request: TopLevelCreationRequest },
333
334    /// CEF on_after_created fired for `label`. If matches in-flight,
335    /// mark Completed and advance queue. If doesn't match (orphan from
336    /// stale state), emit `Effect::CloseOrphanBrowser`.
337    TopLevelCallbackFired { label: String },
338
339    /// CEF on_render_process_terminated fired for the renderer process
340    /// associated with `label`. If matches in-flight, mark Failed and
341    /// advance queue.
342    TopLevelRendererTerminated { label: String, status: String },
343
344    /// CEF on_before_close fired for `label` while still in-flight.
345    /// User or external code closed the window mid-creation. Mark Failed.
346    TopLevelExternallyClosed { label: String },
347
348    // ── Opacity ─────────────────────────────────────────────────────────────
349
350    /// Set per-window opacity. Reducer stores the clamped value in
351    /// `window_opacities`; the IPC handler applies the Win32 side-effect
352    /// after `host_dispatch` returns (pure reducer, no I/O inside).
353    SetWindowOpacity { label: String, opacity: f32 },
354}
355
356impl std::fmt::Debug for HostCommand {
357    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
358        match self {
359            HostCommand::EnqueuePendingWindowCreation { entry } => f
360                .debug_struct("EnqueuePendingWindowCreation")
361                .field("entry", entry)
362                .finish(),
363            HostCommand::DequeuePendingWindowCreation => {
364                f.write_str("DequeuePendingWindowCreation")
365            }
366            HostCommand::EnqueueBrowserPaneCreate { block_id, label } => f
367                .debug_struct("EnqueueBrowserPaneCreate")
368                .field("block_id", block_id)
369                .field("label", label)
370                .finish(),
371            HostCommand::TryRegisterBrowserPaneLive { block_id } => f
372                .debug_struct("TryRegisterBrowserPaneLive")
373                .field("block_id", block_id)
374                .finish(),
375            HostCommand::CompleteBrowserPaneCreate { block_id } => f
376                .debug_struct("CompleteBrowserPaneCreate")
377                .field("block_id", block_id)
378                .finish(),
379            HostCommand::EnqueueBrowserPaneClose { block_id } => f
380                .debug_struct("EnqueueBrowserPaneClose")
381                .field("block_id", block_id)
382                .finish(),
383            HostCommand::CompleteBrowserPaneClose { block_id } => f
384                .debug_struct("CompleteBrowserPaneClose")
385                .field("block_id", block_id)
386                .finish(),
387            HostCommand::DrainBrowserPaneByLabel { label } => f
388                .debug_struct("DrainBrowserPaneByLabel")
389                .field("label", label)
390                .finish(),
391            HostCommand::AbortBrowserPaneCreate { block_id, reason } => f
392                .debug_struct("AbortBrowserPaneCreate")
393                .field("block_id", block_id)
394                .field("reason", reason)
395                .finish(),
396            HostCommand::RegisterBrowser { label, kind, .. } => f
397                .debug_struct("RegisterBrowser")
398                .field("label", label)
399                .field("kind", kind)
400                .field("browser", &"<cef::Browser>")
401                .finish(),
402            HostCommand::UnregisterBrowser { label } => f
403                .debug_struct("UnregisterBrowser")
404                .field("label", label)
405                .finish(),
406            HostCommand::StartDrag { session } => f
407                .debug_struct("StartDrag")
408                .field("drag_id", &session.drag_id)
409                .field("source_window", &session.source_window)
410                .finish(),
411            HostCommand::EndDrag { drag_id, outcome } => f
412                .debug_struct("EndDrag")
413                .field("drag_id", drag_id)
414                .field("outcome", outcome)
415                .finish(),
416            HostCommand::PoolWindowSpawnStart { label } => f
417                .debug_struct("PoolWindowSpawnStart")
418                .field("label", label)
419                .finish(),
420            HostCommand::PoolWindowReady { label } => f
421                .debug_struct("PoolWindowReady")
422                .field("label", label)
423                .finish(),
424            HostCommand::PoolWindowDestroyedBeforePromote { label } => f
425                .debug_struct("PoolWindowDestroyedBeforePromote")
426                .field("label", label)
427                .finish(),
428            HostCommand::PromotePoolWindow { label } => f
429                .debug_struct("PromotePoolWindow")
430                .field("label", label)
431                .finish(),
432            HostCommand::PopAndPromoteFrontPoolWindow => f.write_str("PopAndPromoteFrontPoolWindow"),
433            HostCommand::PoolDrainAll => f.write_str("PoolDrainAll"),
434            HostCommand::BeginDrain { reason } => f
435                .debug_struct("BeginDrain")
436                .field("reason", reason)
437                .finish(),
438            HostCommand::ConfirmDrained => f.write_str("ConfirmDrained"),
439            HostCommand::EnqueueTopLevelWindow { request } => f
440                .debug_struct("EnqueueTopLevelWindow")
441                .field("label", &request.label)
442                .field("source", &request.source)
443                .finish(),
444            HostCommand::TopLevelCallbackFired { label } => f
445                .debug_struct("TopLevelCallbackFired")
446                .field("label", label)
447                .finish(),
448            HostCommand::TopLevelRendererTerminated { label, status } => f
449                .debug_struct("TopLevelRendererTerminated")
450                .field("label", label)
451                .field("status", status)
452                .finish(),
453            HostCommand::TopLevelExternallyClosed { label } => f
454                .debug_struct("TopLevelExternallyClosed")
455                .field("label", label)
456                .finish(),
457            HostCommand::SetWindowOpacity { label, opacity } => f
458                .debug_struct("SetWindowOpacity")
459                .field("label", label)
460                .field("opacity", opacity)
461                .finish(),
462        }
463    }
464}
465
466/// Outcome of an ended drag session.
467#[derive(Clone, Debug, PartialEq, Eq)]
468#[allow(dead_code)]
469pub enum DragOutcome {
470    /// Drop completed successfully (block moved to target).
471    Dropped { target_label: String },
472    /// Drag cancelled by user (e.g., escape key, drop outside any target).
473    Cancelled,
474    /// Tear-off into a new window completed.
475    TornOff { new_label: String },
476}
477
478/// Reason a pool window left the pool.
479#[derive(Clone, Debug, PartialEq, Eq)]
480#[allow(dead_code)]
481pub enum PoolLeaveReason {
482    /// Promoted into a user-visible top-level (tear-off, etc.).
483    Promoted,
484    /// Destroyed before promote (e.g., user closed externally).
485    DestroyedBeforePromote,
486    /// Drained on shutdown.
487    DrainedOnShutdown,
488}
489
490/// Events emitted by the host reducer.
491///
492/// F.1 keeps these in-host: subscribers log them via tracing for
493/// observability, but no IPC propagation. When a future PR adds a
494/// wire-level consumer (host→launcher event for cross-process saga
495/// observability, frontend dispatcher in E.6), that PR promotes the
496/// relevant variants to `agentmux-common::ipc::Event`.
497#[derive(Debug, Clone)]
498pub enum HostEvent {
499    /// A `PendingWindowCreation` was enqueued. Carries a snapshot of
500    /// the current queue length so observers can spot pile-ups.
501    PendingWindowEnqueued {
502        label: String,
503        queue_len_after: usize,
504        version: u64,
505    },
506
507    /// A `PendingWindowCreation` was dequeued. The popped entry
508    /// travels back to the caller; observers see only the label and
509    /// post-pop queue length.
510    PendingWindowDequeued {
511        label: String,
512        queue_len_after: usize,
513        version: u64,
514    },
515
516    /// `DequeuePendingWindowCreation` ran on an empty queue. Caller
517    /// is responsible for the fallback (the legacy code paths
518    /// synthesize a UUID-labelled FullInstance entry).
519    PendingWindowQueueEmpty { version: u64 },
520
521    // ── H.1 — pane lifecycle events ─────────────────────────────────────
522
523    BrowserPaneCreateRequested {
524        block_id: String,
525        label: String,
526        version: u64,
527    },
528    BrowserPaneLive {
529        block_id: String,
530        label: String,
531        version: u64,
532    },
533    BrowserPaneClosing {
534        block_id: String,
535        version: u64,
536    },
537    BrowserPaneClosed {
538        block_id: String,
539        version: u64,
540    },
541    BrowserPaneCreationFailed {
542        block_id: String,
543        reason: String,
544        version: u64,
545    },
546
547    // ── H.2 — browser registry events ───────────────────────────────────
548
549    BrowserRegistered {
550        label: String,
551        kind: BrowserKind,
552        version: u64,
553    },
554    BrowserUnregistered {
555        label: String,
556        version: u64,
557    },
558
559    // ── H.3 — drag events ───────────────────────────────────────────────
560
561    DragStarted {
562        drag_id: String,
563        source_window: String,
564        version: u64,
565    },
566    DragEnded {
567        drag_id: String,
568        outcome: DragOutcome,
569        version: u64,
570    },
571
572    // ── H.4 — pool events ───────────────────────────────────────────────
573
574    PoolWindowEntered {
575        label: String,
576        queue_len_after: usize,
577        version: u64,
578    },
579    PoolWindowLeft {
580        label: String,
581        queue_len_after: usize,
582        reason: PoolLeaveReason,
583        version: u64,
584    },
585    PoolEmpty { version: u64 },
586
587    // ── H.5 — quit events ───────────────────────────────────────────────
588
589    QuitDraining {
590        reason: QuitReason,
591        version: u64,
592    },
593    QuitReady { version: u64 },
594
595    // ── H.6 — top-level creation events ─────────────────────────────────
596
597    TopLevelCreationRequested {
598        creation_id: u64,
599        source: TopLevelSource,
600        label: String,
601        version: u64,
602    },
603    TopLevelCreationStarted {
604        creation_id: u64,
605        label: String,
606        version: u64,
607    },
608    TopLevelCreationCompleted {
609        creation_id: u64,
610        label: String,
611        latency_ms: u64,
612        version: u64,
613    },
614    TopLevelCreationFailed {
615        creation_id: u64,
616        label: String,
617        outcome: TopLevelCreationOutcome,
618        version: u64,
619    },
620    TopLevelQueueLengthChanged {
621        len: usize,
622        version: u64,
623    },
624
625    // ── Opacity events ──────────────────────────────────────────────────
626
627    /// Opacity set successfully. IPC handler applies Win32 side-effect.
628    WindowOpacityApplied { label: String, opacity: f32, version: u64 },
629    /// Opacity cleared (opacity >= 1.0 → remove WS_EX_LAYERED).
630    WindowOpacityCleared { label: String, version: u64 },
631
632    // ── Effect carrier ──────────────────────────────────────────────────
633
634    /// Side-effect descriptor. The reducer emits these but never executes
635    /// them; `AppState::host_dispatch_with_effects` is responsible for
636    /// running each kind. See `EffectKind` for variants.
637    Effect {
638        effect: EffectKind,
639        version: u64,
640    },
641
642    /// A command was rejected. Mirrors `Event::Error` in srv/launcher
643    /// reducers — kept generic for future arms.
644    Error { message: String, version: u64 },
645}
646
647/// Output bundle returned from the reducer.
648///
649/// Most arms communicate via `events` alone, but several arms have callers
650/// that need an atomic value-returning op alongside the state mutation:
651///
652/// - `DequeuePendingWindowCreation` → `dequeued: Option<PendingWindowCreation>`
653///   (`client.rs::on_after_created` needs the popped entry's fields to drive
654///   `window_meta.insert` + `ReportWindowOpened`).
655///
656/// - `UnregisterBrowser` → `removed_browser: Option<Browser>` (the close
657///   path in `browser_panes::AppStateCloseOps::take_browser_hwnd` needs
658///   the Browser handle to extract its HWND for `DestroyWindow`. The
659///   atomicity matters: see codex P2 PR #660 — separating get + dispatch
660///   creates a window where concurrent readers can also resolve the
661///   label and act on the closing handle).
662///
663/// - `TryRegisterBrowserPaneLive` → `browser_pane_register_result: Option<RegisterResult>`
664///   (PR #5 H.1.d: `BrowserPaneManager::create` branches on
665///   Fresh/AlreadyLive/Closing).
666///
667/// - `EnqueueBrowserPaneClose` → `closed_browser_pane_label: Option<String>` (PR #5
668///   H.1.d: the close path needs the label to call `take_browser_hwnd`
669///   without a separate `live_browser_pane_label` query that could race).
670///
671/// - `DrainBrowserPaneByLabel` → `drained_browser_pane_block_id: Option<String>` (PR #5
672///   H.1.d: `drain_closed_label` needs the block_id to dispatch
673///   `CompleteBrowserPaneClose`).
674///
675/// - `EndDrag` → `ended_drag_session: Option<DragSession>` (PR #5
676///   H.3: `complete_cross_drag` / `cancel_cross_drag` need the
677///   session payload to emit the renderer-side cross-drag-end event,
678///   AND need the .is_some() signal to distinguish actual end vs
679///   drag_id mismatch).
680///
681/// - `PoolWindowSpawnStart` → `pool_spawn_proceeding: bool` (PR #5
682///   H.4: spawn_pool_window's single-flight semaphore. true = slot
683///   acquired, caller proceeds with CEF spawn; false = suppressed
684///   (already in flight, or QuitState != Running)).
685///
686/// - `PoolWindowReady` / `PoolWindowDestroyedBeforePromote` /
687///   `PopAndPromoteFrontPoolWindow` → `pool_size_after: Option<usize>`
688///   (PR #5 H.4: caller checks against POOL_TARGET_SIZE to decide
689///   whether to trigger a refill).
690///
691/// - `PoolWindowDestroyedBeforePromote` → `pool_destroyed_was_unpromoted: bool`
692///   (PR #5 H.4: caller gates pool-inventory reports on this — the
693///   post-promote close path doesn't own that update).
694///
695/// - `PopAndPromoteFrontPoolWindow` → `promoted_pool_label: Option<String>`
696///   (PR #5 H.4: caller needs the popped label to drive the CEF
697///   show + emit the pool:promote frontend event).
698///
699/// Default keeps the dispatch return type uniform across arms that
700/// don't populate these fields.
701#[derive(Default)]
702pub struct DispatchOutput {
703    pub events: Vec<HostEvent>,
704    pub dequeued: Option<PendingWindowCreation>,
705    pub removed_browser: Option<Browser>,
706    pub browser_pane_register_result: Option<RegisterResult>,
707    pub closed_browser_pane_label: Option<String>,
708    pub drained_browser_pane_block_id: Option<String>,
709    pub ended_drag_session: Option<DragSession>,
710    pub pool_spawn_proceeding: bool,
711    pub pool_size_after: Option<usize>,
712    pub pool_destroyed_was_unpromoted: bool,
713    pub promoted_pool_label: Option<String>,
714}
715
716// Manual Debug — `cef::Browser` doesn't impl Debug.
717impl std::fmt::Debug for DispatchOutput {
718    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
719        f.debug_struct("DispatchOutput")
720            .field("events", &self.events)
721            .field("dequeued", &self.dequeued)
722            .field(
723                "removed_browser",
724                if self.removed_browser.is_some() {
725                    &"Some(<cef::Browser>)"
726                } else {
727                    &"None"
728                },
729            )
730            .field("browser_pane_register_result", &self.browser_pane_register_result)
731            .field("closed_browser_pane_label", &self.closed_browser_pane_label)
732            .field("drained_browser_pane_block_id", &self.drained_browser_pane_block_id)
733            .field("ended_drag_session", &self.ended_drag_session)
734            .field("pool_spawn_proceeding", &self.pool_spawn_proceeding)
735            .field("pool_size_after", &self.pool_size_after)
736            .field("pool_destroyed_was_unpromoted", &self.pool_destroyed_was_unpromoted)
737            .field("promoted_pool_label", &self.promoted_pool_label)
738            .finish()
739    }
740}
741
742/// Pure functional core of the host reducer.
743///
744/// Returns the events emitted by the command. Side-effecting wiring
745/// (logging, future event broadcast) lives in `host_dispatch` — this
746/// function takes only `&mut HostState` and produces no I/O.
747mod browsers;
748mod drag;
749mod panes;
750mod pool;
751mod quit;
752mod top_level;
753
754pub fn update(state: &mut HostState, cmd: HostCommand) -> DispatchOutput {
755    match cmd {
756        HostCommand::EnqueuePendingWindowCreation { entry } => {
757            handle_enqueue_pending_window_creation(state, entry)
758        }
759        HostCommand::DequeuePendingWindowCreation => {
760            handle_dequeue_pending_window_creation(state)
761        }
762        // H.1 panes
763        HostCommand::EnqueueBrowserPaneCreate { block_id, label } => {
764            panes::handle_enqueue_browser_pane_create(state, block_id, label)
765        }
766        HostCommand::TryRegisterBrowserPaneLive { block_id } => {
767            panes::handle_try_register_browser_pane_live(state, block_id)
768        }
769        HostCommand::CompleteBrowserPaneCreate { block_id } => {
770            panes::handle_complete_browser_pane_create(state, block_id)
771        }
772        HostCommand::EnqueueBrowserPaneClose { block_id } => {
773            panes::handle_enqueue_browser_pane_close(state, block_id)
774        }
775        HostCommand::CompleteBrowserPaneClose { block_id } => {
776            panes::handle_complete_browser_pane_close(state, block_id)
777        }
778        HostCommand::DrainBrowserPaneByLabel { label } => {
779            panes::handle_drain_browser_pane_by_label(state, label)
780        }
781        HostCommand::AbortBrowserPaneCreate { block_id, reason } => {
782            panes::handle_abort_browser_pane_create(state, block_id, reason)
783        }
784        // H.2 browsers
785        HostCommand::RegisterBrowser { label, browser, kind } => {
786            browsers::handle_register_browser(state, label, browser, kind)
787        }
788        HostCommand::UnregisterBrowser { label } => {
789            browsers::handle_unregister_browser(state, label)
790        }
791        // H.3 drag
792        HostCommand::StartDrag { session } => drag::handle_start_drag(state, session),
793        HostCommand::EndDrag { drag_id, outcome } => drag::handle_end_drag(state, drag_id, outcome),
794        // H.4 pool
795        HostCommand::PoolWindowSpawnStart { label } => pool::handle_pool_spawn_start(state, label),
796        HostCommand::PoolWindowReady { label } => pool::handle_pool_ready(state, label),
797        HostCommand::PoolWindowDestroyedBeforePromote { label } => {
798            pool::handle_pool_destroyed_before_promote(state, label)
799        }
800        HostCommand::PromotePoolWindow { label } => pool::handle_promote_pool_window(state, label),
801        HostCommand::PopAndPromoteFrontPoolWindow => pool::handle_pop_and_promote_front_pool_window(state),
802        HostCommand::PoolDrainAll => pool::handle_pool_drain_all(state),
803        // H.5 quit
804        HostCommand::BeginDrain { reason } => quit::handle_begin_drain(state, reason),
805        HostCommand::ConfirmDrained => quit::handle_confirm_drained(state),
806        // H.6 top-level runner
807        HostCommand::EnqueueTopLevelWindow { request } => {
808            top_level::handle_enqueue_top_level_window(state, request)
809        }
810        HostCommand::TopLevelCallbackFired { label } => {
811            top_level::handle_top_level_callback_fired(state, label)
812        }
813        HostCommand::TopLevelRendererTerminated { label, status } => {
814            top_level::handle_top_level_renderer_terminated(state, label, status)
815        }
816        HostCommand::TopLevelExternallyClosed { label } => {
817            top_level::handle_top_level_externally_closed(state, label)
818        }
819        // Opacity
820        HostCommand::SetWindowOpacity { label, opacity } => {
821            handle_set_window_opacity(state, label, opacity)
822        }
823    }
824}
825
826fn handle_enqueue_pending_window_creation(
827    state: &mut HostState,
828    entry: PendingWindowCreation,
829) -> DispatchOutput {
830    if state.lifecycle == HostLifecyclePhase::ShuttingDown {
831        // No new windows accepted during shutdown. Mirrors the
832        // launcher reducer's shutdown gating from B.9.3.
833        let v = state.bump_version();
834        return DispatchOutput {
835            events: vec![HostEvent::Error {
836                message: "enqueue_pending_window_creation: host is shutting down".to_string(),
837                version: v,
838            }],
839            ..Default::default()
840        };
841    }
842    let label = entry.label.clone();
843    state.pending_window_creations.push_back(entry);
844    let queue_len_after = state.pending_window_creations.len();
845    let v = state.bump_version();
846    DispatchOutput {
847        events: vec![HostEvent::PendingWindowEnqueued {
848            label,
849            queue_len_after,
850            version: v,
851        }],
852        ..Default::default()
853    }
854}
855
856fn handle_dequeue_pending_window_creation(state: &mut HostState) -> DispatchOutput {
857    match state.pending_window_creations.pop_front() {
858        Some(entry) => {
859            let queue_len_after = state.pending_window_creations.len();
860            let v = state.bump_version();
861            let label = entry.label.clone();
862            DispatchOutput {
863                events: vec![HostEvent::PendingWindowDequeued {
864                    label,
865                    queue_len_after,
866                    version: v,
867                }],
868                dequeued: Some(entry),
869                ..Default::default()
870            }
871        }
872        None => {
873            let v = state.bump_version();
874            DispatchOutput {
875                events: vec![HostEvent::PendingWindowQueueEmpty { version: v }],
876                ..Default::default()
877            }
878        }
879    }
880}
881
882// ─────────────────────────────────────────────────────────────────────────
883// Phase H reducer arms — added in PR #1 (h1-foundations)
884//
885// All arms are pure: `&mut HostState` in, `DispatchOutput` (events) out.
886// No I/O, no async, no logging (logging happens in `state::log_host_event`
887// after dispatch returns). Reducer arms emit Effect events; the effect
888// handler in `AppState::host_dispatch_with_effects` (added in PR #4)
889// dispatches each Effect to its imperative handler.
890// ─────────────────────────────────────────────────────────────────────────
891
892pub(super) fn emit_error(state: &mut HostState, message: String) -> DispatchOutput {
893    let v = state.bump_version();
894    DispatchOutput {
895        events: vec![HostEvent::Error { message, version: v }],
896        ..Default::default()
897    }
898}
899
900fn handle_set_window_opacity(state: &mut HostState, label: String, opacity: f32) -> DispatchOutput {
901    let clamped = opacity.clamp(0.0, 1.0);
902    let v = state.bump_version();
903    if clamped >= 1.0 {
904        state.window_opacities.remove(&label);
905        DispatchOutput {
906            events: vec![HostEvent::WindowOpacityCleared { label, version: v }],
907            ..Default::default()
908        }
909    } else {
910        state.window_opacities.insert(label.clone(), clamped);
911        DispatchOutput {
912            events: vec![HostEvent::WindowOpacityApplied { label, opacity: clamped, version: v }],
913            ..Default::default()
914        }
915    }
916}
917
918
919#[cfg(test)]
920mod tests;