agentmux_srv\server/
service.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4
5use axum::{extract::State, response::Json};
6use serde_json::json;
7
8use crate::backend::blockcontroller;
9use crate::backend::service::{self, CloseTabRtnType, WebCallType, WebReturnType};
10use crate::backend::storage::store::Store;
11use crate::backend::obj::*;
12use crate::backend::wcore;
13
14use super::AppState;
15
16pub(super) async fn handle_service(
17    State(state): State<AppState>,
18    body: axum::body::Bytes,
19) -> Json<WebReturnType> {
20    let call: WebCallType = match serde_json::from_slice(&body) {
21        Ok(c) => c,
22        Err(e) => return Json(WebReturnType::error(format!("invalid request body: {e}"))),
23    };
24    Json(run_service_call(&state, &call).await)
25}
26
27/// Dispatch a service call and broadcast any resulting `WaveObjUpdate`s to the
28/// event bus — the shared core of `handle_service`. Factored out so the typed
29/// first-class agent-API verbs (e.g. `/api/v1/window/name`) get byte-identical
30/// persistence + broadcast to a raw `/agentmux/service` call without
31/// re-implementing it. See SPEC_AGENT_API_FIRST_CLASS_SURFACE_2026_06_17.md.
32pub(crate) async fn run_service_call(state: &AppState, call: &WebCallType) -> WebReturnType {
33    let service_start = std::time::Instant::now();
34    let result = dispatch_service(state, call).await;
35    let elapsed = service_start.elapsed();
36    tracing::info!(
37        "[http-perf] {}.{}: {:.2}ms",
38        call.service,
39        call.method,
40        elapsed.as_secs_f64() * 1000.0,
41    );
42
43    // Broadcast every WaveObjUpdate the handler returned so other
44    // clients (additional windows, test harnesses, etc.) learn about
45    // changes they didn't initiate. The calling HTTP client also gets
46    // `updates` in the response body — this broadcast is for
47    // everybody else on the event bus. Before this, only a handful
48    // of handlers (agent.open, blockcontroller events) broadcast
49    // manually, so an external harness's CreateTab / UpdateObject
50    // were invisible to the frontend.
51    if let Some(updates) = &result.updates {
52        for update in updates {
53            if let Ok(data) = serde_json::to_value(update) {
54                let oref = format!("{}:{}", update.otype, update.oid);
55                state.event_bus.broadcast_event(
56                    &crate::backend::eventbus::WSEventType {
57                        eventtype: "waveobj:update".to_string(),
58                        oref,
59                        data: Some(data),
60                    },
61                );
62            }
63        }
64    }
65
66    result
67}
68
69/// The agent pane's place in the object tree, resolved from its block id.
70/// Powers `GET /api/v1/self` and lets naming verbs default to "my own X".
71#[derive(Debug, serde::Serialize)]
72pub(crate) struct AgentContext {
73    pub block_id: String,
74    pub block_title: String,
75    pub tab_id: String,
76    pub tab_name: String,
77    pub window_id: Option<String>,
78    pub window_name: String,
79    pub workspace_id: Option<String>,
80    pub workspace_name: String,
81}
82
83/// Find the id of the workspace that owns `tab_id` (in `tabids` or
84/// `pinnedtabids`), or `None` if no workspace references it.
85pub(crate) fn workspace_id_for_tab(store: &Store, tab_id: &str) -> Option<String> {
86    store
87        .get_all::<Workspace>()
88        .unwrap_or_default()
89        .into_iter()
90        .find(|w| {
91            w.tabids.iter().any(|t| t == tab_id) || w.pinnedtabids.iter().any(|t| t == tab_id)
92        })
93        .map(|w| w.oid)
94}
95
96/// Read-only snapshot of the window → workspace → tab → pane tree, for agent
97/// introspection (`GET /api/v1/layout`). Pure wstore reads — no reducer, so
98/// it's hermetic and safe. Lookups use linear scans (a handful of objects).
99pub(crate) fn agent_layout(store: &Store) -> serde_json::Value {
100    let windows = store.get_all::<Window>().unwrap_or_default();
101    let workspaces = store.get_all::<Workspace>().unwrap_or_default();
102    let tabs = store.get_all::<Tab>().unwrap_or_default();
103    let blocks = store.get_all::<Block>().unwrap_or_default();
104
105    let panes_of = |tab: &Tab| -> Vec<serde_json::Value> {
106        tab.blockids
107            .iter()
108            .filter_map(|bid| blocks.iter().find(|b| &b.oid == bid))
109            .map(|b| {
110                json!({
111                    "block_id": b.oid,
112                    "view": meta_get_string(&b.meta, "view", ""),
113                    "title": meta_get_string(&b.meta, "frame:title", ""),
114                })
115            })
116            .collect()
117    };
118
119    let windows_json: Vec<serde_json::Value> = windows
120        .iter()
121        .map(|w| {
122            let ws = workspaces.iter().find(|x| x.oid == w.workspaceid);
123            let tabs_json: Vec<serde_json::Value> = ws
124                .map(|ws| {
125                    // Pinned tabs render first, then the regular tab order.
126                    ws.pinnedtabids
127                        .iter()
128                        .chain(ws.tabids.iter())
129                        .filter_map(|tid| tabs.iter().find(|t| &t.oid == tid))
130                        .map(|t| {
131                            json!({
132                                "tab_id": t.oid,
133                                "name": t.name,
134                                "active": ws.activetabid == t.oid,
135                                "panes": panes_of(t),
136                            })
137                        })
138                        .collect()
139                })
140                .unwrap_or_default();
141            json!({
142                "window_id": w.oid,
143                "name": meta_get_string(&w.meta, "window:displayname", ""),
144                "workspace_id": w.workspaceid,
145                "workspace_name": ws.map(|x| x.name.clone()).unwrap_or_default(),
146                "tabs": tabs_json,
147            })
148        })
149        .collect();
150
151    json!({ "windows": windows_json })
152}
153
154/// Flat list of all windows (id, display name, assigned workspace).
155pub(crate) fn agent_windows(store: &Store) -> serde_json::Value {
156    let workspaces = store.get_all::<Workspace>().unwrap_or_default();
157    let windows: Vec<serde_json::Value> = store
158        .get_all::<Window>()
159        .unwrap_or_default()
160        .iter()
161        .map(|w| {
162            let ws_name = workspaces
163                .iter()
164                .find(|x| x.oid == w.workspaceid)
165                .map(|x| x.name.clone())
166                .unwrap_or_default();
167            json!({
168                "window_id": w.oid,
169                "name": meta_get_string(&w.meta, "window:displayname", ""),
170                "workspace_id": w.workspaceid,
171                "workspace_name": ws_name,
172            })
173        })
174        .collect();
175    json!({ "windows": windows })
176}
177
178/// Flat list of all workspaces (id, name, tab count, active tab).
179pub(crate) fn agent_workspaces(store: &Store) -> serde_json::Value {
180    let workspaces: Vec<serde_json::Value> = store
181        .get_all::<Workspace>()
182        .unwrap_or_default()
183        .iter()
184        .map(|w| {
185            json!({
186                "workspace_id": w.oid,
187                "name": w.name,
188                "tab_count": w.tabids.len() + w.pinnedtabids.len(),
189                "active_tab_id": w.activetabid,
190            })
191        })
192        .collect();
193    json!({ "workspaces": workspaces })
194}
195
196/// Flat list of tabs (id, name, pane count). When `workspace_id` is given,
197/// only that workspace's tabs are returned.
198pub(crate) fn agent_tabs(store: &Store, workspace_id: Option<&str>) -> serde_json::Value {
199    let workspaces = store.get_all::<Workspace>().unwrap_or_default();
200    let scope: Option<Vec<String>> = workspace_id.map(|wid| {
201        workspaces
202            .iter()
203            .find(|w| w.oid == wid)
204            .map(|w| w.pinnedtabids.iter().chain(w.tabids.iter()).cloned().collect())
205            .unwrap_or_default()
206    });
207    let tabs: Vec<serde_json::Value> = store
208        .get_all::<Tab>()
209        .unwrap_or_default()
210        .iter()
211        .filter(|t| scope.as_ref().map(|ids| ids.contains(&t.oid)).unwrap_or(true))
212        .map(|t| {
213            json!({
214                "tab_id": t.oid,
215                "name": t.name,
216                "pane_count": t.blockids.len(),
217            })
218        })
219        .collect();
220    json!({ "tabs": tabs })
221}
222
223/// Walk block → tab → workspace → window from an agent pane's block id.
224///
225/// Tabs carry no parent reference, so the workspace and window are found by
226/// reverse lookup: the workspace whose `tabids`/`pinnedtabids` contains the
227/// tab, then the window assigned that workspace. `window_id`/`workspace_id`
228/// are `None` when the tab isn't attached to a live window (e.g. a torn-off
229/// tab mid-transition) — callers should treat that as "no window to name".
230pub(crate) fn resolve_agent_context(store: &Store, block_id: &str) -> Result<AgentContext, String> {
231    let block = store
232        .get::<Block>(block_id)
233        .map_err(|e| e.to_string())?
234        .ok_or_else(|| format!("block not found: {block_id}"))?;
235    let block_title = meta_get_string(&block.meta, "frame:title", "");
236    let tab_id = block.parentoref.strip_prefix("tab:").unwrap_or("").to_string();
237    let tab = store
238        .get::<Tab>(&tab_id)
239        .map_err(|e| e.to_string())?
240        .ok_or_else(|| format!("tab not found for block {block_id}"))?;
241
242    let mut workspace_id = None;
243    let mut workspace_name = String::new();
244    let mut window_id = None;
245    let mut window_name = String::new();
246
247    if let Ok(workspaces) = store.get_all::<Workspace>() {
248        if let Some(ws) = workspaces
249            .into_iter()
250            .find(|w| w.tabids.contains(&tab_id) || w.pinnedtabids.contains(&tab_id))
251        {
252            workspace_name = ws.name.clone();
253            workspace_id = Some(ws.oid.clone());
254            if let Ok(windows) = store.get_all::<Window>() {
255                if let Some(win) = windows.into_iter().find(|w| w.workspaceid == ws.oid) {
256                    window_name = meta_get_string(&win.meta, "window:displayname", "");
257                    window_id = Some(win.oid);
258                }
259            }
260        }
261    }
262
263    Ok(AgentContext {
264        block_id: block_id.to_string(),
265        block_title,
266        tab_id,
267        tab_name: tab.name,
268        window_id,
269        window_name,
270        workspace_id,
271        workspace_name,
272    })
273}
274
275async fn dispatch_service(state: &AppState, call: &WebCallType) -> WebReturnType {
276    match call.service.as_str() {
277        "object" => handle_object_service(state, call).await,
278        "client" => handle_client_service(state, call).await,
279        "window" => handle_window_service(state, call).await,
280        "workspace" => handle_workspace_service(state, call).await,
281        _ => handle_misc_service(state, call).await,
282    }
283}
284
285async fn handle_object_service(state: &AppState, call: &WebCallType) -> WebReturnType {
286    let store = &state.wstore;
287    let args = &call.args;
288    match call.method.as_str() {
289        "GetObject" => {
290            let oref_str: String = match service::get_arg(args, 0) {
291                Ok(v) => v,
292                Err(e) => return WebReturnType::error(e),
293            };
294            match get_object_by_oref(store, &oref_str) {
295                Ok(data) => WebReturnType::success(data),
296                Err(e) => WebReturnType::error(e),
297            }
298        }
299        "GetObjects" => {
300            let orefs: Vec<String> = match service::get_arg(args, 0) {
301                Ok(v) => v,
302                Err(e) => return WebReturnType::error(e),
303            };
304            let mut results = Vec::new();
305            for oref_str in &orefs {
306                match get_object_by_oref(store, oref_str) {
307                    Ok(data) => results.push(data),
308                    Err(_) => results.push(serde_json::Value::Null),
309                }
310            }
311            WebReturnType::success(serde_json::json!(results))
312        }
313        "CreateBlock" => {
314            let block_def: BlockDef = match service::get_arg(args, 0) {
315                Ok(v) => v,
316                Err(e) => return WebReturnType::error(e),
317            };
318            // Optional explicit tab_id at args[2] (args[1] is rtOpts).
319            // When present, overrides uicontext.active_tab_id — lets
320            // callers like applyTabPreset (frontend) target a specific
321            // tab without depending on which tab happens to be active
322            // when the RPC's uicontext is serialised. Eliminates the
323            // TOCTOU race where the user can switch tabs between the
324            // call site and the server-side handler.
325            //
326            // A *malformed* args[2] (e.g. non-string from a stale SDK)
327            // returns an error — silently falling back to uicontext
328            // would defeat the explicit-targeting contract and make
329            // wrong-tab routing hard to diagnose. Missing/null/empty
330            // is fine: treat as "no override" and use uicontext.
331            let explicit_tab_id: Option<String> = match service::get_optional_arg::<String>(args, 2) {
332                Ok(opt) => opt.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
333                Err(e) => return WebReturnType::error(format!("invalid tabId arg: {}", e)),
334            };
335            let tab_id = match explicit_tab_id {
336                Some(id) => id,
337                None => match call
338                    .uicontext
339                    .as_ref()
340                    .map(|ctx| ctx.active_tab_id.clone())
341                {
342                    Some(id) if !id.is_empty() => id,
343                    _ => return WebReturnType::error("missing uicontext.activetabid"),
344                },
345            };
346            // Phase E.2c.4 — CreateBlock dispatches through the reducer
347            // (forward+compensate on SQLite failure). The reducer
348            // assigns the block_id; the persist subscriber's apply
349            // path writes the Block row with the caller's meta map.
350            let meta_value =
351                serde_json::to_value(&block_def.meta).unwrap_or(serde_json::Value::Null);
352            let events = dispatch_to_reducer(
353                state,
354                agentmux_common::ipc::Command::CreateBlock {
355                    tab_id: tab_id.clone(),
356                    meta: meta_value,
357                },
358            )
359            .await;
360            // Surface reducer Error events (tab not found).
361            if let Some(err_msg) = events.iter().find_map(|e| match e {
362                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
363                _ => None,
364            }) {
365                return WebReturnType::error(err_msg);
366            }
367            let block_id = events.iter().find_map(|e| match e {
368                agentmux_common::ipc::Event::BlockCreated { block_id, .. } => {
369                    Some(block_id.clone())
370                }
371                _ => None,
372            });
373            let mut apply_err: Option<String> = None;
374            for ev in &events {
375                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
376                    apply_err = Some(e.to_string());
377                    break;
378                }
379            }
380            if let Some(err) = apply_err {
381                if let Some(bid) = block_id.as_ref() {
382                    compensate_via_reducer(
383                        state,
384                        agentmux_common::ipc::Command::DeleteBlock {
385                            tab_id: tab_id.clone(),
386                            block_id: bid.clone(),
387                        },
388                        store,
389                    )
390                    .await;
391                }
392                return WebReturnType::error(format!("CreateBlock: SQLite write failed: {}", err));
393            }
394            publish_events(state, &events);
395            match block_id {
396                Some(bid) => {
397                    let mut updates = vec![];
398                    if let Ok(block) = store.must_get::<Block>(&bid) {
399                        updates.push(WaveObjUpdate {
400                            updatetype: "update".into(),
401                            otype: OTYPE_BLOCK.to_string(),
402                            oid: bid.clone(),
403                            obj: Some(wave_obj_to_value(&block)),
404                        });
405                    }
406                    if let Ok(tab) = store.must_get::<Tab>(&tab_id) {
407                        updates.push(WaveObjUpdate {
408                            updatetype: "update".into(),
409                            otype: OTYPE_TAB.to_string(),
410                            oid: tab_id.clone(),
411                            obj: Some(wave_obj_to_value(&tab)),
412                        });
413                    }
414                    WebReturnType::success_data_updates(serde_json::json!(bid), updates)
415                }
416                None => WebReturnType::error(
417                    "CreateBlock: reducer did not emit BlockCreated".to_string(),
418                ),
419            }
420        }
421        // Phase E.5.7 (Step 5 PR 1) — DeleteBlock saga. The legacy
422        // SQLite-first pattern (wcore::delete_block + reducer-sync
423        // dispatch) is replaced by `sagas::delete_block::run`, which
424        // routes through the reducer + persist subscriber. The saga
425        // also handles the controller-kill cascade (matches the old
426        // ordering: controller down → reducer dispatch → SQLite).
427        "DeleteBlock" => {
428            let block_id: String = match service::get_arg(args, 0) {
429                Ok(v) => v,
430                Err(e) => return WebReturnType::error(e),
431            };
432            // Look up the block's owning tab from server state rather than using
433            // uicontext.active_tab_id. Floating pane windows have their own tab
434            // context that differs from the block's owning tab, so using the
435            // uicontext tab always fails for floating-pane closes.
436            let tab_id = {
437                let s = state.srv_state.lock().await;
438                match s.blocks.get(&block_id) {
439                    Some(rec) => rec.tab_id.clone(),
440                    None => return WebReturnType::error(format!("DeleteBlock: block not found: {}", block_id)),
441                }
442            };
443            if let Err(reason) = crate::sagas::delete_block::run(state, tab_id, block_id).await {
444                return WebReturnType::error(reason);
445            }
446            WebReturnType::success_empty()
447        }
448        "UpdateObject" => {
449            let wave_obj_value: serde_json::Value = match service::get_arg(args, 0) {
450                Ok(v) => v,
451                Err(e) => return WebReturnType::error(e),
452            };
453            // Phase E.4 (Option A) — when a LayoutState update lands,
454            // route the focused/magnified slice through the srv reducer
455            // so its canonical state matches what the frontend just
456            // pushed and the persist subscriber emits the new
457            // FocusedNodeChanged / MagnifiedNodeChanged events for E.6
458            // dispatcher consumption. The remaining LayoutState fields
459            // (rootnode/leaforder/pendingbackendactions) keep the
460            // wcore-direct write below per the deferred Option B
461            // decision in `SPEC_PHASE_E4_LAYOUT_REDUCER_2026-05-01.md`.
462            //
463            // (codex P2 PR #632) Capture the slice now but DO NOT
464            // dispatch yet — reducer + subscriber updates must happen
465            // ONLY AFTER update_object succeeds. Otherwise an
466            // UpdateObject failure would leave reducer state and
467            // FocusedNodeChanged/MagnifiedNodeChanged events fired for
468            // a request that returned an error, breaking failure
469            // atomicity.
470            let layout_slice: Option<(String, String, String)> = if wave_obj_value
471                .get("otype")
472                .and_then(|v| v.as_str())
473                == Some(OTYPE_LAYOUT)
474            {
475                wave_obj_value
476                    .get("oid")
477                    .and_then(|v| v.as_str())
478                    .and_then(|layout_oid| find_tab_for_layout(store, layout_oid))
479                    .map(|tab_id| {
480                        let new_focused = wave_obj_value
481                            .get("focusednodeid")
482                            .and_then(|v| v.as_str())
483                            .unwrap_or("")
484                            .to_string();
485                        let new_magnified = wave_obj_value
486                            .get("magnifiednodeid")
487                            .and_then(|v| v.as_str())
488                            .unwrap_or("")
489                            .to_string();
490                        (tab_id, new_focused, new_magnified)
491                    })
492            } else {
493                None
494            };
495            match update_object(store, wave_obj_value) {
496                Ok((otype, oid, obj_val)) => {
497                    // DB write succeeded — now dispatch the layout
498                    // reducer updates so reducer state and persist-
499                    // subscriber events stay aligned with the
500                    // committed wstore state. (codex P2 PR #632)
501                    if let Some((tab_id, new_focused, new_magnified)) = layout_slice {
502                        let focus_events = dispatch_to_reducer(
503                            state,
504                            agentmux_common::ipc::Command::SetFocusedNode {
505                                tab_id: tab_id.clone(),
506                                node_id: new_focused,
507                            },
508                        )
509                        .await;
510                        publish_events(state, &focus_events);
511                        let mag_events = dispatch_to_reducer(
512                            state,
513                            agentmux_common::ipc::Command::SetMagnifiedNode {
514                                tab_id,
515                                node_id: new_magnified,
516                            },
517                        )
518                        .await;
519                        publish_events(state, &mag_events);
520                    }
521                    let update = WaveObjUpdate {
522                        updatetype: "update".into(),
523                        otype,
524                        oid,
525                        obj: Some(obj_val),
526                    };
527                    WebReturnType::success_with_updates(vec![update])
528                }
529                Err(e) => WebReturnType::error(e),
530            }
531        }
532        // Phase E.5.3 — UpdateObjectMeta migrated through the
533        // reducer. Decomposes by otype to the typed Update*Meta
534        // command. Reducer is pass-through (validates entity exists;
535        // emits event); subscriber's apply_*_meta_updated does the
536        // shallow merge against wstore.
537        "UpdateObjectMeta" => {
538            let oref_str: String = match service::get_arg(args, 0) {
539                Ok(v) => v,
540                Err(e) => return WebReturnType::error(e),
541            };
542            let meta_update: MetaMapType = match service::get_arg(args, 1) {
543                Ok(v) => v,
544                Err(e) => return WebReturnType::error(e),
545            };
546            let oref = match crate::backend::ORef::parse(&oref_str) {
547                Ok(v) => v,
548                Err(e) => return WebReturnType::error(e.to_string()),
549            };
550            let meta_value = serde_json::to_value(&meta_update).unwrap_or(serde_json::Value::Null);
551            let cmd = match oref.otype.as_str() {
552                t if t == OTYPE_WORKSPACE => agentmux_common::ipc::Command::UpdateWorkspaceMeta {
553                    workspace_id: oref.oid.clone(),
554                    meta_patch: meta_value,
555                },
556                t if t == OTYPE_TAB => agentmux_common::ipc::Command::UpdateTabMeta {
557                    tab_id: oref.oid.clone(),
558                    meta_patch: meta_value,
559                },
560                t if t == OTYPE_BLOCK => agentmux_common::ipc::Command::UpdateBlockMeta {
561                    block_id: oref.oid.clone(),
562                    meta_patch: meta_value,
563                },
564                t if t == OTYPE_WINDOW => agentmux_common::ipc::Command::UpdateWindowMeta {
565                    window_id: oref.oid.clone(),
566                    meta_patch: meta_value,
567                },
568                other => {
569                    // Remaining otypes (Layout, Client, Temp) aren't
570                    // meta-mutated via the reducer yet; fall back to
571                    // wcore for forward-compat. They publish no event,
572                    // so the WaveObjUpdate bridge can't see them — the
573                    // frontend cache stays stale until next bootstrap
574                    // (deemed acceptable since these aren't user-edited).
575                    // Future Phase E.5.x migrations can add reducer arms
576                    // for any of these following the OTYPE_WINDOW pattern
577                    // above (per issue #855 retro).
578                    return match update_object_meta(store, &oref_str, &meta_update) {
579                        Ok(()) => WebReturnType::success_empty(),
580                        Err(e) => WebReturnType::error(format!(
581                            "UpdateObjectMeta: unsupported otype {} via reducer; wcore fallback failed: {}",
582                            other, e
583                        )),
584                    };
585                }
586            };
587            let events = dispatch_to_reducer(state, cmd).await;
588            if let Some(err_msg) = events.iter().find_map(|e| match e {
589                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
590                _ => None,
591            }) {
592                return WebReturnType::error(err_msg);
593            }
594            for ev in &events {
595                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
596                    return WebReturnType::error(format!(
597                        "UpdateObjectMeta: SQLite write failed: {}",
598                        e
599                    ));
600                }
601            }
602            publish_events(state, &events);
603            // Per-agent zoom persistence (SPEC_AGENT_ZOOM_PERSISTENCE): when an
604            // agent block's `term:zoom` changes, mirror it (debounced) into the
605            // agent's per-agent `ui:zoom` content so the zoom survives the block
606            // and is restored at `agent.open`. Only blocks carrying `agentId`
607            // (agent panes) participate; everything else is untouched. A `null`
608            // `term:zoom` (the frontend's reset-to-1.0 convention) deletes the
609            // saved value so a default agent stores nothing.
610            if oref.otype == OTYPE_BLOCK && meta_update.contains_key("term:zoom") {
611                if let Ok(block) = store.must_get::<Block>(&oref.oid) {
612                    let agent_id = block
613                        .meta
614                        .get("agentId")
615                        .and_then(|v| v.as_str())
616                        .unwrap_or("")
617                        .to_string();
618                    if !agent_id.is_empty() {
619                        let zoom = meta_update.get("term:zoom").and_then(|v| v.as_f64());
620                        schedule_agent_zoom_mirror(store.clone(), agent_id, zoom);
621                    }
622                }
623            }
624            // Return the updated object so the frontend WOS cache stays in sync.
625            if oref.otype == OTYPE_BLOCK {
626                if let Ok(block) = store.must_get::<Block>(&oref.oid) {
627                    return WebReturnType::success_with_updates(vec![WaveObjUpdate {
628                        updatetype: "update".into(),
629                        otype: OTYPE_BLOCK.to_string(),
630                        oid: oref.oid.clone(),
631                        obj: Some(wave_obj_to_value(&block)),
632                    }]);
633                }
634            }
635            if oref.otype == OTYPE_TAB {
636                if let Ok(tab) = store.must_get::<Tab>(&oref.oid) {
637                    return WebReturnType::success_with_updates(vec![WaveObjUpdate {
638                        updatetype: "update".into(),
639                        otype: OTYPE_TAB.to_string(),
640                        oid: oref.oid.clone(),
641                        obj: Some(wave_obj_to_value(&tab)),
642                    }]);
643                }
644            }
645            WebReturnType::success_empty()
646        }
647        // Phase E.5.3 — UpdateTabName migrated through the reducer.
648        "UpdateTabName" => {
649            let tab_id: String = match service::get_arg(args, 0) {
650                Ok(v) => v,
651                Err(e) => return WebReturnType::error(e),
652            };
653            let name: String = match service::get_arg(args, 1) {
654                Ok(v) => v,
655                Err(e) => return WebReturnType::error(e),
656            };
657            let events = dispatch_to_reducer(
658                state,
659                agentmux_common::ipc::Command::RenameTab {
660                    tab_id: tab_id.clone(),
661                    name,
662                },
663            )
664            .await;
665            if let Some(err_msg) = events.iter().find_map(|e| match e {
666                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
667                _ => None,
668            }) {
669                return WebReturnType::error(err_msg);
670            }
671            for ev in &events {
672                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
673                    return WebReturnType::error(format!(
674                        "UpdateTabName: SQLite write failed: {}",
675                        e
676                    ));
677                }
678            }
679            publish_events(state, &events);
680            if let Ok(updated_tab) = store.must_get::<Tab>(&tab_id) {
681                let update = WaveObjUpdate {
682                    updatetype: "update".into(),
683                    otype: OTYPE_TAB.to_string(),
684                    oid: tab_id.clone(),
685                    obj: Some(wave_obj_to_value(&updated_tab)),
686                };
687                return WebReturnType::success_with_updates(vec![update]);
688            }
689            WebReturnType::success_empty()
690        }
691        _ => WebReturnType::error(format!("unknown object method: {}", call.method)),
692    }
693}
694
695async fn handle_client_service(state: &AppState, call: &WebCallType) -> WebReturnType {
696    let store = &state.wstore;
697    let args = &call.args;
698    match call.method.as_str() {
699        "GetClientData" => match wcore::get_client(store) {
700            Ok(client) => {
701                WebReturnType::success(serde_json::to_value(&client).unwrap_or_default())
702            }
703            Err(e) => WebReturnType::error(e.to_string()),
704        },
705        "GetTab" => {
706            let tab_id: String = match service::get_arg(args, 0) {
707                Ok(v) => v,
708                Err(e) => return WebReturnType::error(e),
709            };
710            match store.must_get::<Tab>(&tab_id) {
711                Ok(tab) => WebReturnType::success(serde_json::to_value(&tab).unwrap_or_default()),
712                Err(e) => WebReturnType::error(e.to_string()),
713            }
714        }
715        "FocusWindow" => {
716            let window_id: String = match service::get_arg(args, 0) {
717                Ok(v) => v,
718                Err(e) => return WebReturnType::error(e),
719            };
720            match wcore::focus_window(store, &window_id) {
721                Ok(()) => WebReturnType::success_empty(),
722                Err(e) => WebReturnType::error(e.to_string()),
723            }
724        }
725        "AgreeTos" => match wcore::get_client(store) {
726            Ok(mut client) => {
727                client.tosagreed = chrono::Utc::now().timestamp_millis();
728                match store.update(&mut client) {
729                    Ok(_) => WebReturnType::success_empty(),
730                    Err(e) => WebReturnType::error(e.to_string()),
731                }
732            }
733            Err(e) => WebReturnType::error(e.to_string()),
734        },
735        "GetAllConnStatus" => {
736            // Return empty — connection manager not yet wired
737            // Go returns success with no data (nil slice omitted by omitempty)
738            WebReturnType::success_empty()
739        }
740        "TelemetryUpdate" => {
741            // Accept but ignore — telemetry not implemented
742            WebReturnType::success_empty()
743        }
744        _ => WebReturnType::error(format!("unknown client method: {}", call.method)),
745    }
746}
747
748async fn handle_window_service(state: &AppState, call: &WebCallType) -> WebReturnType {
749    let store = &state.wstore;
750    let args = &call.args;
751    match call.method.as_str() {
752        "GetWindow" => {
753            let window_id: String = match service::get_arg(args, 0) {
754                Ok(v) => v,
755                Err(e) => return WebReturnType::error(e),
756            };
757            match store.must_get::<Window>(&window_id) {
758                Ok(win) => WebReturnType::success(serde_json::to_value(&win).unwrap_or_default()),
759                Err(e) => WebReturnType::error(e.to_string()),
760            }
761        }
762        // Phase E.5.8 — CreateWindow migrated through the reducer.
763        // Two paths: (1) empty workspace_id → CreateWorkspace +
764        // CreateTab + CreateWindow as a multi-step dispatch (mirrors
765        // wcore::create_window_full's "fresh workspace" path); (2)
766        // existing workspace_id → just CreateWindow. The subscriber's
767        // apply_srv_window_opened handles `Client.windowids` updates
768        // and Window-row creation. Layout setup for the new tab uses
769        // the apply_tab_created provisioning (E.4 layout migration is
770        // separate; default rootnode = None matches wcore behaviour).
771        "CreateWindow" => {
772            let requested_ws_id: String = service::get_arg(args, 1).unwrap_or_default();
773            // Resolve / create the workspace.
774            let (ws_id, fresh_workspace_events): (String, Vec<agentmux_common::ipc::Event>) =
775                if requested_ws_id.is_empty() {
776                    // Step 1: create workspace.
777                    let ws_events = dispatch_to_reducer(
778                        state,
779                        agentmux_common::ipc::Command::CreateWorkspace {
780                            name: String::new(),
781                        },
782                    )
783                    .await;
784                    if let Some(err_msg) = ws_events.iter().find_map(|e| match e {
785                        agentmux_common::ipc::Event::Error { message, .. } => {
786                            Some(message.clone())
787                        }
788                        _ => None,
789                    }) {
790                        return WebReturnType::error(err_msg);
791                    }
792                    for ev in &ws_events {
793                        if let Err(e) =
794                            crate::persist_subscriber::apply_event_to_wstore(ev, store)
795                        {
796                            return WebReturnType::error(format!(
797                                "CreateWindow: SQLite write failed: {}",
798                                e
799                            ));
800                        }
801                    }
802                    let new_ws_id = ws_events
803                        .iter()
804                        .find_map(|e| match e {
805                            agentmux_common::ipc::Event::WorkspaceCreated {
806                                workspace_id, ..
807                            } => Some(workspace_id.clone()),
808                            _ => None,
809                        })
810                        .unwrap_or_default();
811                    // Step 2: create tab.
812                    let tab_events = dispatch_to_reducer(
813                        state,
814                        agentmux_common::ipc::Command::CreateTab {
815                            workspace_id: new_ws_id.clone(),
816                            name: String::new(),
817                        },
818                    )
819                    .await;
820                    if let Some(err_msg) = tab_events.iter().find_map(|e| match e {
821                        agentmux_common::ipc::Event::Error { message, .. } => {
822                            Some(message.clone())
823                        }
824                        _ => None,
825                    }) {
826                        // Compensate: delete the empty workspace.
827                        let comp = dispatch_to_reducer(
828                            state,
829                            agentmux_common::ipc::Command::DeleteWorkspace {
830                                workspace_id: new_ws_id.clone(),
831                                // Internal compensation path — not
832                                // saga-driven (Step 5 PR 2 added the
833                                // `force` flag for saga provenance).
834                                force: false,
835                            },
836                        )
837                        .await;
838                        for ev in &comp {
839                            let _ = crate::persist_subscriber::apply_event_to_wstore(ev, store);
840                        }
841                        publish_events(state, &comp);
842                        return WebReturnType::error(err_msg);
843                    }
844                    for ev in &tab_events {
845                        if let Err(e) =
846                            crate::persist_subscriber::apply_event_to_wstore(ev, store)
847                        {
848                            return WebReturnType::error(format!(
849                                "CreateWindow: SQLite write failed: {}",
850                                e
851                            ));
852                        }
853                    }
854                    // Seed the default 3-pane launch layout (agent + sysinfo +
855                    // swarm) into the fresh tab so "Open another window" matches
856                    // first launch instead of opening blank. Only this
857                    // fresh-workspace branch seeds; tear-off (existing workspace,
858                    // the `else` arm) reattaches its populated workspace as-is.
859                    // Non-fatal: a seed failure leaves an empty tab (the prior
860                    // behaviour) rather than failing window creation.
861                    // See docs/retro/retro-blank-new-window-2026-06-21.md.
862                    //
863                    // 2nd-window-tear-off desync fix (#1681): the seed blocks
864                    // MUST be created through the reducer (CreateBlock command),
865                    // NOT via the store-only `seed_default_layout`/`create_block`
866                    // path. This handler runs AFTER bootstrap, so anything
867                    // written straight to SQLite is invisible to the in-memory
868                    // reducer `srv_state` until the next restart. A new window
869                    // seeded store-only renders fine (frontend reads SQLite) but
870                    // its blocks are absent from `srv_state`, so a later
871                    // `TearOffBlock` from that window is rejected "block not
872                    // found" (ws/tab exist — they went through the reducer — but
873                    // the block did not). Dispatch CreateBlock per pane so the
874                    // blocks land in BOTH srv_state and (via the subscriber)
875                    // SQLite, then write the shared layout referencing them.
876                    let mut block_seed_events: Vec<agentmux_common::ipc::Event> = Vec::new();
877                    if let Some(new_tab_id) = tab_events.iter().find_map(|e| match e {
878                        agentmux_common::ipc::Event::TabCreated { tab_id, .. } => {
879                            Some(tab_id.clone())
880                        }
881                        _ => None,
882                    }) {
883                        // Dispatch the three seed blocks through the reducer.
884                        let mut seeded_ids: Vec<String> = Vec::new();
885                        for view in ["agent", "sysinfo", "swarm"] {
886                            let evs = dispatch_to_reducer(
887                                state,
888                                agentmux_common::ipc::Command::CreateBlock {
889                                    tab_id: new_tab_id.clone(),
890                                    meta: serde_json::json!({ "view": view }),
891                                },
892                            )
893                            .await;
894                            if let Some(err_msg) = evs.iter().find_map(|e| match e {
895                                agentmux_common::ipc::Event::Error { message, .. } => {
896                                    Some(message.clone())
897                                }
898                                _ => None,
899                            }) {
900                                tracing::warn!(
901                                    tab_id = %new_tab_id,
902                                    view = %view,
903                                    error = %err_msg,
904                                    "CreateWindow: seed block create failed — opening blank tab"
905                                );
906                                break;
907                            }
908                            for ev in &evs {
909                                if let Err(e) =
910                                    crate::persist_subscriber::apply_event_to_wstore(ev, store)
911                                {
912                                    tracing::warn!(
913                                        tab_id = %new_tab_id,
914                                        error = %e,
915                                        "CreateWindow: seed block SQLite write failed"
916                                    );
917                                }
918                            }
919                            if let Some(block_id) = evs.iter().find_map(|e| match e {
920                                agentmux_common::ipc::Event::BlockCreated { block_id, .. } => {
921                                    Some(block_id.clone())
922                                }
923                                _ => None,
924                            }) {
925                                seeded_ids.push(block_id);
926                            }
927                            block_seed_events.extend(evs);
928                        }
929
930                        if seeded_ids.len() == 3 {
931                            if let Err(e) = crate::backend::wcore::write_default_three_pane_layout(
932                                store,
933                                &new_tab_id,
934                                &seeded_ids[0],
935                                &seeded_ids[1],
936                                &seeded_ids[2],
937                            ) {
938                                tracing::warn!(
939                                    tab_id = %new_tab_id,
940                                    error = %e,
941                                    "CreateWindow: default layout write failed — opening blank tab"
942                                );
943                            }
944                        }
945                    }
946                    let mut combined = ws_events;
947                    combined.extend(tab_events);
948                    combined.extend(block_seed_events);
949                    (new_ws_id, combined)
950                } else {
951                    // Existing workspace — verify it's in the reducer
952                    // (or SQLite), but no creation needed.
953                    let exists_in_sqlite = match store.get::<Workspace>(&requested_ws_id) {
954                        Ok(opt) => opt.is_some(),
955                        Err(e) => {
956                            return WebReturnType::error(format!(
957                                "CreateWindow: workspace lookup failed: {}",
958                                e
959                            ));
960                        }
961                    };
962                    if !exists_in_sqlite {
963                        return WebReturnType::error(format!(
964                            "CreateWindow: workspace not found: {}",
965                            requested_ws_id
966                        ));
967                    }
968                    (requested_ws_id, Vec::new())
969                };
970
971            // Step 3: register the window in the reducer.
972            let window_id = uuid::Uuid::new_v4().to_string();
973            let win_events = dispatch_to_reducer(
974                state,
975                agentmux_common::ipc::Command::CreateWindow {
976                    window_id: window_id.clone(),
977                    workspace_id: ws_id.clone(),
978                },
979            )
980            .await;
981            if let Some(err_msg) = win_events.iter().find_map(|e| match e {
982                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
983                _ => None,
984            }) {
985                // Compensate the fresh workspace if we created one.
986                if !fresh_workspace_events.is_empty() {
987                    let comp = dispatch_to_reducer(
988                        state,
989                        agentmux_common::ipc::Command::DeleteWorkspace {
990                            workspace_id: ws_id.clone(),
991                            // Internal compensation path (Step 5 PR 2).
992                            force: false,
993                        },
994                    )
995                    .await;
996                    for ev in &comp {
997                        let _ = crate::persist_subscriber::apply_event_to_wstore(ev, store);
998                    }
999                    publish_events(state, &comp);
1000                }
1001                return WebReturnType::error(err_msg);
1002            }
1003            for ev in &win_events {
1004                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1005                    return WebReturnType::error(format!(
1006                        "CreateWindow: SQLite write failed: {}",
1007                        e
1008                    ));
1009                }
1010            }
1011            // Mark the window as `isnew` so the host's first-paint
1012            // signaling logic still applies — wcore::create_window_full
1013            // set this; the subscriber's default is `isnew: false`.
1014            if let Ok(mut win) = store.must_get::<Window>(&window_id) {
1015                if !win.isnew {
1016                    win.isnew = true;
1017                    let _ = store.update(&mut win);
1018                }
1019            }
1020            // Publish all events from this multi-step (workspace + tab + window).
1021            let mut all_events = fresh_workspace_events;
1022            all_events.extend(win_events);
1023            publish_events(state, &all_events);
1024            // Return the Window struct (matches the prior RPC contract).
1025            match store.must_get::<Window>(&window_id) {
1026                Ok(win) => WebReturnType::success(serde_json::to_value(&win).unwrap_or_default()),
1027                Err(e) => WebReturnType::error(format!(
1028                    "CreateWindow: window read-back failed: {}",
1029                    e
1030                )),
1031            }
1032        }
1033        // Phase E.5.8 — CloseWindow migrated through the reducer.
1034        // Sequence:
1035        //   1. Look up the window's workspace (for cascade decision).
1036        //   2. Dispatch Command::CloseWindowInternal — emits
1037        //      SrvWindowClosed; subscriber prunes Client.windowids.
1038        //   3. If no other window points at the same workspace, dispatch
1039        //      Command::DeleteWorkspace which cascades through tabs+blocks.
1040        // Mirrors `wcore::close_window` behaviour where each window
1041        // owns one workspace, but uses the reducer-routed conditional
1042        // pattern so future multi-window-on-same-workspace flows
1043        // don't accidentally drop user state.
1044        "CloseWindow" => {
1045            let window_id: String = match service::get_arg(args, 0) {
1046                Ok(v) => v,
1047                Err(e) => return WebReturnType::error(e),
1048            };
1049            // Look up the window's workspace before we close it.
1050            // Read SQLite (source of truth during migration).
1051            let ws_id: Option<String> = match store.get::<Window>(&window_id) {
1052                Ok(Some(w)) => Some(w.workspaceid.clone()),
1053                Ok(None) => None,
1054                Err(e) => {
1055                    return WebReturnType::error(format!(
1056                        "CloseWindow: window lookup failed: {}",
1057                        e
1058                    ));
1059                }
1060            };
1061            // Step 1: drop the window mapping in reducer.
1062            let close_events = dispatch_to_reducer(
1063                state,
1064                agentmux_common::ipc::Command::CloseWindowInternal {
1065                    window_id: window_id.clone(),
1066                },
1067            )
1068            .await;
1069            // reagent P1 #622: surface reducer rejection before
1070            // applying / publishing. Every other primary dispatch in
1071            // this PR follows this pattern; CloseWindow was missing it.
1072            if let Some(err_msg) = close_events.iter().find_map(|e| match e {
1073                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1074                _ => None,
1075            }) {
1076                return WebReturnType::error(err_msg);
1077            }
1078            for ev in &close_events {
1079                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1080                    return WebReturnType::error(format!(
1081                        "CloseWindow: SQLite write failed: {}",
1082                        e
1083                    ));
1084                }
1085            }
1086            publish_events(state, &close_events);
1087            // Step 2: cascade delete the workspace if no other window
1088            // points at it. The reducer keeps state.windows updated;
1089            // check there.
1090            //
1091            // Step 5 PR 2 — route the user-initiated cascade through
1092            // the `delete_workspace` saga instead of dispatching
1093            // `Command::DeleteWorkspace` inline. The saga records
1094            // lifecycle brackets in the durable saga log so a crash
1095            // mid-cascade is recoverable via `recovery::compensate_unresolved`.
1096            // The saga also takes a snapshot of the workspace's
1097            // tabs+blocks before issuing the cascade, so the durable
1098            // log captures what was deleted (provenance for
1099            // `--diag sagas`).
1100            if let Some(ws_id) = ws_id {
1101                let any_other_window = {
1102                    let s = state.srv_state.lock().await;
1103                    s.windows.values().any(|w| w.workspace_id == ws_id)
1104                };
1105                if !any_other_window {
1106                    if let Err(e) =
1107                        crate::sagas::delete_workspace::run(state, ws_id.clone()).await
1108                    {
1109                        tracing::warn!(
1110                            workspace_id = %ws_id,
1111                            "CloseWindow: delete_workspace saga failed: {}",
1112                            e,
1113                        );
1114                    }
1115                }
1116            }
1117            // Subscriber's apply_srv_window_closed already pruned
1118            // Client.windowids and deleted the Window row; nothing
1119            // more for the handler to do.
1120            WebReturnType::success_empty()
1121        }
1122        // Phase E.5.8 — SwitchWorkspace migrated to single-step
1123        // reducer dispatch. The reducer validates window + workspace
1124        // both exist + emits SrvWindowWorkspaceChanged; subscriber
1125        // writes Window.workspaceid in SQLite.
1126        "SwitchWorkspace" => {
1127            let window_id: String = match service::get_arg(args, 0) {
1128                Ok(v) => v,
1129                Err(e) => return WebReturnType::error(e),
1130            };
1131            let ws_id: String = match service::get_arg(args, 1) {
1132                Ok(v) => v,
1133                Err(e) => return WebReturnType::error(e),
1134            };
1135            let events = dispatch_to_reducer(
1136                state,
1137                agentmux_common::ipc::Command::SwitchWorkspace {
1138                    window_id: window_id.clone(),
1139                    workspace_id: ws_id.clone(),
1140                },
1141            )
1142            .await;
1143            if let Some(err_msg) = events.iter().find_map(|e| match e {
1144                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1145                _ => None,
1146            }) {
1147                return WebReturnType::error(err_msg);
1148            }
1149            for ev in &events {
1150                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1151                    return WebReturnType::error(format!(
1152                        "SwitchWorkspace: SQLite write failed: {}",
1153                        e
1154                    ));
1155                }
1156            }
1157            publish_events(state, &events);
1158            WebReturnType::success_empty()
1159        }
1160        "SetWindowPosAndSize" => {
1161            let window_id: String = match service::get_arg(args, 0) {
1162                Ok(v) => v,
1163                Err(e) => return WebReturnType::error(e),
1164            };
1165            let pos: Option<Point> = service::get_optional_arg(args, 1).unwrap_or(None);
1166            let size: Option<WinSize> = service::get_optional_arg(args, 2).unwrap_or(None);
1167            match store.must_get::<Window>(&window_id) {
1168                Ok(mut win) => {
1169                    if let Some(p) = pos {
1170                        win.pos = p;
1171                    }
1172                    if let Some(s) = size {
1173                        win.winsize = s;
1174                    }
1175                    match store.update(&mut win) {
1176                        Ok(_) => WebReturnType::success_empty(),
1177                        Err(e) => WebReturnType::error(e.to_string()),
1178                    }
1179                }
1180                Err(e) => WebReturnType::error(e.to_string()),
1181            }
1182        }
1183        _ => WebReturnType::error(format!("unknown window method: {}", call.method)),
1184    }
1185}
1186
1187async fn handle_workspace_service(state: &AppState, call: &WebCallType) -> WebReturnType {
1188    let store = &state.wstore;
1189    let args = &call.args;
1190    // Phase E.2c.2 — workspace lifecycle dispatches through the
1191    // srv reducer for event emission (sagas / renderer / persist
1192    // subscriber consume them) AND synchronously applies the
1193    // emitted events to SQLite via the subscriber's apply path.
1194    // Synchronous SQLite writes are required during the migration
1195    // window because tab/block RPC still hits wcore directly and
1196    // expects workspaces to be present in SQLite by the time the
1197    // RPC reply returns (e.g., a CreateTab call right after
1198    // CreateWorkspace would 404 on the workspace lookup if we
1199    // only relied on the async subscriber). The subscriber later
1200    // receives the same event on the broadcast bus and re-applies
1201    // idempotently — safe because each apply arm checks SQLite
1202    // state before writing. (Both reagent + codex flagged this
1203    // race as P1 #615.)
1204    //
1205    // Reads (`GetWorkspace` / `ListWorkspaces`) stay on wstore
1206    // until the tab + block RPC layers also migrate (E.2c.3 +
1207    // E.2c.4). The reducer's `WorkspaceRecord` doesn't track
1208    // `pinnedtabids` and its `tabids` / `activetabid` go stale
1209    // immediately after any wcore-direct tab op — reading from
1210    // it before tabs are migrated returns wrong data.
1211    match call.method.as_str() {
1212        "CreateWorkspace" => {
1213            let name: String = service::get_arg(args, 0).unwrap_or_default();
1214            let events = dispatch_to_reducer(
1215                state,
1216                agentmux_common::ipc::Command::CreateWorkspace { name: name.clone() },
1217            )
1218            .await;
1219            let workspace_id = events.iter().find_map(|e| match e {
1220                agentmux_common::ipc::Event::WorkspaceCreated { workspace_id, .. } => {
1221                    Some(workspace_id.clone())
1222                }
1223                _ => None,
1224            });
1225            // Apply synchronously to wstore BEFORE publishing or
1226            // returning. On SQLite failure, dispatch a compensating
1227            // `DeleteWorkspace` so the reducer's session-only state
1228            // doesn't carry a ghost workspace that was never
1229            // persisted (codex P2 #615).
1230            let mut apply_err: Option<String> = None;
1231            for ev in &events {
1232                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1233                    apply_err = Some(e.to_string());
1234                    break;
1235                }
1236            }
1237            if let Some(err) = apply_err {
1238                if let Some(id) = workspace_id.as_ref() {
1239                    compensate_via_reducer(
1240                        state,
1241                        agentmux_common::ipc::Command::DeleteWorkspace {
1242                            workspace_id: id.clone(),
1243                            // Internal compensation path for failed
1244                            // CreateWorkspace SQLite apply — not the
1245                            // saga (Step 5 PR 2).
1246                            force: false,
1247                        },
1248                        store,
1249                    )
1250                    .await;
1251                }
1252                return WebReturnType::error(format!(
1253                    "CreateWorkspace: SQLite write failed: {}",
1254                    err
1255                ));
1256            }
1257            publish_events(state, &events);
1258            match workspace_id {
1259                Some(id) => match wcore::get_workspace(store, &id) {
1260                    Ok(ws) => {
1261                        WebReturnType::success(serde_json::to_value(&ws).unwrap_or_default())
1262                    }
1263                    Err(e) => WebReturnType::error(format!(
1264                        "CreateWorkspace: post-write read failed: {}",
1265                        e
1266                    )),
1267                },
1268                None => WebReturnType::error(
1269                    "CreateWorkspace: reducer did not emit WorkspaceCreated".to_string(),
1270                ),
1271            }
1272        }
1273        "GetWorkspace" => {
1274            let ws_id: String = match service::get_arg(args, 0) {
1275                Ok(v) => v,
1276                Err(e) => return WebReturnType::error(e),
1277            };
1278            // wstore-direct during the migration window (see
1279            // ("workspace", ...) header comment above for the
1280            // rationale). Reducer-state reads return on E.2c.3+ once
1281            // tabs (and pinned tabs) live in the reducer.
1282            match wcore::get_workspace(store, &ws_id) {
1283                Ok(ws) => WebReturnType::success(serde_json::to_value(&ws).unwrap_or_default()),
1284                Err(e) => WebReturnType::error(e.to_string()),
1285            }
1286        }
1287        "DeleteWorkspace" => {
1288            let ws_id: String = match service::get_arg(args, 0) {
1289                Ok(v) => v,
1290                Err(e) => return WebReturnType::error(e),
1291            };
1292            // Step 5 PR 2 — route the user-initiated DeleteWorkspace
1293            // through the `delete_workspace` saga. The saga:
1294            //   1. Snapshots the workspace's tabs+blocks for
1295            //      provenance in the durable saga log.
1296            //   2. Dispatches per-tab `DeleteTab { force: true }`
1297            //      through the reducer (cascades blocks; persist
1298            //      subscriber writes SQLite + kills controllers via
1299            //      `wcore::delete_tab_inner`).
1300            //   3. Dispatches the final
1301            //      `DeleteWorkspace { force: true }` which removes
1302            //      the (now-empty) workspace + window mappings.
1303            //
1304            // The legacy SQLite-first path here (wcore::delete_workspace
1305            // followed by Command::DeleteWorkspace dispatch) is replaced
1306            // by the saga because the durable lifecycle bracket gives
1307            // crash-recovery a chance to retry/compensate via
1308            // `recovery::compensate_unresolved` if the cascade is
1309            // interrupted. Cascade behaviour is preserved 1:1.
1310            //
1311            // Pre-condition: workspace must exist (in reducer or
1312            // SQLite). The saga runs its own existence check; we mirror
1313            // the legacy NotFound semantics here for backward-compat
1314            // error messages.
1315            let exists_in_wstore = match wstore_workspace_exists(store, &ws_id) {
1316                Ok(v) => v,
1317                Err(e) => {
1318                    return WebReturnType::error(format!(
1319                        "DeleteWorkspace: SQLite read failed: {}",
1320                        e
1321                    ))
1322                }
1323            };
1324            if !exists_in_wstore {
1325                let exists_in_state = state
1326                    .srv_state
1327                    .lock()
1328                    .await
1329                    .workspaces
1330                    .contains_key(&ws_id);
1331                if !exists_in_state {
1332                    return WebReturnType::error(format!(
1333                        "DeleteWorkspace: workspace not found: {}",
1334                        ws_id
1335                    ));
1336                }
1337            }
1338            match crate::sagas::delete_workspace::run(state, ws_id.clone()).await {
1339                Ok(_) => WebReturnType::success_empty(),
1340                Err(e) => WebReturnType::error(format!("DeleteWorkspace failed: {}", e)),
1341            }
1342        }
1343        "ListWorkspaces" => match wcore::list_workspaces(store) {
1344            Ok(list) => WebReturnType::success(serde_json::to_value(&list).unwrap_or_default()),
1345            Err(e) => WebReturnType::error(e.to_string()),
1346        },
1347        // Phase E.2c.3b — CreateTab dispatches through the reducer.
1348        // The `pinned` argument from older clients is ignored:
1349        // pinning was a Waveterm feature removed from AgentMux.
1350        // Legacy SQLite databases may still have entries in
1351        // `Workspace.pinnedtabids`; bootstrap merges them into
1352        // `tab_ids` so they behave as regular tabs.
1353        "CreateTab" => {
1354            let ws_id: String = match service::get_arg(args, 0) {
1355                Ok(v) => v,
1356                Err(e) => return WebReturnType::error(e),
1357            };
1358            let tab_name: String = service::get_arg(args, 1).unwrap_or_default();
1359            let activate: bool = service::get_arg(args, 2).unwrap_or(true);
1360            // args[3] (`pinned`) intentionally ignored.
1361            // Auto-generate a `tab{N}` name when the caller passed
1362            // empty so behaviour matches the prior wcore path. Counts
1363            // both `tabids` and any leftover `pinnedtabids` from
1364            // legacy data so the numbering doesn't collide with
1365            // pre-removal entries that bootstrap will surface as
1366            // regular tabs.
1367            let resolved_name = if tab_name.is_empty() {
1368                match store.get::<Workspace>(&ws_id) {
1369                    Ok(Some(ws)) => {
1370                        format!("tab{}", ws.tabids.len() + ws.pinnedtabids.len() + 1)
1371                    }
1372                    _ => "tab1".to_string(),
1373                }
1374            } else {
1375                tab_name.clone()
1376            };
1377            let events = dispatch_to_reducer(
1378                state,
1379                agentmux_common::ipc::Command::CreateTab {
1380                    workspace_id: ws_id.clone(),
1381                    name: resolved_name,
1382                },
1383            )
1384            .await;
1385            // Surface reducer Error events (e.g., workspace not
1386            // found) before any persistence work — they're not
1387            // bug events, they're caller-visible failures, and the
1388            // generic "did not emit TabCreated" message below would
1389            // mask the real reason. Matches the SetActiveTab pattern.
1390            // (reagent P1 #616.)
1391            if let Some(err_msg) = events.iter().find_map(|e| match e {
1392                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1393                _ => None,
1394            }) {
1395                return WebReturnType::error(err_msg);
1396            }
1397            let tab_id = events.iter().find_map(|e| match e {
1398                agentmux_common::ipc::Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
1399                _ => None,
1400            });
1401            // Apply synchronously to wstore (forward+compensate on
1402            // failure — same pattern as CreateWorkspace in E.2c.2).
1403            let mut apply_err: Option<String> = None;
1404            for ev in &events {
1405                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1406                    apply_err = Some(e.to_string());
1407                    break;
1408                }
1409            }
1410            if let Some(err) = apply_err {
1411                if let Some(tid) = tab_id.as_ref() {
1412                    compensate_via_reducer(
1413                        state,
1414                        agentmux_common::ipc::Command::DeleteTab {
1415                            workspace_id: ws_id.clone(),
1416                            tab_id: tid.clone(),
1417                            // Compensation must bypass the last-tab
1418                            // guard to roll back a just-created sole
1419                            // tab when its persist failed (codex P1
1420                            // round 2 + P2 round 4 PR #633).
1421                            force: true,
1422                        },
1423                        store,
1424                    )
1425                    .await;
1426                }
1427                return WebReturnType::error(format!("CreateTab: SQLite write failed: {}", err));
1428            }
1429            publish_events(state, &events);
1430            // If `activate=true` and the reducer didn't auto-activate
1431            // this as the first tab, dispatch SetActiveTab.
1432            let auto_activated = events
1433                .iter()
1434                .any(|e| matches!(e, agentmux_common::ipc::Event::ActiveTabChanged { .. }));
1435            if activate && !auto_activated {
1436                if let Some(tid) = tab_id.as_ref() {
1437                    let active_events = dispatch_to_reducer(
1438                        state,
1439                        agentmux_common::ipc::Command::SetActiveTab {
1440                            workspace_id: ws_id.clone(),
1441                            tab_id: tid.clone(),
1442                        },
1443                    )
1444                    .await;
1445                    let mut active_err: Option<String> = None;
1446                    for ev in &active_events {
1447                        if let Err(e) =
1448                            crate::persist_subscriber::apply_event_to_wstore(ev, store)
1449                        {
1450                            active_err = Some(e.to_string());
1451                            break;
1452                        }
1453                    }
1454                    if active_err.is_none() {
1455                        publish_events(state, &active_events);
1456                    }
1457                    // SetActiveTab failure is non-fatal here — the
1458                    // tab exists; activation can be retried by the
1459                    // caller. Log if it happened.
1460                    if let Some(err) = active_err {
1461                        tracing::warn!(
1462                            "CreateTab: post-create activate failed: {}",
1463                            err
1464                        );
1465                    }
1466                }
1467            }
1468            match tab_id {
1469                Some(id) => {
1470                    let mut updates = vec![];
1471                    if let Ok(tab) = store.must_get::<Tab>(&id) {
1472                        updates.push(WaveObjUpdate {
1473                            updatetype: "update".into(),
1474                            otype: OTYPE_TAB.to_string(),
1475                            oid: id.clone(),
1476                            obj: Some(wave_obj_to_value(&tab)),
1477                        });
1478                    }
1479                    if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
1480                        updates.push(WaveObjUpdate {
1481                            updatetype: "update".into(),
1482                            otype: OTYPE_WORKSPACE.to_string(),
1483                            oid: ws_id.clone(),
1484                            obj: Some(wave_obj_to_value(&ws)),
1485                        });
1486                    }
1487                    WebReturnType::success_data_updates(
1488                        serde_json::to_value(&id).unwrap_or_default(),
1489                        updates,
1490                    )
1491                }
1492                None => WebReturnType::error(
1493                    "CreateTab: reducer did not emit TabCreated".to_string(),
1494                ),
1495            }
1496        }
1497        // Phase E.2c.3 — SetActiveTab routes through the reducer.
1498        // Read-through reads (e.g., GetWorkspace) still hit wstore
1499        // during the migration window, so the synchronous
1500        // apply-to-wstore keeps them consistent.
1501        "SetActiveTab" => {
1502            let ws_id: String = match service::get_arg(args, 0) {
1503                Ok(v) => v,
1504                Err(e) => return WebReturnType::error(e),
1505            };
1506            let tab_id: String = match service::get_arg(args, 1) {
1507                Ok(v) => v,
1508                Err(e) => return WebReturnType::error(e),
1509            };
1510            // Self-heal the layout before activating — remove any
1511            // orphaned block nodes that would render as blank panes.
1512            // (codex P1 PR #632 round 2) heal_layout clears
1513            // focusednodeid in SQLite when rootnode drops to empty,
1514            // bypassing the reducer. Sync the post-heal state through
1515            // the reducer so its tabs[tab_id].focused_node_id mirror
1516            // matches SQLite.
1517            let healed = wcore::heal_layout(store, &tab_id).unwrap_or(false);
1518            if healed {
1519                if let Ok(tab) = store.must_get::<Tab>(&tab_id) {
1520                    if !tab.layoutstate.is_empty() {
1521                        if let Ok(Some(layout)) = store.get::<LayoutState>(&tab.layoutstate) {
1522                            // Best-effort dispatch — failures here
1523                            // don't block SetActiveTab.
1524                            let focus_events = dispatch_to_reducer(
1525                                state,
1526                                agentmux_common::ipc::Command::SetFocusedNode {
1527                                    tab_id: tab_id.clone(),
1528                                    node_id: layout.focusednodeid.clone(),
1529                                },
1530                            )
1531                            .await;
1532                            publish_events(state, &focus_events);
1533                            let mag_events = dispatch_to_reducer(
1534                                state,
1535                                agentmux_common::ipc::Command::SetMagnifiedNode {
1536                                    tab_id: tab_id.clone(),
1537                                    node_id: layout.magnifiednodeid.clone(),
1538                                },
1539                            )
1540                            .await;
1541                            publish_events(state, &mag_events);
1542                        }
1543                    }
1544                }
1545            }
1546
1547            // Phase E.2c.3b — pinning was removed from AgentMux
1548            // (Waveterm legacy). All tabs are regular; dispatch
1549            // straight through the reducer. Bootstrap merges any
1550            // legacy `pinnedtabids` into the reducer's `tab_ids` so
1551            // tabs from older databases are reachable as normal tabs.
1552            let events = dispatch_to_reducer(
1553                state,
1554                agentmux_common::ipc::Command::SetActiveTab {
1555                    workspace_id: ws_id.clone(),
1556                    tab_id: tab_id.clone(),
1557                },
1558            )
1559            .await;
1560            // Reducer emits Event::Error on unknown workspace/tab —
1561            // surface as RPC error.
1562            if let Some(err_msg) = events.iter().find_map(|e| match e {
1563                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1564                _ => None,
1565            }) {
1566                return WebReturnType::error(err_msg);
1567            }
1568            // Apply synchronously. SetActiveTab is reversible at the
1569            // reducer level (just write back the previous active id),
1570            // but we don't track the previous id here; if SQLite
1571            // fails, return the error and accept short-lived
1572            // divergence on this RPC path. (Acceptable: SetActiveTab
1573            // is a UI-driven action; the user can retry.)
1574            let mut apply_err: Option<String> = None;
1575            for ev in &events {
1576                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1577                    apply_err = Some(e.to_string());
1578                    break;
1579                }
1580            }
1581            if let Some(err) = apply_err {
1582                return WebReturnType::error(format!("SetActiveTab: SQLite write failed: {}", err));
1583            }
1584            publish_events(state, &events);
1585            if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
1586                let update = WaveObjUpdate {
1587                    updatetype: "update".into(),
1588                    otype: OTYPE_WORKSPACE.to_string(),
1589                    oid: ws_id.clone(),
1590                    obj: Some(wave_obj_to_value(&ws)),
1591                };
1592                WebReturnType::success_with_updates(vec![update])
1593            } else {
1594                WebReturnType::success_empty()
1595            }
1596        }
1597        // Phase E.5.7 (Step 5 PR 1) — CloseTab via DeleteTab saga.
1598        // Replaces the legacy SQLite-first pattern (wcore::delete_tab
1599        // followed by reducer-sync dispatch) with a saga-driven
1600        // reducer + persist-subscriber flow. The saga also enforces
1601        // the not-the-last-tab pre-condition mirrored from
1602        // TearOffTab; user-facing CloseTab now refuses to drain a
1603        // workspace to zero tabs (callers wanting full teardown
1604        // should issue DeleteWorkspace instead — that path migrates
1605        // in Step 5 PR 2).
1606        "CloseTab" => {
1607            let ws_id: String = match service::get_arg(args, 0) {
1608                Ok(v) => v,
1609                Err(e) => return WebReturnType::error(e),
1610            };
1611            let tab_id: String = match service::get_arg(args, 1) {
1612                Ok(v) => v,
1613                Err(e) => return WebReturnType::error(e),
1614            };
1615            if let Err(reason) =
1616                crate::sagas::delete_tab::run(state, ws_id.clone(), tab_id.clone()).await
1617            {
1618                return WebReturnType::error(reason);
1619            }
1620            let rtn = CloseTabRtnType {
1621                closewindow: false,
1622                newactivetabid: String::new(),
1623            };
1624            let mut updates = vec![WaveObjUpdate {
1625                updatetype: "delete".into(),
1626                otype: OTYPE_TAB.to_string(),
1627                oid: tab_id.clone(),
1628                obj: None,
1629            }];
1630            if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
1631                updates.push(WaveObjUpdate {
1632                    updatetype: "update".into(),
1633                    otype: OTYPE_WORKSPACE.to_string(),
1634                    oid: ws_id.clone(),
1635                    obj: Some(wave_obj_to_value(&ws)),
1636                });
1637            }
1638            WebReturnType::success_data_updates(
1639                serde_json::to_value(&rtn).unwrap_or_default(),
1640                updates,
1641            )
1642        }
1643        // Phase E.5.3 — UpdateWorkspace migrated through the reducer.
1644        // Currently only handles rename (the only field this RPC ever
1645        // mutated). Meta-only updates are dispatched as
1646        // UpdateWorkspaceMeta separately by frontends.
1647        "UpdateWorkspace" => {
1648            let ws_id: String = match service::get_arg(args, 0) {
1649                Ok(v) => v,
1650                Err(e) => return WebReturnType::error(e),
1651            };
1652            let name: Option<String> = service::get_optional_arg(args, 1).unwrap_or(None);
1653            let Some(name) = name else {
1654                return WebReturnType::success_empty();
1655            };
1656            let events = dispatch_to_reducer(
1657                state,
1658                agentmux_common::ipc::Command::RenameWorkspace {
1659                    workspace_id: ws_id.clone(),
1660                    name,
1661                },
1662            )
1663            .await;
1664            if let Some(err_msg) = events.iter().find_map(|e| match e {
1665                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1666                _ => None,
1667            }) {
1668                return WebReturnType::error(err_msg);
1669            }
1670            for ev in &events {
1671                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1672                    return WebReturnType::error(format!(
1673                        "UpdateWorkspace: SQLite write failed: {}",
1674                        e
1675                    ));
1676                }
1677            }
1678            publish_events(state, &events);
1679            WebReturnType::success_empty()
1680        }
1681        // Phase E.5.3 — UpdateTabIds migrated to ReorderTabsBulk
1682        // through the reducer. The legacy `pinned_tab_ids` arg is
1683        // ignored: pinning was a Waveterm feature removed from
1684        // AgentMux. Bootstrap merged any legacy `pinnedtabids` into
1685        // the reducer's `tab_ids`. The subscriber's
1686        // `apply_tabs_reordered_bulk` rewrites `Workspace.tabids`
1687        // and drains any leftover `Workspace.pinnedtabids` so the
1688        // UI's `[...pinnedtabids, ...tabids]` combine never
1689        // double-counts a tab once a workspace's tabs are
1690        // reordered through the reducer.
1691        "UpdateTabIds" => {
1692            let ws_id: String = match service::get_arg(args, 0) {
1693                Ok(v) => v,
1694                Err(e) => return WebReturnType::error(e),
1695            };
1696            let tab_ids: Vec<String> = match service::get_arg(args, 1) {
1697                Ok(v) => v,
1698                Err(e) => return WebReturnType::error(e),
1699            };
1700            // args[2] (pinned_tab_ids) intentionally ignored.
1701            let events = dispatch_to_reducer(
1702                state,
1703                agentmux_common::ipc::Command::ReorderTabsBulk {
1704                    workspace_id: ws_id.clone(),
1705                    tab_ids,
1706                },
1707            )
1708            .await;
1709            if let Some(err_msg) = events.iter().find_map(|e| match e {
1710                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1711                _ => None,
1712            }) {
1713                return WebReturnType::error(err_msg);
1714            }
1715            for ev in &events {
1716                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1717                    return WebReturnType::error(format!(
1718                        "UpdateTabIds: SQLite write failed: {}",
1719                        e
1720                    ));
1721                }
1722            }
1723            publish_events(state, &events);
1724            if let Ok(updated_ws) = store.must_get::<Workspace>(&ws_id) {
1725                let update = WaveObjUpdate {
1726                    updatetype: "update".into(),
1727                    otype: OTYPE_WORKSPACE.to_string(),
1728                    oid: ws_id.clone(),
1729                    obj: Some(wave_obj_to_value(&updated_ws)),
1730                };
1731                return WebReturnType::success_with_updates(vec![update]);
1732            }
1733            WebReturnType::success_empty()
1734        }
1735        // Phase E.5.7 — MoveBlockToTab migrated to dispatch
1736        // Command::MoveBlock through the reducer. Auto-close empty
1737        // source tab still uses Command::DeleteTab. ws_id arg kept
1738        // for backward compat — used only for the post-op SQLite
1739        // refresh + auto-close workspace check.
1740        "MoveBlockToTab" => {
1741            let ws_id: String = match service::get_arg(args, 0) {
1742                Ok(v) => v,
1743                Err(e) => return WebReturnType::error(e),
1744            };
1745            let block_id: String = match service::get_arg(args, 1) {
1746                Ok(v) => v,
1747                Err(e) => return WebReturnType::error(e),
1748            };
1749            let source_tab_id: String = match service::get_arg(args, 2) {
1750                Ok(v) => v,
1751                Err(e) => return WebReturnType::error(e),
1752            };
1753            let dest_tab_id: String = match service::get_arg(args, 3) {
1754                Ok(v) => v,
1755                Err(e) => return WebReturnType::error(e),
1756            };
1757            let auto_close: bool = service::get_arg(args, 4).unwrap_or(true);
1758            tracing::info!(ws_id = %ws_id, block_id = %block_id, source_tab = %source_tab_id, dest_tab = %dest_tab_id, "[dnd:svc] MoveBlockToTab via reducer");
1759            // codex P2 #622: same-tab requests were no-ops in the
1760            // prior wcore handler. The reducer's MoveBlock treats
1761            // same source = dest as an in-tab reorder; with
1762            // `dst_index: u32::MAX` it would silently move the block
1763            // to the end of the list. Short-circuit to preserve the
1764            // prior contract — a `MoveBlockToTab` whose dest equals
1765            // the source is a UI quirk (e.g. drop on origin tab),
1766            // not an intentional reorder.
1767            if source_tab_id == dest_tab_id {
1768                return WebReturnType::success_empty();
1769            }
1770            // Move the block via the reducer. dst_index 0 to mirror
1771            // wcore::move_block_to_tab which appended at end... wait,
1772            // wcore appends, so end-of-list. The reducer's MoveBlock
1773            // clamps dst_index to dst.block_ids.len(); use u32::MAX
1774            // to land at the end.
1775            let events = dispatch_to_reducer(
1776                state,
1777                agentmux_common::ipc::Command::MoveBlock {
1778                    block_id: block_id.clone(),
1779                    src_tab_id: source_tab_id.clone(),
1780                    dst_tab_id: dest_tab_id.clone(),
1781                    dst_index: u32::MAX,
1782                },
1783            )
1784            .await;
1785            if let Some(err_msg) = events.iter().find_map(|e| match e {
1786                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
1787                _ => None,
1788            }) {
1789                return WebReturnType::error(err_msg);
1790            }
1791            for ev in &events {
1792                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1793                    return WebReturnType::error(format!(
1794                        "MoveBlockToTab: SQLite write failed: {}",
1795                        e
1796                    ));
1797                }
1798            }
1799            publish_events(state, &events);
1800            // Auto-close empty source tab (mirrors wcore::move_block_to_tab).
1801            if auto_close {
1802                let should_close = match store.must_get::<Tab>(&source_tab_id) {
1803                    Ok(t) => t.blockids.is_empty(),
1804                    Err(_) => false,
1805                };
1806                if should_close {
1807                    let total_tabs = match store.must_get::<Workspace>(&ws_id) {
1808                        Ok(ws) => ws.tabids.len() + ws.pinnedtabids.len(),
1809                        Err(_) => 0,
1810                    };
1811                    if total_tabs > 1 {
1812                        let close_events = dispatch_to_reducer(
1813                            state,
1814                            agentmux_common::ipc::Command::DeleteTab {
1815                                workspace_id: ws_id.clone(),
1816                                tab_id: source_tab_id.clone(),
1817                                // Auto-close already gated on
1818                                // `total_tabs > 1` above; reducer's
1819                                // last-tab guard is defense-in-depth
1820                                // for the race window.
1821                                force: false,
1822                            },
1823                        )
1824                        .await;
1825                        for ev in &close_events {
1826                            let _ = crate::persist_subscriber::apply_event_to_wstore(ev, store);
1827                        }
1828                        publish_events(state, &close_events);
1829                    }
1830                }
1831            }
1832            let mut updates = vec![];
1833            if let Ok(src) = store.must_get::<Tab>(&source_tab_id) {
1834                updates.push(WaveObjUpdate {
1835                    updatetype: "update".into(),
1836                    otype: OTYPE_TAB.to_string(),
1837                    oid: source_tab_id.clone(),
1838                    obj: Some(wave_obj_to_value(&src)),
1839                });
1840            }
1841            if let Ok(dst) = store.must_get::<Tab>(&dest_tab_id) {
1842                updates.push(WaveObjUpdate {
1843                    updatetype: "update".into(),
1844                    otype: OTYPE_TAB.to_string(),
1845                    oid: dest_tab_id.clone(),
1846                    obj: Some(wave_obj_to_value(&dst)),
1847                });
1848            }
1849            if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
1850                updates.push(WaveObjUpdate {
1851                    updatetype: "update".into(),
1852                    otype: OTYPE_WORKSPACE.to_string(),
1853                    oid: ws_id.clone(),
1854                    obj: Some(wave_obj_to_value(&ws)),
1855                });
1856            }
1857            WebReturnType::success_with_updates(updates)
1858        }
1859        // Phase E.5.7 — PromoteBlockToTab migrated to saga
1860        // (CreateTab + MoveBlock). Layout setup + SetActiveTab +
1861        // auto-close source tab stay wcore-direct here (E.4 layout
1862        // territory). Same shape as TearOffBlock's RPC handler.
1863        "PromoteBlockToTab" => {
1864            let ws_id: String = match service::get_arg(args, 0) {
1865                Ok(v) => v,
1866                Err(e) => return WebReturnType::error(e),
1867            };
1868            let block_id: String = match service::get_arg(args, 1) {
1869                Ok(v) => v,
1870                Err(e) => return WebReturnType::error(e),
1871            };
1872            let source_tab_id: String = match service::get_arg(args, 2) {
1873                Ok(v) => v,
1874                Err(e) => return WebReturnType::error(e),
1875            };
1876            let auto_close: bool = service::get_arg(args, 3).unwrap_or(true);
1877            tracing::info!(ws_id = %ws_id, block_id = %block_id, source_tab = %source_tab_id, "[dnd:svc] PromoteBlockToTab via saga");
1878            let saga_result = crate::sagas::promote_block_to_tab::run(
1879                state,
1880                block_id.clone(),
1881                source_tab_id.clone(),
1882                ws_id.clone(),
1883            )
1884            .await;
1885            let new_tab_oid = match saga_result {
1886                Ok(v) => v
1887                    .get("new_tab_id")
1888                    .and_then(|v| v.as_str())
1889                    .unwrap_or_default()
1890                    .to_string(),
1891                Err(reason) => return WebReturnType::error(reason),
1892            };
1893
1894            // Layout setup: rootnode + leaforder for the new tab so
1895            // the frontend renders the moved block correctly. Same
1896            // helper TearOffBlock uses.
1897            if let Err(e) = setup_torn_off_block_layout(store, &new_tab_oid, &block_id) {
1898                tracing::warn!(new_tab = %new_tab_oid, "PromoteBlockToTab: layout setup failed: {}", e);
1899            }
1900            // Source tab: queue layout-delete action.
1901            if let Err(e) = queue_source_layout_delete(store, &source_tab_id, &block_id) {
1902                tracing::warn!(source_tab = %source_tab_id, "PromoteBlockToTab: source layout delete-action enqueue failed: {}", e);
1903            }
1904            // Set the new tab as active in the workspace via reducer.
1905            let active_events = dispatch_to_reducer(
1906                state,
1907                agentmux_common::ipc::Command::SetActiveTab {
1908                    workspace_id: ws_id.clone(),
1909                    tab_id: new_tab_oid.clone(),
1910                },
1911            )
1912            .await;
1913            for ev in &active_events {
1914                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
1915                    tracing::warn!("PromoteBlockToTab: SetActiveTab apply failed: {}", e);
1916                }
1917            }
1918            publish_events(state, &active_events);
1919
1920            // Auto-close empty source tab (mirrors wcore behaviour).
1921            if auto_close {
1922                let should_close = match store.must_get::<Tab>(&source_tab_id) {
1923                    Ok(t) => t.blockids.is_empty(),
1924                    Err(_) => false,
1925                };
1926                if should_close {
1927                    let total_tabs = match store.must_get::<Workspace>(&ws_id) {
1928                        Ok(ws) => ws.tabids.len() + ws.pinnedtabids.len(),
1929                        Err(_) => 0,
1930                    };
1931                    if total_tabs > 1 {
1932                        let close_events = dispatch_to_reducer(
1933                            state,
1934                            agentmux_common::ipc::Command::DeleteTab {
1935                                workspace_id: ws_id.clone(),
1936                                tab_id: source_tab_id.clone(),
1937                                // Auto-close already gated on
1938                                // `total_tabs > 1` above; reducer's
1939                                // last-tab guard is defense-in-depth
1940                                // for the race window.
1941                                force: false,
1942                            },
1943                        )
1944                        .await;
1945                        for ev in &close_events {
1946                            let _ = crate::persist_subscriber::apply_event_to_wstore(ev, store);
1947                        }
1948                        publish_events(state, &close_events);
1949                    }
1950                }
1951            }
1952
1953            let mut updates = vec![];
1954            if let Ok(new_tab) = store.must_get::<Tab>(&new_tab_oid) {
1955                updates.push(WaveObjUpdate {
1956                    updatetype: "update".into(),
1957                    otype: OTYPE_TAB.to_string(),
1958                    oid: new_tab_oid.clone(),
1959                    obj: Some(wave_obj_to_value(&new_tab)),
1960                });
1961            }
1962            if let Ok(src) = store.must_get::<Tab>(&source_tab_id) {
1963                updates.push(WaveObjUpdate {
1964                    updatetype: "update".into(),
1965                    otype: OTYPE_TAB.to_string(),
1966                    oid: source_tab_id.clone(),
1967                    obj: Some(wave_obj_to_value(&src)),
1968                });
1969            }
1970            if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
1971                updates.push(WaveObjUpdate {
1972                    updatetype: "update".into(),
1973                    otype: OTYPE_WORKSPACE.to_string(),
1974                    oid: ws_id.clone(),
1975                    obj: Some(wave_obj_to_value(&ws)),
1976                });
1977            }
1978            WebReturnType::success_data_updates(
1979                serde_json::to_value(&new_tab_oid).unwrap_or_default(),
1980                updates,
1981            )
1982        }
1983        // Phase E.2c.3b — ReorderTab dispatches through the reducer.
1984        // Forward+compensate isn't useful here (reorder is its own
1985        // inverse), so on SQLite apply failure we just surface the
1986        // error and the reducer state ends up ahead of disk for the
1987        // remainder of the session — converges back at next restart
1988        // via bootstrap.
1989        "ReorderTab" => {
1990            let ws_id: String = match service::get_arg(args, 0) {
1991                Ok(v) => v,
1992                Err(e) => return WebReturnType::error(e),
1993            };
1994            let tab_id: String = match service::get_arg(args, 1) {
1995                Ok(v) => v,
1996                Err(e) => return WebReturnType::error(e),
1997            };
1998            let new_index: usize = match service::get_arg(args, 2) {
1999                Ok(v) => v,
2000                Err(e) => return WebReturnType::error(e),
2001            };
2002            tracing::info!(ws_id = %ws_id, tab_id = %tab_id, new_index = %new_index, "[dnd:svc] ReorderTab");
2003            // Clamp to u32::MAX rather than truncating via `as u32`.
2004            // The reducer further clamps to `tab_ids.len() - 1` so an
2005            // absurd usize ends up at the last position — matching
2006            // the prior `wcore::reorder_tab` behaviour where any
2007            // out-of-range usize clamped to the end. (codex P3 #617.)
2008            let new_index_u32 = u32::try_from(new_index).unwrap_or(u32::MAX);
2009            let events = dispatch_to_reducer(
2010                state,
2011                agentmux_common::ipc::Command::ReorderTab {
2012                    workspace_id: ws_id.clone(),
2013                    tab_id: tab_id.clone(),
2014                    new_index: new_index_u32,
2015                },
2016            )
2017            .await;
2018            // Surface reducer Error events.
2019            if let Some(err_msg) = events.iter().find_map(|e| match e {
2020                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
2021                _ => None,
2022            }) {
2023                return WebReturnType::error(err_msg);
2024            }
2025            let mut apply_err: Option<String> = None;
2026            for ev in &events {
2027                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
2028                    apply_err = Some(e.to_string());
2029                    break;
2030                }
2031            }
2032            if let Some(err) = apply_err {
2033                return WebReturnType::error(format!("ReorderTab: SQLite write failed: {}", err));
2034            }
2035            publish_events(state, &events);
2036            if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
2037                let update = WaveObjUpdate {
2038                    updatetype: "update".into(),
2039                    otype: OTYPE_WORKSPACE.to_string(),
2040                    oid: ws_id.clone(),
2041                    obj: Some(wave_obj_to_value(&ws)),
2042                };
2043                WebReturnType::success_with_updates(vec![update])
2044            } else {
2045                WebReturnType::success_empty()
2046            }
2047        }
2048        // Phase E.5.5 — MoveTabToWorkspace migrated to dispatch
2049        // Command::MoveTab through the reducer. Closes codex P1 #621
2050        // (the saga's reducer-state pre-check rejected tear-off after
2051        // a wcore-direct cross-window drag had left state.tabs stale)
2052        // by routing all tab moves through the reducer so its view
2053        // always matches SQLite.
2054        "MoveTabToWorkspace" => {
2055            let tab_id: String = match service::get_arg(args, 0) {
2056                Ok(v) => v,
2057                Err(e) => return WebReturnType::error(e),
2058            };
2059            let source_ws_id: String = match service::get_arg(args, 1) {
2060                Ok(v) => v,
2061                Err(e) => return WebReturnType::error(e),
2062            };
2063            let dest_ws_id: String = match service::get_arg(args, 2) {
2064                Ok(v) => v,
2065                Err(e) => return WebReturnType::error(e),
2066            };
2067            let insert_index: Option<u32> = service::get_arg::<usize>(args, 3)
2068                .ok()
2069                .map(|v| v.try_into().unwrap_or(u32::MAX));
2070            tracing::info!(tab_id = %tab_id, source_ws = %source_ws_id, dest_ws = %dest_ws_id, insert_index = ?insert_index, "[dnd:svc] MoveTabToWorkspace via reducer");
2071            // Same-workspace short-circuit matches wcore behaviour.
2072            // The reducer rejects same-workspace moves outright (use
2073            // ReorderTab instead); for the RPC contract, treat it as
2074            // a no-op success so existing callers don't see a
2075            // behavioural regression.
2076            if source_ws_id == dest_ws_id {
2077                return WebReturnType::success_empty();
2078            }
2079            // Last-tab guard mirrors wcore::move_tab_to_workspace —
2080            // the reducer's MoveTab doesn't enforce this (intentionally,
2081            // for sagas that legitimately drain a workspace to delete
2082            // it). Keep the guard at the RPC layer where the policy
2083            // belongs. **Read SQLite, not reducer state** — during the
2084            // migration window, wcore-direct tab paths
2085            // (PromoteBlockToTab, etc.) leave reducer.tab_ids stale,
2086            // so a reducer-state guard would falsely reject valid
2087            // moves. SQLite is the source of truth (codex P1 round-2
2088            // #621).
2089            match store.get::<Workspace>(&source_ws_id) {
2090                Ok(Some(src_ws)) => {
2091                    let total_tabs = src_ws.tabids.len() + src_ws.pinnedtabids.len();
2092                    if total_tabs <= 1 {
2093                        return WebReturnType::error(
2094                            "cannot move last tab out of workspace".to_string(),
2095                        );
2096                    }
2097                }
2098                Ok(None) => {
2099                    return WebReturnType::error(format!(
2100                        "MoveTabToWorkspace: source workspace not found: {}",
2101                        source_ws_id
2102                    ));
2103                }
2104                Err(e) => {
2105                    return WebReturnType::error(format!(
2106                        "MoveTabToWorkspace: workspace read failed: {}",
2107                        e
2108                    ));
2109                }
2110            }
2111            let dst_index = insert_index.unwrap_or(u32::MAX);
2112            let events = dispatch_to_reducer(
2113                state,
2114                agentmux_common::ipc::Command::MoveTab {
2115                    tab_id: tab_id.clone(),
2116                    src_workspace_id: source_ws_id.clone(),
2117                    dst_workspace_id: dest_ws_id.clone(),
2118                    dst_index,
2119                },
2120            )
2121            .await;
2122            if let Some(err_msg) = events.iter().find_map(|e| match e {
2123                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
2124                _ => None,
2125            }) {
2126                return WebReturnType::error(err_msg);
2127            }
2128            for ev in &events {
2129                if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
2130                    return WebReturnType::error(format!(
2131                        "MoveTabToWorkspace: SQLite write failed: {}",
2132                        e
2133                    ));
2134                }
2135            }
2136            publish_events(state, &events);
2137            let mut updates = Vec::new();
2138            if let Ok(src_ws) = store.must_get::<Workspace>(&source_ws_id) {
2139                updates.push(WaveObjUpdate {
2140                    updatetype: "update".into(),
2141                    otype: OTYPE_WORKSPACE.to_string(),
2142                    oid: source_ws_id.clone(),
2143                    obj: Some(wave_obj_to_value(&src_ws)),
2144                });
2145            }
2146            if let Ok(dst_ws) = store.must_get::<Workspace>(&dest_ws_id) {
2147                updates.push(WaveObjUpdate {
2148                    updatetype: "update".into(),
2149                    otype: OTYPE_WORKSPACE.to_string(),
2150                    oid: dest_ws_id.clone(),
2151                    obj: Some(wave_obj_to_value(&dst_ws)),
2152                });
2153            }
2154            WebReturnType::success_with_updates(updates)
2155        }
2156        // Phase E.5.6 — RestoreTornOffTab migrated to saga (MoveTab
2157        // back + conditional DeleteWorkspaceCascade if source becomes
2158        // empty). The legacy `was_pinned` arg is ignored — pinning
2159        // was removed from AgentMux in E.2c.3b; restored tabs always
2160        // land in `tab_ids`.
2161        "RestoreTornOffTab" => {
2162            let tab_id: String = match service::get_arg(args, 0) {
2163                Ok(v) => v,
2164                Err(e) => return WebReturnType::error(e),
2165            };
2166            let source_ws_id: String = match service::get_arg(args, 1) {
2167                Ok(v) => v,
2168                Err(e) => return WebReturnType::error(e),
2169            };
2170            let dest_ws_id: String = match service::get_arg(args, 2) {
2171                Ok(v) => v,
2172                Err(e) => return WebReturnType::error(e),
2173            };
2174            let insert_index: Option<u32> = service::get_arg::<usize>(args, 3)
2175                .ok()
2176                .map(|v| v.try_into().unwrap_or(u32::MAX));
2177            tracing::info!(tab_id = %tab_id, source_ws = %source_ws_id, dest_ws = %dest_ws_id, insert_index = ?insert_index, "[dnd:svc] RestoreTornOffTab via saga");
2178            let saga_result = crate::sagas::restore_torn_off_tab::run(
2179                state,
2180                tab_id,
2181                source_ws_id.clone(),
2182                dest_ws_id.clone(),
2183                insert_index,
2184            )
2185            .await;
2186            match saga_result {
2187                Ok(_) => {
2188                    let mut updates = Vec::new();
2189                    match store.get::<Workspace>(&source_ws_id) {
2190                        Ok(Some(src_ws)) => {
2191                            updates.push(WaveObjUpdate {
2192                                updatetype: "update".into(),
2193                                otype: OTYPE_WORKSPACE.to_string(),
2194                                oid: source_ws_id.clone(),
2195                                obj: Some(wave_obj_to_value(&src_ws)),
2196                            });
2197                        }
2198                        Ok(None) => {
2199                            updates.push(WaveObjUpdate {
2200                                updatetype: "delete".into(),
2201                                otype: OTYPE_WORKSPACE.to_string(),
2202                                oid: source_ws_id.clone(),
2203                                obj: None,
2204                            });
2205                        }
2206                        Err(_) => {}
2207                    }
2208                    if let Ok(dst_ws) = store.must_get::<Workspace>(&dest_ws_id) {
2209                        updates.push(WaveObjUpdate {
2210                            updatetype: "update".into(),
2211                            otype: OTYPE_WORKSPACE.to_string(),
2212                            oid: dest_ws_id.clone(),
2213                            obj: Some(wave_obj_to_value(&dst_ws)),
2214                        });
2215                    }
2216                    WebReturnType::success_with_updates(updates)
2217                }
2218                Err(reason) => WebReturnType::error(reason),
2219            }
2220        }
2221        // Phase E.5.5 — TearOffBlock migrated to saga (reducer-state
2222        // portion: CreateWorkspace + CreateTab + MoveBlock). Layout
2223        // tree setup on the new tab + queueing the source tab's
2224        // layout-delete action stay wcore-direct here — layout state
2225        // is E.4 work, separately scoped. The saga's atomicity is
2226        // limited to the reducer-state portion; layout writes are
2227        // best-effort and can leave a torn-off block with a malformed
2228        // layout if the post-saga step fails. Acceptable trade-off
2229        // for the smoke regression fix; full atomicity is a Phase F+
2230        // gap (see saga-coordinator-location-analysis-2026-04-30.md
2231        // §4.2).
2232        "TearOffBlock" => {
2233            let block_id: String = match service::get_arg(args, 0) {
2234                Ok(v) => v,
2235                Err(e) => return WebReturnType::error(e),
2236            };
2237            let source_tab_id: String = match service::get_arg(args, 1) {
2238                Ok(v) => v,
2239                Err(e) => return WebReturnType::error(e),
2240            };
2241            let source_ws_id: String = match service::get_arg(args, 2) {
2242                Ok(v) => v,
2243                Err(e) => return WebReturnType::error(e),
2244            };
2245            let auto_close: bool = service::get_arg(args, 3).unwrap_or(true);
2246            tracing::info!(block_id = %block_id, source_tab = %source_tab_id, source_ws = %source_ws_id, "[dnd:svc] TearOffBlock via saga");
2247            let saga_result = crate::sagas::tear_off_block::run(
2248                state,
2249                block_id.clone(),
2250                source_tab_id.clone(),
2251                source_ws_id.clone(),
2252            )
2253            .await;
2254            let (new_ws_oid, new_tab_oid) = match saga_result {
2255                Ok(value) => {
2256                    let new_ws_oid = value
2257                        .get("new_workspace_id")
2258                        .and_then(|v| v.as_str())
2259                        .unwrap_or_default()
2260                        .to_string();
2261                    let new_tab_oid = value
2262                        .get("new_tab_id")
2263                        .and_then(|v| v.as_str())
2264                        .unwrap_or_default()
2265                        .to_string();
2266                    (new_ws_oid, new_tab_oid)
2267                }
2268                Err(reason) => return WebReturnType::error(reason),
2269            };
2270
2271            // Layout setup for the new tab — make the moved block its
2272            // single root node so the frontend renders it. Mirrors
2273            // wcore::tear_off_block. Best-effort; layout migration is
2274            // E.4 territory and not yet reducer-routed.
2275            if let Err(e) = setup_torn_off_block_layout(store, &new_tab_oid, &block_id) {
2276                tracing::warn!(new_tab = %new_tab_oid, "TearOffBlock: layout setup failed: {} (block in tab but layout malformed)", e);
2277            }
2278            // Source tab: queue a layout-delete action so the source
2279            // window's frontend removes the node from its tree.
2280            if let Err(e) = queue_source_layout_delete(store, &source_tab_id, &block_id) {
2281                tracing::warn!(source_tab = %source_tab_id, "TearOffBlock: source layout delete-action enqueue failed: {}", e);
2282            }
2283
2284            // Auto-close empty source tab. Route through the reducer
2285            // (DeleteTab cascade is built in; the tab has no blocks
2286            // at this point — we just moved the only one out). Skip
2287            // when source workspace would become empty.
2288            if auto_close {
2289                let should_close = match store.must_get::<Tab>(&source_tab_id) {
2290                    Ok(t) => t.blockids.is_empty(),
2291                    Err(_) => false,
2292                };
2293                if should_close {
2294                    let total_tabs = match store.must_get::<Workspace>(&source_ws_id) {
2295                        Ok(ws) => ws.tabids.len() + ws.pinnedtabids.len(),
2296                        Err(_) => 0,
2297                    };
2298                    if total_tabs > 1 {
2299                        tracing::info!(source_tab = %source_tab_id, "[dnd:svc] auto-closing empty source tab after TearOffBlock");
2300                        let close_events = dispatch_to_reducer(
2301                            state,
2302                            agentmux_common::ipc::Command::DeleteTab {
2303                                workspace_id: source_ws_id.clone(),
2304                                tab_id: source_tab_id.clone(),
2305                                // Auto-close already gated on
2306                                // total_tabs > 1.
2307                                force: false,
2308                            },
2309                        )
2310                        .await;
2311                        for ev in &close_events {
2312                            let _ = crate::persist_subscriber::apply_event_to_wstore(ev, store);
2313                        }
2314                        publish_events(state, &close_events);
2315                    }
2316                }
2317            }
2318
2319            let mut updates = Vec::new();
2320            if let Ok(src_tab) = store.must_get::<Tab>(&source_tab_id) {
2321                updates.push(WaveObjUpdate {
2322                    updatetype: "update".into(),
2323                    otype: OTYPE_TAB.to_string(),
2324                    oid: source_tab_id.clone(),
2325                    obj: Some(wave_obj_to_value(&src_tab)),
2326                });
2327            }
2328            if let Ok(src_ws) = store.must_get::<Workspace>(&source_ws_id) {
2329                updates.push(WaveObjUpdate {
2330                    updatetype: "update".into(),
2331                    otype: OTYPE_WORKSPACE.to_string(),
2332                    oid: source_ws_id.clone(),
2333                    obj: Some(wave_obj_to_value(&src_ws)),
2334                });
2335            }
2336            if let Ok(new_ws) = store.must_get::<Workspace>(&new_ws_oid) {
2337                updates.push(WaveObjUpdate {
2338                    updatetype: "update".into(),
2339                    otype: OTYPE_WORKSPACE.to_string(),
2340                    oid: new_ws_oid.clone(),
2341                    obj: Some(wave_obj_to_value(&new_ws)),
2342                });
2343            }
2344            WebReturnType::success_data_updates(
2345                serde_json::to_value(&new_ws_oid).unwrap_or_default(),
2346                updates,
2347            )
2348        }
2349        // Floating-pane Phase 4a — re-dock a floater's block back into
2350        // an existing tab in another workspace. Same shape as
2351        // TearOffBlock's RPC handler: saga handles the reducer-state
2352        // move (MoveBlock); layout writes are wcore-direct (the
2353        // target's layout grows a leaf; the source's layout enqueues
2354        // a delete action). Source floater closes via PR #1089's
2355        // empty-tab watcher once its tab.blockids is empty.
2356        // Spec: docs/specs/SPEC_FLOATING_PANE_REDOCK_2026-05-27.md
2357        "RedockFloatingPane" => {
2358            let block_id: String = match service::get_arg(args, 0) {
2359                Ok(v) => v,
2360                Err(e) => return WebReturnType::error(e),
2361            };
2362            let source_tab_id: String = match service::get_arg(args, 1) {
2363                Ok(v) => v,
2364                Err(e) => return WebReturnType::error(e),
2365            };
2366            let source_ws_id: String = match service::get_arg(args, 2) {
2367                Ok(v) => v,
2368                Err(e) => return WebReturnType::error(e),
2369            };
2370            let target_tab_id: String = match service::get_arg(args, 3) {
2371                Ok(v) => v,
2372                Err(e) => return WebReturnType::error(e),
2373            };
2374            let target_ws_id: String = match service::get_arg(args, 4) {
2375                Ok(v) => v,
2376                Err(e) => return WebReturnType::error(e),
2377            };
2378            tracing::info!(
2379                block_id = %block_id,
2380                source_tab = %source_tab_id,
2381                source_ws = %source_ws_id,
2382                target_tab = %target_tab_id,
2383                target_ws = %target_ws_id,
2384                "[dnd:svc] RedockFloatingPane via saga"
2385            );
2386            let saga_result = crate::sagas::redock_floating_pane::run(
2387                state,
2388                block_id.clone(),
2389                source_tab_id.clone(),
2390                source_ws_id.clone(),
2391                target_tab_id.clone(),
2392                target_ws_id.clone(),
2393                None,
2394            )
2395            .await;
2396            if let Err(reason) = saga_result {
2397                return WebReturnType::error(reason);
2398            }
2399
2400            // Target layout: enqueue an "insert" action on its
2401            // `pendingbackendactions` so the target window's frontend
2402            // grows a new leaf for the redocked block through its
2403            // standard LayoutTreeActionType.InsertNode reducer.
2404            // Direct rootnode writes don't propagate because the
2405            // LayoutModel doesn't auto-sync from external WaveObj
2406            // updates — see `queue_target_layout_insert`'s docstring.
2407            // Layout writes are required before the Tab broadcast — if
2408            // either fails the block becomes invisible (moved in SQLite
2409            // but no LayoutState entry in the target). Return error so
2410            // the caller can retry; the saga state is dirty but no
2411            // visible change has propagated to the renderers yet.
2412            if let Err(e) = queue_target_layout_insert(store, &target_tab_id, &block_id) {
2413                tracing::error!(
2414                    target_tab = %target_tab_id,
2415                    "RedockFloatingPane: target layout insert failed — aborting broadcast: {}",
2416                    e
2417                );
2418                return WebReturnType::error(format!(
2419                    "redock layout insert failed: {e}"
2420                ));
2421            }
2422            if let Err(e) = queue_source_layout_delete(store, &source_tab_id, &block_id) {
2423                tracing::error!(
2424                    source_tab = %source_tab_id,
2425                    "RedockFloatingPane: source layout delete failed — aborting broadcast: {}",
2426                    e
2427                );
2428                return WebReturnType::error(format!(
2429                    "redock layout delete failed: {e}"
2430                ));
2431            }
2432
2433            let mut updates = Vec::new();
2434            // Layout updates MUST come first — `append_block_to_target_layout`
2435            // and `queue_source_layout_delete` write straight to wstore via
2436            // `store.update`, which is NOT auto-broadcast (only the SQLite
2437            // row gets a new version). Without these entries in the response,
2438            // the target window's frontend never sees the new leaf and
2439            // renders nothing; the source's pending delete action never
2440            // gets pulled either. Both layouts are read AFTER the helpers
2441            // run so we capture the fresh state.
2442            if let Ok(src_tab) = store.must_get::<Tab>(&source_tab_id) {
2443                if let Ok(src_layout) = store.must_get::<LayoutState>(&src_tab.layoutstate) {
2444                    updates.push(WaveObjUpdate {
2445                        updatetype: "update".into(),
2446                        otype: OTYPE_LAYOUT.to_string(),
2447                        oid: src_tab.layoutstate.clone(),
2448                        obj: Some(wave_obj_to_value(&src_layout)),
2449                    });
2450                }
2451                updates.push(WaveObjUpdate {
2452                    updatetype: "update".into(),
2453                    otype: OTYPE_TAB.to_string(),
2454                    oid: source_tab_id.clone(),
2455                    obj: Some(wave_obj_to_value(&src_tab)),
2456                });
2457            }
2458            if let Ok(dst_tab) = store.must_get::<Tab>(&target_tab_id) {
2459                if let Ok(dst_layout) = store.must_get::<LayoutState>(&dst_tab.layoutstate) {
2460                    updates.push(WaveObjUpdate {
2461                        updatetype: "update".into(),
2462                        otype: OTYPE_LAYOUT.to_string(),
2463                        oid: dst_tab.layoutstate.clone(),
2464                        obj: Some(wave_obj_to_value(&dst_layout)),
2465                    });
2466                }
2467                updates.push(WaveObjUpdate {
2468                    updatetype: "update".into(),
2469                    otype: OTYPE_TAB.to_string(),
2470                    oid: target_tab_id.clone(),
2471                    obj: Some(wave_obj_to_value(&dst_tab)),
2472                });
2473            }
2474            // CRITICAL: WaveObjUpdates in the response only reach the
2475            // CALLING renderer (the floater that's about to close). The
2476            // TARGET window's renderer is a different process and won't
2477            // see the layout change unless we explicitly broadcast on
2478            // the event bus. Mirrors the pattern in `app_api.rs:399-410`.
2479            // Without this the target tab.blockids includes the new
2480            // block but its layout.leaforder doesn't → block invisible.
2481            for update in &updates {
2482                let oref = format!("{}:{}", update.otype, update.oid);
2483                if let Ok(data) = serde_json::to_value(update) {
2484                    state.event_bus.broadcast_event(
2485                        &crate::backend::eventbus::WSEventType {
2486                            eventtype: "waveobj:update".to_string(),
2487                            oref,
2488                            data: Some(data),
2489                        },
2490                    );
2491                }
2492            }
2493
2494            WebReturnType::success_data_updates(
2495                serde_json::json!({
2496                    "redocked": true,
2497                    "block_id": block_id,
2498                    "target_tab_id": target_tab_id,
2499                }),
2500                updates,
2501            )
2502        }
2503
2504        // Phase E.5.5 — TearOffTab migrated to saga. Closes the
2505        // smoke regression where wcore::tear_off_tab created the new
2506        // workspace bypassing the reducer, leaving the new window's
2507        // CreateTab/etc. calls failing on "workspace not found"
2508        // checks against the reducer's stale view.
2509        "TearOffTab" => {
2510            let tab_id: String = match service::get_arg(args, 0) {
2511                Ok(v) => v,
2512                Err(e) => return WebReturnType::error(e),
2513            };
2514            let source_ws_id: String = match service::get_arg(args, 1) {
2515                Ok(v) => v,
2516                Err(e) => return WebReturnType::error(e),
2517            };
2518            tracing::info!(tab_id = %tab_id, source_ws = %source_ws_id, "[dnd:svc] TearOffTab via saga");
2519            match crate::sagas::tear_off_tab::run(state, tab_id, source_ws_id.clone()).await {
2520                Ok(saga_result) => {
2521                    let new_ws_oid = saga_result
2522                        .get("new_workspace_id")
2523                        .and_then(|v| v.as_str())
2524                        .unwrap_or_default()
2525                        .to_string();
2526                    let mut updates = Vec::new();
2527                    if let Ok(src_ws) = store.must_get::<Workspace>(&source_ws_id) {
2528                        updates.push(WaveObjUpdate {
2529                            updatetype: "update".into(),
2530                            otype: OTYPE_WORKSPACE.to_string(),
2531                            oid: source_ws_id.clone(),
2532                            obj: Some(wave_obj_to_value(&src_ws)),
2533                        });
2534                    }
2535                    if let Ok(new_ws) = store.must_get::<Workspace>(&new_ws_oid) {
2536                        updates.push(WaveObjUpdate {
2537                            updatetype: "update".into(),
2538                            otype: OTYPE_WORKSPACE.to_string(),
2539                            oid: new_ws_oid.clone(),
2540                            obj: Some(wave_obj_to_value(&new_ws)),
2541                        });
2542                    }
2543                    WebReturnType::success_data_updates(
2544                        serde_json::to_value(&new_ws_oid).unwrap_or_default(),
2545                        updates,
2546                    )
2547                }
2548                Err(reason) => WebReturnType::error(reason),
2549            }
2550        }
2551        _ => WebReturnType::error(format!("unknown workspace method: {}", call.method)),
2552    }
2553}
2554
2555async fn handle_misc_service(state: &AppState, call: &WebCallType) -> WebReturnType {
2556    let _store = &state.wstore;
2557    let args = &call.args;
2558    match (call.service.as_str(), call.method.as_str()) {
2559        // ---- UserInputService ----
2560        ("userinput", "SendUserInputResponse") => {
2561            // Accept but drop — user input routing not yet wired
2562            WebReturnType::success_empty()
2563        }
2564
2565        // ---- BlockService ----
2566        ("block", "GetControllerStatus") => {
2567            let block_id: String = match service::get_arg(args, 0) {
2568                Ok(v) => v,
2569                Err(e) => return WebReturnType::error(e),
2570            };
2571            match crate::backend::blockcontroller::get_block_controller_status(&block_id) {
2572                Some(status) => WebReturnType::success(
2573                    serde_json::to_value(&status).unwrap_or(serde_json::Value::Null),
2574                ),
2575                None => {
2576                    let default_status = crate::backend::blockcontroller::BlockControllerRuntimeStatus {
2577                        blockid: block_id,
2578                        ..Default::default()
2579                    };
2580                    WebReturnType::success(
2581                        serde_json::to_value(&default_status).unwrap_or(serde_json::Value::Null),
2582                    )
2583                }
2584            }
2585        }
2586        ("block", "SendCommand") | ("block", "SaveTerminalState") => {
2587            WebReturnType::success_empty()
2588        }
2589
2590        // ---- SubagentService ----
2591        ("subagent", "ListActive") => {
2592            let subagents = state.subagent_watcher.list_active();
2593            WebReturnType::success(serde_json::to_value(&subagents).unwrap_or_default())
2594        }
2595        ("subagent", "GetHistory") => {
2596            let agent_id: String = match service::get_arg(args, 0) {
2597                Ok(v) => v,
2598                Err(e) => return WebReturnType::error(e),
2599            };
2600            let limit: usize = service::get_arg(args, 1).unwrap_or(100);
2601            let history = state.subagent_watcher.get_history(&agent_id, limit);
2602            WebReturnType::success(serde_json::to_value(&history).unwrap_or_default())
2603        }
2604        // ---- HistoryService ----
2605        ("history", "List") => {
2606            let provider: Option<String> = service::get_optional_arg(args, 0).unwrap_or(None);
2607            let project: Option<String> = service::get_optional_arg(args, 1).unwrap_or(None);
2608            let offset: usize = service::get_arg(args, 2).unwrap_or(0);
2609            let limit: usize = service::get_arg(args, 3).unwrap_or(50);
2610            let sort_by: String = service::get_arg(args, 4).unwrap_or_else(|_| "modified_at".to_string());
2611            let sort_dir: String = service::get_arg(args, 5).unwrap_or_else(|_| "desc".to_string());
2612            let result = state.history_service.list(
2613                provider.as_deref(),
2614                project.as_deref(),
2615                offset,
2616                limit,
2617                &sort_by,
2618                &sort_dir,
2619            );
2620            WebReturnType::success(result)
2621        }
2622        ("history", "Get") => {
2623            let session_id: String = match service::get_arg(args, 0) {
2624                Ok(v) => v,
2625                Err(e) => return WebReturnType::error(e),
2626            };
2627            let result = state.history_service.get(&session_id);
2628            WebReturnType::success(result)
2629        }
2630        ("history", "Refresh") => {
2631            let result = state.history_service.refresh();
2632            WebReturnType::success(result)
2633        }
2634        ("history", "Delete") => {
2635            let session_id: String = match service::get_arg(args, 0) {
2636                Ok(v) => v,
2637                Err(e) => return WebReturnType::error(e),
2638            };
2639            let result = state.history_service.delete(&session_id);
2640            WebReturnType::success(result)
2641        }
2642        ("history", "Clear") => {
2643            let provider: Option<String> = service::get_optional_arg(args, 0).unwrap_or(None);
2644            let project: Option<String> = service::get_optional_arg(args, 1).unwrap_or(None);
2645            let result = state
2646                .history_service
2647                .clear(provider.as_deref(), project.as_deref());
2648            WebReturnType::success(result)
2649        }
2650
2651        ("subagent", "WatchAgent") => {
2652            let agent_id: String = match service::get_arg(args, 0) {
2653                Ok(v) => v,
2654                Err(e) => return WebReturnType::error(e),
2655            };
2656            let config_dir: String = match service::get_arg(args, 1) {
2657                Ok(v) => v,
2658                Err(e) => return WebReturnType::error(e),
2659            };
2660            // Optional block_id (arg 2) — stamps emitted subagent events with the
2661            // owning pane so the frontend can filter. Defaults to "" for callers
2662            // that don't supply it (events then match no pane, which is correct
2663            // for this manual/legacy entry point).
2664            let block_id: String = service::get_optional_arg(args, 2)
2665                .unwrap_or(None)
2666                .unwrap_or_default();
2667            state.subagent_watcher.watch_agent(&agent_id, &block_id, std::path::PathBuf::from(config_dir));
2668            WebReturnType::success_empty()
2669        }
2670
2671        // ---- App API (also reachable via WebSocket RPC in app_api.rs) ----
2672        ("agent", "define") => {
2673            let data: crate::backend::rpc_types::CommandAgentDefineData =
2674                match service::get_arg(args, 0) {
2675                    Ok(v) => v,
2676                    Err(e) => return WebReturnType::error(e),
2677                };
2678            match super::app_api::agent_define_core(state.wstore.clone(), state.broker.clone(), data).await {
2679                Ok(result) => WebReturnType::success(serde_json::to_value(&result).unwrap_or_default()),
2680                Err(e) => WebReturnType::error(e),
2681            }
2682        }
2683
2684        _ => WebReturnType::error(format!(
2685            "unknown service method: {}.{}",
2686            call.service, call.method
2687        )),
2688    }
2689}
2690
2691
2692/// Phase E.4 (Option A) — reverse lookup: given a `LayoutState.oid`,
2693/// find the `Tab.oid` that owns it (i.e., the tab whose `layoutstate`
2694/// field matches). Returns `None` when the layout is unowned (legacy
2695/// or partially-migrated row) or the wstore read fails — caller treats
2696/// either as "skip the reducer dispatch and fall through to the wcore
2697/// write." Linear scan over all tabs; acceptable here because the
2698/// layout-update path is low-frequency relative to drag-resize and
2699/// the reducer mutex itself is held for sub-millisecond intervals.
2700fn find_tab_for_layout(store: &Store, layout_oid: &str) -> Option<String> {
2701    let tabs = store.get_all::<Tab>().ok()?;
2702    tabs.into_iter()
2703        .find(|t| t.layoutstate == layout_oid)
2704        .map(|t| t.oid)
2705}
2706
2707/// Resolve an "otype:oid" string to the corresponding wave object JSON.
2708fn get_object_by_oref(store: &Store, oref_str: &str) -> Result<serde_json::Value, String> {
2709    let oref = crate::backend::ORef::parse(oref_str).map_err(|e| e.to_string())?;
2710
2711    // Validate otype is known
2712    match oref.otype.as_str() {
2713        OTYPE_CLIENT | OTYPE_WINDOW | OTYPE_WORKSPACE | OTYPE_TAB | OTYPE_LAYOUT | OTYPE_BLOCK => {}
2714        _ => return Err(format!("unknown otype: {}", oref.otype)),
2715    }
2716
2717    // Use raw JSON read to avoid strict struct deserialization issues
2718    // (e.g. layout leaforder with embedded BlockDef objects).
2719    // This matches Go's generic map-based GetObject behavior.
2720    store
2721        .get_raw(&oref.otype, &oref.oid)
2722        .map_err(|e| e.to_string())?
2723        .ok_or_else(|| format!("not found: {}", oref_str))
2724}
2725
2726/// Update a wave object by replacing it wholesale in the store.
2727/// The incoming value must have `otype` and `oid` fields.
2728/// Matches Go's ObjectService.UpdateObject behavior.
2729/// Returns (otype, oid, updated_value_with_new_version) on success.
2730fn update_object(
2731    store: &Store,
2732    mut value: serde_json::Value,
2733) -> Result<(String, String, serde_json::Value), String> {
2734    let otype = value
2735        .get("otype")
2736        .and_then(|v| v.as_str())
2737        .ok_or_else(|| "UpdateObject: missing otype field".to_string())?
2738        .to_string();
2739    let oid = value
2740        .get("oid")
2741        .and_then(|v| v.as_str())
2742        .ok_or_else(|| "UpdateObject: missing oid field".to_string())?
2743        .to_string();
2744
2745    // Validate the otype is known
2746    match otype.as_str() {
2747        OTYPE_CLIENT | OTYPE_WINDOW | OTYPE_WORKSPACE | OTYPE_TAB | OTYPE_LAYOUT | OTYPE_BLOCK => {}
2748        _ => return Err(format!("UpdateObject: unsupported otype: {}", otype)),
2749    }
2750
2751    // Use raw JSON storage (matching Go's generic map-based UpdateObject).
2752    // The frontend sends the full replacement object; strict Rust struct deserialization
2753    // can fail on dynamic fields (e.g. layout rootnode with embedded BlockDefs).
2754    let new_version = store
2755        .update_raw(&otype, &oid, &value)
2756        .map_err(|e| format!("UpdateObject: {}", e))?;
2757
2758    // Update version in the value for the returned update event
2759    if let Some(obj) = value.as_object_mut() {
2760        obj.insert("version".to_string(), serde_json::json!(new_version));
2761    }
2762
2763    Ok((otype, oid, value))
2764}
2765
2766/// Update object meta by oref string. Merges meta into existing object.
2767pub(crate) fn update_object_meta(
2768    store: &Store,
2769    oref_str: &str,
2770    meta_update: &MetaMapType,
2771) -> Result<(), String> {
2772    let oref = crate::backend::ORef::parse(oref_str).map_err(|e| e.to_string())?;
2773    match oref.otype.as_str() {
2774        OTYPE_CLIENT => {
2775            let mut obj = store.must_get::<Client>(&oref.oid).map_err(|e| e.to_string())?;
2776            obj.meta = merge_meta(&obj.meta, meta_update, true);
2777            store.update(&mut obj).map_err(|e| e.to_string())?;
2778        }
2779        OTYPE_WINDOW => {
2780            let mut obj = store.must_get::<Window>(&oref.oid).map_err(|e| e.to_string())?;
2781            obj.meta = merge_meta(&obj.meta, meta_update, true);
2782            store.update(&mut obj).map_err(|e| e.to_string())?;
2783        }
2784        OTYPE_WORKSPACE => {
2785            let mut obj = store
2786                .must_get::<Workspace>(&oref.oid)
2787                .map_err(|e| e.to_string())?;
2788            obj.meta = merge_meta(&obj.meta, meta_update, true);
2789            store.update(&mut obj).map_err(|e| e.to_string())?;
2790        }
2791        OTYPE_TAB => {
2792            let mut obj = store.must_get::<Tab>(&oref.oid).map_err(|e| e.to_string())?;
2793            obj.meta = merge_meta(&obj.meta, meta_update, true);
2794            store.update(&mut obj).map_err(|e| e.to_string())?;
2795        }
2796        OTYPE_BLOCK => {
2797            let mut obj = store.must_get::<Block>(&oref.oid).map_err(|e| e.to_string())?;
2798            obj.meta = merge_meta(&obj.meta, meta_update, true);
2799            store.update(&mut obj).map_err(|e| e.to_string())?;
2800        }
2801        _ => return Err(format!("cannot update meta for otype: {}", oref.otype)),
2802    }
2803    Ok(())
2804}
2805
2806
2807// ---- Phase E.2c.2 reducer-dispatch helpers ----
2808
2809/// Dispatch a command into the srv reducer and return the emitted
2810/// events. Locks the reducer mutex briefly; the lock is released
2811/// before any I/O (caller is responsible for publishing the events
2812/// to the broadcast bus).
2813/// Per-`agent_id` debounce generation for zoom mirroring. Each `term:zoom`
2814/// change bumps the agent's generation; the spawned trailing-write task only
2815/// commits if its captured generation is still current 300ms later, so a
2816/// Ctrl+Wheel burst collapses into a single durable write (+ one global
2817/// def-registry re-mirror). See SPEC_AGENT_ZOOM_PERSISTENCE §4.3.
2818static ZOOM_MIRROR_GEN: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, u64>>> =
2819    std::sync::OnceLock::new();
2820
2821fn zoom_mirror_gen() -> &'static std::sync::Mutex<std::collections::HashMap<String, u64>> {
2822    ZOOM_MIRROR_GEN.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
2823}
2824
2825/// Debounced trailing mirror of an agent block's `term:zoom` into the per-agent
2826/// `ui:zoom` content blob. `zoom = Some(z)` upserts; `zoom = None` (term:zoom
2827/// reset to null / 1.0) deletes the row so a default agent persists nothing.
2828fn schedule_agent_zoom_mirror(
2829    store: std::sync::Arc<crate::backend::storage::store::Store>,
2830    agent_id: String,
2831    zoom: Option<f64>,
2832) {
2833    let generation = {
2834        let mut gens = zoom_mirror_gen().lock().unwrap();
2835        let g = gens.entry(agent_id.clone()).or_insert(0);
2836        *g += 1;
2837        *g
2838    };
2839    tokio::spawn(async move {
2840        tokio::time::sleep(std::time::Duration::from_millis(300)).await;
2841        // Superseded by a newer zoom change for this agent → drop this write.
2842        if zoom_mirror_gen().lock().unwrap().get(&agent_id).copied() != Some(generation) {
2843            return;
2844        }
2845        let now = chrono::Utc::now().timestamp_millis();
2846        let result = match zoom {
2847            Some(z) => store.agent_content_set(
2848                &crate::backend::storage::store::AgentContent {
2849                    agent_id: agent_id.clone(),
2850                    content_type: "ui:zoom".to_string(),
2851                    content: format!("{}", z),
2852                    updated_at: now,
2853                },
2854            ),
2855            None => store.agent_content_delete(&agent_id, "ui:zoom").map(|_| ()),
2856        };
2857        if let Err(e) = result {
2858            tracing::warn!(agent_id = %agent_id, error = %e, "[zoom] agent zoom mirror write failed");
2859        }
2860    });
2861}
2862
2863pub(crate) async fn dispatch_to_reducer(
2864    state: &AppState,
2865    cmd: agentmux_common::ipc::Command,
2866) -> Vec<agentmux_common::ipc::Event> {
2867    let now = chrono::Utc::now().to_rfc3339();
2868    let mut s = state.srv_state.lock().await;
2869    let ctx = crate::reducer::Ctx {
2870        now_rfc3339: now,
2871        // RPC-originated dispatch has no IPC connection — sentinel.
2872        conn_id: 0,
2873        registered_pid: None,
2874    };
2875    crate::reducer::update(&mut s, cmd, &ctx)
2876}
2877
2878/// Publish each event on the srv broadcast bus. Failures (no
2879/// subscribers) are non-fatal.
2880pub(crate) fn publish_events(state: &AppState, events: &[agentmux_common::ipc::Event]) {
2881    for event in events {
2882        let _ = state.srv_events_tx.send(event.clone());
2883    }
2884}
2885
2886/// Compensation helper: dispatch a command into the reducer and
2887/// apply its emitted events to wstore best-effort. Used when an
2888/// earlier sync apply partially wrote SQLite and we need to undo
2889/// the leaked rows. SQLite errors during compensation are logged
2890/// but ignored — the caller is already returning an error to the
2891/// client; throwing on the cleanup just hides the original cause.
2892/// (codex P1 + reagent P2 #616 — partial-write cleanup.)
2893async fn compensate_via_reducer(
2894    state: &AppState,
2895    cmd: agentmux_common::ipc::Command,
2896    store: &Store,
2897) {
2898    let events = dispatch_to_reducer(state, cmd).await;
2899    for ev in &events {
2900        if let Err(e) = crate::persist_subscriber::apply_event_to_wstore(ev, store) {
2901            tracing::warn!(
2902                "compensation: SQLite cleanup failed for event {:?}: {}",
2903                std::mem::discriminant(ev),
2904                e
2905            );
2906        }
2907    }
2908}
2909
2910/// Phase E.5.5 — set up the layout tree for a tab that just received
2911/// its first block via the TearOffBlock saga. Called from the
2912/// TearOffBlock RPC handler after the saga's reducer-state portion
2913/// (CreateTab + MoveBlock) completes. Mirrors the layout-rootnode
2914/// + leaforder construction that `wcore::tear_off_block` previously
2915/// embedded in its single function.
2916///
2917/// Layout state migration is E.4 — until then layout writes are
2918/// wcore-direct and not reducer-routed. Best-effort: a failure here
2919/// leaves the new tab with the moved block but a malformed layout;
2920/// the user-visible symptom is an empty render in the new window.
2921pub(crate) fn setup_torn_off_block_layout(
2922    store: &Store,
2923    new_tab_id: &str,
2924    block_id: &str,
2925) -> Result<(), Box<dyn std::error::Error>> {
2926    let new_tab = store.must_get::<Tab>(new_tab_id)?;
2927    let mut layout = store.must_get::<LayoutState>(&new_tab.layoutstate)?;
2928    let node_id = uuid::Uuid::new_v4().to_string();
2929    // Phase E.4.B Phase 2 — construct typed LayoutNode (was inline JSON).
2930    layout.rootnode = Some(LayoutNode {
2931        id: node_id.clone(),
2932        flex_direction: FlexDirection::Row,
2933        size: 1.0,
2934        children: Vec::new(),
2935        data: Some(LayoutNodeData {
2936            block_id: block_id.to_string(),
2937            ..Default::default()
2938        }),
2939        ..Default::default()
2940    });
2941    layout.leaforder = Some(vec![LeafOrderEntry {
2942        nodeid: node_id,
2943        blockid: block_id.to_string(),
2944    }]);
2945    store.update(&mut layout)?;
2946    Ok(())
2947}
2948
2949/// Floating-pane re-dock — enqueue an "insert" action on the TARGET
2950/// tab's `LayoutState.pendingbackendactions` so the target window's
2951/// frontend adds a new leaf for the redocked block through its
2952/// `LayoutTreeActionType.InsertNode` reducer pathway.
2953///
2954/// Why this and not direct rootnode/leaforder writes? The frontend's
2955/// LayoutModel maintains its own in-memory tree state and doesn't
2956/// auto-sync from external `LayoutState` WaveObj updates — so a
2957/// backend `store.update` to the rootnode lands in the WOS cache
2958/// but the LayoutModel never picks it up, and the next frontend-
2959/// initiated `object.UpdateObject` overwrites the backend version
2960/// with the LayoutModel's stale tree. The pending-actions queue
2961/// (`onBackendUpdate` in `layoutPersistence.ts:50`) is the canonical
2962/// channel for "backend wants the frontend to mutate its layout
2963/// tree". Source-delete on tear-off uses the same channel via
2964/// `queue_source_layout_delete`.
2965fn queue_target_layout_insert(
2966    store: &Store,
2967    target_tab_id: &str,
2968    block_id: &str,
2969) -> Result<(), Box<dyn std::error::Error>> {
2970    let target_tab = store.must_get::<Tab>(target_tab_id)?;
2971    let mut target_layout = store.must_get::<LayoutState>(&target_tab.layoutstate)?;
2972    let mut actions = target_layout.pendingbackendactions.take().unwrap_or_default();
2973    actions.push(LayoutActionData {
2974        // Matches `LayoutTreeActionType.InsertNode = "insert"` in
2975        // `frontend/layout/lib/types.ts:73`.
2976        actiontype: "insert".to_string(),
2977        actionid: uuid::Uuid::new_v4().to_string(),
2978        blockid: block_id.to_string(),
2979        nodesize: None,
2980        indexarr: None,
2981        focused: true,
2982        magnified: false,
2983        ephemeral: false,
2984        targetblockid: String::new(),
2985        position: String::new(),
2986    });
2987    target_layout.pendingbackendactions = Some(actions);
2988    store.update(&mut target_layout)?;
2989    Ok(())
2990}
2991
2992/// Phase E.5.5 — append a layout-delete action to the source tab's
2993/// `LayoutState.pendingbackendactions` so the source window's
2994/// frontend tears the moved block out of its layout tree on next
2995/// poll. Mirrors the action-queueing portion of
2996/// `wcore::tear_off_block`. Layout migration is E.4.
2997fn queue_source_layout_delete(
2998    store: &Store,
2999    source_tab_id: &str,
3000    block_id: &str,
3001) -> Result<(), Box<dyn std::error::Error>> {
3002    let source_tab = store.must_get::<Tab>(source_tab_id)?;
3003    let mut source_layout = store.must_get::<LayoutState>(&source_tab.layoutstate)?;
3004    let mut actions = source_layout.pendingbackendactions.take().unwrap_or_default();
3005    actions.push(LayoutActionData {
3006        actiontype: "delete".to_string(),
3007        actionid: uuid::Uuid::new_v4().to_string(),
3008        blockid: block_id.to_string(),
3009        nodesize: None,
3010        indexarr: None,
3011        focused: false,
3012        magnified: false,
3013        ephemeral: false,
3014        targetblockid: String::new(),
3015        position: String::new(),
3016    });
3017    source_layout.pendingbackendactions = Some(actions);
3018    store.update(&mut source_layout)?;
3019    Ok(())
3020}
3021
3022/// Existence check used by `DeleteWorkspace` to decide whether to
3023/// run the wcore delete path. Propagates `StoreError` so the caller
3024/// can surface real I/O / corruption failures instead of
3025/// misclassifying them as "not found" (codex P2 #615 carryover —
3026/// the prior `bool` return collapsed `Err(_)` into `false`, which
3027/// led to silent successes when SQLite was unhealthy: reducer would
3028/// delete its own copy and report success while the disk row was
3029/// never touched).
3030fn wstore_workspace_exists(
3031    store: &Store,
3032    workspace_id: &str,
3033) -> Result<bool, crate::backend::storage::StoreError> {
3034    Ok(store.get::<Workspace>(workspace_id)?.is_some())
3035}
3036
3037// `build_workspace_from_state` removed in E.2c.2. The reducer's
3038// WorkspaceRecord can't faithfully render a Workspace during the
3039// migration window (no pinnedtabids; tabids/activetabid go stale
3040// vs wcore-direct tab ops). It will be reintroduced in E.2c.3
3041// when tabs migrate into the reducer and pinned/active state is
3042// authoritative there. (reagent + codex P1 #615.)
3043
3044#[cfg(test)]
3045mod agent_context_tests {
3046    use super::{resolve_agent_context, workspace_id_for_tab};
3047    use crate::backend::obj::Tab;
3048    use crate::backend::storage::store::Store;
3049    use crate::backend::wcore;
3050
3051    #[test]
3052    fn workspace_id_for_tab_finds_owner_and_misses_cleanly() {
3053        let store = Store::open_in_memory().unwrap();
3054        wcore::ensure_initial_data(&store).unwrap();
3055        let tab = store
3056            .get_all::<Tab>()
3057            .unwrap()
3058            .into_iter()
3059            .next()
3060            .expect("seeded tab");
3061        assert!(workspace_id_for_tab(&store, &tab.oid).is_some(), "owner found");
3062        assert!(workspace_id_for_tab(&store, "nope").is_none(), "miss is None");
3063    }
3064
3065    // `ensure_initial_data` seeds one workspace ("Starter workspace") with a
3066    // window and an initial tab holding a default agent block. The resolver
3067    // must walk that agent block back up to its tab, workspace, and window.
3068    #[test]
3069    fn resolves_block_to_tab_workspace_and_window() {
3070        let store = Store::open_in_memory().unwrap();
3071        wcore::ensure_initial_data(&store).unwrap();
3072
3073        let tab = store
3074            .get_all::<Tab>()
3075            .unwrap()
3076            .into_iter()
3077            .next()
3078            .expect("seeded tab");
3079        let block_id = tab.blockids.first().expect("seeded agent block").clone();
3080
3081        let ctx = resolve_agent_context(&store, &block_id).expect("resolves context");
3082        assert_eq!(ctx.tab_id, tab.oid);
3083        assert_eq!(ctx.workspace_name, "Starter workspace");
3084        assert!(ctx.workspace_id.is_some(), "workspace should resolve");
3085        assert!(ctx.window_id.is_some(), "window should resolve via reverse lookup");
3086    }
3087
3088    #[test]
3089    fn unknown_block_errors() {
3090        let store = Store::open_in_memory().unwrap();
3091        wcore::ensure_initial_data(&store).unwrap();
3092        let err = resolve_agent_context(&store, "does-not-exist").unwrap_err();
3093        assert!(err.contains("block not found"), "unexpected error: {err}");
3094    }
3095}
3096
3097#[cfg(test)]
3098mod create_window_seed_tests {
3099    use super::handle_window_service;
3100    use crate::backend::service::WebCallType;
3101    use crate::server::tests::test_state;
3102
3103    fn create_window_call(workspace_id: &str) -> WebCallType {
3104        WebCallType {
3105            service: "window".to_string(),
3106            method: "CreateWindow".to_string(),
3107            uicontext: None,
3108            // arg0 is ignored by the handler; arg1 is the (optional) workspace
3109            // to reattach. Empty → fresh-workspace seed path.
3110            args: vec![
3111                serde_json::Value::Null,
3112                serde_json::Value::String(workspace_id.to_string()),
3113            ],
3114        }
3115    }
3116
3117    /// Regression for the 2nd-window-tear-off desync (#1681).
3118    ///
3119    /// "Open another window" used to seed its three default blocks straight
3120    /// into SQLite (`seed_default_layout` → `create_block`), bypassing the
3121    /// reducer. The handler runs after bootstrap, so those blocks never
3122    /// reached the in-memory `srv_state`. The frontend rendered them (it reads
3123    /// SQLite) but a subsequent `TearOffBlock` from that window was rejected
3124    /// "block not found" — the workspace/tab existed (they went through the
3125    /// reducer) but the block did not. This asserts the seed blocks now land in
3126    /// `srv_state` AND that a tear-off from the new window succeeds end-to-end.
3127    #[tokio::test]
3128    async fn new_window_seed_blocks_are_in_reducer_state_and_tear_off_succeeds() {
3129        let state = test_state();
3130        let blocks_before = state.srv_state.lock().await.blocks.len();
3131
3132        let ret = handle_window_service(&state, &create_window_call("")).await;
3133        assert!(ret.success, "CreateWindow failed: {:?}", ret.error);
3134
3135        let win = ret.data.expect("CreateWindow returns the Window");
3136        let workspace_id = win
3137            .get("workspaceid")
3138            .and_then(|v| v.as_str())
3139            .expect("window has a workspaceid")
3140            .to_string();
3141
3142        // The fix: the three seed blocks are present in the in-memory reducer
3143        // state, attached to the new window's tab.
3144        let (tab_id, block_id) = {
3145            let s = state.srv_state.lock().await;
3146            assert_eq!(
3147                s.blocks.len(),
3148                blocks_before + 3,
3149                "the 3 seed blocks must be tracked in srv_state, not only SQLite"
3150            );
3151            let ws = s
3152                .workspaces
3153                .get(&workspace_id)
3154                .expect("new workspace is in the reducer");
3155            let tab_id = ws.tab_ids.first().expect("new workspace has a tab").clone();
3156            let tab = s.tabs.get(&tab_id).expect("new tab is in the reducer");
3157            assert_eq!(
3158                tab.block_ids.len(),
3159                3,
3160                "the new window's tab must hold its 3 seed blocks in the reducer"
3161            );
3162            (tab_id, tab.block_ids[0].clone())
3163        };
3164
3165        // End-to-end: tearing a block off the freshly-created window no longer
3166        // hits the "block not found" pre-condition.
3167        let result =
3168            crate::sagas::tear_off_block::run(&state, block_id, tab_id, workspace_id).await;
3169        assert!(
3170            result.is_ok(),
3171            "tear-off from a freshly-created window must succeed, got: {:?}",
3172            result.err()
3173        );
3174    }
3175}
3176
3177#[cfg(test)]
3178mod agent_zoom_mirror_tests {
3179    use super::schedule_agent_zoom_mirror;
3180    use crate::backend::storage::store::{AgentContent, AgentDefinition, Store};
3181    use std::sync::Arc;
3182
3183    /// `db_agent_content.agent_id` has a FK to the agent-definitions table, so a
3184    /// real mirror only ever fires for an existing agent (the def was loaded at
3185    /// agent.open). Seed a minimal def so the test mirrors that invariant.
3186    fn seed_agent_def(store: &Store, id: &str) {
3187        let mut def: AgentDefinition = serde_json::from_value(serde_json::json!({
3188            "id": id,
3189            "name": id,
3190            "icon": "sparkles",
3191            "provider": "claude",
3192            "description": "",
3193            "created_at": 1,
3194        }))
3195        .expect("build minimal AgentDefinition");
3196        store.agent_def_insert(&mut def).expect("insert agent def");
3197    }
3198
3199    /// A Ctrl+Wheel burst of `term:zoom` changes must collapse into a single
3200    /// durable write of the FINAL value (SPEC_AGENT_ZOOM_PERSISTENCE §4.3).
3201    #[tokio::test]
3202    async fn debounced_mirror_persists_only_final_value() {
3203        let store = Arc::new(Store::open_in_memory().unwrap());
3204        let agent = "agent-zoom-burst";
3205        seed_agent_def(&store, agent);
3206
3207        schedule_agent_zoom_mirror(store.clone(), agent.into(), Some(1.1));
3208        schedule_agent_zoom_mirror(store.clone(), agent.into(), Some(1.2));
3209        schedule_agent_zoom_mirror(store.clone(), agent.into(), Some(1.4));
3210
3211        // Nothing committed before the debounce window elapses.
3212        assert!(store.agent_content_get(agent, "ui:zoom").unwrap().is_none());
3213
3214        tokio::time::sleep(std::time::Duration::from_millis(400)).await;
3215
3216        let saved = store
3217            .agent_content_get(agent, "ui:zoom")
3218            .unwrap()
3219            .expect("zoom persisted after debounce");
3220        assert_eq!(saved.content, "1.4", "only the final zoom of the burst persists");
3221    }
3222
3223    /// `term:zoom` reset to null/1.0 → `None` → delete the saved row so a
3224    /// default agent stores nothing.
3225    #[tokio::test]
3226    async fn reset_to_default_deletes_saved_zoom() {
3227        let store = Arc::new(Store::open_in_memory().unwrap());
3228        let agent = "agent-zoom-reset";
3229        seed_agent_def(&store, agent);
3230        store
3231            .agent_content_set(&AgentContent {
3232                agent_id: agent.into(),
3233                content_type: "ui:zoom".into(),
3234                content: "1.5".into(),
3235                updated_at: 1,
3236            })
3237            .unwrap();
3238
3239        schedule_agent_zoom_mirror(store.clone(), agent.into(), None);
3240        tokio::time::sleep(std::time::Duration::from_millis(400)).await;
3241
3242        assert!(
3243            store.agent_content_get(agent, "ui:zoom").unwrap().is_none(),
3244            "reset-to-default removes the persisted zoom"
3245        );
3246    }
3247}