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}