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;