Skip to main content

agentmux_srv/server/service/
object.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `object` service handler (GetObject, CreateBlock, UpdateObject, …).
5
6use crate::backend::obj::*;
7use crate::backend::service::{self, WebCallType, WebReturnType};
8
9use super::super::AppState;
10use super::object_helpers::{
11    find_tab_for_layout, get_object_by_oref, schedule_agent_zoom_mirror, update_object,
12    update_object_meta,
13};
14use super::reducer_helpers::{compensate_via_reducer, dispatch_to_reducer, publish_events};
15
16pub(super) async fn handle_object_service(state: &AppState, call: &WebCallType) -> WebReturnType {
17    let store = &state.mstore;
18    let args = &call.args;
19    match call.method.as_str() {
20        "GetObject" => {
21            let oref_str: String = match service::get_arg(args, 0) {
22                Ok(v) => v,
23                Err(e) => return WebReturnType::error(e),
24            };
25            match get_object_by_oref(store, &oref_str) {
26                Ok(data) => WebReturnType::success(data),
27                Err(e) => WebReturnType::error(e),
28            }
29        }
30        "GetObjects" => {
31            let orefs: Vec<String> = match service::get_arg(args, 0) {
32                Ok(v) => v,
33                Err(e) => return WebReturnType::error(e),
34            };
35            let mut results = Vec::new();
36            for oref_str in &orefs {
37                match get_object_by_oref(store, oref_str) {
38                    Ok(data) => results.push(data),
39                    Err(_) => results.push(serde_json::Value::Null),
40                }
41            }
42            WebReturnType::success(serde_json::json!(results))
43        }
44        "CreateBlock" => {
45            let mut block_def: BlockDef = match service::get_arg(args, 0) {
46                Ok(v) => v,
47                Err(e) => return WebReturnType::error(e),
48            };
49            crate::server::browser_owner::strip_srv_only_keys(&mut block_def.meta);
50            if let Err(e) = crate::server::browser_identity::check_new_tab(state, &block_def.meta) {
51                return WebReturnType::error(e);
52            }
53            // Optional explicit tab_id at args[2] (args[1] is rtOpts).
54            // When present, overrides uicontext.active_tab_id — lets
55            // callers like applyTabPreset (frontend) target a specific
56            // tab without depending on which tab happens to be active
57            // when the RPC's uicontext is serialised. Eliminates the
58            // TOCTOU race where the user can switch tabs between the
59            // call site and the server-side handler.
60            //
61            // A *malformed* args[2] (e.g. non-string from a stale SDK)
62            // returns an error — silently falling back to uicontext
63            // would defeat the explicit-targeting contract and make
64            // wrong-tab routing hard to diagnose. Missing/null/empty
65            // is fine: treat as "no override" and use uicontext.
66            let explicit_tab_id: Option<String> = match service::get_optional_arg::<String>(args, 2) {
67                Ok(opt) => opt.map(|s| s.trim().to_string()).filter(|s| !s.is_empty()),
68                Err(e) => return WebReturnType::error(format!("invalid tabId arg: {}", e)),
69            };
70            let tab_id = match explicit_tab_id {
71                Some(id) => id,
72                None => match call
73                    .uicontext
74                    .as_ref()
75                    .map(|ctx| ctx.active_tab_id.clone())
76                {
77                    Some(id) if !id.is_empty() => id,
78                    _ => return WebReturnType::error("missing uicontext.activetabid"),
79                },
80            };
81            // Phase E.2c.4 — CreateBlock dispatches through the reducer
82            // (forward+compensate on SQLite failure). The reducer
83            // assigns the block_id; the persist subscriber's apply
84            // path writes the Block row with the caller's meta map.
85            let meta_value =
86                serde_json::to_value(&block_def.meta).unwrap_or(serde_json::Value::Null);
87            let events = dispatch_to_reducer(
88                state,
89                agentmux_common::ipc::Command::CreateBlock {
90                    tab_id: tab_id.clone(),
91                    meta: meta_value,
92                },
93            )
94            .await;
95            // Surface reducer Error events (tab not found).
96            if let Some(err_msg) = events.iter().find_map(|e| match e {
97                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
98                _ => None,
99            }) {
100                return WebReturnType::error(err_msg);
101            }
102            let block_id = events.iter().find_map(|e| match e {
103                agentmux_common::ipc::Event::BlockCreated { block_id, .. } => {
104                    Some(block_id.clone())
105                }
106                _ => None,
107            });
108            let mut apply_err: Option<String> = None;
109            for ev in &events {
110                if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
111                    apply_err = Some(e.to_string());
112                    break;
113                }
114            }
115            if let Some(err) = apply_err {
116                if let Some(bid) = block_id.as_ref() {
117                    compensate_via_reducer(
118                        state,
119                        agentmux_common::ipc::Command::DeleteBlock {
120                            tab_id: tab_id.clone(),
121                            block_id: bid.clone(),
122                        },
123                        store,
124                    )
125                    .await;
126                }
127                return WebReturnType::error(format!("CreateBlock: SQLite write failed: {}", err));
128            }
129            publish_events(state, &events);
130            match block_id {
131                Some(bid) => {
132                    let mut updates = vec![];
133                    if let Ok(block) = store.must_get::<Block>(&bid) {
134                        updates.push(MuxObjUpdate {
135                            updatetype: "update".into(),
136                            otype: OTYPE_BLOCK.to_string(),
137                            oid: bid.clone(),
138                            obj: Some(mux_obj_to_value(&block)),
139                        });
140                    }
141                    if let Ok(tab) = store.must_get::<Tab>(&tab_id) {
142                        updates.push(MuxObjUpdate {
143                            updatetype: "update".into(),
144                            otype: OTYPE_TAB.to_string(),
145                            oid: tab_id.clone(),
146                            obj: Some(mux_obj_to_value(&tab)),
147                        });
148                    }
149                    WebReturnType::success_data_updates(serde_json::json!(bid), updates)
150                }
151                None => WebReturnType::error(
152                    "CreateBlock: reducer did not emit BlockCreated".to_string(),
153                ),
154            }
155        }
156        // Phase E.5.7 (Step 5 PR 1) — DeleteBlock saga. The legacy
157        // SQLite-first pattern (wcore::delete_block + reducer-sync
158        // dispatch) is replaced by `sagas::delete_block::run`, which
159        // routes through the reducer + persist subscriber. The saga
160        // also handles the controller-kill cascade (matches the old
161        // ordering: controller down → reducer dispatch → SQLite).
162        "DeleteBlock" => {
163            let block_id: String = match service::get_arg(args, 0) {
164                Ok(v) => v,
165                Err(e) => return WebReturnType::error(e),
166            };
167            // Look up the block's owning tab from server state rather than using
168            // uicontext.active_tab_id. Floating pane windows have their own tab
169            // context that differs from the block's owning tab, so using the
170            // uicontext tab always fails for floating-pane closes.
171            let tab_id = {
172                let s = state.srv_state.lock().await;
173                match s.blocks.get(&block_id) {
174                    Some(rec) => rec.tab_id.clone(),
175                    None => return WebReturnType::error(format!("DeleteBlock: block not found: {}", block_id)),
176                }
177            };
178            if let Err(reason) = crate::sagas::delete_block::run(state, tab_id, block_id).await {
179                return WebReturnType::error(reason);
180            }
181            WebReturnType::success_empty()
182        }
183        // `/quit` / `/exit` in an agent's composer: end that agent gracefully
184        // and close its own tab only (SPEC_AGENT_SELF_QUIT_2026_09_24.md §3).
185        // Returns the quit summary for the "<agent> quit" notice.
186        "QuitAgent" => {
187            let block_id: String = match service::get_arg(args, 0) {
188                Ok(v) => v,
189                Err(e) => return WebReturnType::error(e),
190            };
191            match crate::sagas::self_quit::run(state, &block_id, crate::sagas::self_quit::QuitOrigin::UserSlash).await {
192                Ok(summary) => WebReturnType::success(serde_json::to_value(&summary).unwrap_or_default()),
193                Err(reason) => WebReturnType::error(reason),
194            }
195        }
196        // A pane's × — every block in the pane's stack, stopped before its
197        // records are deleted (SPEC_AGENT_PANE_CLOSE_GRACEFUL_SHUTDOWN_2026_09_18.md).
198        // Closing one tab of a stack sends that one id; the saga keeps the
199        // pane when other members survive.
200        "ClosePane" => {
201            let block_ids: Vec<String> = match service::get_arg(args, 0) {
202                Ok(v) => v,
203                Err(e) => return WebReturnType::error(e),
204            };
205            // Optional: the frontend kept the pane on screen to show the
206            // shutdown log (SPEC_AGENT_SELF_QUIT_2026_09_24.md §5.5), so srv
207            // removes it member by member. Absent = the historical flow.
208            let frontend_waits: bool = service::get_arg(args, 1).unwrap_or(false);
209            let opts = crate::sagas::close_pane::CloseOpts { frontend_waits };
210            if let Err(reason) = crate::sagas::close_pane::run_with(state, block_ids, opts).await {
211                return WebReturnType::error(reason);
212            }
213            WebReturnType::success_empty()
214        }
215        "UpdateObject" => {
216            let mux_obj_value: serde_json::Value = match service::get_arg(args, 0) {
217                Ok(v) => v,
218                Err(e) => return WebReturnType::error(e),
219            };
220            // SPEC_864 Phase 2 — an OTYPE_LAYOUT push routes through the
221            // reducer as a single `LayoutSetTree` (tree + client slices),
222            // replacing the legacy `update_raw` whole-row write PLUS the
223            // separate SetFocusedNode/SetMagnifiedNode dispatches (the
224            // "double-write": two SQLite writes, two version bumps, per
225            // push, with `TabRecord.rootnode` left a stale shadow). One
226            // dispatch → one persist-subscriber write → one version bump,
227            // and the reducer's in-memory tree stays authoritative.
228            //
229            // Fallback: an unowned layout row (no tab references it) or a
230            // rootnode that fails typed deserialization takes the legacy
231            // wcore-direct path below, loudly — never silently drop a push.
232            // For an OWNED row whose rootnode failed the typed parse, the
233            // legacy path must still dispatch the focus/magnify slice to
234            // the reducer (the pre-Phase-2 Option-A behavior) so
235            // `TabRecord` focus state can't silently diverge on that
236            // branch (reagent P1 #1970, review 3).
237            let mut fallback_slice: Option<(String, String, String)> = None;
238            if mux_obj_value.get("otype").and_then(|v| v.as_str()) == Some(OTYPE_LAYOUT) {
239                let layout_route: Option<(String, Option<agentmux_common::LayoutNode>)> =
240                    match mux_obj_value
241                        .get("oid")
242                        .and_then(|v| v.as_str())
243                        .and_then(|layout_oid| find_tab_for_layout(store, layout_oid))
244                    {
245                        Some(tab_id) => match mux_obj_value.get("rootnode") {
246                            None | Some(serde_json::Value::Null) => Some((tab_id, None)),
247                            Some(v) => match serde_json::from_value(v.clone()) {
248                                Ok(tree) => Some((tab_id, Some(tree))),
249                                Err(e) => {
250                                    tracing::warn!(
251                                        error = %e,
252                                        "UpdateObject: layout rootnode failed typed parse; \
253                                         falling back to legacy wcore-direct write"
254                                    );
255                                    let get_str = |key: &str| -> String {
256                                        mux_obj_value
257                                            .get(key)
258                                            .and_then(|v| v.as_str())
259                                            .unwrap_or("")
260                                            .to_string()
261                                    };
262                                    fallback_slice = Some((
263                                        tab_id,
264                                        get_str("focusednodeid"),
265                                        get_str("magnifiednodeid"),
266                                    ));
267                                    None
268                                }
269                            },
270                        },
271                        None => {
272                            tracing::warn!(
273                                "UpdateObject: layout row not owned by any tab; \
274                                 falling back to legacy wcore-direct write"
275                            );
276                            None
277                        }
278                    };
279                if let Some((tab_id, new_tree)) = layout_route {
280                    return update_layout_via_reducer(state, mux_obj_value, tab_id, new_tree)
281                        .await;
282                }
283            }
284            // Non-layout otypes and the layout fallbacks: legacy wholesale
285            // row replace. The unowned-row fallback has no reducer-known
286            // tab, so there is nothing to dispatch; the owned-but-unparsable
287            // fallback dispatches the focus/magnify slice AFTER the write
288            // succeeds (failure-atomicity ordering per codex P2 PR #632).
289            match update_object(store, mux_obj_value) {
290                Ok((otype, oid, obj_val)) => {
291                    if let Some((tab_id, new_focused, new_magnified)) = fallback_slice {
292                        let focus_events = dispatch_to_reducer(
293                            state,
294                            agentmux_common::ipc::Command::SetFocusedNode {
295                                tab_id: tab_id.clone(),
296                                node_id: new_focused,
297                            },
298                        )
299                        .await;
300                        publish_events(state, &focus_events);
301                        let mag_events = dispatch_to_reducer(
302                            state,
303                            agentmux_common::ipc::Command::SetMagnifiedNode {
304                                tab_id,
305                                node_id: new_magnified,
306                            },
307                        )
308                        .await;
309                        publish_events(state, &mag_events);
310                    }
311                    let update = MuxObjUpdate {
312                        updatetype: "update".into(),
313                        otype,
314                        oid,
315                        obj: Some(obj_val),
316                    };
317                    WebReturnType::success_with_updates(vec![update])
318                }
319                Err(e) => WebReturnType::error(e),
320            }
321        }
322        // Phase E.5.3 — UpdateObjectMeta migrated through the
323        // reducer. Decomposes by otype to the typed Update*Meta
324        // command. Reducer is pass-through (validates entity exists;
325        // emits event); subscriber's apply_*_meta_updated does the
326        // shallow merge against mstore.
327        "UpdateObjectMeta" => {
328            let oref_str: String = match service::get_arg(args, 0) {
329                Ok(v) => v,
330                Err(e) => return WebReturnType::error(e),
331            };
332            let meta_update: MetaMapType = match service::get_arg(args, 1) {
333                Ok(v) => v,
334                Err(e) => return WebReturnType::error(e),
335            };
336            let oref = match crate::backend::ORef::parse(&oref_str) {
337                Ok(v) => v,
338                Err(e) => return WebReturnType::error(e.to_string()),
339            };
340            if let Err(e) = crate::server::browser_owner::guard_client_meta_write(&oref_str, &meta_update) {
341                return WebReturnType::error(e);
342            }
343            let meta_value = serde_json::to_value(&meta_update).unwrap_or(serde_json::Value::Null);
344            let cmd = match oref.otype.as_str() {
345                t if t == OTYPE_WORKSPACE => agentmux_common::ipc::Command::UpdateWorkspaceMeta {
346                    workspace_id: oref.oid.clone(),
347                    meta_patch: meta_value,
348                },
349                t if t == OTYPE_TAB => agentmux_common::ipc::Command::UpdateTabMeta {
350                    tab_id: oref.oid.clone(),
351                    meta_patch: meta_value,
352                },
353                t if t == OTYPE_BLOCK => agentmux_common::ipc::Command::UpdateBlockMeta {
354                    block_id: oref.oid.clone(),
355                    meta_patch: meta_value,
356                },
357                t if t == OTYPE_WINDOW => agentmux_common::ipc::Command::UpdateWindowMeta {
358                    window_id: oref.oid.clone(),
359                    meta_patch: meta_value,
360                },
361                other => {
362                    // Remaining otypes (Layout, Client, Temp) aren't
363                    // meta-mutated via the reducer yet; fall back to
364                    // wcore for forward-compat. They publish no event,
365                    // so the MuxObjUpdate bridge can't see them — the
366                    // frontend cache stays stale until next bootstrap
367                    // (deemed acceptable since these aren't user-edited).
368                    // Future Phase E.5.x migrations can add reducer arms
369                    // for any of these following the OTYPE_WINDOW pattern
370                    // above (per issue #855 retro).
371                    return match update_object_meta(store, &oref_str, &meta_update) {
372                        Ok(()) => WebReturnType::success_empty(),
373                        Err(e) => WebReturnType::error(format!(
374                            "UpdateObjectMeta: unsupported otype {} via reducer; wcore fallback failed: {}",
375                            other, e
376                        )),
377                    };
378                }
379            };
380            let events = dispatch_to_reducer(state, cmd).await;
381            if let Some(err_msg) = events.iter().find_map(|e| match e {
382                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
383                _ => None,
384            }) {
385                return WebReturnType::error(err_msg);
386            }
387            for ev in &events {
388                if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
389                    return WebReturnType::error(format!(
390                        "UpdateObjectMeta: SQLite write failed: {}",
391                        e
392                    ));
393                }
394            }
395            publish_events(state, &events);
396            // Per-agent zoom persistence (SPEC_AGENT_ZOOM_PERSISTENCE): when an
397            // agent block's `term:zoom` changes, mirror it (debounced) into the
398            // agent's per-agent `ui:zoom` content so the zoom survives the block
399            // and is restored at `agent.open`. Only blocks carrying `agentId`
400            // (agent panes) participate; everything else is untouched. A `null`
401            // `term:zoom` (the frontend's reset-to-1.0 convention) deletes the
402            // saved value so a default agent stores nothing.
403            if oref.otype == OTYPE_BLOCK && meta_update.contains_key("term:zoom") {
404                if let Ok(block) = store.must_get::<Block>(&oref.oid) {
405                    let agent_id = block
406                        .meta
407                        .get("agentId")
408                        .and_then(|v| v.as_str())
409                        .unwrap_or("")
410                        .to_string();
411                    if !agent_id.is_empty() {
412                        let zoom = meta_update.get("term:zoom").and_then(|v| v.as_f64());
413                        schedule_agent_zoom_mirror(store.clone(), agent_id, zoom);
414                    }
415                }
416            }
417            // Return the updated object so the frontend MOS cache stays in sync.
418            if oref.otype == OTYPE_BLOCK {
419                if let Ok(block) = store.must_get::<Block>(&oref.oid) {
420                    return WebReturnType::success_with_updates(vec![MuxObjUpdate {
421                        updatetype: "update".into(),
422                        otype: OTYPE_BLOCK.to_string(),
423                        oid: oref.oid.clone(),
424                        obj: Some(mux_obj_to_value(&block)),
425                    }]);
426                }
427            }
428            if oref.otype == OTYPE_TAB {
429                if let Ok(tab) = store.must_get::<Tab>(&oref.oid) {
430                    return WebReturnType::success_with_updates(vec![MuxObjUpdate {
431                        updatetype: "update".into(),
432                        otype: OTYPE_TAB.to_string(),
433                        oid: oref.oid.clone(),
434                        obj: Some(mux_obj_to_value(&tab)),
435                    }]);
436                }
437            }
438            WebReturnType::success_empty()
439        }
440        // Phase E.5.3 — UpdateTabName migrated through the reducer.
441        "UpdateTabName" => {
442            let tab_id: String = match service::get_arg(args, 0) {
443                Ok(v) => v,
444                Err(e) => return WebReturnType::error(e),
445            };
446            let name: String = match service::get_arg(args, 1) {
447                Ok(v) => v,
448                Err(e) => return WebReturnType::error(e),
449            };
450            let events = dispatch_to_reducer(
451                state,
452                agentmux_common::ipc::Command::RenameTab {
453                    tab_id: tab_id.clone(),
454                    name,
455                },
456            )
457            .await;
458            if let Some(err_msg) = events.iter().find_map(|e| match e {
459                agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
460                _ => None,
461            }) {
462                return WebReturnType::error(err_msg);
463            }
464            for ev in &events {
465                if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
466                    return WebReturnType::error(format!(
467                        "UpdateTabName: SQLite write failed: {}",
468                        e
469                    ));
470                }
471            }
472            publish_events(state, &events);
473            if let Ok(updated_tab) = store.must_get::<Tab>(&tab_id) {
474                let update = MuxObjUpdate {
475                    updatetype: "update".into(),
476                    otype: OTYPE_TAB.to_string(),
477                    oid: tab_id.clone(),
478                    obj: Some(mux_obj_to_value(&updated_tab)),
479                };
480                return WebReturnType::success_with_updates(vec![update]);
481            }
482            WebReturnType::success_empty()
483        }
484        _ => WebReturnType::error(format!("unknown object method: {}", call.method)),
485    }
486}
487
488/// SPEC_864 Phase 2 — route a frontend `UpdateObject` layout push through
489/// the reducer as a single `LayoutSetTree` dispatch.
490///
491/// Dispatch AND SQLite apply happen under ONE hold of the `srv_state`
492/// mutex (reagent P1 #1970): with `dispatch_to_reducer`'s usual
493/// release-before-I/O contract, two concurrent pushes for the same tab
494/// could dispatch in order A→B but persist B→A, leaving `db_layout` on
495/// the older tree while `TabRecord` (authoritative as of this route)
496/// holds the newer one — the exact coherence this route exists to
497/// guarantee, and a regression vs. the legacy single atomic `update_raw`.
498/// Holding the lock across the row UPDATE (a sub-ms local SQLite write)
499/// makes persist order equal dispatch order; the SQLite-failure rollback
500/// re-dispatch also runs inside the same hold so no interleaved dispatch
501/// can observe the un-rolled-back state. Publish happens after release
502/// (the wave-obj bridge re-reads the row, so publish must follow the
503/// write; subscribers never see events out of dispatch order because
504/// publish order still matches — see below).
505///
506/// Not carried: `LayoutState.meta`. The legacy whole-row write persisted
507/// it incidentally, but nothing mutates layout meta (`UpdateObjectMeta`
508/// rejects OTYPE_LAYOUT and the frontend's `persistToBackend` writes only
509/// tree/focus/magnify/leaforder/pendingbackendactions), so the reducer
510/// route leaves the stored meta untouched.
511async fn update_layout_via_reducer(
512    state: &AppState,
513    mux_obj_value: serde_json::Value,
514    tab_id: String,
515    new_tree: Option<agentmux_common::LayoutNode>,
516) -> WebReturnType {
517    let store = &state.mstore;
518    let oid = mux_obj_value
519        .get("oid")
520        .and_then(|v| v.as_str())
521        .unwrap_or_default()
522        .to_string();
523
524    let get_str = |key: &str| -> String {
525        mux_obj_value
526            .get(key)
527            .and_then(|v| v.as_str())
528            .unwrap_or("")
529            .to_string()
530    };
531    let get_json = |key: &str| -> Option<serde_json::Value> {
532        mux_obj_value.get(key).filter(|v| !v.is_null()).cloned()
533    };
534    let slices = agentmux_common::LayoutClientSlices {
535        leaforder: get_json("leaforder"),
536        focused_node_id: get_str("focusednodeid"),
537        magnified_node_id: get_str("magnifiednodeid"),
538        pending_backend_actions: get_json("pendingbackendactions"),
539    };
540    let cmd = agentmux_common::ipc::Command::LayoutSetTree {
541        tab_id: tab_id.clone(),
542        new_tree,
543        correlation_id: String::new(),
544        slices: Some(slices),
545    };
546
547    // ── single critical section: snapshot + dispatch + persist (+ rollback) ──
548    let mut apply_err: Option<String> = None;
549    let mut early_err: Option<String> = None;
550    let events = {
551        let mut s = state.srv_state.lock().await;
552        // Snapshot the current row for the rollback path INSIDE the
553        // critical section (reagent P1 #1970, review 4): a pre-lock
554        // snapshot could be overtaken by a concurrent push committing
555        // between the read and this lock acquisition — a subsequent
556        // rollback would then restore the STALE snapshot, clobbering the
557        // concurrently-committed newer state in both TabRecord and
558        // db_layout. Read under the lock, immediately before the forward
559        // dispatch, no interleaving is possible.
560        let old_row = match store.get::<LayoutState>(&oid) {
561            Ok(Some(row)) => row,
562            Ok(None) => {
563                early_err = Some(format!("UpdateObject: layout not found: {}", oid));
564                LayoutState::default()
565            }
566            Err(e) => {
567                early_err = Some(format!("UpdateObject: {}", e));
568                LayoutState::default()
569            }
570        };
571        if early_err.is_some() {
572            Vec::new()
573        } else {
574            let ctx = crate::reducer::Ctx {
575                registered_pid: None,
576            };
577            let events = crate::reducer::update(&mut s, cmd, &ctx);
578            let has_error = events
579                .iter()
580                .any(|e| matches!(e, agentmux_common::ipc::Event::Error { .. }));
581            if !has_error {
582                for ev in &events {
583                    if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
584                        apply_err = Some(e.to_string());
585                        break;
586                    }
587                }
588                if apply_err.is_some() {
589                    // Roll the reducer back to the pre-push row inside the
590                    // same lock hold so no concurrent dispatch can observe
591                    // the divergent state; best-effort mirror to SQLite.
592                    let rollback_cmd = agentmux_common::ipc::Command::LayoutSetTree {
593                        tab_id,
594                        new_tree: old_row.rootnode.clone(),
595                        correlation_id: String::new(),
596                        slices: Some(agentmux_common::LayoutClientSlices {
597                            leaforder: old_row
598                                .leaforder
599                                .as_ref()
600                                .and_then(|v| serde_json::to_value(v).ok()),
601                            focused_node_id: old_row.focusednodeid.clone(),
602                            magnified_node_id: old_row.magnifiednodeid.clone(),
603                            pending_backend_actions: old_row
604                                .pendingbackendactions
605                                .as_ref()
606                                .and_then(|v| serde_json::to_value(v).ok()),
607                        }),
608                    };
609                    let rb_events = crate::reducer::update(&mut s, rollback_cmd, &ctx);
610                    for ev in &rb_events {
611                        if let Err(e) =
612                            crate::persist_subscriber::apply_event_to_mstore(ev, store)
613                        {
614                            tracing::warn!(
615                                error = %e,
616                                "UpdateObject: layout rollback SQLite mirror failed"
617                            );
618                        }
619                    }
620                }
621            }
622            events
623        }
624    };
625
626    if let Some(err) = early_err {
627        return WebReturnType::error(err);
628    }
629    if let Some(err_msg) = events.iter().find_map(|e| match e {
630        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
631        _ => None,
632    }) {
633        return WebReturnType::error(err_msg);
634    }
635    if let Some(err) = apply_err {
636        return WebReturnType::error(format!("UpdateObject: SQLite write failed: {}", err));
637    }
638    publish_events(state, &events);
639
640    // Return the committed row (fresh version) so the pusher's MOS cache
641    // stays in sync — same response shape as the legacy path.
642    match get_object_by_oref(store, &format!("{}:{}", OTYPE_LAYOUT, oid)) {
643        Ok(obj_val) => WebReturnType::success_with_updates(vec![MuxObjUpdate {
644            updatetype: "update".into(),
645            otype: OTYPE_LAYOUT.to_string(),
646            oid,
647            obj: Some(obj_val),
648        }]),
649        Err(e) => WebReturnType::error(format!("UpdateObject: re-read failed: {}", e)),
650    }
651}