agentmux_common/ipc.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Phase B.2 IPC wire protocol — shared between agentmux-launcher
5//! (server) and agentmux-cef (client). One source of truth so the
6//! Command / Event shapes can't drift between binaries on a
7//! version-skew release.
8//!
9//! See `docs/specs/SPEC_WINDOW_PROCESS_STATE_MACHINE_2026_04_27.md` §5.
10//!
11//! Wire format: newline-delimited JSON. One message per line,
12//! parsed via serde_json. Format chosen for debuggability —
13//! operators can `cat` / `nc` the named pipe and read traffic
14//! without a binary protocol decoder.
15//!
16//! Backward compat policy (B.2 baseline; harden in Phase D):
17//! * Externally tagged enums (`{"cmd":"register",...}`) so adding
18//! variants is non-breaking; clients send what they know.
19//! * Unknown commands → server replies `Event::Error` rather than
20//! crashing.
21//! * `version: u64` on every Event lets Phase D's GetSnapshot /
22//! resync detect skew. For B.2 it's set but not enforced.
23
24use serde::{Deserialize, Serialize};
25
26/// Stable identifier for a connected client. Tagged so the launcher
27/// can route replies + log who said what.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum ClientKind {
31 /// The CEF host process (one per launcher run).
32 Host,
33 /// A frontend renderer (proxied via the host's CEF JS bridge — Phase B.7).
34 Renderer,
35 /// The agentmux-srv backend (proxy connection used for Quit ack
36 /// + process-tree facts; the workspace data path stays on
37 /// HTTP/WS through host).
38 Srv,
39 /// External tooling (`agentmux.exe --diag` etc.).
40 Tool,
41}
42
43/// Commands flow client → launcher.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45#[serde(tag = "cmd", rename_all = "snake_case")]
46pub enum Command {
47 /// Identifies the connection. MUST be the first command on every
48 /// new connection. Server enforces.
49 Register {
50 kind: ClientKind,
51 /// PID of the connecting process — used for cross-checking
52 /// against the launcher's `ProcessRecord` map and for
53 /// log correlation.
54 pid: u32,
55 /// Free-form version string of the client binary, for log
56 /// correlation across version skew.
57 version: String,
58 },
59 /// Health probe — server replies with `Event::Pong` carrying the
60 /// same nonce. NOT a polling heartbeat (per spec §4.3) — sent
61 /// only on demand by clients that need round-trip confirmation.
62 Ping {
63 nonce: u64,
64 },
65 /// SPEC_LAUNCHER_TEARDOWN_BACKSTOP_2026_07_11 Phase 1 — launcher → host
66 /// (over the host pipe, like the saga `IssueCmd::Host` commands): probe
67 /// whether the host's CEF **UI thread** is actually pumping. The host
68 /// replies with `ReportUiThreadAlive { nonce }` from a posted UI task —
69 /// NEVER from the pipe-reader thread (a wedged host's tokio reader keeps
70 /// answering; only a UI-thread round-trip is liveness evidence). A host
71 /// whose UI thread is hung, or not yet pumping (the known pre-ready
72 /// `post_task` silent drop), simply never replies — silence is the
73 /// signal; there is no host-side timeout.
74 ProbeUiThread {
75 nonce: u64,
76 },
77 /// Reply to `ProbeUiThread`, sent host → launcher via the `Report*`
78 /// path once the probe's posted UI task actually executes. Echoes the
79 /// nonce; the launcher treats ANY receipt as "UI thread pumped after
80 /// the matching probe was sent" (staleness bounded by the probe
81 /// interval, which is all the Phase-2 rule consumes).
82 ReportUiThreadAlive {
83 nonce: u64,
84 },
85 /// Launcher → host: the level of srv's health-probe latency changed —
86 /// the rolling average of the launcher's probe round-trip times crossed a
87 /// threshold. The host shows it through the memory-pressure banner system
88 /// as kind `"backend"`. `level` is `"normal" | "warn" | "critical"`.
89 /// See docs/analysis/ANALYSIS_SRV_HTTP_STALL_IO_DRIVER_STARVATION_2026_09_26.md §8.2.
90 NotifySrvLatency {
91 level: String,
92 avg_ms: u64,
93 },
94 /// Graceful disconnect. Server logs and closes the connection.
95 /// In B.3+ this becomes `Quit { reason }` with shutdown semantics;
96 /// for B.2 it's just a polite goodbye.
97 Goodbye,
98 /// Phase B.4: host reports that a real window has been created
99 /// (CEF `on_after_created` fired). Launcher records it in its
100 /// read-only mirror and broadcasts `Event::WindowOpened` to other
101 /// subscribers. Pool windows do NOT report via this command —
102 /// they get their own `ReportPool*` commands in a follow-up so
103 /// the mirror can distinguish user-visible windows from pool
104 /// inventory.
105 ReportWindowOpened {
106 /// Stable label assigned by the host (e.g. "main", "window-{uuid}").
107 label: String,
108 kind: WindowKind,
109 /// For `Subwindow` only: label of the `FullInstance` parent.
110 /// `None` for `FullInstance`.
111 parent_label: Option<String>,
112 },
113 /// Phase B.4: host reports a window is closing (`on_before_close`).
114 /// Launcher removes from mirror, broadcasts `Event::WindowClosed`.
115 /// Idempotent: a missing label is logged but not an error (covers
116 /// the close-before-launcher-saw-the-open race).
117 ReportWindowClosed {
118 label: String,
119 },
120 /// Workstream 0 Phase 1 (`SPEC_TRAY_OPTIONAL_BACKGROUND_SERVICE_2026_09_04.md`
121 /// §7) — host reports whether background-service mode
122 /// (`AGENTMUX_BACKGROUND_SERVICE`) is enabled for this process. Sent
123 /// once, right after connecting (mirrors `GetSnapshot`'s
124 /// once-per-connect timing). The launcher's own last-window
125 /// orphan-drift detection and the teardown backstop it arms must not
126 /// treat an intentionally-resting host (zero windows, by design, for
127 /// however long the user leaves it) as a stuck/orphaned one — see
128 /// PR #2983 review (Codex P2). Host-only.
129 ReportBackgroundServiceEnabled {
130 enabled: bool,
131 },
132 /// Phase B.4 follow-up — host reports a pre-warmed pool window
133 /// being added (`spawn_pool_window`). Pool windows live in a
134 /// SEPARATE map from the user-visible window mirror; the host
135 /// transitions them out of the pool with `ReportPoolWindowRemoved`
136 /// + `ReportWindowOpened` on promote, or just
137 /// `ReportPoolWindowRemoved` on pre-promote destroy.
138 ReportPoolWindowAdded {
139 label: String,
140 /// Phase CPD-1 (cross-process dispatch) — saga correlation
141 /// echo. `Some(N)` when the host is replying to a saga-issued
142 /// `Command::SpawnPoolWindow { saga_id: N }`; `None` for
143 /// organic refills (e.g. host's implicit `spawn_pool_window`
144 /// inside `promote_pool_window`). The launcher reducer
145 /// passes the value through to `Event::PoolWindowAdded` so
146 /// per-saga correlation (CPD-4) can match the response to
147 /// the originating saga.
148 ///
149 /// `#[serde(default)]` for forward-compat with hosts running
150 /// pre-CPD-1 builds — they emit no `saga_id` field, which
151 /// deserializes as `None` (organic). Removed once CPD-1+CPD-3
152 /// have soaked one release cycle.
153 #[serde(default)]
154 saga_id: Option<u64>,
155 },
156 /// Phase B.4 follow-up — host reports a pool window leaving the
157 /// pool (promote, destroy, or app exit).
158 ReportPoolWindowRemoved {
159 label: String,
160 },
161 /// Phase B.4 follow-up — drift detection (full snapshot). Host
162 /// sends its own post-mutation counts after each window-level
163 /// transition; the launcher reducer compares both dimensions to
164 /// its mirror counts and emits `Event::DriftDetected` per
165 /// disagreeing dimension. Sent in a separate command (rather
166 /// than embedded in each Report*) so the existing wire shapes
167 /// stay unchanged.
168 ///
169 /// Known limitation (B.4 observe-only): emissions originate
170 /// from multiple execution contexts (CEF UI thread for
171 /// `on_after_created`/`on_before_close`, IPC handler thread
172 /// for `promote_pool_window`). Cross-thread interleaving in
173 /// the outbound channel can produce a snapshot whose counts
174 /// were taken at a moment that doesn't match the channel
175 /// order seen by the reducer, occasionally emitting a
176 /// transient false `DriftDetected`. Acceptable for B.4
177 /// (drift is diagnostic — false positives are ephemeral and
178 /// self-correct on the next stable state). B.5 will tighten
179 /// with a transition-ID protocol once the launcher is
180 /// authoritative. (codex P2 PR #578 round-4 — accepted as
181 /// known limitation.)
182 ReportHostCounts {
183 /// User-visible top-level windows in the host's
184 /// authoritative store (`browsers` minus browser-pane
185 /// children minus unpromoted pool labels).
186 windows: u32,
187 /// Pre-promote pool inventory size.
188 pool: u32,
189 },
190 /// Phase B.4 follow-up — pool-dimension-only drift check. Used
191 /// by `spawn_pool_window` (pool transitions) where snapshotting
192 /// the windows dimension would produce transient false drift:
193 /// pool refill is triggered DURING `on_before_close` BEFORE the
194 /// matching `ReportWindowClosed` lands, so the host's window
195 /// count is mid-flight relative to the launcher mirror. Pool
196 /// count IS stable at that moment (the new pool label was just
197 /// added), so checking pool alone preserves the "check every
198 /// transition" guarantee for the dimension that actually
199 /// changed. (codex P2 PR #578 round-3.)
200 ReportHostPoolCount {
201 count: u32,
202 },
203 /// Phase B.5 (window_id_map step a) — host reports the
204 /// frontend's `register_backend_window` call: a window's label
205 /// → backend window ID (a srv-side UUID the frontend resolves
206 /// via `MOS.makeORef`). The launcher mirrors it for the same
207 /// reasons it mirrors `instance_registry`: host's authoritative
208 /// copy will be retired through the a→b→c→d→e ratchet.
209 ReportBackendWindowIdRegistered {
210 label: String,
211 window_id: String,
212 },
213 /// Phase B.5 (window_id_map step a) — host reports a window
214 /// closing, so the launcher should drop the label→window_id
215 /// mapping. Sent from the same close path that emits
216 /// `ReportWindowClosed`.
217 ReportBackendWindowIdUnregistered {
218 label: String,
219 },
220 /// Phase B.9.1 (WRR — Window Reality Reconciliation) — host
221 /// reports a Win32 top-level window was created. Hooked off
222 /// `EVENT_OBJECT_CREATE` (objid=`OBJID_WINDOW`) via
223 /// `SetWinEventHook(WINEVENT_OUTOFCONTEXT)`. The host filters
224 /// CEF subprocess HWNDs (renderer, GPU, plugin) at the hook
225 /// callback so the launcher only sees plausible app windows.
226 /// `label_hint` is the host's best guess from
227 /// `pending_window_creations` if it can disambiguate; `None`
228 /// when the create event arrives before the host has linked
229 /// the HWND back to a label (caught up later by the reducer's
230 /// pending_hwnds).
231 ReportHwndOpened {
232 hwnd: u64,
233 class_name: String,
234 title: String,
235 label_hint: Option<String>,
236 },
237 /// Phase B.9.1 — host reports a Win32 top-level window was
238 /// destroyed. Hooked off `EVENT_OBJECT_DESTROY`.
239 ReportHwndDestroyed {
240 hwnd: u64,
241 },
242 /// Phase B.9.1 — `EVENT_OBJECT_SHOW` / `EVENT_OBJECT_HIDE`.
243 ReportHwndVisibilityChanged {
244 hwnd: u64,
245 visible: bool,
246 },
247 /// Phase B.9.1 — `EVENT_SYSTEM_FOREGROUND`. Tells the launcher
248 /// the user actually saw this window (not just opened it).
249 /// Used to distinguish "opened but never foregrounded" (drift)
250 /// from "opened and shown".
251 ReportHwndForegroundChanged {
252 hwnd: u64,
253 },
254 /// Phase B.9.1 — `EVENT_SYSTEM_MINIMIZESTART` /
255 /// `EVENT_SYSTEM_MINIMIZEEND`.
256 ReportHwndIconicChanged {
257 hwnd: u64,
258 iconic: bool,
259 },
260 /// Phase B.9.1 — `WM_WINDOWPOSCHANGED` from the host's wndproc
261 /// wrapper. The host coalesces bursts (50ms debounce per HWND)
262 /// before sending so the wire stays light during topology
263 /// changes. Reducer compares `rect` against `state.monitors` to
264 /// classify off-monitor drift.
265 ReportHwndPositionChanged {
266 hwnd: u64,
267 rect: Rect,
268 },
269 /// Phase B.9.1 — `WM_DISPLAYCHANGE`. Replaces the launcher's
270 /// `state.monitors` wholesale; reducer re-evaluates every
271 /// known window's last_rect against the new topology and
272 /// emits `OffMonitor` drift for any that newly fall off.
273 ReportMonitorTopologyChanged {
274 rects: Vec<Rect>,
275 },
276 /// Phase D.1 — request a `Event::Snapshot` reply containing the
277 /// reducer's current canonical state. Used by `--diag wrr` for
278 /// state-now visibility, by the frontend reducer for mid-session
279 /// resync after disconnect, and by future Tool clients that need
280 /// to bootstrap without observing every prior event.
281 GetSnapshot,
282 /// Phase E.1b — srv-side equivalent of `GetSnapshot`. Routed to
283 /// the srv pipe; reducer replies with `Event::SrvSnapshot`.
284 /// Separate from `GetSnapshot` (launcher) per spec §4.3 — each
285 /// reducer is canonical for its domain and replies on its own
286 /// pipe.
287 ///
288 /// Reply contents grow with each phase: E.1b had lifecycle +
289 /// version only; E.2 added `workspaces`; E.2b+ will add tabs /
290 /// blocks / layouts. See `Event::SrvSnapshot` for the current
291 /// shape.
292 GetSrvSnapshot,
293 /// Phase E.2 — create a new workspace. The reducer assigns the
294 /// `oid` (UUID) and emits `Event::WorkspaceCreated`. In E.2 the
295 /// reducer is a session-only projection (no persist subscriber);
296 /// E.2c adds the persist subscriber + migrates HTTP/WS RPC
297 /// to flow through the reducer.
298 CreateWorkspace {
299 name: String,
300 },
301 /// Phase E.2 — delete a workspace. Reducer removes from canonical
302 /// state and emits `Event::WorkspaceDeleted`. Cascade-to-tabs +
303 /// SQLite write happen via wcore today (RPC path); migrating to
304 /// reducer-driven persistence is E.2c.
305 DeleteWorkspace {
306 workspace_id: String,
307 /// Step 5 PR 2 — provenance marker for the `delete_workspace`
308 /// saga. `true` means the dispatch is being driven by the
309 /// saga coordinator (per-tab DeleteTab dispatches already
310 /// happened in saga steps; this final cascade is just the
311 /// workspace row + window mappings). `false` for legacy /
312 /// internal compensation paths (e.g. `tear_off_tab` /
313 /// `tear_off_block` rolling back a freshly-created empty
314 /// workspace) which keep the existing cascade behaviour.
315 ///
316 /// The reducer's `handle_delete_workspace` cascades regardless
317 /// of `force` — the reducer is a pure mutator and the cascade
318 /// must always execute to keep state consistent. The flag is
319 /// recorded in the saga log purely for provenance, mirroring
320 /// the saga-as-narrator pattern documented in
321 /// `docs/retro/phase-fg-roadmap-2026-05-01.md`.
322 ///
323 /// Defaults to `false` via `#[serde(default)]` so all existing
324 /// producers (RPC, internal compensation) keep working.
325 #[serde(default)]
326 force: bool,
327 },
328 /// Phase E.2b — create a tab inside an existing workspace. The
329 /// reducer assigns the `tab_id` (UUID), appends to the workspace's
330 /// ordered tab list, and emits `Event::TabCreated`. Validates the
331 /// parent workspace exists; returns `Event::Error` if not.
332 /// Session-only projection (no persist subscriber yet).
333 CreateTab {
334 workspace_id: String,
335 name: String,
336 },
337 /// Phase E.2b — delete a tab from a workspace. Reducer removes the
338 /// tab from canonical state, removes its id from the workspace's
339 /// ordered tab list, and emits `Event::TabDeleted`. If the deleted
340 /// tab was the active tab, also emits `Event::ActiveTabChanged`
341 /// pointing at the new active (next-or-prev tab, or empty if the
342 /// workspace has no tabs left).
343 DeleteTab {
344 workspace_id: String,
345 tab_id: String,
346 /// (codex P2 PR #633 round 4 + codex P1 round 2.) Bypass the
347 /// reducer's atomic last-tab guard. `false` for user-facing
348 /// flows (close button, keyboard shortcut) — reducer rejects
349 /// last-tab deletes to keep workspaces non-empty. `true` for
350 /// internal compensation paths (`CreateTab` rollback,
351 /// `PromoteBlockToTab` compensation) where rolling back a
352 /// just-created tab requires deleting the only tab.
353 ///
354 /// Defaults to `false` via `#[serde(default)]` for backwards
355 /// compatibility — pre-existing producers that don't set the
356 /// field get the safe (guarded) behavior.
357 #[serde(default)]
358 force: bool,
359 },
360 /// Phase E.2b — set a workspace's active tab. No-op if already
361 /// active. Errors if the workspace doesn't exist or the tab isn't
362 /// in that workspace's tab list.
363 SetActiveTab {
364 workspace_id: String,
365 tab_id: String,
366 },
367 /// Phase E.2c.3b — reorder a tab within its workspace's
368 /// `tab_ids`. `new_index` is clamped to the list length; no-op
369 /// if the tab is already at that position. Errors if the
370 /// workspace doesn't exist or the tab isn't in its tab list.
371 ReorderTab {
372 workspace_id: String,
373 tab_id: String,
374 new_index: u32,
375 },
376 /// Phase E.5 — record a new window in the srv reducer's
377 /// `state.windows` map. Caller pre-assigns `window_id` (sagas
378 /// use a fresh UUID; RPC migration in PR 4 will likewise mint
379 /// the id at the RPC boundary). Validates the parent workspace
380 /// exists; errors otherwise. Used by CreateWindow + TearOff
381 /// sagas to track window↔workspace association.
382 CreateWindow {
383 window_id: String,
384 workspace_id: String,
385 },
386 /// Phase E.5 — remove a window's workspace mapping from the
387 /// reducer. Called by CloseWindow sagas after the host's CEF
388 /// window-close completes. Idempotent silent no-op on missing.
389 CloseWindowInternal {
390 window_id: String,
391 },
392 /// Phase E.5 — switch which workspace a window points at.
393 /// Errors if the window or destination workspace is unknown.
394 SwitchWorkspace {
395 window_id: String,
396 workspace_id: String,
397 },
398 /// Phase E.5.3 — replace a workspace's `tab_ids` with the given
399 /// list. The new list must be a permutation of the current set
400 /// (same elements, possibly different order); reducer errors
401 /// otherwise. Used by the drag-reorder UI's bulk-reorder path
402 /// (replaces wcore-direct `UpdateTabIds`).
403 ReorderTabsBulk {
404 workspace_id: String,
405 tab_ids: Vec<String>,
406 },
407 /// Phase E.5.3 — rename a workspace. Errors if the workspace
408 /// doesn't exist; no-op if the name is identical.
409 RenameWorkspace {
410 workspace_id: String,
411 name: String,
412 },
413 /// Phase E.5.3 — rename a tab. Errors if the tab doesn't exist;
414 /// no-op if identical.
415 RenameTab {
416 tab_id: String,
417 name: String,
418 },
419 /// Phase E.5.3 — apply a meta-patch to a workspace. The reducer
420 /// validates the entity exists and emits `Event::WorkspaceMetaUpdated`
421 /// with the patch payload; the persist subscriber performs the
422 /// actual merge against mstore. Reducer state does NOT track meta
423 /// in E.5.3 — pass-through preserves the reducer's small footprint
424 /// without losing the migration property (every mutation goes
425 /// through the reducer's broadcast bus).
426 UpdateWorkspaceMeta {
427 workspace_id: String,
428 meta_patch: serde_json::Value,
429 },
430 /// Phase E.5.3 — apply a meta-patch to a tab. Same pass-through
431 /// shape as `UpdateWorkspaceMeta`.
432 UpdateTabMeta {
433 tab_id: String,
434 meta_patch: serde_json::Value,
435 },
436 /// Phase E.5.3 — apply a meta-patch to a block. Same pass-through
437 /// shape as `UpdateWorkspaceMeta`.
438 UpdateBlockMeta {
439 block_id: String,
440 meta_patch: serde_json::Value,
441 },
442 /// Phase E.5.x — apply a meta-patch to a window's `meta` map.
443 /// Same pass-through shape as `UpdateWorkspaceMeta`. Migrated
444 /// through the reducer per issue #855 so `Event::WindowMetaUpdated`
445 /// lands on `srv_events_tx` and the MuxObjUpdate broadcast bridge
446 /// picks it up — replaces the wcore-direct fallback that bypassed
447 /// reducer + bridge entirely.
448 UpdateWindowMeta {
449 window_id: String,
450 meta_patch: serde_json::Value,
451 },
452 /// Phase E.5.5 — move a tab from one workspace to another.
453 /// Reducer:
454 /// * Removes `tab_id` from `src_workspace_id.tab_ids`.
455 /// * Updates `tab.workspace_id = dst_workspace_id`.
456 /// * Inserts `tab_id` at `dst_index` in `dst_workspace_id.tab_ids`,
457 /// clamping to the dst list length.
458 /// * If `tab_id` was the source workspace's `active_tab_id`, the
459 /// source's active reverts to its first remaining tab (or
460 /// `None` if the source becomes empty).
461 /// Errors if any of: source workspace, dest workspace, or tab is
462 /// missing; if `tab.workspace_id != src_workspace_id`; or if
463 /// the tab is the source workspace's last tab AND no caller has
464 /// arranged a fallback (callers like tear-off should reject the
465 /// move at the saga layer if removing the tab would empty the
466 /// source — preserving the "workspaces have at least one tab"
467 /// invariant most UI paths assume). Used by the TearOffTab,
468 /// MoveTabToWorkspace, and RestoreTornOffTab sagas.
469 MoveTab {
470 tab_id: String,
471 src_workspace_id: String,
472 dst_workspace_id: String,
473 dst_index: u32,
474 },
475 /// Phase E.5.5 — move a block from one tab to another (or to a
476 /// different position in the same tab).
477 /// Reducer:
478 /// * Removes `block_id` from `src_tab_id.block_ids`.
479 /// * Updates `block.tab_id = dst_tab_id`.
480 /// * Inserts `block_id` at `dst_index` in `dst_tab_id.block_ids`,
481 /// clamping to the dst list length.
482 /// Errors if source tab, dest tab, or block is missing, or if
483 /// `block.tab_id != src_tab_id`. Used by TearOffBlock and the
484 /// MoveBlockToTab saga.
485 MoveBlock {
486 block_id: String,
487 src_tab_id: String,
488 dst_tab_id: String,
489 dst_index: u32,
490 },
491 /// Phase E.3 — create a block inside an existing tab. Reducer
492 /// validates parent tab exists, assigns the `block_id` (UUID),
493 /// appends to the tab's `block_ids`, emits `Event::BlockCreated`.
494 /// Session-only projection (no persist subscriber yet).
495 CreateBlock {
496 tab_id: String,
497 /// Phase E.2c.4 — block metadata (`view`, layout hints, etc.)
498 /// passed through to the persisted Block row. The reducer
499 /// itself doesn't track meta — it forwards it untouched into
500 /// `Event::BlockCreated` so the persist subscriber writes
501 /// the Block with the correct meta map. `#[serde(default)]`
502 /// for forward-compat with old log entries that pre-date the
503 /// meta field.
504 #[serde(default)]
505 meta: serde_json::Value,
506 },
507 /// Phase E.3 — delete a block from a tab. Idempotent silent no-op
508 /// on missing tab or missing block.
509 DeleteBlock {
510 tab_id: String,
511 block_id: String,
512 },
513 /// Phase E.4 (Option A) — set a tab's `focusednodeid`. Errors if
514 /// the tab is unknown to the reducer; no-op short-circuit when the
515 /// value is already current. Empty `node_id` clears the field.
516 /// Routes through the reducer so the persist subscriber writes the
517 /// new value into `LayoutState.focusednodeid` for the tab. The
518 /// rest of `LayoutState` (rootnode/leaforder/pendingbackendactions)
519 /// keeps its existing wcore-direct path until Option B lands.
520 SetFocusedNode {
521 tab_id: String,
522 node_id: String,
523 },
524 /// Phase E.4 (Option A) — set a tab's `magnifiednodeid`. Same
525 /// shape as `SetFocusedNode`; empty `node_id` clears (toggle-off).
526 SetMagnifiedNode {
527 tab_id: String,
528 node_id: String,
529 },
530
531 // ── Phase E.4.B — Layout tree commands ──────────────────────────────
532 //
533 // Each command carries a `correlation_id` (UUID string) for the
534 // frontend optimistic-confirm pattern: the slice-#8 subscriber uses
535 // it to distinguish "my own command echoing back" from "a remote
536 // command I must apply locally".
537 //
538 // `focus_after` / `magnify_after` flags mirror the semantics of the
539 // frontend's `LayoutTreeActionType` (focused / magnified side effects
540 // on InsertNode, SplitH, SplitV, ReplaceNode).
541 //
542 // See docs/specs/srv-phase-e4b-formal-spec-2026-05-03.md §5.
543
544 /// Insert a new node into the tree. If `parent_id` is `None`, the
545 /// heuristic `findNextInsertLocation` is used (first available slot);
546 /// `index` positions within the parent's children (None = append).
547 LayoutInsertNode {
548 tab_id: String,
549 node: crate::LayoutNode,
550 parent_id: Option<String>,
551 index: Option<usize>,
552 focus_after: bool,
553 magnify_after: bool,
554 correlation_id: String,
555 },
556 /// Insert at an exact index path through the tree (e.g. `[0, 2]`).
557 LayoutInsertNodeAtIndex {
558 tab_id: String,
559 node: crate::LayoutNode,
560 index_arr: Vec<usize>,
561 focus_after: bool,
562 magnify_after: bool,
563 correlation_id: String,
564 },
565 /// Remove a node by id; collapse empty parents.
566 LayoutDeleteNode {
567 tab_id: String,
568 node_id: String,
569 correlation_id: String,
570 },
571 /// Remove the node holding `block_id`; collapse empty parents.
572 /// SPEC_864 site #6 — the block→node resolution happens in the
573 /// reducer arm (the reducer owns the tree), so callers that only
574 /// know a block id (the `delete_block` saga) don't need tree
575 /// access. Resolving to no node is a silent idempotent no-op:
576 /// blocks may legitimately have no layout node (already pruned by
577 /// a frontend push, or never laid out).
578 LayoutDeleteNodeByBlock {
579 tab_id: String,
580 block_id: String,
581 correlation_id: String,
582 },
583 /// Add `block_id` to the tab stack of the pane holding
584 /// `target_block_id`, optionally as the visible tab.
585 /// SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §3.2.
586 LayoutStackPush {
587 tab_id: String,
588 target_block_id: String,
589 block_id: String,
590 activate: bool,
591 correlation_id: String,
592 },
593 /// Make `block_id` the visible tab of the pane whose stack holds it.
594 LayoutStackActivate {
595 tab_id: String,
596 block_id: String,
597 correlation_id: String,
598 },
599 /// Create a block AND place it in the tab stack of the pane holding
600 /// `target_block_id`, in one reducer step — the block never exists
601 /// without a pane (the two-step `pane.open { skip_placement }` + frontend
602 /// push could be interrupted in between, orphaning it).
603 /// SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §3.2.
604 CreateBlockInStack {
605 tab_id: String,
606 target_block_id: String,
607 #[serde(default)]
608 meta: serde_json::Value,
609 activate: bool,
610 },
611 /// Move `block_id` to `position` relative to `target_block_id` — a
612 /// same-leaf reorder when both are already members of the same pane's
613 /// stack, otherwise a cross-pane move (removed from its current leaf,
614 /// placed into `target_block_id`'s leaf, promoting a single-block leaf
615 /// into a stack if needed). Refuses (silent no-op-with-error) if
616 /// `block_id` is its source leaf's only member — removing the last
617 /// member is "close the pane," a different command. A same-leaf reorder
618 /// never changes which member is visible unless `activate` is set,
619 /// even when the reordered member was already the visible one.
620 /// SPEC_PANE_TAB_DRAG_AND_DROP_2026_09_19.md §4.1.
621 LayoutStackMove {
622 tab_id: String,
623 block_id: String,
624 target_block_id: String,
625 position: crate::StackMovePosition,
626 activate: bool,
627 correlation_id: String,
628 },
629 /// Reparent a node to a new parent at the given child index.
630 LayoutMoveNode {
631 tab_id: String,
632 node_id: String,
633 new_parent_id: String,
634 index: usize,
635 correlation_id: String,
636 },
637 /// Swap two sibling (or cross-parent) nodes. Sizes travel with nodes.
638 LayoutSwapNodes {
639 tab_id: String,
640 node1_id: String,
641 node2_id: String,
642 correlation_id: String,
643 },
644 /// Apply N resize operations atomically. Rejected entirely if any
645 /// `size` is out of range (reducer validates; early-return on first
646 /// invalid op, matching the frontend's existing semantic).
647 LayoutResizeNodes {
648 tab_id: String,
649 ops: Vec<crate::ResizeOp>,
650 correlation_id: String,
651 },
652 /// Replace a node with a new one, preserving the target's flex size.
653 LayoutReplaceNode {
654 tab_id: String,
655 target_id: String,
656 new_node: crate::LayoutNode,
657 focus_after: bool,
658 correlation_id: String,
659 },
660 /// Horizontal split: inserts `new_node` before/after `target_id`
661 /// in a Row parent (or wraps them in a new Row group if parent is not Row).
662 LayoutSplitHorizontal {
663 tab_id: String,
664 target_id: String,
665 new_node: crate::LayoutNode,
666 position: crate::SplitPosition,
667 focus_after: bool,
668 correlation_id: String,
669 },
670 /// Vertical split: inserts `new_node` before/after `target_id`
671 /// in a Column parent (or wraps them in a new Column group).
672 LayoutSplitVertical {
673 tab_id: String,
674 target_id: String,
675 new_node: crate::LayoutNode,
676 position: crate::SplitPosition,
677 focus_after: bool,
678 correlation_id: String,
679 },
680 /// Wipe the entire tree (sets rootnode = None, clears focus/magnify).
681 LayoutClear {
682 tab_id: String,
683 correlation_id: String,
684 },
685 /// Bulk-replace the tree. Used during Phase 7a writer migration and
686 /// for tear-off where the whole subtree changes atomically.
687 ///
688 /// SPEC_864 Phase 2 — when the command originates from the frontend's
689 /// full-row `UpdateObject` push, `slices` carries the remaining
690 /// frontend-owned `LayoutState` columns (leaforder / focus / magnify /
691 /// pendingbackendactions) with REPLACE semantics, so the single
692 /// dispatch fully supersedes the legacy `update_raw` whole-row write.
693 /// `slices: None` (tree-only callers) leaves those columns untouched.
694 LayoutSetTree {
695 tab_id: String,
696 new_tree: Option<crate::LayoutNode>,
697 correlation_id: String,
698 #[serde(default, skip_serializing_if = "Option::is_none")]
699 slices: Option<crate::LayoutClientSlices>,
700 },
701 /// SPEC_864 Phase 4 — append actions to a tab's
702 /// `pendingbackendactions` queue through the reducer. `actions` is a
703 /// JSON array of `LayoutActionData` objects (raw-value pass-through;
704 /// the reducer does not model the queue — see
705 /// `Event::LayoutBackendActionsQueued`).
706 LayoutQueueBackendActions {
707 tab_id: String,
708 actions: serde_json::Value,
709 correlation_id: String,
710 },
711
712 /// Phase D.3 — request an `Event::EventList` reply containing the
713 /// events the launcher has emitted with version > `since`. Used
714 /// by subscribers that hold a snapshot at version V and want to
715 /// catch up to the live stream by replaying missed events.
716 ///
717 /// Typical resync flow:
718 /// 1. `Register` → `Registered { version: V0 }`
719 /// 2. `GetSnapshot` → `Snapshot { version: V1 }` (V1 > V0)
720 /// — apply the snapshot
721 /// 3. `GetEvents { since: V1 }` → `EventList { events, version: V2 }`
722 /// — apply replay events to catch up to V2
723 /// 4. live broadcast events flow with version > V2
724 ///
725 /// Replay is best-effort: the launcher's in-memory ring is
726 /// bounded; if `since` is older than the oldest retained event,
727 /// the reply still contains all retained events but the
728 /// subscriber may have missed some. Caller should treat the
729 /// result as "everything I have for you" and re-fetch a snapshot
730 /// if state inconsistency is detected.
731 GetEvents {
732 /// Exclusive lower bound — events with `version > since` are
733 /// returned, in ascending order.
734 since: u64,
735 },
736 /// Phase F.5 — host explicitly reports that a pool window was
737 /// promoted to a user-visible top-level window (the
738 /// `promote_pool_window` flow in `agentmux-cef`). Sent BETWEEN the
739 /// `ReportPoolWindowRemoved` + `ReportWindowOpened` pair so the
740 /// launcher reducer has unambiguous evidence the transition was a
741 /// promote (vs a destroy followed by an unrelated open) and can
742 /// emit `Event::PoolWindowPromoted`. The pool-respawn saga
743 /// consumes the event to bracket the implicit refill in
744 /// `SagaStarted`/`SagaCompleted`.
745 ///
746 /// Host-only — same gate as `ReportPoolWindowRemoved`.
747 ReportPoolWindowPromoted {
748 label: String,
749 },
750 /// Phase F.5 — launcher-side saga coordinator asks the host to
751 /// spawn a fresh pool window (refill after promote).
752 ///
753 /// **Status: live.** Cross-process dispatch (CPD-1 through CPD-5)
754 /// shipped; the saga coordinator's `apply_action` for
755 /// `PipeTarget::Host` writes this command through `host_pipe`
756 /// (`crates/launcher/src/host_pipe/`) and waits on the
757 /// `Event::PoolWindowAdded { saga_id: Some(N) }` echo. Host's
758 /// implicit `spawn_pool_window` call inside `promote_pool_window`
759 /// remains the organic refill path for non-saga-driven
760 /// promotions (with `saga_id: None`).
761 ///
762 /// `saga_id`: every host-bound command carries the originating
763 /// saga's id so the host can echo it on the corresponding
764 /// `Report*` reply. `0` is reserved as "no saga" and treated as a
765 /// non-saga dispatch. `#[serde(default)]` retained for
766 /// forward-compat with any pre-CPD-1 deserializers (no real-world
767 /// consumer today; portable runtime is bundled per release).
768 SpawnPoolWindow {
769 #[serde(default)]
770 saga_id: u64,
771 },
772 /// Phase F.6 — host reports that all browser-pane HWNDs belonging
773 /// to a closing top-level window have been reaped. Emitted from
774 /// `client.rs::on_before_close` after the subwindow cascade and
775 /// pane lifecycle drain finish for the closing window.
776 ///
777 /// Distinct from `ReportWindowClosed` — that event marks the
778 /// CEF browser leaving the host's `browsers` map; this one marks
779 /// the host's pane bookkeeping (lifecycle entries, pane HWND map)
780 /// for that label being fully drained. Today both happen in the
781 /// same `on_before_close` body so the events arrive back-to-back,
782 /// but the saga distinguishes them so future fine-grained
783 /// reapers (e.g. async pane teardown for embedded browsers) can
784 /// land without rewriting the saga.
785 ///
786 /// Host-only — same gate as `ReportWindowClosed`.
787 ReportPanesReaped {
788 label: String,
789 /// Phase CPD-1 — saga correlation echo. `Some(N)` when the
790 /// host is replying to a saga-issued
791 /// `Command::ReapPanes { saga_id: N }`; `None` for organic
792 /// reports (e.g. host's existing implicit pane drain inside
793 /// `on_before_close` that wasn't saga-driven).
794 ///
795 /// `#[serde(default)]` for forward-compat with pre-CPD-1
796 /// hosts.
797 #[serde(default)]
798 saga_id: Option<u64>,
799 },
800 /// Phase F.6 — host reports the result of the post-close
801 /// drain-pool-if-last decision. `was_last == true` when the
802 /// closing window was the last user-visible window and the host
803 /// just kicked off the warm-pool drain (Stage 1 of the two-stage
804 /// close cascade in `client.rs::on_before_close`); `false` when
805 /// other user-visible windows remain and the pool stays warm.
806 ///
807 /// The launcher's window-cleanup-cascade saga uses this to close
808 /// out its bracket regardless of which branch fires (both are
809 /// terminal for the saga).
810 ///
811 /// Host-only.
812 ReportPoolDrainDecision {
813 label: String,
814 was_last: bool,
815 /// Phase CPD-1 — saga correlation echo. `Some(N)` when the
816 /// host is replying to a saga-issued
817 /// `Command::DrainPoolIfLast { saga_id: N }`; `None` for
818 /// organic reports.
819 ///
820 /// `#[serde(default)]` for forward-compat with pre-CPD-1
821 /// hosts.
822 #[serde(default)]
823 saga_id: Option<u64>,
824 },
825 /// Phase F.6 — launcher-side saga coordinator asks the host to
826 /// reap all browser-pane HWNDs for a window that just closed.
827 ///
828 /// **Status: live.** Wired through `host_pipe` post-CPD-3. The
829 /// saga issues this with `target = PipeTarget::Host`; the
830 /// coordinator's `apply_action` writes it through the host pipe
831 /// and waits for the `Event::PanesReaped { saga_id: Some(N) }`
832 /// echo. Host's organic pane drain inside `on_before_close` still
833 /// emits the same Report with `saga_id: None` for non-saga-driven
834 /// closes.
835 ///
836 /// `saga_id`: mandatory on the wire (host echoes back on
837 /// `ReportPanesReaped`). `#[serde(default)]` retained for
838 /// forward-compat (see `SpawnPoolWindow` for rationale).
839 ReapPanes {
840 label: String,
841 #[serde(default)]
842 saga_id: u64,
843 },
844 /// Phase F.6 — launcher-side saga coordinator asks the host to
845 /// drain the warm pool if the just-closed window was the last
846 /// user-visible window (i.e. trigger Stage 1 of the close
847 /// cascade).
848 ///
849 /// **Status: live** (same shipping path as `ReapPanes`). Wired
850 /// through `host_pipe` post-CPD-3; saga waits on
851 /// `Event::PoolDrained { saga_id: Some(N) }` /
852 /// `Event::PoolNotLast { saga_id: Some(N) }` echo. Host's
853 /// `on_before_close` still runs the equivalent inline check
854 /// organically (with `saga_id: None`).
855 ///
856 /// `saga_id`: mandatory on the wire. `#[serde(default)]` retained
857 /// for forward-compat.
858 DrainPoolIfLast {
859 label: String,
860 #[serde(default)]
861 saga_id: u64,
862 },
863 /// Phase CPD-1 — host-emitted report that a saga-issued action
864 /// failed (e.g. window not found, IPC error). Carries the
865 /// originating `saga_id` and a human-readable `reason`. The
866 /// launcher reducer translates this into `Event::SagaActionFailed`
867 /// so the saga coordinator can terminate the matching saga as
868 /// `SagaFailed`.
869 ///
870 /// Schema-only in CPD-1: hosts don't yet read commands from the
871 /// pipe (CPD-2 wires that), so no producer for this command
872 /// exists yet. The shape is added now so launcher reducer arms
873 /// + saga coordinator wiring can soak before CPD-3 makes the
874 /// dispatch live.
875 ReportSagaActionFailed {
876 saga_id: u64,
877 reason: String,
878 },
879 /// Host reports the start of a named startup phase, forwarded by
880 /// the launcher into its `StartupEventSink` so it renders live in
881 /// the splash telemetry panel — same stage/label shape as the
882 /// launcher's own internal `saga`/`backend`/`host` stages
883 /// (`crates/launcher/src/startup_events.rs`), just sourced from
884 /// inside the host process instead.
885 ///
886 /// The host can only send this AFTER `connect_to_launcher`
887 /// succeeds, so phases that finish before the IPC connection
888 /// exists (e.g. the CEF framework `dlopen` on macOS, which
889 /// happens before the connection is even opened) can't be
890 /// reported live — the host times those with a local `Instant`
891 /// and sends both `ReportStartupStageBegin` + `ReportStartupStageEnd`
892 /// back-to-back once connected, with the already-elapsed duration
893 /// on the `End` message. Phases that start after the connection
894 /// exists (CEF `cef::initialize`, first-paint) are reported live.
895 ReportStartupStageBegin {
896 /// Stable machine-readable stage key (e.g. `"dlopen"`,
897 /// `"cef_init"`, `"first_paint"`) — matches
898 /// `StartupEvent::StageBegin`'s `stage` field on the launcher
899 /// side.
900 stage: String,
901 /// Human-readable label shown in the splash panel.
902 label: String,
903 },
904 /// Companion to `ReportStartupStageBegin` — see its doc comment.
905 ReportStartupStageEnd {
906 stage: String,
907 duration_ms: u64,
908 /// `"ok"` | `"warn"` | `"error"` — kept as a plain string on
909 /// the wire rather than importing `agentmux-launcher`'s
910 /// `StartupStatus` (which lives in the launcher binary crate,
911 /// not a shared crate); the launcher's IPC handler maps it to
912 /// `StartupStatus` when forwarding into the sink.
913 status: String,
914 detail: Option<String>,
915 },
916}
917
918/// Phase B.9.1 — rectangle in Win32 screen coordinates (pixels).
919/// Matches Windows' `RECT` semantics: `right` and `bottom` are one
920/// past the last included pixel, so `right - left == width`.
921#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
922pub struct Rect {
923 pub left: i32,
924 pub top: i32,
925 pub right: i32,
926 pub bottom: i32,
927}
928
929/// Window role in the multi-window model — the single definition, used by
930/// the host (`agentmux-cef::state` re-exports it) and deserialized by the
931/// launcher. The host used to carry a byte-identical private copy and map
932/// between the two variant by variant; that copy is gone
933/// (`docs/reports/REPORT_DRY_AND_MODULARITY_AUDIT_2026_09_06.md` §2.2).
934#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
935#[serde(rename_all = "snake_case")]
936pub enum WindowKind {
937 /// Independent AgentMux window. Appears in the Windows taskbar.
938 FullInstance,
939 /// Hidden from the taskbar; closes when its parent FullInstance closes.
940 Subwindow,
941}
942
943/// Events flow launcher → client. Versioned per spec §5.2 — every
944/// event carries a monotonic `version: u64` per launcher run, used
945/// by Phase D's resync protocol.
946///
947/// Phase B.3 introduces the first non-handshake events
948/// (ProcessSpawned, ProcessExited, LifecyclePhaseChanged) emitted
949/// by the launcher's reducer when commands transition state. B.4+
950/// adds the window-state events (WindowAdded, WindowStateChanged,
951/// WindowRemoved) per spec §5.2.
952/// Note: `Eq` is not derived because layout variants carry `LayoutNode`
953/// which contains `f32` (not `Eq`). `PartialEq` is sufficient for all
954/// current use-sites.
955#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
956#[serde(tag = "event", rename_all = "snake_case")]
957pub enum Event {
958 /// Reply to `Command::Register`. Acknowledges the client kind +
959 /// confirms the launcher's view of the world.
960 Registered {
961 client_id: u64,
962 launcher_pid: u32,
963 launcher_version: String,
964 version: u64,
965 },
966 /// Reply to `Command::Ping`. Echoes the nonce.
967 Pong {
968 nonce: u64,
969 version: u64,
970 },
971 /// Sent when an incoming command can't be parsed or violates an
972 /// invariant (e.g. Command before Register). Connection stays
973 /// open unless `fatal: true`.
974 Error {
975 code: ErrorCode,
976 message: String,
977 fatal: bool,
978 version: u64,
979 },
980 /// A process joined the launcher's canonical registry. Emitted
981 /// when a client first Registers (B.3) and, in B.4+, when the
982 /// launcher itself spawns a child.
983 ProcessSpawned {
984 pid: u32,
985 kind: ClientKind,
986 client_version: String,
987 version: u64,
988 },
989 /// A process exited or disconnected gracefully. Emitted on
990 /// Goodbye (B.3) and, in B.4+, on detected child exit.
991 ProcessExited {
992 pid: u32,
993 /// Exit code. 0 = clean Goodbye; non-zero = OS-reported
994 /// exit code or synthetic value for crashes.
995 code: i32,
996 version: u64,
997 },
998 /// The launcher's lifecycle phase changed. Spec §4 defines the
999 /// valid transitions: Starting → Running → Quitting → Dead.
1000 /// Emitted at most once per transition.
1001 LifecyclePhaseChanged {
1002 from: LifecyclePhase,
1003 to: LifecyclePhase,
1004 version: u64,
1005 },
1006 /// Phase B.4: a window joined the launcher's mirror. Emitted in
1007 /// response to `Command::ReportWindowOpened` from the host. Other
1008 /// subscribers (Tool clients, eventually srv) receive this to
1009 /// keep their own views consistent.
1010 WindowOpened {
1011 label: String,
1012 kind: WindowKind,
1013 parent_label: Option<String>,
1014 version: u64,
1015 },
1016 /// Phase B.4: a window left the launcher's mirror. Emitted on
1017 /// `Command::ReportWindowClosed`. Cascades for FullInstance
1018 /// closures are NOT modeled here yet (B.5 tightens) — for now
1019 /// the host emits one ReportWindowClosed per window even on
1020 /// cascade closes, so subscribers see the same N events.
1021 WindowClosed {
1022 label: String,
1023 version: u64,
1024 /// (codex P1 PR #637.) `true` when the close was detected by
1025 /// `wrr::apply_hwnd_destroyed` after a host/renderer crash —
1026 /// no clean `on_before_close` ran, so the host did NOT send
1027 /// the `ReportPanesReaped` / `ReportPoolDrainDecision` reports
1028 /// the F.6 saga waits for. Subscribers that drive
1029 /// cleanup-cascade sagas must filter on `!crash_detected` to
1030 /// avoid spawning an in-flight saga that can never reach a
1031 /// terminal state.
1032 ///
1033 /// `#[serde(default)]` so pre-existing producers default to
1034 /// `false` (clean close).
1035 #[serde(default)]
1036 crash_detected: bool,
1037 },
1038 /// Phase B.4 follow-up — pool inventory transitioned. Emitted in
1039 /// response to `ReportPoolWindow{Added,Removed}`. Subscribers
1040 /// (Tool clients) use this to track pool warmth without polling.
1041 PoolWindowAdded {
1042 label: String,
1043 version: u64,
1044 /// Phase CPD-1 — saga correlation. Mirrors the `saga_id` that
1045 /// arrived on the originating
1046 /// `Command::ReportPoolWindowAdded { saga_id }`. `None` for
1047 /// organic refills (no saga in flight). Subscribers (CPD-4
1048 /// per-saga correlation) match on this to scope events to the
1049 /// originating saga.
1050 ///
1051 /// `#[serde(default)]` for forward-compat with old
1052 /// `launcher-events.log` entries that pre-date CPD-1.
1053 #[serde(default)]
1054 saga_id: Option<u64>,
1055 },
1056 PoolWindowRemoved {
1057 label: String,
1058 version: u64,
1059 },
1060 /// Phase F.5 — emitted by the launcher when the host explicitly
1061 /// reports a pool window was promoted to a user-visible
1062 /// top-level window (i.e. the pool→window handoff inside
1063 /// `agentmux-cef::commands::window_pool::promote_pool_window`).
1064 /// The pool-respawn saga (launcher-side coordinator) starts on
1065 /// this event, brackets the implicit refill in
1066 /// `SagaStarted`/`SagaCompleted`, and waits for the matching
1067 /// `PoolWindowAdded` for a fresh pool label.
1068 ///
1069 /// Distinct from `PoolWindowRemoved` because that event also
1070 /// fires on pre-promote destroy (closing without promoting), where
1071 /// no refill saga should run.
1072 PoolWindowPromoted {
1073 label: String,
1074 version: u64,
1075 },
1076 /// Phase F.6 — emitted by the launcher when the host reports that
1077 /// all browser-pane HWNDs belonging to a closing top-level window
1078 /// have been reaped (`Command::ReportPanesReaped`). Step-1
1079 /// terminal signal for the window-cleanup-cascade saga.
1080 PanesReaped {
1081 label: String,
1082 version: u64,
1083 /// Phase CPD-1 — saga correlation. Mirrors `saga_id` from the
1084 /// originating `ReportPanesReaped`. `None` for organic
1085 /// reports (non-saga-driven pane drains).
1086 ///
1087 /// `#[serde(default)]` for forward-compat with pre-CPD-1
1088 /// log entries.
1089 #[serde(default)]
1090 saga_id: Option<u64>,
1091 },
1092 /// Phase F.6 — emitted by the launcher when the host reports it
1093 /// kicked off Stage 1 of the close-cascade pool drain (i.e. the
1094 /// just-closed window was the last user-visible window).
1095 /// Step-2 terminal signal (success branch) for the
1096 /// window-cleanup-cascade saga.
1097 ///
1098 /// "Drained" here means "drain initiated"; the actual pool
1099 /// teardown is async and surfaces as a series of
1100 /// `PoolWindowRemoved` events as each pool browser's
1101 /// `on_before_close` fires. The saga doesn't wait for those —
1102 /// the bracket closes when drain is *decided*, not when it
1103 /// completes.
1104 PoolDrained {
1105 label: String,
1106 version: u64,
1107 /// Phase CPD-1 — saga correlation. Mirrors `saga_id` from the
1108 /// originating `ReportPoolDrainDecision`. `None` for organic
1109 /// reports.
1110 #[serde(default)]
1111 saga_id: Option<u64>,
1112 },
1113 /// Phase F.6 — emitted by the launcher when the host reports a
1114 /// close that did NOT trigger a pool drain (other user-visible
1115 /// windows remain). Step-2 terminal signal (no-op branch) for
1116 /// the window-cleanup-cascade saga; the bracket closes
1117 /// successfully because nothing further is needed.
1118 PoolNotLast {
1119 label: String,
1120 version: u64,
1121 /// Phase CPD-1 — saga correlation. Mirrors `saga_id` from the
1122 /// originating `ReportPoolDrainDecision`. `None` for organic
1123 /// reports.
1124 #[serde(default)]
1125 saga_id: Option<u64>,
1126 },
1127 /// Phase B.5 (window_id_map step a) — launcher recorded the
1128 /// label → backend window ID mapping. Subscribers (host's
1129 /// shadow, eventually srv-side consumers) update their
1130 /// projections.
1131 BackendWindowIdRegistered {
1132 label: String,
1133 window_id: String,
1134 version: u64,
1135 },
1136 /// Phase B.5 (window_id_map step a) — launcher dropped the
1137 /// label → backend window ID mapping (window closed).
1138 BackendWindowIdUnregistered {
1139 label: String,
1140 window_id: String,
1141 version: u64,
1142 },
1143 /// Phase B.5 — sequential instance number assigned to a window
1144 /// by the launcher's authoritative registry. Numbers start at 1
1145 /// for "main" and increment for each subsequent open. Never
1146 /// reused within a launcher run. The host caches these to
1147 /// display window titles; B.5 step 2 will retire host's own
1148 /// `WindowInstanceRegistry` in favor of this stream.
1149 WindowInstanceAssigned {
1150 label: String,
1151 num: u32,
1152 version: u64,
1153 },
1154 /// Phase B.5 — instance number released (window closed). The
1155 /// launcher's authoritative registry drops the label; the slot
1156 /// is NOT reused (numbers monotonic per spec invariant: stable
1157 /// instance# across promotions / unregisters).
1158 WindowInstanceReleased {
1159 label: String,
1160 num: u32,
1161 version: u64,
1162 },
1163 /// Phase B.4 follow-up — emitted when the launcher's mirror
1164 /// disagrees with the host's reported counts. Logged at WARN
1165 /// level so operators see drift immediately. Drift in B.4 is
1166 /// a CONTRACT BUG (the host should report every state change);
1167 /// B.5 will turn drift into a hard failure once the mirror is
1168 /// authoritative.
1169 DriftDetected {
1170 kind: DriftKind,
1171 host_count: u32,
1172 mirror_count: u32,
1173 version: u64,
1174 },
1175 /// Phase B.9.1 (WRR) — emitted when the launcher's reducer
1176 /// detects a divergence between CEF browser identity (tracked
1177 /// in `state.windows` / `state.pool` via `ReportWindow*`) and
1178 /// Win32 reality (newly tracked via `ReportHwnd*`). Each variant
1179 /// of `kind` is emitted at the moment the OS event that
1180 /// surfaces it is dispatched through the reducer — there is no
1181 /// timer / heartbeat. See `docs/retro/wrr-design-2026-04-28.md`
1182 /// for the full classification table.
1183 HwndDriftDetected {
1184 kind: HwndDriftKind,
1185 /// Affected label, when known.
1186 label: Option<String>,
1187 /// Affected HWND, when known. `u64` to keep the wire format
1188 /// stable across pointer-width differences.
1189 hwnd: Option<u64>,
1190 /// Human-readable description for the launcher log + `--diag`
1191 /// output. Free-form; not parsed by any consumer.
1192 detail: String,
1193 severity: Severity,
1194 version: u64,
1195 },
1196 /// Phase B.9.2 — pure-reducer self-heal. Emitted alongside an
1197 /// `HwndDriftDetected` when the reducer is confident the bug
1198 /// happened at OPEN TIME (not from later user action) and a
1199 /// safe corrective rect is computable. The host's WRR
1200 /// subscriber listens for this and applies `SetWindowPos` on
1201 /// the UI thread. No timers — the trigger is the same OS
1202 /// event tick that surfaced the bug, so the correction lands
1203 /// before the user has time to notice the orphan window.
1204 ///
1205 /// Guard for emission (per the design, suppress over-correction):
1206 /// - The window's `mirror.foregrounded_since_open == false`
1207 /// (we never auto-move a window the user has already
1208 /// touched).
1209 /// - The reducer can compute a target rect from
1210 /// `state.monitors` (i.e., monitors are known).
1211 CorrectiveWindowMove {
1212 /// Win32 HWND to move.
1213 hwnd: u64,
1214 /// Reducer-computed target rect. Default policy:
1215 /// primary-monitor-centered at default window size.
1216 target_rect: Rect,
1217 /// Why correction fired — surfaces in host log + audit.
1218 reason: HwndDriftKind,
1219 version: u64,
1220 },
1221 /// Phase B.9.3 — saga-style corrective. Reducer detected the
1222 /// `OrphanInstance` transition (last user-visible label
1223 /// removed from `state.windows`, host still Running). Host
1224 /// subscriber handles by reaping the warm pool and calling
1225 /// `quit_message_loop()`. ADVISORY, not a hard command —
1226 /// the host's handler should re-check `state.browsers`
1227 /// before actually quitting (the user could open a new
1228 /// window in the same dispatch tick race window). Event is
1229 /// idempotent: multiple emissions are safe; reaping pool +
1230 /// quit are themselves idempotent.
1231 HostShouldQuit {
1232 version: u64,
1233 },
1234 /// Phase D.1 — reply to `Command::GetSnapshot`. Carries the
1235 /// reducer's current canonical state (the projections subscribers
1236 /// most commonly need). The `version` field is the reducer's
1237 /// `event_version` at the moment the snapshot was taken; events
1238 /// the subscriber receives AFTER this snapshot have monotonically
1239 /// greater version numbers, letting the subscriber apply them as
1240 /// deltas without missing or duplicating updates.
1241 ///
1242 /// What's included: lifecycle, windows (label + kind + parent +
1243 /// HWND-observation axis), pool labels, instance numbers,
1244 /// backend window IDs, monitor topology. What's intentionally
1245 /// excluded: `processes` (PID metadata is launcher-internal),
1246 /// `pending_hwnds` (transient reconciliation state), event log
1247 /// (Phase D.2 adds a separate snapshot-with-replay variant).
1248 Snapshot {
1249 version: u64,
1250 lifecycle: LifecyclePhase,
1251 windows: Vec<WindowSnapshot>,
1252 pool: Vec<String>,
1253 instance_registry: Vec<(String, u32)>,
1254 backend_window_ids: Vec<(String, String)>,
1255 monitors: Vec<Rect>,
1256 },
1257 /// Phase D.3 — reply to `Command::GetEvents { since }`. Carries
1258 /// the events the launcher has emitted with `version > since`,
1259 /// in ascending version order. Subscribers apply them in order
1260 /// to catch up to the launcher's live stream.
1261 ///
1262 /// The `version` field on this event is the launcher's
1263 /// `event_version` at the moment the reply was assembled — not
1264 /// the highest version inside `events`. A subscriber treating
1265 /// this as the "as-of" point for subsequent events should use
1266 /// `events.last().version` (or fall back to this `version` if
1267 /// `events` is empty) to know where to resume the live stream.
1268 ///
1269 /// Replay is best-effort: if `since` predates the launcher's
1270 /// in-memory ring, `events` contains everything still retained
1271 /// but the subscriber should expect potential missed events
1272 /// before the first one in this reply.
1273 EventList {
1274 events: Vec<Event>,
1275 version: u64,
1276 },
1277 /// Phase E.1a — emitted by the saga coordinator when a new saga
1278 /// starts. `name` is the saga's static name (e.g. "tear_off_block")
1279 /// for `--diag` output. Subscribers (renderer especially) use
1280 /// `saga_id` to start buffering subsequent events with that id
1281 /// until the matching `SagaCompleted` or `SagaFailed` arrives.
1282 ///
1283 /// `saga_id` is monotonic per launcher run, allocated by the
1284 /// coordinator. Persisting saga state across launcher restarts
1285 /// is deferred (Phase F or beyond); restart abandons in-flight
1286 /// sagas, and renderer-side timeouts handle the visible
1287 /// consequence.
1288 SagaStarted {
1289 saga_id: u64,
1290 name: String,
1291 version: u64,
1292 },
1293 /// Phase E.1a — saga ended successfully. All events with this
1294 /// `saga_id` have been emitted; subscribers can flush their
1295 /// buffers and apply the changes atomically.
1296 SagaCompleted {
1297 saga_id: u64,
1298 version: u64,
1299 },
1300 /// Phase E.1a — saga ended in failure. `reason` is operator-
1301 /// readable; if compensation actions were issued, they appear
1302 /// as ordinary commands/events on the bus before this event.
1303 /// Renderers should discard their buffer for this `saga_id` —
1304 /// no atomic apply.
1305 SagaFailed {
1306 saga_id: u64,
1307 reason: String,
1308 version: u64,
1309 },
1310 /// Phase E.1b — srv-side snapshot reply. Phase E.2 populates
1311 /// `workspaces` (canonical Vec); subsequent sub-phases add
1312 /// `tabs`, `blocks`, `layouts`, etc.
1313 ///
1314 /// `version` is the srv reducer's `event_version` at snapshot
1315 /// time, monotonically distinct from prior srv events so
1316 /// subscribers know the "as-of" point for delta application.
1317 SrvSnapshot {
1318 version: u64,
1319 lifecycle: LifecyclePhase,
1320 /// Phase E.2 — sorted list of workspaces in the reducer's
1321 /// canonical state. (id, name) pairs for compactness; full
1322 /// state available via per-event subscription. Empty before
1323 /// E.2 lands.
1324 ///
1325 /// `#[serde(default)]` so old `srv-events.log` entries
1326 /// written by E.1b (which had no `workspaces` field) still
1327 /// deserialize when later sub-phases add bootstrap-replay
1328 /// from the on-disk log. Same forward-compat treatment will
1329 /// apply to E.3's `blocks`, etc. (reagent P2 #611.)
1330 #[serde(default)]
1331 workspaces: Vec<(String, String)>,
1332 /// Phase E.2b — sorted list of tabs in the reducer's canonical
1333 /// state. `(tab_id, workspace_id, name)` triples for
1334 /// compactness. `#[serde(default)]` for forward-compat with
1335 /// pre-E.2b log entries.
1336 #[serde(default)]
1337 tabs: Vec<(String, String, String)>,
1338 /// Phase E.2b — sorted list of `(workspace_id, active_tab_id)`
1339 /// pairs for workspaces that have an active tab set.
1340 /// Workspaces with no active tab are omitted.
1341 #[serde(default)]
1342 active_tabs: Vec<(String, String)>,
1343 /// Phase E.3 — sorted list of blocks in the reducer's
1344 /// canonical state. `(block_id, tab_id)` pairs for
1345 /// compactness. `#[serde(default)]` for forward-compat with
1346 /// pre-E.3 log entries.
1347 #[serde(default)]
1348 blocks: Vec<(String, String)>,
1349 },
1350 /// Phase E.2 — workspace was created. Carries the assigned
1351 /// `oid` and `name` so subscribers (renderer, persist) can
1352 /// apply the change without further round-trips.
1353 WorkspaceCreated {
1354 workspace_id: String,
1355 name: String,
1356 version: u64,
1357 },
1358 /// Phase E.2 — workspace was deleted.
1359 WorkspaceDeleted {
1360 workspace_id: String,
1361 /// Every block_id cascaded out with this workspace (every block in
1362 /// every tab it contained) — `TabDeleted`/`WorkspaceDeleted` never
1363 /// emitted per-block events (see `reducer/tab.rs::handle_delete_tab`),
1364 /// so this is the host's only signal to tear down a browser-pane
1365 /// renderer whose tab/workspace was deleted while unloaded/inactive
1366 /// (issue #2218, B.4). `#[serde(default)]` so replaying an old
1367 /// saga-log entry (predating this field) still deserializes.
1368 #[serde(default)]
1369 block_ids: Vec<String>,
1370 version: u64,
1371 },
1372 /// Phase E.2b — tab was created inside a workspace. Carries the
1373 /// assigned `tab_id` and parent `workspace_id` so subscribers can
1374 /// place it in the correct workspace's tab list.
1375 TabCreated {
1376 workspace_id: String,
1377 tab_id: String,
1378 name: String,
1379 version: u64,
1380 },
1381 /// Phase E.2b — tab was deleted from a workspace.
1382 TabDeleted {
1383 workspace_id: String,
1384 tab_id: String,
1385 /// Every block_id cascaded out with this tab. See `WorkspaceDeleted`'s
1386 /// `block_ids` doc — same rationale (issue #2218, B.4).
1387 #[serde(default)]
1388 block_ids: Vec<String>,
1389 version: u64,
1390 },
1391 /// Phase E.2b — a workspace's active tab changed. `tab_id: None`
1392 /// means the workspace has no active tab (e.g., last tab deleted).
1393 ActiveTabChanged {
1394 workspace_id: String,
1395 tab_id: Option<String>,
1396 version: u64,
1397 },
1398 /// Phase E.2c.3b — a tab was reordered within its workspace's
1399 /// `tab_ids`. Subscribers should rewrite the workspace's tab
1400 /// order to match the reducer's authoritative list (which lives
1401 /// in the snapshot's `tabs` field; subscribers can also recompute
1402 /// from `tab_id` + `new_index` against their last-known order).
1403 TabReordered {
1404 workspace_id: String,
1405 tab_id: String,
1406 new_index: u32,
1407 version: u64,
1408 },
1409 /// Phase E.5 — srv-side window→workspace mapping established.
1410 /// Distinct from launcher's `WindowOpened` (which tracks CEF
1411 /// window lifecycle). Subscribers update their view of "which
1412 /// workspace is each window showing."
1413 SrvWindowOpened {
1414 window_id: String,
1415 workspace_id: String,
1416 version: u64,
1417 },
1418 /// Phase E.5 — srv-side window mapping removed. Distinct from
1419 /// launcher's `WindowClosed`.
1420 SrvWindowClosed {
1421 window_id: String,
1422 version: u64,
1423 },
1424 /// Phase E.5 — a window now points at a different workspace
1425 /// (used by the SwitchWorkspace command + the CloseWindow saga
1426 /// when reassigning during cleanup).
1427 SrvWindowWorkspaceChanged {
1428 window_id: String,
1429 workspace_id: String,
1430 version: u64,
1431 },
1432 /// Phase E.5.3 — workspace's `tab_ids` was replaced wholesale.
1433 /// Subscribers should rewrite the persistent `Workspace.tabids`
1434 /// to match the new list (preserving `pinnedtabids` separately —
1435 /// pinning is a Waveterm legacy and not in scope here).
1436 TabsReorderedBulk {
1437 workspace_id: String,
1438 tab_ids: Vec<String>,
1439 version: u64,
1440 },
1441 /// Phase E.5.3 — workspace was renamed.
1442 WorkspaceRenamed {
1443 workspace_id: String,
1444 name: String,
1445 version: u64,
1446 },
1447 /// Phase E.5.3 — tab was renamed.
1448 TabRenamed {
1449 tab_id: String,
1450 name: String,
1451 version: u64,
1452 },
1453 /// Phase E.5.3 — meta-patch applied to a workspace. Carries the
1454 /// patch (NOT the resolved meta map); subscribers merge against
1455 /// the workspace's existing meta. This shape lets sagas inspect
1456 /// what changed without needing the prior state.
1457 WorkspaceMetaUpdated {
1458 workspace_id: String,
1459 meta_patch: serde_json::Value,
1460 version: u64,
1461 },
1462 /// Phase E.5.x (issue #855) — meta-patch applied to a window's
1463 /// `meta` map. Same shape as `WorkspaceMetaUpdated`. Persist
1464 /// subscriber merges into mstore; MuxObjUpdate bridge translates
1465 /// to a frontend `waveobj:update` broadcast.
1466 WindowMetaUpdated {
1467 window_id: String,
1468 meta_patch: serde_json::Value,
1469 version: u64,
1470 },
1471 /// Phase E.5.3 — meta-patch applied to a tab.
1472 TabMetaUpdated {
1473 tab_id: String,
1474 meta_patch: serde_json::Value,
1475 version: u64,
1476 },
1477 /// Phase E.5.3 — meta-patch applied to a block.
1478 BlockMetaUpdated {
1479 block_id: String,
1480 meta_patch: serde_json::Value,
1481 version: u64,
1482 },
1483 /// Phase E.5.5 — a tab was moved from one workspace to another.
1484 /// Subscribers should rewrite both workspaces' `tabids` and the
1485 /// tab's `parentoref`/`workspaceid` to match the reducer's view.
1486 /// `dst_index` reflects the position in `dst_workspace_id.tab_ids`
1487 /// AFTER insertion (already clamped by the reducer).
1488 /// Carries enough information to re-derive the new state without
1489 /// reading the reducer (subscribers replay events post-Lagged).
1490 TabMoved {
1491 tab_id: String,
1492 src_workspace_id: String,
1493 dst_workspace_id: String,
1494 dst_index: u32,
1495 /// The source workspace's new `active_tab_id` after the move,
1496 /// or `None` if the source has no remaining tabs. Subscribers
1497 /// rewrite the source's `activetabid` to match.
1498 new_src_active_tab_id: Option<String>,
1499 /// The destination workspace's new `active_tab_id` after the
1500 /// move. Wcore behavior (`move_tab_to_workspace`) was to
1501 /// always set the moved tab as dst's active; the reducer
1502 /// mirrors that. `None` means "do not change dst.active_tab_id"
1503 /// — reserved for future flows where the moved tab shouldn't
1504 /// steal focus. Codex P2 #621.
1505 ///
1506 /// `#[serde(default)]` for forward-compat with pre-PR3
1507 /// `srv-events.log` entries (none in production yet, but the
1508 /// pattern is established).
1509 #[serde(default)]
1510 new_dst_active_tab_id: Option<String>,
1511 version: u64,
1512 },
1513 /// Phase E.5.5 — a block was moved from one tab to another (or
1514 /// repositioned within the same tab). Subscribers update both
1515 /// tabs' `blockids` and the block's `parentoref`. `dst_index`
1516 /// reflects post-insertion position.
1517 BlockMoved {
1518 block_id: String,
1519 src_tab_id: String,
1520 dst_tab_id: String,
1521 dst_index: u32,
1522 version: u64,
1523 },
1524 /// Phase E.3 — block was created inside a tab.
1525 BlockCreated {
1526 tab_id: String,
1527 block_id: String,
1528 /// Phase E.2c.4 — meta carried through from
1529 /// `Command::CreateBlock`. The persist subscriber writes
1530 /// the Block row with this meta map. `#[serde(default)]` for
1531 /// forward-compat with pre-E.2c.4 log entries.
1532 #[serde(default)]
1533 meta: serde_json::Value,
1534 version: u64,
1535 },
1536 /// Phase E.3 — block was deleted from a tab.
1537 BlockDeleted {
1538 tab_id: String,
1539 block_id: String,
1540 version: u64,
1541 },
1542 /// Phase E.4 (Option A) — a tab's `focusednodeid` changed via the
1543 /// reducer. Subscribers (persist, eventually the renderer's E.6
1544 /// dispatcher) update the tab's layout view. Empty `node_id`
1545 /// reflects a clear.
1546 FocusedNodeChanged {
1547 tab_id: String,
1548 node_id: String,
1549 version: u64,
1550 },
1551 /// Phase E.4 (Option A) — a tab's `magnifiednodeid` changed via
1552 /// the reducer. Empty `node_id` reflects a clear (toggle-off).
1553 MagnifiedNodeChanged {
1554 tab_id: String,
1555 node_id: String,
1556 version: u64,
1557 },
1558
1559 // ── Phase E.4.B — Layout tree events ───────────────────────────────
1560 //
1561 // Mirror of the 11 layout commands (§6 of the formal spec). Each
1562 // event carries a `correlation_id` matching its command and a
1563 // `version` for sequencing. The persist subscriber applies the same
1564 // tree mutation as the reducer used, making applies idempotent.
1565 //
1566 // See docs/specs/srv-phase-e4b-formal-spec-2026-05-03.md §6.
1567
1568 LayoutNodeInserted {
1569 tab_id: String,
1570 node: crate::LayoutNode,
1571 parent_id: Option<String>,
1572 index: Option<usize>,
1573 correlation_id: String,
1574 version: u64,
1575 },
1576 LayoutNodeInsertedAtIndex {
1577 tab_id: String,
1578 /// The reducer's resulting tree (post-op, post-balance) for the
1579 /// persist subscriber to write to `db_layout` — single-writer, no
1580 /// algebra re-run (SPEC_STRONG_REDUCER_AUTHORITY_LAYOUT). `None` =
1581 /// empty tree. `#[serde(default)]` for forward-compat with senders
1582 /// that predate the field.
1583 #[serde(default)]
1584 new_tree: Option<crate::LayoutNode>,
1585 node: crate::LayoutNode,
1586 index_arr: Vec<usize>,
1587 correlation_id: String,
1588 version: u64,
1589 },
1590 LayoutNodeDeleted {
1591 tab_id: String,
1592 node_id: String,
1593 /// The reducer's resulting tree (post-delete, post-collapse) for
1594 /// the persist subscriber to write to `db_layout` — single-writer,
1595 /// no algebra re-run (SPEC_STRONG_REDUCER_AUTHORITY_LAYOUT /
1596 /// SPEC_864 site #6). Unlike the other structural events, a delete
1597 /// CAN legitimately empty the tree (root-orphan case) — `None`
1598 /// alone is ambiguous with a version-skewed pre-change sender, so
1599 /// `tree_cleared` below disambiguates. `#[serde(default)]` for
1600 /// forward-compat.
1601 #[serde(default)]
1602 new_tree: Option<crate::LayoutNode>,
1603 /// True when the delete legitimately emptied the tree (the deleted
1604 /// node WAS the root). The persist subscriber clears `db_layout`'s
1605 /// rootnode only when `new_tree.is_none() && tree_cleared` — a bare
1606 /// `None` (old sender / replayed pre-change JSON) is ignored rather
1607 /// than erasing the persisted layout (same skew concern as codex
1608 /// P2 on #1883). `#[serde(default)]` = false for old JSON.
1609 #[serde(default)]
1610 tree_cleared: bool,
1611 /// True if the deleted node was the focused one (subscribers may
1612 /// need to refocus).
1613 was_focused: bool,
1614 /// True if the deleted node was the magnified one (subscribers
1615 /// may need to re-magnify or clear their magnification UI).
1616 /// Reagent P1 PR #715 round 3: reducer was clearing
1617 /// `magnified_node_id` internally but not reporting it.
1618 ///
1619 /// `#[serde(default)]` for forward-compat with replay /
1620 /// version-skewed senders that emit pre-round-3
1621 /// `LayoutNodeDeleted` JSON without this field (codex P2 PR
1622 /// #715 round 5).
1623 #[serde(default)]
1624 was_magnified: bool,
1625 correlation_id: String,
1626 version: u64,
1627 },
1628 LayoutNodeMoved {
1629 tab_id: String,
1630 /// Resulting tree for the persist subscriber (see LayoutNodeInsertedAtIndex).
1631 #[serde(default)]
1632 new_tree: Option<crate::LayoutNode>,
1633 node_id: String,
1634 new_parent_id: String,
1635 index: usize,
1636 correlation_id: String,
1637 version: u64,
1638 },
1639 LayoutNodesSwapped {
1640 tab_id: String,
1641 /// Resulting tree for the persist subscriber (see LayoutNodeInsertedAtIndex).
1642 #[serde(default)]
1643 new_tree: Option<crate::LayoutNode>,
1644 node1_id: String,
1645 node2_id: String,
1646 correlation_id: String,
1647 version: u64,
1648 },
1649 LayoutNodesResized {
1650 tab_id: String,
1651 /// Resulting tree for the persist subscriber (see LayoutNodeInsertedAtIndex).
1652 #[serde(default)]
1653 new_tree: Option<crate::LayoutNode>,
1654 ops: Vec<crate::ResizeOp>,
1655 correlation_id: String,
1656 version: u64,
1657 },
1658 LayoutNodeReplaced {
1659 tab_id: String,
1660 /// Resulting tree for the persist subscriber (see LayoutNodeInsertedAtIndex).
1661 #[serde(default)]
1662 new_tree: Option<crate::LayoutNode>,
1663 target_id: String,
1664 new_node: crate::LayoutNode,
1665 correlation_id: String,
1666 version: u64,
1667 },
1668 LayoutSplitHorizontalApplied {
1669 tab_id: String,
1670 /// Resulting tree for the persist subscriber (see LayoutNodeInsertedAtIndex).
1671 #[serde(default)]
1672 new_tree: Option<crate::LayoutNode>,
1673 target_id: String,
1674 new_node: crate::LayoutNode,
1675 position: crate::SplitPosition,
1676 correlation_id: String,
1677 version: u64,
1678 },
1679 LayoutSplitVerticalApplied {
1680 tab_id: String,
1681 /// Resulting tree for the persist subscriber (see LayoutNodeInsertedAtIndex).
1682 #[serde(default)]
1683 new_tree: Option<crate::LayoutNode>,
1684 target_id: String,
1685 new_node: crate::LayoutNode,
1686 position: crate::SplitPosition,
1687 correlation_id: String,
1688 version: u64,
1689 },
1690 LayoutCleared {
1691 tab_id: String,
1692 correlation_id: String,
1693 version: u64,
1694 },
1695 /// SPEC_864 Phase 2 — `slices` mirrors the command's field: when
1696 /// present, the persist subscriber also writes leaforder / focus /
1697 /// magnify / pendingbackendactions (REPLACE semantics). `None` =
1698 /// tree-only replace; those columns stay untouched.
1699 LayoutTreeReplaced {
1700 tab_id: String,
1701 new_tree: Option<crate::LayoutNode>,
1702 correlation_id: String,
1703 #[serde(default, skip_serializing_if = "Option::is_none")]
1704 slices: Option<crate::LayoutClientSlices>,
1705 version: u64,
1706 },
1707
1708 /// SPEC_864 Phase 4 — actions appended to a tab's
1709 /// `pendingbackendactions` queue through the reducer (the
1710 /// backend→frontend "please mutate your layout tree" channel).
1711 /// `actions` is a JSON array of `LayoutActionData` objects, carried
1712 /// as a raw value the same way `LayoutClientSlices` carries the
1713 /// queue: the reducer does NOT model the queue in `TabRecord`
1714 /// (pass-through), and the persist subscriber APPENDS these to the
1715 /// existing `LayoutState.pendingbackendactions` (append semantics —
1716 /// unlike the slices REPLACE semantics of a frontend push, which is
1717 /// how the frontend acks/clears the queue).
1718 LayoutBackendActionsQueued {
1719 tab_id: String,
1720 actions: serde_json::Value,
1721 correlation_id: String,
1722 version: u64,
1723 },
1724
1725 /// Phase CPD-1 (cross-process dispatch) — emitted by the launcher
1726 /// when the host reports that a saga-issued action failed
1727 /// (`Command::ReportSagaActionFailed { saga_id, reason }`). The
1728 /// saga coordinator's bus loop will (in CPD-3) treat this as a
1729 /// terminal signal for the matching saga, emitting
1730 /// `Event::SagaFailed` and removing it from the in-flight
1731 /// registry. CPD-1 ships the wire shape only; no producer
1732 /// (host) and no consumer (saga coordinator) are wired yet.
1733 SagaActionFailed {
1734 saga_id: u64,
1735 reason: String,
1736 version: u64,
1737 },
1738}
1739
1740/// Phase CPD-1 (cross-process dispatch) — envelope enum for frames
1741/// sent over the launcher → host pipe direction. Today the host's
1742/// read loop only expects `Event` JSON; CPD-2 extends the read loop
1743/// to recognize this tagged union and dispatch by `kind`:
1744///
1745/// * `event` → existing event-handling code (state sync from
1746/// launcher reducer broadcasts).
1747/// * `command` → new command-handling code (saga-issued actions).
1748///
1749/// Newline-delimited JSON, one frame per line. `#[serde(tag = "kind",
1750/// rename_all = "snake_case")]` so the wire shape is e.g.
1751/// `{"kind":"event","event":"pool_window_added",...}` or
1752/// `{"kind":"command","cmd":"spawn_pool_window","saga_id":42}`.
1753///
1754/// Schema-only in CPD-1: introduced now so the launcher's host-pipe
1755/// writer (CPD-2) and the host's read loop (CPD-2/CPD-3) can be
1756/// built against a stable wire envelope without further schema
1757/// churn.
1758#[derive(Debug, Clone, Serialize, Deserialize)]
1759#[serde(tag = "kind", rename_all = "snake_case")]
1760pub enum HostFrame {
1761 /// Wraps a launcher-emitted Event being pushed down to the host
1762 /// (existing fanout path; CPD-2 refactors the writer to go
1763 /// through this envelope).
1764 Event(Event),
1765 /// Wraps a saga-issued Command being dispatched from the launcher
1766 /// to the host. Carries `saga_id` inside the Command payload.
1767 Command(Command),
1768}
1769
1770/// Phase D.1 — serializable view of one window in the launcher
1771/// reducer's canonical state. Maps 1:1 to the launcher's internal
1772/// `WindowMirror` minus `opened_at` (which is launcher-local clock
1773/// data not meaningful to subscribers).
1774#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1775pub struct WindowSnapshot {
1776 pub label: String,
1777 pub kind: WindowKind,
1778 pub parent_label: Option<String>,
1779 pub hwnd: Option<u64>,
1780 pub visible: bool,
1781 pub iconic: bool,
1782 pub last_rect: Option<Rect>,
1783 pub foregrounded_since_open: bool,
1784}
1785
1786/// Phase B.9.1 — six classes of CEF↔Win32 disagreement the reducer
1787/// can detect at event-dispatch time. See the WRR design doc for
1788/// the per-kind triggering Command and reducer action.
1789#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1790#[serde(rename_all = "snake_case")]
1791pub enum HwndDriftKind {
1792 /// `state.windows` has a label whose `hwnd` field never got
1793 /// populated, AND a follow-up event arrived that should have
1794 /// reconciled it. CEF says open, Win32 has no matching HWND.
1795 BrowserWithoutHwnd,
1796 /// HWND in the host's report doesn't map to any
1797 /// `state.windows` / `state.pool` label. Win32 has it, CEF
1798 /// didn't open it. The "stray taskbar" case.
1799 HwndWithoutBrowser,
1800 /// HWND for a known label was never SHOWN (no foreground
1801 /// event) since open and a subsequent state transition has
1802 /// elapsed. User can't see it.
1803 HiddenSinceOpen,
1804 /// Window rect doesn't intersect any monitor in
1805 /// `state.monitors`. Off-screen orphan.
1806 OffMonitor,
1807 /// `ReportHwndDestroyed` arrived without a preceding
1808 /// `ReportWindowClosed` for the matching label. Renderer
1809 /// crashed, took the HWND with it.
1810 OrphanDestroy,
1811 /// `ReportWindowClosed` arrived, but subsequent OS events for
1812 /// the HWND keep firing (it never went away on the Win32 side).
1813 LingeringHwnd,
1814 /// Phase B.9.3 — host process is alive and registered, but
1815 /// `state.windows` just transitioned to empty. The host's own
1816 /// close path doesn't reap the warm pool when the last
1817 /// user-visible window closes (pool windows hold
1818 /// `state.browsers` non-empty, so `quit_message_loop` never
1819 /// fires). The launcher's reducer is the only place that knows
1820 /// "all user-meaningful labels are gone" cleanly, so we
1821 /// surface the signal here. Paired with `Event::HostShouldQuit`
1822 /// emitted in the same reducer call (see B.9.3 saga).
1823 OrphanInstance,
1824}
1825
1826/// Phase B.9.1 — drift severity, assigned per drift kind by the
1827/// launcher's `severity_for` (`crates/launcher/src/wrr/mod.rs`). The
1828/// operator-tunable severity floor the B.9 design described was never
1829/// built: nothing filters events by severity.
1830#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, PartialOrd, Ord)]
1831#[serde(rename_all = "lowercase")]
1832pub enum Severity {
1833 Info,
1834 Warn,
1835 Error,
1836}
1837
1838/// Coarse-grained launcher state. Spec §4: Starting → Running →
1839/// Quitting → Dead, no other transitions allowed. The reducer in
1840/// agentmux-launcher::reducer enforces this; a violation panics
1841/// (Job Object reaps via OS).
1842#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1843#[serde(rename_all = "lowercase")]
1844pub enum LifecyclePhase {
1845 /// Initial state; launcher has not yet seen the host register.
1846 Starting,
1847 /// Host has registered and the canonical state is being
1848 /// maintained. Steady state.
1849 Running,
1850 /// Quit { reason } received, ack outstanding to subscribers.
1851 /// Phase B.3 keeps this state-shape only — the actual Quit
1852 /// command lands in a later sub-PR.
1853 Quitting,
1854 /// Cleanup done; launcher about to exit. Transient.
1855 Dead,
1856}
1857
1858/// Phase B.4 follow-up — which mirror diverged. Tagged so subscribers
1859/// can route alerts (windows-drift might page; pool-drift is more
1860/// ephemeral since the pool turns over fast).
1861#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1862#[serde(rename_all = "snake_case")]
1863pub enum DriftKind {
1864 Windows,
1865 Pool,
1866}
1867
1868/// Discriminant for `Event::Error` — keeps clients structured against
1869/// failure modes without parsing message text.
1870#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1871#[serde(rename_all = "snake_case")]
1872pub enum ErrorCode {
1873 /// Couldn't deserialize the line into a Command.
1874 InvalidCommand,
1875 /// Command sent before Register.
1876 NotRegistered,
1877 /// Register sent twice on the same connection.
1878 AlreadyRegistered,
1879}
1880
1881#[cfg(test)]
1882mod tests {
1883 use super::*;
1884
1885 #[test]
1886 fn command_register_roundtrip() {
1887 let c = Command::Register {
1888 kind: ClientKind::Host,
1889 pid: 12345,
1890 version: "0.33.449".into(),
1891 };
1892 let json = serde_json::to_string(&c).unwrap();
1893 assert!(json.contains("\"cmd\":\"register\""));
1894 assert!(json.contains("\"kind\":\"host\""));
1895 let back: Command = serde_json::from_str(&json).unwrap();
1896 if let Command::Register { kind, pid, version } = back {
1897 assert_eq!(kind, ClientKind::Host);
1898 assert_eq!(pid, 12345);
1899 assert_eq!(version, "0.33.449");
1900 } else {
1901 panic!("wrong variant");
1902 }
1903 }
1904
1905 #[test]
1906 fn event_registered_roundtrip() {
1907 let e = Event::Registered {
1908 client_id: 1,
1909 launcher_pid: 9999,
1910 launcher_version: "0.33.449".into(),
1911 version: 42,
1912 };
1913 let json = serde_json::to_string(&e).unwrap();
1914 assert!(json.contains("\"event\":\"registered\""));
1915 let back: Event = serde_json::from_str(&json).unwrap();
1916 if let Event::Registered { client_id, version, .. } = back {
1917 assert_eq!(client_id, 1);
1918 assert_eq!(version, 42);
1919 } else {
1920 panic!("wrong variant");
1921 }
1922 }
1923
1924 #[test]
1925 fn unknown_cmd_fails_to_deserialize() {
1926 let json = r#"{"cmd":"banana"}"#;
1927 let r: Result<Command, _> = serde_json::from_str(json);
1928 assert!(r.is_err());
1929 }
1930
1931 // ---------- Phase CPD-1 — saga_id schema additions ----------
1932
1933 #[test]
1934 fn cpd1_spawn_pool_window_round_trip_with_saga_id() {
1935 let c = Command::SpawnPoolWindow { saga_id: 42 };
1936 let json = serde_json::to_string(&c).unwrap();
1937 assert!(json.contains("\"cmd\":\"spawn_pool_window\""));
1938 assert!(json.contains("\"saga_id\":42"));
1939 let back: Command = serde_json::from_str(&json).unwrap();
1940 match back {
1941 Command::SpawnPoolWindow { saga_id } => assert_eq!(saga_id, 42),
1942 other => panic!("wrong variant: {:?}", other),
1943 }
1944 }
1945
1946 #[test]
1947 fn cpd1_spawn_pool_window_forward_compat_default_zero() {
1948 // Pre-CPD-1 hosts emit the bare command with no `saga_id`
1949 // field; serde_default fills in 0.
1950 let json = r#"{"cmd":"spawn_pool_window"}"#;
1951 let back: Command = serde_json::from_str(json).unwrap();
1952 match back {
1953 Command::SpawnPoolWindow { saga_id } => assert_eq!(saga_id, 0),
1954 other => panic!("wrong variant: {:?}", other),
1955 }
1956 }
1957
1958 #[test]
1959 fn cpd1_reap_panes_round_trip_with_saga_id() {
1960 let c = Command::ReapPanes {
1961 label: "main".into(),
1962 saga_id: 7,
1963 };
1964 let json = serde_json::to_string(&c).unwrap();
1965 assert!(json.contains("\"cmd\":\"reap_panes\""));
1966 assert!(json.contains("\"saga_id\":7"));
1967 let back: Command = serde_json::from_str(&json).unwrap();
1968 match back {
1969 Command::ReapPanes { label, saga_id } => {
1970 assert_eq!(label, "main");
1971 assert_eq!(saga_id, 7);
1972 }
1973 other => panic!("wrong variant: {:?}", other),
1974 }
1975 }
1976
1977 #[test]
1978 fn cpd1_reap_panes_forward_compat_default_zero() {
1979 let json = r#"{"cmd":"reap_panes","label":"main"}"#;
1980 let back: Command = serde_json::from_str(json).unwrap();
1981 match back {
1982 Command::ReapPanes { label, saga_id } => {
1983 assert_eq!(label, "main");
1984 assert_eq!(saga_id, 0);
1985 }
1986 other => panic!("wrong variant: {:?}", other),
1987 }
1988 }
1989
1990 #[test]
1991 fn cpd1_drain_pool_if_last_round_trip_with_saga_id() {
1992 let c = Command::DrainPoolIfLast {
1993 label: "main".into(),
1994 saga_id: 13,
1995 };
1996 let json = serde_json::to_string(&c).unwrap();
1997 assert!(json.contains("\"cmd\":\"drain_pool_if_last\""));
1998 assert!(json.contains("\"saga_id\":13"));
1999 let back: Command = serde_json::from_str(&json).unwrap();
2000 match back {
2001 Command::DrainPoolIfLast { label, saga_id } => {
2002 assert_eq!(label, "main");
2003 assert_eq!(saga_id, 13);
2004 }
2005 other => panic!("wrong variant: {:?}", other),
2006 }
2007 }
2008
2009 #[test]
2010 fn cpd1_drain_pool_if_last_forward_compat_default_zero() {
2011 let json = r#"{"cmd":"drain_pool_if_last","label":"main"}"#;
2012 let back: Command = serde_json::from_str(json).unwrap();
2013 match back {
2014 Command::DrainPoolIfLast { label, saga_id } => {
2015 assert_eq!(label, "main");
2016 assert_eq!(saga_id, 0);
2017 }
2018 other => panic!("wrong variant: {:?}", other),
2019 }
2020 }
2021
2022 #[test]
2023 fn cpd1_report_pool_window_added_round_trip_with_some() {
2024 let c = Command::ReportPoolWindowAdded {
2025 label: "pool-xyz".into(),
2026 saga_id: Some(99),
2027 };
2028 let json = serde_json::to_string(&c).unwrap();
2029 assert!(json.contains("\"saga_id\":99"));
2030 let back: Command = serde_json::from_str(&json).unwrap();
2031 match back {
2032 Command::ReportPoolWindowAdded { label, saga_id } => {
2033 assert_eq!(label, "pool-xyz");
2034 assert_eq!(saga_id, Some(99));
2035 }
2036 other => panic!("wrong variant: {:?}", other),
2037 }
2038 }
2039
2040 #[test]
2041 fn cpd1_report_pool_window_added_forward_compat_default_none() {
2042 // Pre-CPD-1 hosts omit `saga_id` entirely; deserializes to
2043 // `None`. JSON with explicit null is also valid.
2044 let json = r#"{"cmd":"report_pool_window_added","label":"pool-xyz"}"#;
2045 let back: Command = serde_json::from_str(json).unwrap();
2046 match back {
2047 Command::ReportPoolWindowAdded { label, saga_id } => {
2048 assert_eq!(label, "pool-xyz");
2049 assert_eq!(saga_id, None);
2050 }
2051 other => panic!("wrong variant: {:?}", other),
2052 }
2053 }
2054
2055 #[test]
2056 fn cpd1_report_panes_reaped_round_trip_with_some() {
2057 let c = Command::ReportPanesReaped {
2058 label: "main".into(),
2059 saga_id: Some(11),
2060 };
2061 let json = serde_json::to_string(&c).unwrap();
2062 let back: Command = serde_json::from_str(&json).unwrap();
2063 match back {
2064 Command::ReportPanesReaped { label, saga_id } => {
2065 assert_eq!(label, "main");
2066 assert_eq!(saga_id, Some(11));
2067 }
2068 other => panic!("wrong variant: {:?}", other),
2069 }
2070 }
2071
2072 #[test]
2073 fn cpd1_report_panes_reaped_forward_compat_default_none() {
2074 let json = r#"{"cmd":"report_panes_reaped","label":"main"}"#;
2075 let back: Command = serde_json::from_str(json).unwrap();
2076 match back {
2077 Command::ReportPanesReaped { label, saga_id } => {
2078 assert_eq!(label, "main");
2079 assert_eq!(saga_id, None);
2080 }
2081 other => panic!("wrong variant: {:?}", other),
2082 }
2083 }
2084
2085 #[test]
2086 fn cpd1_report_pool_drain_decision_round_trip_with_some() {
2087 let c = Command::ReportPoolDrainDecision {
2088 label: "main".into(),
2089 was_last: true,
2090 saga_id: Some(101),
2091 };
2092 let json = serde_json::to_string(&c).unwrap();
2093 let back: Command = serde_json::from_str(&json).unwrap();
2094 match back {
2095 Command::ReportPoolDrainDecision {
2096 label,
2097 was_last,
2098 saga_id,
2099 } => {
2100 assert_eq!(label, "main");
2101 assert!(was_last);
2102 assert_eq!(saga_id, Some(101));
2103 }
2104 other => panic!("wrong variant: {:?}", other),
2105 }
2106 }
2107
2108 #[test]
2109 fn cpd1_report_pool_drain_decision_forward_compat_default_none() {
2110 let json = r#"{"cmd":"report_pool_drain_decision","label":"main","was_last":false}"#;
2111 let back: Command = serde_json::from_str(json).unwrap();
2112 match back {
2113 Command::ReportPoolDrainDecision {
2114 label,
2115 was_last,
2116 saga_id,
2117 } => {
2118 assert_eq!(label, "main");
2119 assert!(!was_last);
2120 assert_eq!(saga_id, None);
2121 }
2122 other => panic!("wrong variant: {:?}", other),
2123 }
2124 }
2125
2126 #[test]
2127 fn cpd1_report_saga_action_failed_round_trip() {
2128 let c = Command::ReportSagaActionFailed {
2129 saga_id: 55,
2130 reason: "window not found".into(),
2131 };
2132 let json = serde_json::to_string(&c).unwrap();
2133 assert!(json.contains("\"cmd\":\"report_saga_action_failed\""));
2134 assert!(json.contains("\"saga_id\":55"));
2135 let back: Command = serde_json::from_str(&json).unwrap();
2136 match back {
2137 Command::ReportSagaActionFailed { saga_id, reason } => {
2138 assert_eq!(saga_id, 55);
2139 assert_eq!(reason, "window not found");
2140 }
2141 other => panic!("wrong variant: {:?}", other),
2142 }
2143 }
2144
2145 #[test]
2146 fn cpd1_event_pool_window_added_round_trip_with_saga_id() {
2147 let e = Event::PoolWindowAdded {
2148 label: "pool-xyz".into(),
2149 version: 7,
2150 saga_id: Some(42),
2151 };
2152 let json = serde_json::to_string(&e).unwrap();
2153 assert!(json.contains("\"saga_id\":42"));
2154 let back: Event = serde_json::from_str(&json).unwrap();
2155 assert_eq!(back, e);
2156 }
2157
2158 #[test]
2159 fn cpd1_event_panes_reaped_forward_compat_default_none() {
2160 // Pre-CPD-1 launcher-events.log entries lack `saga_id`.
2161 let json = r#"{"event":"panes_reaped","label":"main","version":1}"#;
2162 let back: Event = serde_json::from_str(json).unwrap();
2163 match back {
2164 Event::PanesReaped {
2165 label,
2166 version,
2167 saga_id,
2168 } => {
2169 assert_eq!(label, "main");
2170 assert_eq!(version, 1);
2171 assert_eq!(saga_id, None);
2172 }
2173 other => panic!("wrong variant: {:?}", other),
2174 }
2175 }
2176
2177 #[test]
2178 fn cpd1_event_pool_drained_round_trip_with_saga_id() {
2179 let e = Event::PoolDrained {
2180 label: "main".into(),
2181 version: 9,
2182 saga_id: Some(3),
2183 };
2184 let json = serde_json::to_string(&e).unwrap();
2185 let back: Event = serde_json::from_str(&json).unwrap();
2186 assert_eq!(back, e);
2187 }
2188
2189 #[test]
2190 fn cpd1_event_pool_not_last_forward_compat_default_none() {
2191 let json = r#"{"event":"pool_not_last","label":"main","version":3}"#;
2192 let back: Event = serde_json::from_str(json).unwrap();
2193 match back {
2194 Event::PoolNotLast {
2195 label,
2196 version,
2197 saga_id,
2198 } => {
2199 assert_eq!(label, "main");
2200 assert_eq!(version, 3);
2201 assert_eq!(saga_id, None);
2202 }
2203 other => panic!("wrong variant: {:?}", other),
2204 }
2205 }
2206
2207 #[test]
2208 fn cpd1_event_saga_action_failed_round_trip() {
2209 let e = Event::SagaActionFailed {
2210 saga_id: 12,
2211 reason: "host pipe broken".into(),
2212 version: 100,
2213 };
2214 let json = serde_json::to_string(&e).unwrap();
2215 assert!(json.contains("\"event\":\"saga_action_failed\""));
2216 assert!(json.contains("\"saga_id\":12"));
2217 let back: Event = serde_json::from_str(&json).unwrap();
2218 assert_eq!(back, e);
2219 }
2220
2221 #[test]
2222 fn cpd1_host_frame_event_round_trip() {
2223 let frame = HostFrame::Event(Event::PoolWindowAdded {
2224 label: "pool-xyz".into(),
2225 version: 5,
2226 saga_id: Some(7),
2227 });
2228 let json = serde_json::to_string(&frame).unwrap();
2229 assert!(json.contains("\"kind\":\"event\""));
2230 assert!(json.contains("\"event\":\"pool_window_added\""));
2231 let back: HostFrame = serde_json::from_str(&json).unwrap();
2232 match back {
2233 HostFrame::Event(Event::PoolWindowAdded {
2234 label,
2235 version,
2236 saga_id,
2237 }) => {
2238 assert_eq!(label, "pool-xyz");
2239 assert_eq!(version, 5);
2240 assert_eq!(saga_id, Some(7));
2241 }
2242 other => panic!("wrong frame: {:?}", other),
2243 }
2244 }
2245
2246 #[test]
2247 fn cpd1_host_frame_command_round_trip() {
2248 let frame = HostFrame::Command(Command::SpawnPoolWindow { saga_id: 31 });
2249 let json = serde_json::to_string(&frame).unwrap();
2250 assert!(json.contains("\"kind\":\"command\""));
2251 assert!(json.contains("\"cmd\":\"spawn_pool_window\""));
2252 assert!(json.contains("\"saga_id\":31"));
2253 let back: HostFrame = serde_json::from_str(&json).unwrap();
2254 match back {
2255 HostFrame::Command(Command::SpawnPoolWindow { saga_id }) => {
2256 assert_eq!(saga_id, 31);
2257 }
2258 other => panic!("wrong frame: {:?}", other),
2259 }
2260 }
2261
2262 #[test]
2263 fn cpd1_host_frame_unknown_kind_fails() {
2264 let json = r#"{"kind":"banana"}"#;
2265 let r: Result<HostFrame, _> = serde_json::from_str(json);
2266 assert!(r.is_err());
2267 }
2268}