Skip to main content

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.)