agentmux_srv/server/service/reducer_helpers.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Phase E.2c.2 reducer-dispatch helpers shared by the service handlers and
5//! (via `crate::server::service::…`) the sagas.
6
7use crate::backend::obj::Workspace;
8use crate::backend::storage::store::Store;
9use crate::backend::storage::StoreError;
10
11use super::super::AppState;
12
13/// Dispatch a command into the srv reducer and return the emitted
14/// events. Locks the reducer mutex briefly; the lock is released
15/// before any I/O (caller is responsible for publishing the events
16/// to the broadcast bus).
17pub(crate) async fn dispatch_to_reducer(
18 state: &AppState,
19 cmd: agentmux_common::ipc::Command,
20) -> Vec<agentmux_common::ipc::Event> {
21 let mut s = state.srv_state.lock().await;
22 let ctx = crate::reducer::Ctx {
23 registered_pid: None,
24 };
25 crate::reducer::update(&mut s, cmd, &ctx)
26}
27
28/// Publish each event on the srv broadcast bus. Failures (no
29/// subscribers) are non-fatal.
30pub(crate) fn publish_events(state: &AppState, events: &[agentmux_common::ipc::Event]) {
31 for event in events {
32 let _ = state.srv_events_tx.send(event.clone());
33 }
34}
35
36/// Compensation helper: dispatch a command into the reducer and
37/// apply its emitted events to mstore best-effort. Used when an
38/// earlier sync apply partially wrote SQLite and we need to undo
39/// the leaked rows. SQLite errors during compensation are logged
40/// but ignored — the caller is already returning an error to the
41/// client; throwing on the cleanup just hides the original cause.
42/// (codex P1 + reagent P2 #616 — partial-write cleanup.)
43pub(crate) async fn compensate_via_reducer(
44 state: &AppState,
45 cmd: agentmux_common::ipc::Command,
46 store: &Store,
47) {
48 let events = dispatch_to_reducer(state, cmd).await;
49 for ev in &events {
50 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
51 tracing::warn!(
52 "compensation: SQLite cleanup failed for event {:?}: {}",
53 std::mem::discriminant(ev),
54 e
55 );
56 }
57 }
58}
59
60/// SPEC_864 Phase 3 — single-writer layout seeding. Dispatch a
61/// `LayoutSetTree` (tree + focus + leaforder as client slices) and persist
62/// it inside ONE hold of the reducer mutex, so persist order equals
63/// dispatch order — the same contract as `UpdateObject`'s Phase-2 route in
64/// `object.rs`. Replaces the wcore-direct seeders
65/// (`write_default_three_pane_layout` post-bootstrap caller,
66/// `setup_torn_off_block_layout`): the reducer's `TabRecord.rootnode` is
67/// authoritative, and the persist subscriber is the sole `db_layout`
68/// writer on this path.
69///
70/// On SQLite apply failure the reducer is rolled back to an empty tree
71/// (the seed target is a fresh tab whose row was empty) inside the same
72/// lock hold. Publishes events on success. Requires the tab to be known
73/// to the reducer — true for every caller (all run after reducer-routed
74/// CreateTab / sagas); the pre-bootstrap first-launch seed
75/// (`ensure_initial_data` → `seed_default_layout`) deliberately stays
76/// store-direct because the reducer isn't hydrated yet (spec site #3).
77pub(crate) async fn seed_layout_via_reducer(
78 state: &AppState,
79 tab_id: &str,
80 new_tree: agentmux_common::LayoutNode,
81 focused_node_id: String,
82 leaforder: Vec<crate::backend::obj::LeafOrderEntry>,
83 magnified_node_id: String,
84) -> Result<(), String> {
85 let store = &state.mstore;
86 let slices = agentmux_common::LayoutClientSlices {
87 leaforder: serde_json::to_value(&leaforder).ok(),
88 focused_node_id,
89 magnified_node_id,
90 // Fresh-tab seed: REPLACE semantics clear any stale queue.
91 pending_backend_actions: None,
92 };
93 let cmd = agentmux_common::ipc::Command::LayoutSetTree {
94 tab_id: tab_id.to_string(),
95 new_tree: Some(new_tree),
96 correlation_id: String::new(),
97 slices: Some(slices),
98 };
99
100 let mut apply_err: Option<String> = None;
101 let events = {
102 let mut s = state.srv_state.lock().await;
103 let ctx = crate::reducer::Ctx {
104 registered_pid: None,
105 };
106 let events = crate::reducer::update(&mut s, cmd, &ctx);
107 let has_error = events
108 .iter()
109 .any(|e| matches!(e, agentmux_common::ipc::Event::Error { .. }));
110 if !has_error {
111 for ev in &events {
112 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
113 apply_err = Some(e.to_string());
114 break;
115 }
116 }
117 if apply_err.is_some() {
118 // Roll the reducer back to the empty pre-seed tree inside
119 // the same lock hold; best-effort SQLite mirror.
120 let rollback = agentmux_common::ipc::Command::LayoutSetTree {
121 tab_id: tab_id.to_string(),
122 new_tree: None,
123 correlation_id: String::new(),
124 slices: Some(agentmux_common::LayoutClientSlices::default()),
125 };
126 let rb_events = crate::reducer::update(&mut s, rollback, &ctx);
127 for ev in &rb_events {
128 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
129 tracing::warn!(
130 error = %e,
131 "seed_layout_via_reducer: rollback SQLite mirror failed"
132 );
133 }
134 }
135 }
136 }
137 events
138 };
139
140 if let Some(err_msg) = events.iter().find_map(|e| match e {
141 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
142 _ => None,
143 }) {
144 return Err(err_msg);
145 }
146 if let Some(err) = apply_err {
147 return Err(format!("layout seed SQLite write failed: {}", err));
148 }
149 publish_events(state, &events);
150 Ok(())
151}
152
153/// SPEC_864 Phase 4 — single-writer queue append. Dispatch a
154/// `LayoutQueueBackendActions` and persist the resulting
155/// `LayoutBackendActionsQueued` inside ONE hold of the reducer mutex
156/// (same critical-section contract as `seed_layout_via_reducer`), so a
157/// concurrent frontend ACK slice (`LayoutSetTree` REPLACE) can't
158/// interleave between dispatch and persist. Replaces the five
159/// store-direct `pendingbackendactions` writers (`layout_helpers`'
160/// insert/split/delete queuers + the two inline app_api sites).
161///
162/// No rollback arm: the reducer does not model the queue in
163/// `TabRecord` (pass-through validation only), so a SQLite apply
164/// failure leaves no reducer↔SQLite divergence to repair — just
165/// return the error.
166///
167/// `actions` must be non-empty; the reducer rejects an empty array.
168pub(crate) async fn queue_layout_actions_via_reducer(
169 state: &AppState,
170 tab_id: &str,
171 actions: Vec<crate::backend::obj::LayoutActionData>,
172) -> Result<(), String> {
173 let store = &state.mstore;
174 let actions_json = serde_json::to_value(&actions)
175 .map_err(|e| format!("queue actions serialize failed: {}", e))?;
176 let cmd = agentmux_common::ipc::Command::LayoutQueueBackendActions {
177 tab_id: tab_id.to_string(),
178 actions: actions_json,
179 correlation_id: String::new(),
180 };
181
182 let mut apply_err: Option<String> = None;
183 let events = {
184 let mut s = state.srv_state.lock().await;
185 let ctx = crate::reducer::Ctx {
186 registered_pid: None,
187 };
188 let events = crate::reducer::update(&mut s, cmd, &ctx);
189 let has_error = events
190 .iter()
191 .any(|e| matches!(e, agentmux_common::ipc::Event::Error { .. }));
192 if !has_error {
193 for ev in &events {
194 if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
195 apply_err = Some(e.to_string());
196 break;
197 }
198 }
199 }
200 events
201 };
202
203 if let Some(err_msg) = events.iter().find_map(|e| match e {
204 agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
205 _ => None,
206 }) {
207 return Err(err_msg);
208 }
209 if let Some(err) = apply_err {
210 return Err(format!("queue append SQLite write failed: {}", err));
211 }
212 publish_events(state, &events);
213 Ok(())
214}
215
216/// Existence check used by `DeleteWorkspace` to decide whether to
217/// run the wcore delete path. Propagates `StoreError` so the caller
218/// can surface real I/O / corruption failures instead of
219/// misclassifying them as "not found" (codex P2 #615 carryover —
220/// the prior `bool` return collapsed `Err(_)` into `false`, which
221/// led to silent successes when SQLite was unhealthy: reducer would
222/// delete its own copy and report success while the disk row was
223/// never touched).
224pub(crate) fn mstore_workspace_exists(
225 store: &Store,
226 workspace_id: &str,
227) -> Result<bool, StoreError> {
228 Ok(store.get::<Workspace>(workspace_id)?.is_some())
229}
230
231// `build_workspace_from_state` removed in E.2c.2. The reducer's
232// WorkspaceRecord can't faithfully render a Workspace during the
233// migration window (no pinnedtabids; tabids/activetabid go stale
234// vs wcore-direct tab ops). It will be reintroduced in E.2c.3
235// when tabs migrate into the reducer and pinned/active state is
236// authoritative there. (reagent + codex P1 #615.)