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