Skip to main content

agentmux_srv/
persist_subscriber.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase E.2c.1+E.2c.2 — persist subscriber. A tokio task that
5// consumes the srv reducer's broadcast bus and mirrors
6// workspace/tab/block lifecycle events back to SQLite via wcore.
7//
8// Status:
9//   * E.2c.1 (#614) shipped the per-event apply path. Subscriber was
10//     dead-code in production because no producer existed.
11//   * E.2c.2 (this) wires the workspace HTTP/WS RPC handlers through
12//     the reducer, making the subscriber LIVE for workspace events.
13//     Adds full-resync-on-`Lagged` for workspaces (RPC-migrated
14//     entities only — tab/block resync lands in E.2c.3 / E.2c.4).
15//
16// Bus-lag handling:
17//   On RecvError::Lagged(n) the subscriber drops `n` events.
18//   Naive HWM advancement past dropped events would permanently
19//   diverge SQLite from reducer state. Instead we do a workspace-
20//   scoped resync: snapshot `state.workspaces` under the reducer
21//   mutex, write each workspace into SQLite (idempotent insert /
22//   no-op / update). The resync is INSERT/UPDATE only — no deletes —
23//   because workspaces can also be created OUTSIDE the reducer
24//   during the migration window (e.g., the still-wcore-direct
25//   `CreateWindow` flow calls `wcore::create_window_full` which
26//   creates a workspace under the hood). Deleting workspaces
27//   missing from the reducer snapshot would lose those legitimate
28//   rows. Stale workspaces deleted-via-reducer that the subscriber
29//   missed on Lagged linger on disk until the next user-driven
30//   DeleteWorkspace cleans them up via the wcore fallback in
31//   service.rs. (codex P1 #615.)
32//
33//   IMPORTANT: tab/block resync is NOT done here yet. Tabs and
34//   blocks remain RPC-direct via wcore; the reducer's view of them
35//   is FROZEN at bootstrap. Doing tab/block resync before E.2c.3
36//   and E.2c.4 land would clobber RPC-driven writes the reducer
37//   doesn't see. Resync expands as each entity migrates.
38
39use std::sync::Arc;
40
41use agentmux_common::ipc::Event;
42use tokio::sync::{broadcast, Mutex};
43
44use crate::backend::obj::{
45    Block, Client, LayoutActionData, LayoutState as PersistedLayoutState, LeafOrderEntry, Tab,
46    Window, Workspace,
47};
48use crate::backend::storage::store::Store;
49use crate::backend::wcore;
50use crate::state::State;
51
52/// Spawn the persist subscriber task. Runs until the broadcast
53/// channel closes (i.e., the reducer's bus is dropped at process
54/// shutdown). The `state` handle is used for workspace-scoped
55/// full-resync after a `RecvError::Lagged`.
56pub fn spawn_persist_subscriber(
57    events_rx: broadcast::Receiver<Event>,
58    mstore: Arc<Store>,
59    state: Arc<Mutex<State>>,
60) -> tokio::task::JoinHandle<()> {
61    tokio::spawn(run_persist_subscriber(events_rx, mstore, state))
62}
63
64async fn run_persist_subscriber(
65    mut events_rx: broadcast::Receiver<Event>,
66    mstore: Arc<Store>,
67    state: Arc<Mutex<State>>,
68) {
69    tracing::info!(target: "srv-persist-subscriber", "[srv-persist-subscriber] started");
70    loop {
71        match events_rx.recv().await {
72            Ok(event) => {
73                if let Err(e) = apply_event_to_mstore(&event, &mstore) {
74                    tracing::warn!(
75                        target: "srv-persist-subscriber",
76                        "[srv-persist-subscriber] apply failed for event {:?}: {}",
77                        event_kind(&event),
78                        e
79                    );
80                }
81            }
82            Err(broadcast::error::RecvError::Lagged(n)) => {
83                // Workspace-scoped full resync. Subscriber dropped
84                // `n` events; rather than guess which entities were
85                // affected, snapshot the reducer's workspace map and
86                // reconcile SQLite against it. RPC-migrated entities
87                // (workspaces in E.2c.2) are correctly recovered;
88                // not-yet-migrated entities (tab/block) are left
89                // alone — see module-level docs.
90                tracing::warn!(
91                    target: "srv-persist-subscriber",
92                    "[srv-persist-subscriber] dropped {} event(s) — running workspace resync",
93                    n
94                );
95                if let Err(e) = resync_workspaces(&state, &mstore).await {
96                    tracing::error!(
97                        target: "srv-persist-subscriber",
98                        "[srv-persist-subscriber] resync failed: {} — SQLite may diverge from reducer until next event",
99                        e
100                    );
101                }
102            }
103            Err(broadcast::error::RecvError::Closed) => {
104                tracing::info!(target: "srv-persist-subscriber", "[srv-persist-subscriber] bus closed — exiting");
105                return;
106            }
107        }
108    }
109}
110
111/// Snapshot `state.workspaces` and reconcile against SQLite —
112/// INSERT/UPDATE only, no deletes. Workspace-scoped (tab/block
113/// resync expands here as each entity's RPC layer migrates).
114///
115/// Why no delete-phase: during the migration window, workspaces
116/// can be created OUTSIDE the reducer (e.g., the still-wcore-direct
117/// `CreateWindow` flow calls `wcore::create_window_full` which
118/// creates a workspace under the hood). Those workspaces aren't in
119/// `state.workspaces`. If the subscriber lagged and we did a
120/// delete-not-in-snapshot pass, we'd cascade-delete legitimate
121/// workspaces — data loss triggered by bus pressure rather than a
122/// user action. Far better for a stale workspace deleted-via-reducer
123/// to linger on disk after a Lagged event than to lose a real one.
124/// (codex P1 #615.)
125///
126/// Stale-after-Lagged scenario: reducer fires WorkspaceDeleted, the
127/// subscriber drops it on Lagged, resync runs but only insert/updates.
128/// SQLite still has the deleted workspace's row. The next user-driven
129/// DeleteWorkspace (which falls through to `wcore::delete_workspace`
130/// for unknown-to-reducer rows) cleans it up. Acceptable behaviour
131/// during the migration; tightens once all RPC is reducer-driven.
132///
133/// Strategy:
134///   1. Lock state, snapshot the workspace map (ids + names),
135///      release the lock.
136///   2. For each workspace in the snapshot: insert if missing,
137///      no-op if name matches, update if name differs.
138async fn resync_workspaces(
139    state: &Arc<Mutex<State>>,
140    mstore: &Store,
141) -> Result<(), Box<dyn std::error::Error>> {
142    // Snapshot under lock; release before any I/O.
143    let snapshot: Vec<(String, String)> = {
144        let s = state.lock().await;
145        s.workspaces
146            .values()
147            .map(|w| (w.workspace_id.clone(), w.name.clone()))
148            .collect()
149    };
150
151    for (workspace_id, name) in &snapshot {
152        match mstore.get::<Workspace>(workspace_id)? {
153            Some(existing) if existing.name == *name => {
154                // Already in sync.
155            }
156            Some(mut existing) => {
157                existing.name = name.clone();
158                mstore.update(&mut existing)?;
159            }
160            None => {
161                let mut ws = Workspace {
162                    oid: workspace_id.clone(),
163                    name: name.clone(),
164                    ..Default::default()
165                };
166                mstore.insert(&mut ws)?;
167            }
168        }
169    }
170    Ok(())
171}
172
173/// Apply one reducer event to the on-disk store. Idempotent: each
174/// arm checks for the entity's current SQLite state before writing
175/// so duplicate events (from at-least-once delivery semantics) don't
176/// produce duplicate rows or wcore errors.
177///
178/// Phase E.2c.2 — exposed at crate visibility so RPC handlers in
179/// `service.rs` can apply events synchronously after dispatching
180/// through the reducer. This closes the race where the async
181/// subscriber hadn't yet written SQLite by the time a follow-up
182/// RPC (e.g., `CreateTab` against a just-created workspace) tried
183/// to read it. Calling this from the RPC handler followed by the
184/// subscriber receiving the broadcast event is safe because the
185/// arms are idempotent — the subscriber's later apply is a no-op.
186pub(crate) fn apply_event_to_mstore(
187    event: &Event,
188    mstore: &Store,
189) -> Result<(), Box<dyn std::error::Error>> {
190    match event {
191        Event::WorkspaceCreated {
192            workspace_id, name, ..
193        } => apply_workspace_created(mstore, workspace_id, name),
194        Event::WorkspaceDeleted { workspace_id, .. } => {
195            apply_workspace_deleted(mstore, workspace_id)
196        }
197        Event::TabCreated {
198            workspace_id,
199            tab_id,
200            name,
201            ..
202        } => apply_tab_created(mstore, workspace_id, tab_id, name),
203        Event::TabDeleted {
204            workspace_id,
205            tab_id,
206            ..
207        } => apply_tab_deleted(mstore, workspace_id, tab_id),
208        Event::ActiveTabChanged {
209            workspace_id,
210            tab_id,
211            ..
212        } => apply_active_tab_changed(mstore, workspace_id, tab_id.as_deref()),
213        Event::TabReordered {
214            workspace_id,
215            tab_id,
216            new_index,
217            ..
218        } => apply_tab_reordered(mstore, workspace_id, tab_id, *new_index),
219        Event::BlockCreated {
220            tab_id,
221            block_id,
222            meta,
223            ..
224        } => apply_block_created(mstore, tab_id, block_id, meta),
225        Event::BlockDeleted {
226            tab_id, block_id, ..
227        } => apply_block_deleted(mstore, tab_id, block_id),
228        Event::SrvWindowOpened {
229            window_id,
230            workspace_id,
231            ..
232        } => apply_srv_window_opened(mstore, window_id, workspace_id),
233        Event::SrvWindowClosed { window_id, .. } => apply_srv_window_closed(mstore, window_id),
234        Event::SrvWindowWorkspaceChanged {
235            window_id,
236            workspace_id,
237            ..
238        } => apply_srv_window_workspace_changed(mstore, window_id, workspace_id),
239        Event::TabsReorderedBulk {
240            workspace_id,
241            tab_ids,
242            ..
243        } => apply_tabs_reordered_bulk(mstore, workspace_id, tab_ids),
244        Event::WorkspaceRenamed {
245            workspace_id, name, ..
246        } => apply_workspace_renamed(mstore, workspace_id, name),
247        Event::TabRenamed { tab_id, name, .. } => apply_tab_renamed(mstore, tab_id, name),
248        Event::WorkspaceMetaUpdated {
249            workspace_id,
250            meta_patch,
251            ..
252        } => apply_workspace_meta_updated(mstore, workspace_id, meta_patch),
253        Event::WindowMetaUpdated {
254            window_id,
255            meta_patch,
256            ..
257        } => apply_window_meta_updated(mstore, window_id, meta_patch),
258        Event::TabMetaUpdated {
259            tab_id,
260            meta_patch,
261            ..
262        } => apply_tab_meta_updated(mstore, tab_id, meta_patch),
263        Event::BlockMetaUpdated {
264            block_id,
265            meta_patch,
266            ..
267        } => apply_block_meta_updated(mstore, block_id, meta_patch),
268        Event::TabMoved {
269            tab_id,
270            src_workspace_id,
271            dst_workspace_id,
272            dst_index,
273            new_src_active_tab_id,
274            new_dst_active_tab_id,
275            ..
276        } => apply_tab_moved(
277            mstore,
278            tab_id,
279            src_workspace_id,
280            dst_workspace_id,
281            *dst_index,
282            new_src_active_tab_id.as_deref(),
283            new_dst_active_tab_id.as_deref(),
284        ),
285        Event::BlockMoved {
286            block_id,
287            src_tab_id,
288            dst_tab_id,
289            dst_index,
290            ..
291        } => apply_block_moved(mstore, block_id, src_tab_id, dst_tab_id, *dst_index),
292        Event::FocusedNodeChanged { tab_id, node_id, .. } => {
293            apply_focused_node_changed(mstore, tab_id, node_id)
294        }
295        Event::MagnifiedNodeChanged { tab_id, node_id, .. } => {
296            apply_magnified_node_changed(mstore, tab_id, node_id)
297        }
298        // Phase E.4.B — layout-tree persistence. These two are the
299        // first tree-mutating layout events to persist through the
300        // subscriber (rather than wcore-direct). They are behaviour-
301        // neutral until a production caller dispatches LayoutClear /
302        // LayoutSetTree (the Layout* arms are still dormant per
303        // reducer.rs §"4 of 11"); they land the persistence path that
304        // the UpdateObject→LayoutSetTree reroute (next phase) needs.
305        // Insert is deliberately NOT here yet — its event carries op
306        // params, not the resulting tree, so persisting it would require
307        // re-running the algebra in the subscriber (a new divergence
308        // surface). Delete WAS in the same boat until SPEC_864 site #6
309        // gave `LayoutNodeDeleted` a `new_tree` (+ `tree_cleared`) and its
310        // own arm below; Insert follows when a production caller needs it.
311        Event::LayoutCleared { tab_id, .. } => apply_layout_cleared(mstore, tab_id),
312        Event::LayoutTreeReplaced {
313            tab_id,
314            new_tree,
315            slices,
316            ..
317        } => apply_layout_tree_replaced(mstore, tab_id, new_tree, slices.as_ref()),
318        // SPEC_864 site #6 — LayoutNodeDeleted gets its own arm (not the
319        // group below) because a delete is the one structural op that can
320        // legitimately EMPTY the tree (root-orphan case). `tree_cleared`
321        // disambiguates a real root-delete clear from a version-skewed
322        // sender's absent `new_tree`, which must be ignored rather than
323        // applied as an erase (same skew concern as the group below).
324        // Leaforder handling follows the granular-arm convention: untouched
325        // for a non-empty tree (frontend-recomputed geometry cache, not
326        // read on reproject), cleared with everything else on a real
327        // empty-tree clear (apply_layout_tree_replaced's clears path).
328        Event::LayoutNodeDeleted {
329            tab_id,
330            new_tree,
331            tree_cleared,
332            ..
333        } => match (new_tree.is_some(), tree_cleared) {
334            (true, _) => apply_layout_tree_replaced(mstore, tab_id, new_tree, None),
335            (false, true) => apply_layout_tree_replaced(mstore, tab_id, &None, None),
336            (false, false) => Ok(()),
337        },
338        // The 7 granular structural arms each carry the reducer's resulting
339        // tree in `new_tree` (post-op, post-balance), so persistence is the
340        // same rootnode write as LayoutTreeReplaced — no algebra re-run in the
341        // subscriber (the divergence surface we're avoiding). Focus/magnify
342        // changes from these ops are not persisted here yet (the frontend
343        // reconciles focus on load); that's a tracked follow-up.
344        Event::LayoutNodeMoved { tab_id, new_tree, .. }
345        | Event::LayoutNodesSwapped { tab_id, new_tree, .. }
346        | Event::LayoutNodesResized { tab_id, new_tree, .. }
347        | Event::LayoutNodeReplaced { tab_id, new_tree, .. }
348        | Event::LayoutSplitHorizontalApplied { tab_id, new_tree, .. }
349        | Event::LayoutSplitVerticalApplied { tab_id, new_tree, .. }
350        | Event::LayoutNodeInsertedAtIndex { tab_id, new_tree, .. } => {
351            // These structural ops NEVER legitimately produce an empty tree,
352            // so a `None` here is only a version-skewed / pre-change sender
353            // (`new_tree` is `#[serde(default)]`). Ignore it rather than
354            // passing it to `apply_layout_tree_replaced`, which would treat
355            // `None` as an intentional empty-tree clear and ERASE the
356            // persisted layout (codex P2 on #1883). Persist only a real tree.
357            match new_tree {
358                // Granular events carry no client slices — tree-only write.
359                Some(_) => apply_layout_tree_replaced(mstore, tab_id, new_tree, None),
360                None => Ok(()),
361            }
362        }
363        // SPEC_864 Phase 4 — APPEND to the pending queue (unlike the
364        // slices path above, which REPLACEs it: pushing a slice without
365        // processed actions is how the frontend ACKS the queue; the
366        // backend enqueues by appending). Idempotent under the
367        // double-apply contract (sync from the dispatch helper, again
368        // from broadcast) by deduping on `actionid` — every writer
369        // stamps a fresh UUID per action.
370        Event::LayoutBackendActionsQueued { tab_id, actions, .. } => {
371            apply_layout_backend_actions_queued(mstore, tab_id, actions)
372        }
373        // All other event variants are not domain-state mutations
374        // (lifecycle, errors, snapshots). The subscriber ignores them.
375        _ => Ok(()),
376    }
377}
378
379fn apply_workspace_created(
380    mstore: &Store,
381    workspace_id: &str,
382    name: &str,
383) -> Result<(), Box<dyn std::error::Error>> {
384    if mstore.get::<Workspace>(workspace_id)?.is_some() {
385        return Ok(()); // already present
386    }
387    let mut ws = Workspace {
388        oid: workspace_id.to_string(),
389        name: name.to_string(),
390        ..Default::default()
391    };
392    mstore.insert(&mut ws)?;
393    Ok(())
394}
395
396fn apply_workspace_deleted(
397    mstore: &Store,
398    workspace_id: &str,
399) -> Result<(), Box<dyn std::error::Error>> {
400    if mstore.get::<Workspace>(workspace_id)?.is_none() {
401        return Ok(()); // already gone
402    }
403    wcore::delete_workspace(mstore, workspace_id)?;
404    Ok(())
405}
406
407fn apply_tab_created(
408    mstore: &Store,
409    workspace_id: &str,
410    tab_id: &str,
411    name: &str,
412) -> Result<(), Box<dyn std::error::Error>> {
413    // F1.A — wrap the insert-layout + insert-tab + update-workspace
414    // sequence in a single SQLite transaction so a partial failure
415    // can't leave a tab without a layout or a workspace whose
416    // tab_ids points at a tab that doesn't exist on disk.
417    mstore.with_tx(|tx| {
418        if tx.get::<Tab>(tab_id)?.is_none() {
419            // Phase E.2c.2 — create a LayoutState row alongside the
420            // Tab so reducer-originated tabs have a `Tab.layoutstate`
421            // to seed (e.g. `setup_torn_off_block_layout`'s
422            // `seed_layout_via_reducer` call) just like wcore-created
423            // tabs. Mirrors the wcore::create_tab pattern. (codex P2 #614.)
424            let mut layout = PersistedLayoutState {
425                oid: uuid::Uuid::new_v4().to_string(),
426                rootnode: None,
427                magnifiednodeid: String::new(),
428                focusednodeid: String::new(),
429                leaforder: None,
430                pendingbackendactions: None,
431                meta: None,
432                ..Default::default()
433            };
434            tx.insert(&mut layout)?;
435            let mut tab = Tab {
436                oid: tab_id.to_string(),
437                name: name.to_string(),
438                layoutstate: layout.oid.clone(),
439                ..Default::default()
440            };
441            tx.insert(&mut tab)?;
442        }
443        // Idempotently link tab into the workspace's tabids.
444        if let Some(mut ws) = tx.get::<Workspace>(workspace_id)? {
445            let already_present = ws.tabids.iter().any(|t| t == tab_id)
446                || ws.pinnedtabids.iter().any(|t| t == tab_id);
447            if !already_present {
448                ws.tabids.push(tab_id.to_string());
449                tx.update(&mut ws)?;
450            }
451        }
452        Ok(())
453    })?;
454    Ok(())
455}
456
457fn apply_tab_deleted(
458    mstore: &Store,
459    workspace_id: &str,
460    tab_id: &str,
461) -> Result<(), Box<dyn std::error::Error>> {
462    if mstore.get::<Tab>(tab_id)?.is_none() {
463        return Ok(()); // already gone (or never existed)
464    }
465    // wcore::delete_tab handles unlinking from the workspace's
466    // tabids, deleting the tab's blocks + layout, and the tab row
467    // itself. Returns NotFound errors if any of those don't exist —
468    // we surface those as the operation having already happened.
469    match wcore::delete_tab(mstore, workspace_id, tab_id) {
470        Ok(()) => Ok(()),
471        Err(crate::backend::storage::StoreError::NotFound) => Ok(()),
472        Err(e) => Err(Box::new(e)),
473    }
474}
475
476fn apply_active_tab_changed(
477    mstore: &Store,
478    workspace_id: &str,
479    tab_id: Option<&str>,
480) -> Result<(), Box<dyn std::error::Error>> {
481    let Some(mut ws) = mstore.get::<Workspace>(workspace_id)? else {
482        return Ok(()); // workspace already gone — nothing to update
483    };
484    let new = tab_id.unwrap_or("").to_string();
485    if ws.activetabid == new {
486        return Ok(());
487    }
488    ws.activetabid = new;
489    mstore.update(&mut ws)?;
490    Ok(())
491}
492
493fn apply_tab_reordered(
494    mstore: &Store,
495    workspace_id: &str,
496    tab_id: &str,
497    new_index: u32,
498) -> Result<(), Box<dyn std::error::Error>> {
499    let Some(mut ws) = mstore.get::<Workspace>(workspace_id)? else {
500        return Ok(());
501    };
502    // Pinning was removed from AgentMux but legacy SQLite databases
503    // can still have entries in `Workspace.pinnedtabids`; bootstrap
504    // surfaces those as regular tabs in the reducer's `tab_ids`. The
505    // reducer can therefore emit a TabReordered for a tab that on
506    // disk still lives in `pinnedtabids`. Search both lists so the
507    // reorder lands wherever the tab actually is. (codex P2 #617.)
508    let target_index = new_index as usize;
509    if let Some(current_pos) = ws.tabids.iter().position(|t| t == tab_id) {
510        let len = ws.tabids.len();
511        let target = target_index.min(len.saturating_sub(1));
512        if current_pos == target {
513            return Ok(());
514        }
515        let id = ws.tabids.remove(current_pos);
516        ws.tabids.insert(target, id);
517        mstore.update(&mut ws)?;
518    } else if let Some(current_pos) = ws.pinnedtabids.iter().position(|t| t == tab_id) {
519        let len = ws.pinnedtabids.len();
520        let target = target_index.min(len.saturating_sub(1));
521        if current_pos == target {
522            return Ok(());
523        }
524        let id = ws.pinnedtabids.remove(current_pos);
525        ws.pinnedtabids.insert(target, id);
526        mstore.update(&mut ws)?;
527    }
528    // Tab not in either list — silent no-op (idempotent).
529    Ok(())
530}
531
532fn apply_block_created(
533    mstore: &Store,
534    tab_id: &str,
535    block_id: &str,
536    meta: &serde_json::Value,
537) -> Result<(), Box<dyn std::error::Error>> {
538    // F1.A — block insert + tab.blockids update wrapped in one tx.
539    let meta_map: crate::backend::obj::MetaMapType = match meta {
540        serde_json::Value::Object(_) => {
541            serde_json::from_value(meta.clone()).unwrap_or_default()
542        }
543        _ => Default::default(),
544    };
545    mstore.with_tx(|tx| {
546        if tx.get::<Block>(block_id)?.is_none() {
547            // Phase E.2c.4 — write the meta map carried in the event
548            // (`view`, layout hints, etc.) so reducer-routed
549            // CreateBlock RPC produces blocks with valid view/meta in
550            // SQLite. Without this, the frontend sees a block with
551            // empty meta and renders a blank pane.
552            let mut block = Block {
553                oid: block_id.to_string(),
554                parentoref: format!("tab:{}", tab_id),
555                meta: meta_map,
556                ..Default::default()
557            };
558            tx.insert(&mut block)?;
559        }
560        if let Some(mut tab) = tx.get::<Tab>(tab_id)? {
561            if !tab.blockids.iter().any(|b| b == block_id) {
562                tab.blockids.push(block_id.to_string());
563                tx.update(&mut tab)?;
564            }
565        }
566        Ok(())
567    })?;
568    Ok(())
569}
570
571fn apply_block_deleted(
572    mstore: &Store,
573    tab_id: &str,
574    block_id: &str,
575) -> Result<(), Box<dyn std::error::Error>> {
576    if mstore.get::<Block>(block_id)?.is_none() {
577        return Ok(()); // already gone
578    }
579    match wcore::delete_block(mstore, tab_id, block_id) {
580        Ok(()) => Ok(()),
581        Err(crate::backend::storage::StoreError::NotFound) => Ok(()),
582        Err(e) => Err(Box::new(e)),
583    }
584}
585
586/// Phase E.5 — record/update a window's workspace pointer on the
587/// persisted Window row. Idempotent: inserts if missing, updates
588/// if `workspaceid` differs, no-ops if identical.
589///
590/// Also keeps `Client.windowids` in sync — appends the new window_id
591/// when the Window row is freshly created. The legacy
592/// `wcore::create_window_full` path does this too; without it, the
593/// Window row exists but `GetClientData` / focus-order logic can't
594/// see it. (codex P1 #619.)
595fn apply_srv_window_opened(
596    mstore: &Store,
597    window_id: &str,
598    workspace_id: &str,
599) -> Result<(), Box<dyn std::error::Error>> {
600    // F1.A — Window upsert + Client.windowids update wrapped in one
601    // tx so a partial failure can't leave a Window row that
602    // `GetClientData` doesn't see (or a `windowids` entry pointing
603    // at a Window row that wasn't written).
604    mstore.with_tx(|tx| {
605        let was_new = match tx.get::<Window>(window_id)? {
606            Some(existing) if existing.workspaceid == workspace_id => false,
607            Some(mut existing) => {
608                existing.workspaceid = workspace_id.to_string();
609                tx.update(&mut existing)?;
610                false
611            }
612            None => {
613                let mut window = Window {
614                    oid: window_id.to_string(),
615                    workspaceid: workspace_id.to_string(),
616                    ..Default::default()
617                };
618                tx.insert(&mut window)?;
619                true
620            }
621        };
622        if was_new {
623            // Inline `wcore::get_client` against the tx-handle to keep
624            // the Client.windowids update in the same transaction.
625            let clients = tx.get_all::<Client>()?;
626            if let Some(mut client) = clients.into_iter().next() {
627                if !client.windowids.iter().any(|id| id == window_id) {
628                    client.windowids.push(window_id.to_string());
629                    tx.update(&mut client)?;
630                }
631            }
632        }
633        Ok(())
634    })?;
635    Ok(())
636}
637
638/// Phase E.5 — delete the persisted Window row + remove the id
639/// from `Client.windowids`. Mirrors `wcore::close_window`'s order:
640/// prune client.windowids FIRST (so any read between the two ops
641/// doesn't see a dangling id), then delete the Window row.
642/// (codex P1 #619.)
643///
644/// `pub(crate)`: besides the `SrvWindowClosed` subscriber arm, the
645/// `CloseWindow` handler calls this directly on its divergence path —
646/// when the reducer never knew the window (silent no-op, no event) but
647/// the store still holds it (#2051). Deliberately defensive/idempotent:
648/// both callers rely on the already-gone cases being safe.
649pub(crate) fn apply_srv_window_closed(
650    mstore: &Store,
651    window_id: &str,
652) -> Result<(), Box<dyn std::error::Error>> {
653    // F1.A — Client.windowids prune + Window-row delete in one tx.
654    // Order matters (mirrors `wcore::close_window`): prune client
655    // FIRST so any read between the two ops doesn't see a dangling
656    // id pointing at a Window row that's already been removed.
657    mstore.with_tx(|tx| {
658        let window_exists = tx.get::<Window>(window_id)?.is_some();
659        // Always defensively prune the client list — handles the
660        // already-gone case where divergence left a stale entry.
661        let clients = tx.get_all::<Client>()?;
662        if let Some(mut client) = clients.into_iter().next() {
663            if client.windowids.iter().any(|id| id == window_id) {
664                client.windowids.retain(|id| id != window_id);
665                tx.update(&mut client)?;
666            }
667        }
668        if window_exists {
669            tx.delete::<Window>(window_id)?;
670        }
671        Ok(())
672    })?;
673    Ok(())
674}
675
676/// Phase E.5 — same shape as `apply_srv_window_opened` for the
677/// upsert behavior; separate function for log-clarity since the
678/// emitted event is distinct.
679fn apply_srv_window_workspace_changed(
680    mstore: &Store,
681    window_id: &str,
682    workspace_id: &str,
683) -> Result<(), Box<dyn std::error::Error>> {
684    apply_srv_window_opened(mstore, window_id, workspace_id)
685}
686
687/// Phase E.5.3 — replace the workspace's `tabids` with the new
688/// list and drain any legacy `pinnedtabids` rows. Pinning was a
689/// Waveterm feature removed from AgentMux; bootstrap merges legacy
690/// pinned tabs into the reducer's `tab_ids`, so the next reorder
691/// is the canonical full ordering. Leaving stale `pinnedtabids`
692/// in SQLite would cause UI double-insertion (`workspace.tsx`
693/// builds the displayed tab list as `[...pinnedtabids, ...tabids]`).
694fn apply_tabs_reordered_bulk(
695    mstore: &Store,
696    workspace_id: &str,
697    tab_ids: &[String],
698) -> Result<(), Box<dyn std::error::Error>> {
699    let Some(mut ws) = mstore.get::<Workspace>(workspace_id)? else {
700        return Ok(());
701    };
702    let tabids_match = ws.tabids == tab_ids;
703    let pinned_clear = ws.pinnedtabids.is_empty();
704    if tabids_match && pinned_clear {
705        return Ok(());
706    }
707    ws.tabids = tab_ids.to_vec();
708    ws.pinnedtabids.clear();
709    mstore.update(&mut ws)?;
710    Ok(())
711}
712
713/// Phase E.5.3 — rename a persisted workspace.
714fn apply_workspace_renamed(
715    mstore: &Store,
716    workspace_id: &str,
717    name: &str,
718) -> Result<(), Box<dyn std::error::Error>> {
719    let Some(mut ws) = mstore.get::<Workspace>(workspace_id)? else {
720        return Ok(());
721    };
722    if ws.name == name {
723        return Ok(());
724    }
725    ws.name = name.to_string();
726    mstore.update(&mut ws)?;
727    Ok(())
728}
729
730/// Phase E.5.3 — rename a persisted tab.
731fn apply_tab_renamed(
732    mstore: &Store,
733    tab_id: &str,
734    name: &str,
735) -> Result<(), Box<dyn std::error::Error>> {
736    let Some(mut tab) = mstore.get::<Tab>(tab_id)? else {
737        return Ok(());
738    };
739    if tab.name == name {
740        return Ok(());
741    }
742    tab.name = name.to_string();
743    mstore.update(&mut tab)?;
744    Ok(())
745}
746
747/// Phase E.4 (Option A) — write the reducer-emitted focused node id
748/// onto the tab's `LayoutState.focusednodeid` column. Tab→layout is
749/// a join through `Tab.layoutstate`. Idempotent: silent no-op when the
750/// tab is unknown (deleted concurrently), the layout row is missing
751/// (legacy tab without one), or the value already matches. Other
752/// `LayoutState` fields stay on their existing wcore-direct path until
753/// Option B lands.
754fn apply_focused_node_changed(
755    mstore: &Store,
756    tab_id: &str,
757    node_id: &str,
758) -> Result<(), Box<dyn std::error::Error>> {
759    let Some(tab) = mstore.get::<Tab>(tab_id)? else {
760        return Ok(());
761    };
762    if tab.layoutstate.is_empty() {
763        return Ok(());
764    }
765    let Some(mut layout) = mstore.get::<PersistedLayoutState>(&tab.layoutstate)? else {
766        return Ok(());
767    };
768    if layout.focusednodeid == node_id {
769        return Ok(());
770    }
771    layout.focusednodeid = node_id.to_string();
772    mstore.update(&mut layout)?;
773    Ok(())
774}
775
776/// Phase E.4 (Option A) — write the reducer-emitted magnified node id
777/// onto the tab's `LayoutState.magnifiednodeid` column. Same shape as
778/// `apply_focused_node_changed`.
779fn apply_magnified_node_changed(
780    mstore: &Store,
781    tab_id: &str,
782    node_id: &str,
783) -> Result<(), Box<dyn std::error::Error>> {
784    let Some(tab) = mstore.get::<Tab>(tab_id)? else {
785        return Ok(());
786    };
787    if tab.layoutstate.is_empty() {
788        return Ok(());
789    }
790    let Some(mut layout) = mstore.get::<PersistedLayoutState>(&tab.layoutstate)? else {
791        return Ok(());
792    };
793    if layout.magnifiednodeid == node_id {
794        return Ok(());
795    }
796    layout.magnifiednodeid = node_id.to_string();
797    mstore.update(&mut layout)?;
798    Ok(())
799}
800
801/// Phase E.4.B — apply a `LayoutCleared` event: wipe the tab's persisted
802/// layout tree and the focus/magnify ids (and the leaforder cache — an
803/// empty tree has no leaves). Mirrors
804/// `reducer::layout::handle_layout_clear`. Idempotent: silent no-op when
805/// the tab is unknown, the layout row is missing, or it is already clear
806/// (so a double-apply — once synchronously from the RPC handler, once
807/// from the broadcast — does not churn the version).
808fn apply_layout_cleared(
809    mstore: &Store,
810    tab_id: &str,
811) -> Result<(), Box<dyn std::error::Error>> {
812    let Some(tab) = mstore.get::<Tab>(tab_id)? else {
813        return Ok(());
814    };
815    if tab.layoutstate.is_empty() {
816        return Ok(());
817    }
818    let Some(mut layout) = mstore.get::<PersistedLayoutState>(&tab.layoutstate)? else {
819        return Ok(());
820    };
821    if layout.rootnode.is_none()
822        && layout.focusednodeid.is_empty()
823        && layout.magnifiednodeid.is_empty()
824        && layout.leaforder.is_none()
825    {
826        return Ok(());
827    }
828    layout.rootnode = None;
829    layout.focusednodeid = String::new();
830    layout.magnifiednodeid = String::new();
831    layout.leaforder = None;
832    mstore.update(&mut layout)?;
833    Ok(())
834}
835
836/// Phase E.4.B — apply a `LayoutTreeReplaced` event: write the reducer-
837/// supplied tree onto `LayoutState.rootnode`. Mirrors
838/// `reducer::layout::handle_layout_set_tree` — when the new tree is
839/// empty, also clear the focus/magnify ids (and leaforder) so they don't
840/// dangle at nodes that no longer exist.
841///
842/// SPEC_864 Phase 2 — when the event carries `slices` (the
843/// `UpdateObject`→`LayoutSetTree` full-row push), the frontend-owned
844/// columns are REPLACED to mirror the legacy `update_raw` whole-row write
845/// this route retires: leaforder / pendingbackendactions are written
846/// verbatim (absent = clear — pushing without processed actions is how
847/// the frontend acks its queue); focus/magnify strings overwrite (empty =
848/// clear). `slices: None` (granular events, tree-only callers) leaves
849/// those columns untouched — leaforder is a frontend-recomputed geometry
850/// cache (`getLeafOrder` in `layoutGeometry.ts`) and is NOT read on
851/// reproject (`persist::bootstrap_state_from_mstore` reads
852/// rootnode/focus/magnify only). Idempotent: no-op (no version bump)
853/// when nothing changes.
854fn apply_layout_tree_replaced(
855    mstore: &Store,
856    tab_id: &str,
857    new_tree: &Option<agentmux_common::LayoutNode>,
858    slices: Option<&agentmux_common::LayoutClientSlices>,
859) -> Result<(), Box<dyn std::error::Error>> {
860    let Some(tab) = mstore.get::<Tab>(tab_id)? else {
861        return Ok(());
862    };
863    if tab.layoutstate.is_empty() {
864        return Ok(());
865    }
866    let Some(mut layout) = mstore.get::<PersistedLayoutState>(&tab.layoutstate)? else {
867        return Ok(());
868    };
869    let clears = new_tree.is_none();
870
871    // Compute the candidate values for every column this event owns, then
872    // write once only if something actually changed (idempotency contract:
873    // a double-apply — sync from the RPC handler, again from broadcast —
874    // must not churn the version).
875    let new_focused = match (slices, clears) {
876        (_, true) => String::new(),
877        (Some(s), false) => s.focused_node_id.clone(),
878        (None, false) => layout.focusednodeid.clone(),
879    };
880    let new_magnified = match (slices, clears) {
881        (_, true) => String::new(),
882        (Some(s), false) => s.magnified_node_id.clone(),
883        (None, false) => layout.magnifiednodeid.clone(),
884    };
885    let new_leaforder: Option<Vec<LeafOrderEntry>> = match (slices, clears) {
886        (_, true) => None,
887        (Some(s), false) => match &s.leaforder {
888            Some(v) if !v.is_null() => Some(serde_json::from_value(v.clone())?),
889            _ => None,
890        },
891        (None, false) => layout.leaforder.clone(),
892    };
893    let new_pending: Option<Vec<LayoutActionData>> = match slices {
894        // The pending queue is frontend-acked state, not tree geometry —
895        // an empty-tree push still replaces it (unlike focus/leaforder,
896        // which dangle without a tree).
897        Some(s) => match &s.pending_backend_actions {
898            Some(v) if !v.is_null() => Some(serde_json::from_value(v.clone())?),
899            _ => None,
900        },
901        None => layout.pendingbackendactions.clone(),
902    };
903
904    let already = layout.rootnode == *new_tree
905        && layout.focusednodeid == new_focused
906        && layout.magnifiednodeid == new_magnified
907        && layout.leaforder == new_leaforder
908        && layout.pendingbackendactions == new_pending;
909    if already {
910        return Ok(());
911    }
912    layout.rootnode = new_tree.clone();
913    layout.focusednodeid = new_focused;
914    layout.magnifiednodeid = new_magnified;
915    layout.leaforder = new_leaforder;
916    layout.pendingbackendactions = new_pending;
917    mstore.update(&mut layout)?;
918    Ok(())
919}
920
921/// SPEC_864 Phase 4 — apply a `LayoutBackendActionsQueued` event:
922/// APPEND the reducer-validated actions to
923/// `LayoutState.pendingbackendactions`. The queue is a backend→frontend
924/// mailbox: the backend appends here; the frontend drains it by pushing
925/// a full slice without the processed actions (the REPLACE path in
926/// `apply_layout_tree_replaced`).
927///
928/// Idempotency: the event is applied twice on the happy path (sync in
929/// `queue_layout_actions_via_reducer`, again by the subscriber task
930/// consuming the broadcast), so actions whose `actionid` is already in
931/// the queue are skipped. Every writer stamps a fresh UUID per action;
932/// an (out-of-contract) empty `actionid` dedupes against other empties,
933/// which is the safe direction — skip rather than duplicate.
934fn apply_layout_backend_actions_queued(
935    mstore: &Store,
936    tab_id: &str,
937    actions: &serde_json::Value,
938) -> Result<(), Box<dyn std::error::Error>> {
939    let Some(tab) = mstore.get::<Tab>(tab_id)? else {
940        return Ok(());
941    };
942    if tab.layoutstate.is_empty() {
943        return Ok(());
944    }
945    let Some(mut layout) = mstore.get::<PersistedLayoutState>(&tab.layoutstate)? else {
946        return Ok(());
947    };
948    let incoming: Vec<LayoutActionData> = serde_json::from_value(actions.clone())?;
949    let mut pending = layout.pendingbackendactions.take().unwrap_or_default();
950    let before = pending.len();
951    for action in incoming {
952        if !pending.iter().any(|p| p.actionid == action.actionid) {
953            pending.push(action);
954        }
955    }
956    if pending.len() == before {
957        // Every action already queued (double-apply) — no version churn.
958        layout.pendingbackendactions = Some(pending);
959        return Ok(());
960    }
961    layout.pendingbackendactions = Some(pending);
962    mstore.update(&mut layout)?;
963    Ok(())
964}
965
966/// Phase E.5.3 — apply a meta-patch to a workspace's `meta` map.
967/// Reducer doesn't track meta in `WorkspaceRecord`; this subscriber
968/// is the sole authority that mutates persisted meta. Patch is a
969/// JSON object that merges shallow-key-by-shallow-key on top of the
970/// existing meta. `null` values in the patch delete the key.
971fn apply_workspace_meta_updated(
972    mstore: &Store,
973    workspace_id: &str,
974    meta_patch: &serde_json::Value,
975) -> Result<(), Box<dyn std::error::Error>> {
976    let Some(mut ws) = mstore.get::<Workspace>(workspace_id)? else {
977        return Ok(());
978    };
979    if merge_meta_patch(&mut ws.meta, meta_patch) {
980        mstore.update(&mut ws)?;
981    }
982    Ok(())
983}
984
985/// Phase E.5.x (issue #855) — apply a meta-patch to a window's `meta`
986/// map. Same shape as `apply_workspace_meta_updated`. Silent no-op if
987/// the window doesn't exist in mstore (preserves the idempotency
988/// contract — duplicate or stale events fold to no-op).
989fn apply_window_meta_updated(
990    mstore: &Store,
991    window_id: &str,
992    meta_patch: &serde_json::Value,
993) -> Result<(), Box<dyn std::error::Error>> {
994    let Some(mut window) = mstore.get::<Window>(window_id)? else {
995        return Ok(());
996    };
997    if merge_meta_patch(&mut window.meta, meta_patch) {
998        mstore.update(&mut window)?;
999    }
1000    Ok(())
1001}
1002
1003/// Phase E.5.3 — apply a meta-patch to a tab's `meta` map.
1004fn apply_tab_meta_updated(
1005    mstore: &Store,
1006    tab_id: &str,
1007    meta_patch: &serde_json::Value,
1008) -> Result<(), Box<dyn std::error::Error>> {
1009    let Some(mut tab) = mstore.get::<Tab>(tab_id)? else {
1010        return Ok(());
1011    };
1012    if merge_meta_patch(&mut tab.meta, meta_patch) {
1013        mstore.update(&mut tab)?;
1014    }
1015    Ok(())
1016}
1017
1018/// Phase E.5.3 — apply a meta-patch to a block's `meta` map.
1019fn apply_block_meta_updated(
1020    mstore: &Store,
1021    block_id: &str,
1022    meta_patch: &serde_json::Value,
1023) -> Result<(), Box<dyn std::error::Error>> {
1024    let Some(mut block) = mstore.get::<Block>(block_id)? else {
1025        return Ok(());
1026    };
1027    if merge_meta_patch(&mut block.meta, meta_patch) {
1028        mstore.update(&mut block)?;
1029    }
1030    Ok(())
1031}
1032
1033/// Phase E.5.5 — apply a `TabMoved` event to SQLite.
1034/// Idempotent: detects already-moved state (tab in dst, not in src)
1035/// and returns `Ok(())` without re-mutating.
1036///
1037/// What this writes:
1038/// * Removes `tab_id` from `src_workspace.tabids` (and `pinnedtabids`
1039///   for legacy rows — bootstrap merges them into reducer state, so
1040///   any leftover here is a stray legacy entry that should be drained).
1041/// * Updates `src_workspace.activetabid` to `new_src_active_tab_id`
1042///   (which may be empty when the source emptied).
1043/// * Inserts `tab_id` at `dst_index` in `dst_workspace.tabids`,
1044///   clamping to the dst list length.
1045/// * Updates the `Tab` row's parent ref so loaders find it under
1046///   the new workspace.
1047fn apply_tab_moved(
1048    mstore: &Store,
1049    tab_id: &str,
1050    src_workspace_id: &str,
1051    dst_workspace_id: &str,
1052    dst_index: u32,
1053    new_src_active_tab_id: Option<&str>,
1054    new_dst_active_tab_id: Option<&str>,
1055) -> Result<(), Box<dyn std::error::Error>> {
1056    // F1.A — both workspaces' updates wrapped in one tx so a partial
1057    // failure can't leave the tab in both src.tabids and dst.tabids
1058    // simultaneously, or in neither.
1059    mstore.with_tx(|tx| {
1060        // Source workspace: remove the tab and update activetabid.
1061        if let Some(mut src_ws) = tx.get::<Workspace>(src_workspace_id)? {
1062            let len_before_tabids = src_ws.tabids.len();
1063            let len_before_pinned = src_ws.pinnedtabids.len();
1064            src_ws.tabids.retain(|id| id != tab_id);
1065            src_ws.pinnedtabids.retain(|id| id != tab_id);
1066            let new_active = new_src_active_tab_id.unwrap_or("").to_string();
1067            let active_changed = src_ws.activetabid != new_active;
1068            if src_ws.tabids.len() != len_before_tabids
1069                || src_ws.pinnedtabids.len() != len_before_pinned
1070                || active_changed
1071            {
1072                src_ws.activetabid = new_active;
1073                tx.update(&mut src_ws)?;
1074            }
1075        }
1076
1077        // Dest workspace: insert at clamped index + update
1078        // active_tab_id if the event carries one (codex P2 #621).
1079        // Skip insert if the tab is already present (idempotent on
1080        // duplicate delivery).
1081        if let Some(mut dst_ws) = tx.get::<Workspace>(dst_workspace_id)? {
1082            let mut changed = false;
1083            if !dst_ws.tabids.iter().any(|id| id == tab_id) {
1084                let clamped = (dst_index as usize).min(dst_ws.tabids.len());
1085                dst_ws.tabids.insert(clamped, tab_id.to_string());
1086                changed = true;
1087            }
1088            if let Some(new_active) = new_dst_active_tab_id {
1089                if dst_ws.activetabid != new_active {
1090                    dst_ws.activetabid = new_active.to_string();
1091                    changed = true;
1092                }
1093            }
1094            if changed {
1095                tx.update(&mut dst_ws)?;
1096            }
1097        }
1098
1099        // No per-Tab parent ref to update — the workspace owns the
1100        // parentage relationship via its `tabids` list.
1101        Ok(())
1102    })?;
1103    Ok(())
1104}
1105
1106/// Phase E.5.5 — apply a `BlockMoved` event to SQLite.
1107/// Handles both cross-tab moves and intra-tab repositioning.
1108/// Idempotent on re-delivery (checks current parent before mutating).
1109fn apply_block_moved(
1110    mstore: &Store,
1111    block_id: &str,
1112    src_tab_id: &str,
1113    dst_tab_id: &str,
1114    dst_index: u32,
1115) -> Result<(), Box<dyn std::error::Error>> {
1116    // F1.A — src.blockids + dst.blockids + block.parentoref updates
1117    // wrapped in one tx so a partial failure can't leave the block in
1118    // both tabs' blockids or with parentoref pointing at the wrong
1119    // tab.
1120    mstore.with_tx(|tx| {
1121        if src_tab_id == dst_tab_id {
1122            // Intra-tab reposition: remove + re-insert in the same tab.
1123            if let Some(mut tab) = tx.get::<Tab>(src_tab_id)? {
1124                tab.blockids.retain(|id| id != block_id);
1125                let clamped = (dst_index as usize).min(tab.blockids.len());
1126                tab.blockids.insert(clamped, block_id.to_string());
1127                tx.update(&mut tab)?;
1128            }
1129            return Ok(());
1130        }
1131
1132        // Cross-tab: remove from src.
1133        if let Some(mut src_tab) = tx.get::<Tab>(src_tab_id)? {
1134            let before = src_tab.blockids.len();
1135            src_tab.blockids.retain(|id| id != block_id);
1136            if src_tab.blockids.len() != before {
1137                tx.update(&mut src_tab)?;
1138            }
1139        }
1140
1141        // Insert into dst (skip if already there).
1142        if let Some(mut dst_tab) = tx.get::<Tab>(dst_tab_id)? {
1143            if !dst_tab.blockids.iter().any(|id| id == block_id) {
1144                let clamped = (dst_index as usize).min(dst_tab.blockids.len());
1145                dst_tab.blockids.insert(clamped, block_id.to_string());
1146                tx.update(&mut dst_tab)?;
1147            }
1148        }
1149
1150        // Block row: update parent.
1151        if let Some(mut block) = tx.get::<Block>(block_id)? {
1152            let new_parent = format!("tab:{}", dst_tab_id);
1153            if block.parentoref != new_parent {
1154                block.parentoref = new_parent;
1155                tx.update(&mut block)?;
1156            }
1157        }
1158
1159        Ok(())
1160    })?;
1161    Ok(())
1162}
1163
1164/// Phase E.5.3 — shallow merge a JSON object patch into a
1165/// `MetaMapType`. Mirrors `backend::obj::merge_meta` semantics so
1166/// `UpdateObjectMeta` keeps behaving the same way after the reducer
1167/// migration:
1168/// - Keys ending in `:*` with a `true` value clear all keys with
1169///   that prefix (e.g. `{"term:*": true}` removes every `term*`
1170///   key) before regular merging.
1171/// - `null` patch values delete the corresponding key.
1172/// - Other values replace the key.
1173/// Returns `true` if anything actually changed.
1174fn merge_meta_patch(
1175    meta: &mut crate::backend::obj::MetaMapType,
1176    patch: &serde_json::Value,
1177) -> bool {
1178    let serde_json::Value::Object(patch_map) = patch else {
1179        return false;
1180    };
1181    let mut changed = false;
1182    // First pass: section-clear keys (`prefix:*` with `true`).
1183    for (k, v) in patch_map {
1184        if !k.ends_with(":*") {
1185            continue;
1186        }
1187        if !matches!(v, serde_json::Value::Bool(true)) {
1188            continue;
1189        }
1190        let prefix = k.trim_end_matches(":*");
1191        if prefix.is_empty() {
1192            continue;
1193        }
1194        let prefix_colon = format!("{prefix}:");
1195        let before = meta.len();
1196        meta.retain(|k2, _| k2 != prefix && !k2.starts_with(&prefix_colon));
1197        if meta.len() != before {
1198            changed = true;
1199        }
1200    }
1201    // Second pass: regular merges and null deletes.
1202    for (k, v) in patch_map {
1203        if k.ends_with(":*") {
1204            continue;
1205        }
1206        if v.is_null() {
1207            if meta.remove(k).is_some() {
1208                changed = true;
1209            }
1210            continue;
1211        }
1212        match meta.get(k) {
1213            Some(existing) if existing == v => {}
1214            _ => {
1215                meta.insert(k.clone(), v.clone());
1216                changed = true;
1217            }
1218        }
1219    }
1220    changed
1221}
1222
1223/// Compact textual identifier for an event (debug logging only).
1224fn event_kind(event: &Event) -> &'static str {
1225    match event {
1226        Event::WorkspaceCreated { .. } => "WorkspaceCreated",
1227        Event::WorkspaceDeleted { .. } => "WorkspaceDeleted",
1228        Event::TabCreated { .. } => "TabCreated",
1229        Event::TabDeleted { .. } => "TabDeleted",
1230        Event::ActiveTabChanged { .. } => "ActiveTabChanged",
1231        Event::TabReordered { .. } => "TabReordered",
1232        Event::SrvWindowOpened { .. } => "SrvWindowOpened",
1233        Event::SrvWindowClosed { .. } => "SrvWindowClosed",
1234        Event::SrvWindowWorkspaceChanged { .. } => "SrvWindowWorkspaceChanged",
1235        Event::TabsReorderedBulk { .. } => "TabsReorderedBulk",
1236        Event::WorkspaceRenamed { .. } => "WorkspaceRenamed",
1237        Event::TabRenamed { .. } => "TabRenamed",
1238        Event::WorkspaceMetaUpdated { .. } => "WorkspaceMetaUpdated",
1239        Event::WindowMetaUpdated { .. } => "WindowMetaUpdated",
1240        Event::TabMetaUpdated { .. } => "TabMetaUpdated",
1241        Event::BlockMetaUpdated { .. } => "BlockMetaUpdated",
1242        Event::TabMoved { .. } => "TabMoved",
1243        Event::BlockMoved { .. } => "BlockMoved",
1244        Event::BlockCreated { .. } => "BlockCreated",
1245        Event::BlockDeleted { .. } => "BlockDeleted",
1246        Event::FocusedNodeChanged { .. } => "FocusedNodeChanged",
1247        Event::MagnifiedNodeChanged { .. } => "MagnifiedNodeChanged",
1248        _ => "Other",
1249    }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255    use crate::backend::storage::store::Store;
1256
1257    fn store() -> Arc<Store> {
1258        // In-memory SQLite for tests (matches existing mstore test pattern).
1259        let store = Store::open_in_memory().expect("in-memory mstore");
1260        Arc::new(store)
1261    }
1262
1263    #[test]
1264    fn workspace_created_inserts_row() {
1265        let s = store();
1266        apply_event_to_mstore(
1267            &Event::WorkspaceCreated {
1268                workspace_id: "ws-1".into(),
1269                name: "Alpha".into(),
1270                version: 1,
1271            },
1272            &s,
1273        )
1274        .unwrap();
1275        let ws = s.get::<Workspace>("ws-1").unwrap().unwrap();
1276        assert_eq!(ws.name, "Alpha");
1277    }
1278
1279    #[test]
1280    fn workspace_created_idempotent_on_duplicate() {
1281        let s = store();
1282        let ev = Event::WorkspaceCreated {
1283            workspace_id: "ws-1".into(),
1284            name: "Alpha".into(),
1285            version: 1,
1286        };
1287        apply_event_to_mstore(&ev, &s).unwrap();
1288        // Second application should not error.
1289        apply_event_to_mstore(&ev, &s).unwrap();
1290        // Original name preserved (no overwrite).
1291        let ws = s.get::<Workspace>("ws-1").unwrap().unwrap();
1292        assert_eq!(ws.name, "Alpha");
1293    }
1294
1295    #[test]
1296    fn workspace_deleted_silent_when_missing() {
1297        let s = store();
1298        apply_event_to_mstore(
1299            &Event::WorkspaceDeleted {
1300                workspace_id: "ghost".into(),
1301                block_ids: Vec::new(),
1302                version: 1,
1303            },
1304            &s,
1305        )
1306        .unwrap();
1307    }
1308
1309    #[test]
1310    fn tab_created_links_into_workspace() {
1311        let s = store();
1312        apply_event_to_mstore(
1313            &Event::WorkspaceCreated {
1314                workspace_id: "ws-1".into(),
1315                name: "Alpha".into(),
1316                version: 1,
1317            },
1318            &s,
1319        )
1320        .unwrap();
1321        apply_event_to_mstore(
1322            &Event::TabCreated {
1323                workspace_id: "ws-1".into(),
1324                tab_id: "tab-1".into(),
1325                name: "Tab".into(),
1326                version: 2,
1327            },
1328            &s,
1329        )
1330        .unwrap();
1331        let ws = s.get::<Workspace>("ws-1").unwrap().unwrap();
1332        assert_eq!(ws.tabids, vec!["tab-1".to_string()]);
1333        let tab = s.get::<Tab>("tab-1").unwrap().unwrap();
1334        assert_eq!(tab.name, "Tab");
1335    }
1336
1337    #[test]
1338    fn tab_created_idempotent_on_duplicate_link() {
1339        let s = store();
1340        apply_event_to_mstore(
1341            &Event::WorkspaceCreated {
1342                workspace_id: "ws-1".into(),
1343                name: "Alpha".into(),
1344                version: 1,
1345            },
1346            &s,
1347        )
1348        .unwrap();
1349        let ev = Event::TabCreated {
1350            workspace_id: "ws-1".into(),
1351            tab_id: "tab-1".into(),
1352            name: "Tab".into(),
1353            version: 2,
1354        };
1355        apply_event_to_mstore(&ev, &s).unwrap();
1356        apply_event_to_mstore(&ev, &s).unwrap();
1357        let ws = s.get::<Workspace>("ws-1").unwrap().unwrap();
1358        assert_eq!(ws.tabids.len(), 1);
1359    }
1360
1361    #[test]
1362    fn active_tab_changed_updates_workspace() {
1363        let s = store();
1364        apply_event_to_mstore(
1365            &Event::WorkspaceCreated {
1366                workspace_id: "ws-1".into(),
1367                name: "Alpha".into(),
1368                version: 1,
1369            },
1370            &s,
1371        )
1372        .unwrap();
1373        apply_event_to_mstore(
1374            &Event::TabCreated {
1375                workspace_id: "ws-1".into(),
1376                tab_id: "tab-1".into(),
1377                name: "Tab".into(),
1378                version: 2,
1379            },
1380            &s,
1381        )
1382        .unwrap();
1383        apply_event_to_mstore(
1384            &Event::ActiveTabChanged {
1385                workspace_id: "ws-1".into(),
1386                tab_id: Some("tab-1".into()),
1387                version: 3,
1388            },
1389            &s,
1390        )
1391        .unwrap();
1392        let ws = s.get::<Workspace>("ws-1").unwrap().unwrap();
1393        assert_eq!(ws.activetabid, "tab-1");
1394    }
1395
1396    #[test]
1397    fn active_tab_changed_to_none_clears_activetabid() {
1398        let s = store();
1399        apply_event_to_mstore(
1400            &Event::WorkspaceCreated {
1401                workspace_id: "ws-1".into(),
1402                name: "Alpha".into(),
1403                version: 1,
1404            },
1405            &s,
1406        )
1407        .unwrap();
1408        apply_event_to_mstore(
1409            &Event::TabCreated {
1410                workspace_id: "ws-1".into(),
1411                tab_id: "tab-1".into(),
1412                name: "Tab".into(),
1413                version: 2,
1414            },
1415            &s,
1416        )
1417        .unwrap();
1418        apply_event_to_mstore(
1419            &Event::ActiveTabChanged {
1420                workspace_id: "ws-1".into(),
1421                tab_id: Some("tab-1".into()),
1422                version: 3,
1423            },
1424            &s,
1425        )
1426        .unwrap();
1427        apply_event_to_mstore(
1428            &Event::ActiveTabChanged {
1429                workspace_id: "ws-1".into(),
1430                tab_id: None,
1431                version: 4,
1432            },
1433            &s,
1434        )
1435        .unwrap();
1436        let ws = s.get::<Workspace>("ws-1").unwrap().unwrap();
1437        assert_eq!(ws.activetabid, "");
1438    }
1439
1440    #[test]
1441    fn tab_created_provisions_layoutstate() {
1442        let s = store();
1443        apply_event_to_mstore(
1444            &Event::WorkspaceCreated {
1445                workspace_id: "ws-1".into(),
1446                name: "Alpha".into(),
1447                version: 1,
1448            },
1449            &s,
1450        )
1451        .unwrap();
1452        apply_event_to_mstore(
1453            &Event::TabCreated {
1454                workspace_id: "ws-1".into(),
1455                tab_id: "tab-1".into(),
1456                name: "Tab".into(),
1457                version: 2,
1458            },
1459            &s,
1460        )
1461        .unwrap();
1462        let tab = s.get::<Tab>("tab-1").unwrap().unwrap();
1463        // codex P2 #614: tab must reference a real LayoutState row,
1464        // not be left with empty layoutstate.
1465        assert!(!tab.layoutstate.is_empty());
1466        let layout = s
1467            .get::<PersistedLayoutState>(&tab.layoutstate)
1468            .unwrap()
1469            .expect("layout row must exist");
1470        assert_eq!(layout.oid, tab.layoutstate);
1471    }
1472
1473    // ── Layout-tree persistence (Phase E.4.B) ───────────────────────────
1474    fn leaf(id: &str, block: &str) -> agentmux_common::LayoutNode {
1475        agentmux_common::LayoutNode {
1476            id: id.into(),
1477            data: Some(agentmux_common::LayoutNodeData {
1478                block_id: block.into(),
1479                ..Default::default()
1480            }),
1481            ..Default::default()
1482        }
1483    }
1484
1485    /// Create ws-1 + tab-1 (which provisions a real LayoutState row) and
1486    /// return the tab id.
1487    fn ws_tab(s: &Store) -> String {
1488        apply_event_to_mstore(
1489            &Event::WorkspaceCreated {
1490                workspace_id: "ws-1".into(),
1491                name: "A".into(),
1492                version: 1,
1493            },
1494            s,
1495        )
1496        .unwrap();
1497        apply_event_to_mstore(
1498            &Event::TabCreated {
1499                workspace_id: "ws-1".into(),
1500                tab_id: "tab-1".into(),
1501                name: "T".into(),
1502                version: 2,
1503            },
1504            s,
1505        )
1506        .unwrap();
1507        "tab-1".to_string()
1508    }
1509
1510    fn layout_of(s: &Store, tab_id: &str) -> PersistedLayoutState {
1511        let tab = s.get::<Tab>(tab_id).unwrap().unwrap();
1512        s.get::<PersistedLayoutState>(&tab.layoutstate)
1513            .unwrap()
1514            .unwrap()
1515    }
1516
1517    #[test]
1518    fn layout_tree_replaced_persists_rootnode() {
1519        let s = store();
1520        let tab = ws_tab(&s);
1521        apply_event_to_mstore(
1522            &Event::LayoutTreeReplaced {
1523                tab_id: tab.clone(),
1524                new_tree: Some(leaf("n1", "b1")),
1525                correlation_id: "c1".into(),
1526                slices: None,
1527                version: 3,
1528            },
1529            &s,
1530        )
1531        .unwrap();
1532        let root = layout_of(&s, &tab).rootnode.expect("rootnode set");
1533        assert_eq!(root.id, "n1");
1534        assert_eq!(root.data.unwrap().block_id, "b1");
1535    }
1536
1537    #[test]
1538    fn layout_granular_event_persists_new_tree() {
1539        // The 7 granular structural events each carry the reducer's resulting
1540        // tree in `new_tree`; the unified subscriber arm writes it to
1541        // db_layout (same path as LayoutTreeReplaced). Representative check
1542        // via LayoutNodeMoved — the |-pattern routes all 7 identically.
1543        let s = store();
1544        let tab = ws_tab(&s);
1545        apply_event_to_mstore(
1546            &Event::LayoutNodeMoved {
1547                tab_id: tab.clone(),
1548                new_tree: Some(leaf("moved-root", "bm")),
1549                node_id: "x".into(),
1550                new_parent_id: "y".into(),
1551                index: 0,
1552                correlation_id: "c".into(),
1553                version: 3,
1554            },
1555            &s,
1556        )
1557        .unwrap();
1558        let root = layout_of(&s, &tab)
1559            .rootnode
1560            .expect("granular event persisted rootnode");
1561        assert_eq!(root.id, "moved-root");
1562    }
1563
1564    #[test]
1565    fn layout_granular_event_with_none_tree_does_not_clear() {
1566        // A version-skewed granular event (new_tree defaulted to None) must NOT
1567        // erase the persisted layout — these ops never produce an empty tree.
1568        // (codex P2 #1883.)
1569        let s = store();
1570        let tab = ws_tab(&s);
1571        apply_event_to_mstore(
1572            &Event::LayoutTreeReplaced {
1573                tab_id: tab.clone(),
1574                new_tree: Some(leaf("keep", "bk")),
1575                correlation_id: "seed".into(),
1576                slices: None,
1577                version: 2,
1578            },
1579            &s,
1580        )
1581        .unwrap();
1582        apply_event_to_mstore(
1583            &Event::LayoutNodeMoved {
1584                tab_id: tab.clone(),
1585                new_tree: None, // version skew — must be a no-op, not a clear
1586                node_id: "x".into(),
1587                new_parent_id: "y".into(),
1588                index: 0,
1589                correlation_id: "c".into(),
1590                version: 3,
1591            },
1592            &s,
1593        )
1594        .unwrap();
1595        let root = layout_of(&s, &tab)
1596            .rootnode
1597            .expect("tree preserved, not cleared by a None granular event");
1598        assert_eq!(root.id, "keep");
1599    }
1600
1601    // ── SPEC_864 site #6 — LayoutNodeDeleted persistence ────────────────────
1602
1603    #[test]
1604    fn layout_node_deleted_persists_new_tree() {
1605        let s = store();
1606        let tab = ws_tab(&s);
1607        apply_event_to_mstore(
1608            &Event::LayoutNodeDeleted {
1609                tab_id: tab.clone(),
1610                node_id: "gone".into(),
1611                new_tree: Some(leaf("survivor", "bs")),
1612                tree_cleared: false,
1613                was_focused: false,
1614                was_magnified: false,
1615                correlation_id: "c".into(),
1616                version: 3,
1617            },
1618            &s,
1619        )
1620        .unwrap();
1621        let root = layout_of(&s, &tab)
1622            .rootnode
1623            .expect("delete event persisted the resulting tree");
1624        assert_eq!(root.id, "survivor");
1625    }
1626
1627    #[test]
1628    fn layout_node_deleted_none_without_cleared_is_ignored() {
1629        // A version-skewed LayoutNodeDeleted (pre-site-#6 JSON: no
1630        // new_tree, tree_cleared defaults false) must NOT erase the
1631        // persisted layout — same skew rule as the granular group.
1632        let s = store();
1633        let tab = ws_tab(&s);
1634        apply_event_to_mstore(
1635            &Event::LayoutTreeReplaced {
1636                tab_id: tab.clone(),
1637                new_tree: Some(leaf("keep", "bk")),
1638                correlation_id: "seed".into(),
1639                slices: None,
1640                version: 2,
1641            },
1642            &s,
1643        )
1644        .unwrap();
1645        apply_event_to_mstore(
1646            &Event::LayoutNodeDeleted {
1647                tab_id: tab.clone(),
1648                node_id: "x".into(),
1649                new_tree: None,
1650                tree_cleared: false, // skewed sender — not a real clear
1651                was_focused: false,
1652                was_magnified: false,
1653                correlation_id: "c".into(),
1654                version: 3,
1655            },
1656            &s,
1657        )
1658        .unwrap();
1659        let root = layout_of(&s, &tab)
1660            .rootnode
1661            .expect("tree preserved — skewed delete event must not clear");
1662        assert_eq!(root.id, "keep");
1663    }
1664
1665    #[test]
1666    fn layout_node_deleted_tree_cleared_erases_tree() {
1667        // Root-orphan delete: new_tree=None + tree_cleared=true is a REAL
1668        // clear (the deleted node was the root) and must wipe rootnode.
1669        let s = store();
1670        let tab = ws_tab(&s);
1671        apply_event_to_mstore(
1672            &Event::LayoutTreeReplaced {
1673                tab_id: tab.clone(),
1674                new_tree: Some(leaf("doomed-root", "bd")),
1675                correlation_id: "seed".into(),
1676                slices: None,
1677                version: 2,
1678            },
1679            &s,
1680        )
1681        .unwrap();
1682        apply_event_to_mstore(
1683            &Event::LayoutNodeDeleted {
1684                tab_id: tab.clone(),
1685                node_id: "doomed-root".into(),
1686                new_tree: None,
1687                tree_cleared: true,
1688                was_focused: true,
1689                was_magnified: false,
1690                correlation_id: "c".into(),
1691                version: 3,
1692            },
1693            &s,
1694        )
1695        .unwrap();
1696        assert!(
1697            layout_of(&s, &tab).rootnode.is_none(),
1698            "tree_cleared delete must wipe the persisted tree"
1699        );
1700    }
1701
1702    #[test]
1703    fn layout_cleared_wipes_tree_and_ids() {
1704        let s = store();
1705        let tab = ws_tab(&s);
1706        apply_event_to_mstore(
1707            &Event::LayoutTreeReplaced {
1708                tab_id: tab.clone(),
1709                new_tree: Some(leaf("n1", "b1")),
1710                correlation_id: "c".into(),
1711                slices: None,
1712                version: 3,
1713            },
1714            &s,
1715        )
1716        .unwrap();
1717        apply_event_to_mstore(
1718            &Event::FocusedNodeChanged {
1719                tab_id: tab.clone(),
1720                node_id: "n1".into(),
1721                version: 4,
1722            },
1723            &s,
1724        )
1725        .unwrap();
1726        apply_event_to_mstore(
1727            &Event::MagnifiedNodeChanged {
1728                tab_id: tab.clone(),
1729                node_id: "n1".into(),
1730                version: 5,
1731            },
1732            &s,
1733        )
1734        .unwrap();
1735        apply_event_to_mstore(
1736            &Event::LayoutCleared {
1737                tab_id: tab.clone(),
1738                correlation_id: "c".into(),
1739                version: 6,
1740            },
1741            &s,
1742        )
1743        .unwrap();
1744        let layout = layout_of(&s, &tab);
1745        assert!(layout.rootnode.is_none());
1746        assert!(layout.focusednodeid.is_empty());
1747        assert!(layout.magnifiednodeid.is_empty());
1748        assert!(layout.leaforder.is_none());
1749    }
1750
1751    #[test]
1752    fn layout_tree_replaced_empty_clears_focus_magnify() {
1753        let s = store();
1754        let tab = ws_tab(&s);
1755        apply_event_to_mstore(
1756            &Event::LayoutTreeReplaced {
1757                tab_id: tab.clone(),
1758                new_tree: Some(leaf("n1", "b1")),
1759                correlation_id: "c".into(),
1760                slices: None,
1761                version: 3,
1762            },
1763            &s,
1764        )
1765        .unwrap();
1766        apply_event_to_mstore(
1767            &Event::FocusedNodeChanged {
1768                tab_id: tab.clone(),
1769                node_id: "n1".into(),
1770                version: 4,
1771            },
1772            &s,
1773        )
1774        .unwrap();
1775        apply_event_to_mstore(
1776            &Event::LayoutTreeReplaced {
1777                tab_id: tab.clone(),
1778                new_tree: None,
1779                correlation_id: "c".into(),
1780                slices: None,
1781                version: 5,
1782            },
1783            &s,
1784        )
1785        .unwrap();
1786        let layout = layout_of(&s, &tab);
1787        assert!(layout.rootnode.is_none());
1788        assert!(layout.focusednodeid.is_empty());
1789        assert!(layout.magnifiednodeid.is_empty());
1790    }
1791
1792    #[test]
1793    fn layout_tree_replaced_idempotent_no_version_churn() {
1794        let s = store();
1795        let tab = ws_tab(&s);
1796        let ev = Event::LayoutTreeReplaced {
1797            tab_id: tab.clone(),
1798            new_tree: Some(leaf("n1", "b1")),
1799            correlation_id: "c".into(),
1800            slices: None,
1801            version: 3,
1802        };
1803        apply_event_to_mstore(&ev, &s).unwrap();
1804        let v1 = layout_of(&s, &tab).version;
1805        apply_event_to_mstore(&ev, &s).unwrap();
1806        let v2 = layout_of(&s, &tab).version;
1807        assert_eq!(v1, v2, "idempotent re-apply must not bump version");
1808    }
1809
1810    // ── SPEC_864 Phase 2 — client slices on LayoutTreeReplaced ─────────
1811
1812    fn slices(
1813        leaforder: Option<serde_json::Value>,
1814        focused: &str,
1815        magnified: &str,
1816        pending: Option<serde_json::Value>,
1817    ) -> agentmux_common::LayoutClientSlices {
1818        agentmux_common::LayoutClientSlices {
1819            leaforder,
1820            focused_node_id: focused.into(),
1821            magnified_node_id: magnified.into(),
1822            pending_backend_actions: pending,
1823        }
1824    }
1825
1826    #[test]
1827    fn layout_tree_replaced_with_slices_writes_full_row() {
1828        let s = store();
1829        let tab = ws_tab(&s);
1830        apply_event_to_mstore(
1831            &Event::LayoutTreeReplaced {
1832                tab_id: tab.clone(),
1833                new_tree: Some(leaf("n1", "b1")),
1834                correlation_id: "c".into(),
1835                slices: Some(slices(
1836                    Some(serde_json::json!([{ "nodeid": "n1", "blockid": "b1" }])),
1837                    "n1",
1838                    "n1",
1839                    None,
1840                )),
1841                version: 3,
1842            },
1843            &s,
1844        )
1845        .unwrap();
1846        let layout = layout_of(&s, &tab);
1847        assert_eq!(layout.rootnode.unwrap().id, "n1");
1848        assert_eq!(layout.focusednodeid, "n1");
1849        assert_eq!(layout.magnifiednodeid, "n1");
1850        let lo = layout.leaforder.expect("leaforder written");
1851        assert_eq!(lo.len(), 1);
1852        assert_eq!(lo[0].nodeid, "n1");
1853        assert_eq!(lo[0].blockid, "b1");
1854    }
1855
1856    #[test]
1857    fn layout_tree_replaced_slices_ack_clears_pending_actions() {
1858        // Pushing the row without pendingbackendactions is how the frontend
1859        // acks its processed backend-action queue — the slice-carrying write
1860        // must CLEAR the column, not leave it (else actions re-apply on the
1861        // next reload).
1862        let s = store();
1863        let tab = ws_tab(&s);
1864        // Seed a pending action directly (as queue_target_layout_* would).
1865        {
1866            let t = s.get::<Tab>(&tab).unwrap().unwrap();
1867            let mut layout = s
1868                .get::<PersistedLayoutState>(&t.layoutstate)
1869                .unwrap()
1870                .unwrap();
1871            layout.pendingbackendactions = Some(vec![LayoutActionData {
1872                actiontype: "insert".into(),
1873                actionid: "a1".into(),
1874                blockid: "b-new".into(),
1875                nodesize: None,
1876                nodesizefraction: None,
1877                indexarr: None,
1878                focused: true,
1879                magnified: false,
1880                ephemeral: false,
1881                targetblockid: String::new(),
1882                position: String::new(),
1883            }]);
1884            s.update(&mut layout).unwrap();
1885        }
1886        apply_event_to_mstore(
1887            &Event::LayoutTreeReplaced {
1888                tab_id: tab.clone(),
1889                new_tree: Some(leaf("n1", "b1")),
1890                correlation_id: "c".into(),
1891                slices: Some(slices(None, "n1", "", None)),
1892                version: 4,
1893            },
1894            &s,
1895        )
1896        .unwrap();
1897        assert!(
1898            layout_of(&s, &tab).pendingbackendactions.is_none(),
1899            "slice write with absent pending queue must clear the column (ack path)"
1900        );
1901    }
1902
1903    #[test]
1904    fn layout_tree_replaced_without_slices_leaves_client_columns_untouched() {
1905        // Tree-only events (granular arms, dormant callers) must not clobber
1906        // focus/magnify/leaforder/pending — only a slice-carrying full-row
1907        // push owns those columns.
1908        let s = store();
1909        let tab = ws_tab(&s);
1910        apply_event_to_mstore(
1911            &Event::LayoutTreeReplaced {
1912                tab_id: tab.clone(),
1913                new_tree: Some(leaf("n1", "b1")),
1914                correlation_id: "seed".into(),
1915                slices: Some(slices(
1916                    Some(serde_json::json!([{ "nodeid": "n1", "blockid": "b1" }])),
1917                    "n1",
1918                    "",
1919                    Some(serde_json::json!([{
1920                        "actiontype": "insert",
1921                        "actionid": "a1",
1922                        "blockid": "b2",
1923                        "focused": false,
1924                        "magnified": false,
1925                        "ephemeral": false,
1926                    }])),
1927                )),
1928                version: 3,
1929            },
1930            &s,
1931        )
1932        .unwrap();
1933        // Now a tree-only replace (slices: None) with a different tree.
1934        apply_event_to_mstore(
1935            &Event::LayoutTreeReplaced {
1936                tab_id: tab.clone(),
1937                new_tree: Some(leaf("n2", "b2")),
1938                correlation_id: "c".into(),
1939                slices: None,
1940                version: 4,
1941            },
1942            &s,
1943        )
1944        .unwrap();
1945        let layout = layout_of(&s, &tab);
1946        assert_eq!(layout.rootnode.unwrap().id, "n2", "tree replaced");
1947        assert_eq!(layout.focusednodeid, "n1", "focus untouched");
1948        assert!(layout.leaforder.is_some(), "leaforder untouched");
1949        assert!(
1950            layout.pendingbackendactions.is_some(),
1951            "pending queue untouched by tree-only write"
1952        );
1953    }
1954
1955    #[test]
1956    fn layout_events_silent_when_tab_missing() {
1957        let s = store();
1958        apply_event_to_mstore(
1959            &Event::LayoutCleared {
1960                tab_id: "ghost".into(),
1961                correlation_id: "c".into(),
1962                version: 1,
1963            },
1964            &s,
1965        )
1966        .unwrap();
1967        apply_event_to_mstore(
1968            &Event::LayoutTreeReplaced {
1969                tab_id: "ghost".into(),
1970                new_tree: Some(leaf("n", "b")),
1971                correlation_id: "c".into(),
1972                slices: None,
1973                version: 1,
1974            },
1975            &s,
1976        )
1977        .unwrap();
1978    }
1979
1980    #[test]
1981    fn workspace_deleted_cascades_pinned_tabs() {
1982        let s = store();
1983        // Build a workspace with a pinned tab via wcore (the bug
1984        // path: pinned tab created via wcore::create_tab_with_opts).
1985        let ws = wcore::create_workspace(&s, "Alpha").unwrap();
1986        let pinned_tab =
1987            wcore::create_tab_with_opts(&s, &ws.oid, "PinnedTab", true).unwrap();
1988        // Verify pinned tab is in pinnedtabids (sanity).
1989        let ws_loaded = s.get::<Workspace>(&ws.oid).unwrap().unwrap();
1990        assert!(ws_loaded.pinnedtabids.contains(&pinned_tab.oid));
1991        // Delete via the subscriber's WorkspaceDeleted handler.
1992        apply_event_to_mstore(
1993            &Event::WorkspaceDeleted {
1994                workspace_id: ws.oid.clone(),
1995                block_ids: Vec::new(),
1996                version: 1,
1997            },
1998            &s,
1999        )
2000        .unwrap();
2001        // codex P1 #614: pinned tab must be cascade-deleted, not
2002        // orphaned.
2003        assert!(s.get::<Tab>(&pinned_tab.oid).unwrap().is_none());
2004        assert!(s.get::<Workspace>(&ws.oid).unwrap().is_none());
2005    }
2006
2007    #[test]
2008    fn block_created_links_into_tab() {
2009        let s = store();
2010        apply_event_to_mstore(
2011            &Event::WorkspaceCreated {
2012                workspace_id: "ws-1".into(),
2013                name: "Alpha".into(),
2014                version: 1,
2015            },
2016            &s,
2017        )
2018        .unwrap();
2019        apply_event_to_mstore(
2020            &Event::TabCreated {
2021                workspace_id: "ws-1".into(),
2022                tab_id: "tab-1".into(),
2023                name: "Tab".into(),
2024                version: 2,
2025            },
2026            &s,
2027        )
2028        .unwrap();
2029        apply_event_to_mstore(
2030            &Event::BlockCreated { tab_id: "tab-1".into(), block_id: "block-1".into(), meta: serde_json::Value::Null, version: 3 },
2031            &s,
2032        )
2033        .unwrap();
2034        let tab = s.get::<Tab>("tab-1").unwrap().unwrap();
2035        assert_eq!(tab.blockids, vec!["block-1".to_string()]);
2036        let block = s.get::<Block>("block-1").unwrap().unwrap();
2037        assert_eq!(block.parentoref, "tab:tab-1");
2038    }
2039
2040    #[test]
2041    fn block_deleted_unlinks_from_tab() {
2042        let s = store();
2043        apply_event_to_mstore(
2044            &Event::WorkspaceCreated {
2045                workspace_id: "ws-1".into(),
2046                name: "Alpha".into(),
2047                version: 1,
2048            },
2049            &s,
2050        )
2051        .unwrap();
2052        apply_event_to_mstore(
2053            &Event::TabCreated {
2054                workspace_id: "ws-1".into(),
2055                tab_id: "tab-1".into(),
2056                name: "Tab".into(),
2057                version: 2,
2058            },
2059            &s,
2060        )
2061        .unwrap();
2062        apply_event_to_mstore(
2063            &Event::BlockCreated { tab_id: "tab-1".into(), block_id: "block-1".into(), meta: serde_json::Value::Null, version: 3 },
2064            &s,
2065        )
2066        .unwrap();
2067        apply_event_to_mstore(
2068            &Event::BlockDeleted {
2069                tab_id: "tab-1".into(),
2070                block_id: "block-1".into(),
2071                version: 4,
2072            },
2073            &s,
2074        )
2075        .unwrap();
2076        assert!(s.get::<Block>("block-1").unwrap().is_none());
2077        let tab = s.get::<Tab>("tab-1").unwrap().unwrap();
2078        assert!(tab.blockids.is_empty());
2079    }
2080
2081    /// codex P1 #620: a workspace with legacy `pinnedtabids` rows must
2082    /// have those drained the first time `TabsReorderedBulk` writes
2083    /// the workspace; otherwise the UI's
2084    /// `[...pinnedtabids, ...tabids]` combine duplicates the pinned
2085    /// tab IDs once they have been merged into the reducer's
2086    /// `tab_ids` at bootstrap.
2087    #[test]
2088    fn tabs_reordered_bulk_drains_legacy_pinned_tabids() {
2089        let s = store();
2090        let ws = wcore::create_workspace(&s, "Alpha").unwrap();
2091        let pinned_tab =
2092            wcore::create_tab_with_opts(&s, &ws.oid, "Pinned", true).unwrap();
2093        let regular_tab =
2094            wcore::create_tab_with_opts(&s, &ws.oid, "Regular", false).unwrap();
2095        // Sanity: pinned tab is in `pinnedtabids` on disk.
2096        let ws_before = s.get::<Workspace>(&ws.oid).unwrap().unwrap();
2097        assert!(ws_before.pinnedtabids.contains(&pinned_tab.oid));
2098
2099        // Reducer-driven bulk reorder treating the pinned tab as a
2100        // regular tab (mirrors what bootstrap-merge produces).
2101        apply_event_to_mstore(
2102            &Event::TabsReorderedBulk {
2103                workspace_id: ws.oid.clone(),
2104                tab_ids: vec![pinned_tab.oid.clone(), regular_tab.oid.clone()],
2105                version: ws_before.version as u64 + 1,
2106            },
2107            &s,
2108        )
2109        .unwrap();
2110        let ws_after = s.get::<Workspace>(&ws.oid).unwrap().unwrap();
2111        assert_eq!(
2112            ws_after.tabids,
2113            vec![pinned_tab.oid.clone(), regular_tab.oid.clone()]
2114        );
2115        assert!(
2116            ws_after.pinnedtabids.is_empty(),
2117            "pinnedtabids must be drained, was {:?}",
2118            ws_after.pinnedtabids
2119        );
2120    }
2121
2122    /// codex P2 #620: `merge_meta_patch` must honour the existing
2123    /// `section:*` clear-prefix semantics so `UpdateObjectMeta`
2124    /// behaviour stays the same after the reducer migration.
2125    #[test]
2126    fn meta_updated_clears_section_prefix() {
2127        let s = store();
2128        apply_event_to_mstore(
2129            &Event::TabCreated {
2130                workspace_id: "ws-1".into(),
2131                tab_id: "tab-1".into(),
2132                name: "Tab".into(),
2133                version: 1,
2134            },
2135            &s,
2136        )
2137        .unwrap();
2138        apply_event_to_mstore(
2139            &Event::WorkspaceCreated {
2140                workspace_id: "ws-1".into(),
2141                name: "Alpha".into(),
2142                version: 1,
2143            },
2144            &s,
2145        )
2146        .unwrap();
2147        // Seed the tab with grouped meta entries.
2148        let mut tab = s.get::<Tab>("tab-1").unwrap().unwrap();
2149        tab.meta
2150            .insert("term:fontsize".into(), serde_json::json!(14));
2151        tab.meta
2152            .insert("term:theme".into(), serde_json::json!("solarized"));
2153        tab.meta.insert("name".into(), serde_json::json!("keep"));
2154        s.update(&mut tab).unwrap();
2155        // Patch with `term:*` clear plus a single replacement key.
2156        apply_event_to_mstore(
2157            &Event::TabMetaUpdated {
2158                tab_id: "tab-1".into(),
2159                meta_patch: serde_json::json!({
2160                    "term:*": true,
2161                    "term:fontsize": 18,
2162                }),
2163                version: 2,
2164            },
2165            &s,
2166        )
2167        .unwrap();
2168        let after = s.get::<Tab>("tab-1").unwrap().unwrap();
2169        assert!(!after.meta.contains_key("term:theme"),
2170            "term:theme should be cleared by `term:*` patch");
2171        assert_eq!(
2172            after.meta.get("term:fontsize"),
2173            Some(&serde_json::json!(18)),
2174            "term:fontsize replacement must take effect after the section clear"
2175        );
2176        assert_eq!(after.meta.get("name"), Some(&serde_json::json!("keep")));
2177    }
2178
2179    // ---- Phase E.5.5 — TabMoved / BlockMoved subscriber tests ----
2180
2181    #[test]
2182    fn tab_moved_cross_workspace_rewrites_both_tabids() {
2183        let s = store();
2184        // Two workspaces, each pre-existing in SQLite.
2185        for (id, name) in &[("src-ws", "Src"), ("dst-ws", "Dst")] {
2186            apply_event_to_mstore(
2187                &Event::WorkspaceCreated {
2188                    workspace_id: id.to_string(),
2189                    name: name.to_string(),
2190                    version: 1,
2191                },
2192                &s,
2193            )
2194            .unwrap();
2195        }
2196        // Tab in src.
2197        apply_event_to_mstore(
2198            &Event::TabCreated {
2199                workspace_id: "src-ws".into(),
2200                tab_id: "tab-1".into(),
2201                name: "T".into(),
2202                version: 2,
2203            },
2204            &s,
2205        )
2206        .unwrap();
2207        let src = s.get::<Workspace>("src-ws").unwrap().unwrap();
2208        assert_eq!(src.tabids, vec!["tab-1".to_string()]);
2209
2210        // Move it. Set dst active to the moved tab (per reducer
2211        // semantics + codex P2 #621).
2212        apply_event_to_mstore(
2213            &Event::TabMoved {
2214                tab_id: "tab-1".into(),
2215                src_workspace_id: "src-ws".into(),
2216                dst_workspace_id: "dst-ws".into(),
2217                dst_index: 0,
2218                new_src_active_tab_id: None,
2219                new_dst_active_tab_id: Some("tab-1".into()),
2220                version: 3,
2221            },
2222            &s,
2223        )
2224        .unwrap();
2225        let src = s.get::<Workspace>("src-ws").unwrap().unwrap();
2226        let dst = s.get::<Workspace>("dst-ws").unwrap().unwrap();
2227        assert!(src.tabids.is_empty());
2228        assert_eq!(src.activetabid, "");
2229        assert_eq!(dst.tabids, vec!["tab-1".to_string()]);
2230        assert_eq!(dst.activetabid, "tab-1", "dst active should be the moved tab");
2231    }
2232
2233    #[test]
2234    fn tab_moved_idempotent_on_re_delivery() {
2235        let s = store();
2236        for (id, name) in &[("src-ws", "Src"), ("dst-ws", "Dst")] {
2237            apply_event_to_mstore(
2238                &Event::WorkspaceCreated {
2239                    workspace_id: id.to_string(),
2240                    name: name.to_string(),
2241                    version: 1,
2242                },
2243                &s,
2244            )
2245            .unwrap();
2246        }
2247        apply_event_to_mstore(
2248            &Event::TabCreated {
2249                workspace_id: "src-ws".into(),
2250                tab_id: "tab-1".into(),
2251                name: "T".into(),
2252                version: 2,
2253            },
2254            &s,
2255        )
2256        .unwrap();
2257        let ev = Event::TabMoved {
2258            tab_id: "tab-1".into(),
2259            src_workspace_id: "src-ws".into(),
2260            dst_workspace_id: "dst-ws".into(),
2261            dst_index: 0,
2262            new_src_active_tab_id: None,
2263            new_dst_active_tab_id: Some("tab-1".into()),
2264            version: 3,
2265        };
2266        apply_event_to_mstore(&ev, &s).unwrap();
2267        // Re-deliver the same event.
2268        apply_event_to_mstore(&ev, &s).unwrap();
2269        let dst = s.get::<Workspace>("dst-ws").unwrap().unwrap();
2270        // Still exactly one entry — no duplicate insert.
2271        assert_eq!(dst.tabids, vec!["tab-1".to_string()]);
2272    }
2273
2274    #[test]
2275    fn block_moved_cross_tab_updates_block_lists_and_parent() {
2276        let s = store();
2277        let ws = wcore::create_workspace(&s, "W").unwrap();
2278        let src_tab = wcore::create_tab_with_opts(&s, &ws.oid, "src", false).unwrap();
2279        let dst_tab = wcore::create_tab_with_opts(&s, &ws.oid, "dst", false).unwrap();
2280        // Create a block in src via the subscriber path.
2281        apply_event_to_mstore(
2282            &Event::BlockCreated {
2283                tab_id: src_tab.oid.clone(),
2284                block_id: "blk-1".into(),
2285                meta: serde_json::Value::Null,
2286                version: 1,
2287            },
2288            &s,
2289        )
2290        .unwrap();
2291        let src_before = s.get::<Tab>(&src_tab.oid).unwrap().unwrap();
2292        assert_eq!(src_before.blockids, vec!["blk-1".to_string()]);
2293
2294        apply_event_to_mstore(
2295            &Event::BlockMoved {
2296                block_id: "blk-1".into(),
2297                src_tab_id: src_tab.oid.clone(),
2298                dst_tab_id: dst_tab.oid.clone(),
2299                dst_index: 0,
2300                version: 2,
2301            },
2302            &s,
2303        )
2304        .unwrap();
2305        let src_after = s.get::<Tab>(&src_tab.oid).unwrap().unwrap();
2306        let dst_after = s.get::<Tab>(&dst_tab.oid).unwrap().unwrap();
2307        let block = s.get::<Block>("blk-1").unwrap().unwrap();
2308        assert!(src_after.blockids.is_empty());
2309        assert_eq!(dst_after.blockids, vec!["blk-1".to_string()]);
2310        assert_eq!(block.parentoref, format!("tab:{}", dst_tab.oid));
2311    }
2312
2313    #[test]
2314    fn block_moved_intra_tab_repositions() {
2315        let s = store();
2316        let ws = wcore::create_workspace(&s, "W").unwrap();
2317        let tab = wcore::create_tab_with_opts(&s, &ws.oid, "t", false).unwrap();
2318        for id in &["b1", "b2", "b3"] {
2319            apply_event_to_mstore(
2320                &Event::BlockCreated {
2321                    tab_id: tab.oid.clone(),
2322                    block_id: id.to_string(),
2323                    meta: serde_json::Value::Null,
2324                    version: 1,
2325                },
2326                &s,
2327            )
2328            .unwrap();
2329        }
2330        // Move b1 to position 2 (post-removal end).
2331        apply_event_to_mstore(
2332            &Event::BlockMoved {
2333                block_id: "b1".into(),
2334                src_tab_id: tab.oid.clone(),
2335                dst_tab_id: tab.oid.clone(),
2336                dst_index: 2,
2337                version: 2,
2338            },
2339            &s,
2340        )
2341        .unwrap();
2342        let tab_after = s.get::<Tab>(&tab.oid).unwrap().unwrap();
2343        assert_eq!(tab_after.blockids, vec!["b2".to_string(), "b3".to_string(), "b1".to_string()]);
2344    }
2345
2346    // ── SPEC_864 Phase 4 — pendingbackendactions APPEND semantics ──────────
2347
2348    fn action(id: &str, block: &str) -> LayoutActionData {
2349        LayoutActionData {
2350            actiontype: "insert".into(),
2351            actionid: id.into(),
2352            blockid: block.into(),
2353            nodesize: None,
2354            nodesizefraction: None,
2355            indexarr: None,
2356            focused: true,
2357            magnified: false,
2358            ephemeral: false,
2359            targetblockid: String::new(),
2360            position: String::new(),
2361        }
2362    }
2363
2364    #[test]
2365    fn layout_backend_actions_queued_appends_not_replaces() {
2366        let s = store();
2367        let tab = ws_tab(&s);
2368        apply_event_to_mstore(
2369            &Event::LayoutBackendActionsQueued {
2370                tab_id: tab.clone(),
2371                actions: serde_json::to_value(vec![action("a1", "b1")]).unwrap(),
2372                correlation_id: "c1".into(),
2373                version: 3,
2374            },
2375            &s,
2376        )
2377        .unwrap();
2378        apply_event_to_mstore(
2379            &Event::LayoutBackendActionsQueued {
2380                tab_id: tab.clone(),
2381                actions: serde_json::to_value(vec![action("a2", "b2")]).unwrap(),
2382                correlation_id: "c2".into(),
2383                version: 4,
2384            },
2385            &s,
2386        )
2387        .unwrap();
2388        let pending = layout_of(&s, &tab).pendingbackendactions.unwrap();
2389        assert_eq!(pending.len(), 2, "second event must APPEND, not replace");
2390        assert_eq!(pending[0].actionid, "a1");
2391        assert_eq!(pending[1].actionid, "a2");
2392    }
2393
2394    #[test]
2395    fn layout_backend_actions_queued_dedupes_on_double_apply() {
2396        // Same event applied twice (sync dispatch + broadcast redelivery)
2397        // must not duplicate the queued action.
2398        let s = store();
2399        let tab = ws_tab(&s);
2400        let ev = Event::LayoutBackendActionsQueued {
2401            tab_id: tab.clone(),
2402            actions: serde_json::to_value(vec![action("a1", "b1")]).unwrap(),
2403            correlation_id: "c1".into(),
2404            version: 3,
2405        };
2406        apply_event_to_mstore(&ev, &s).unwrap();
2407        apply_event_to_mstore(&ev, &s).unwrap();
2408        let pending = layout_of(&s, &tab).pendingbackendactions.unwrap();
2409        assert_eq!(pending.len(), 1, "double-apply must not duplicate by actionid");
2410    }
2411
2412    #[test]
2413    fn layout_backend_actions_queued_silent_when_tab_missing() {
2414        let s = store();
2415        apply_event_to_mstore(
2416            &Event::LayoutBackendActionsQueued {
2417                tab_id: "ghost".into(),
2418                actions: serde_json::to_value(vec![action("a1", "b1")]).unwrap(),
2419                correlation_id: "c1".into(),
2420                version: 1,
2421            },
2422            &s,
2423        )
2424        .unwrap();
2425    }
2426}