Skip to main content

agentmux_srv/
reducer.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase E — srv reducer.
5//
6// The "same discipline as `agentmux-launcher::reducer`" below means exactly
7// that: same purity rules, same `agentmux_common::ipc::{Command, Event}` wire
8// types. Only `State` differs (each crate's own) and which variants this
9// `update` actually handles — workspaces/tabs/blocks, not window/process.
10// Two reducers by design; see
11// docs/architecture/DECISION_SAGA_REDUCER_TWO_FRAMEWORKS_2026_09_07.md.
12//
13// Pure functional core: `update(&mut State, Command, &Ctx) -> Vec<Event>`.
14// Never blocks, never awaits, never does I/O. Same discipline as
15// `agentmux-launcher::reducer`. Mutex held only during dispatch
16// (sub-millisecond).
17//
18// Arms by phase:
19//   * E.1b — Register / Goodbye / Ping / GetSrvSnapshot / GetEvents
20//   * E.2  — CreateWorkspace / DeleteWorkspace
21//   * E.2b — CreateTab / DeleteTab / SetActiveTab / ReorderTab
22//   * E.3  — CreateBlock / DeleteBlock
23//   * E.5  — CreateWindow / CloseWindowInternal / SwitchWorkspace
24//             (window↔workspace mapping for sagas)
25//   * E.5+ — saga-driven multi-step commands (TearOff/Restore/Move)
26//             land via the saga coordinator dispatching atomic arms
27//
28// `Command::GetEvents` is intercepted by the IPC server before
29// reaching the reducer (server queries the event log; reducer
30// stays pure). The reducer's arm exists only for match
31// exhaustiveness; same pattern as the launcher reducer.
32
33
34mod block;
35mod layout;
36mod lifecycle;
37mod snapshot;
38mod tab;
39#[cfg(test)]
40mod test_support;
41mod window;
42mod workspace;
43
44use agentmux_common::ipc::{Command, ErrorCode, Event};
45use crate::state::State;
46
47/// Per-dispatch context: the pid the dispatching connection registered.
48#[derive(Debug, Clone)]
49pub struct Ctx {
50    pub registered_pid: Option<u32>,
51}
52
53pub fn update(state: &mut State, cmd: Command, ctx: &Ctx) -> Vec<Event> {
54    match cmd {
55        Command::Register { kind, pid, version } => lifecycle::handle_register(state, kind, pid, version),
56        Command::Goodbye => lifecycle::handle_goodbye(state, ctx),
57        Command::Ping { nonce } => {
58            let v = state.bump_version();
59            vec![Event::Pong { nonce, version: v }]
60        }
61        Command::GetSrvSnapshot => snapshot::handle_get_srv_snapshot(state),
62        Command::GetEvents { .. } => Vec::new(), // intercepted by server; unreachable
63        Command::CreateWorkspace { name } => workspace::handle_create_workspace(state, name),
64        Command::DeleteWorkspace { workspace_id, force } => {
65            workspace::handle_delete_workspace(state, workspace_id, force)
66        }
67        Command::CreateTab { workspace_id, name } => tab::handle_create_tab(state, workspace_id, name),
68        Command::DeleteTab { workspace_id, tab_id, force } => tab::handle_delete_tab(state, workspace_id, tab_id, force),
69        Command::SetActiveTab { workspace_id, tab_id } => {
70            tab::handle_set_active_tab(state, workspace_id, tab_id)
71        }
72        Command::ReorderTab {
73            workspace_id,
74            tab_id,
75            new_index,
76        } => tab::handle_reorder_tab(state, workspace_id, tab_id, new_index),
77        Command::CreateBlock { tab_id, meta } => block::handle_create_block(state, tab_id, meta),
78        Command::DeleteBlock { tab_id, block_id } => block::handle_delete_block(state, tab_id, block_id),
79        Command::SetFocusedNode { tab_id, node_id } => {
80            layout::handle_set_focused_node(state, tab_id, node_id)
81        }
82        Command::SetMagnifiedNode { tab_id, node_id } => {
83            layout::handle_set_magnified_node(state, tab_id, node_id)
84        }
85        // Phase E.4.B Phase 5 — layout tree mutation arms. All 11 arms are
86        // wired. First production dispatcher: the `UpdateObject`→
87        // `LayoutSetTree` reroute (SPEC_864 Phase 2, `object.rs`); the
88        // remaining wcore-direct writers migrate in SPEC_864 Phases 3–5.
89        Command::LayoutClear {
90            tab_id,
91            correlation_id,
92        } => layout::handle_layout_clear(state, tab_id, correlation_id),
93        Command::LayoutSetTree {
94            tab_id,
95            new_tree,
96            correlation_id,
97            slices,
98        } => layout::handle_layout_set_tree(state, tab_id, new_tree, correlation_id, slices),
99        Command::LayoutInsertNode {
100            tab_id,
101            node,
102            parent_id,
103            index,
104            focus_after,
105            magnify_after,
106            correlation_id,
107        } => layout::handle_layout_insert_node(
108            state,
109            tab_id,
110            node,
111            parent_id,
112            index,
113            focus_after,
114            magnify_after,
115            correlation_id,
116        ),
117        Command::LayoutDeleteNode {
118            tab_id,
119            node_id,
120            correlation_id,
121        } => layout::handle_layout_delete_node(state, tab_id, node_id, correlation_id),
122        // SPEC_864 site #6 — block→node resolution in the arm; silent
123        // no-op when the block has no layout node (see the handler doc).
124        Command::LayoutDeleteNodeByBlock {
125            tab_id,
126            block_id,
127            correlation_id,
128        } => layout::handle_layout_delete_node_by_block(state, tab_id, block_id, correlation_id),
129        // SPEC_PANE_TABS_REDUCER_COMMANDS_2026_09_18.md §3.2 — pane tabs.
130        Command::LayoutStackPush {
131            tab_id,
132            target_block_id,
133            block_id,
134            activate,
135            correlation_id,
136        } => layout::handle_layout_stack_push(state, tab_id, target_block_id, block_id, activate, correlation_id),
137        Command::LayoutStackActivate {
138            tab_id,
139            block_id,
140            correlation_id,
141        } => layout::handle_layout_stack_activate(state, tab_id, block_id, correlation_id),
142        Command::CreateBlockInStack {
143            tab_id,
144            target_block_id,
145            meta,
146            activate,
147        } => layout::handle_create_block_in_stack(state, tab_id, target_block_id, meta, activate),
148        // SPEC_PANE_TAB_DRAG_AND_DROP_2026_09_19.md §4.1 — reorder within a
149        // pane or move a tab into a different pane.
150        Command::LayoutStackMove {
151            tab_id,
152            block_id,
153            target_block_id,
154            position,
155            activate,
156            correlation_id,
157        } => layout::handle_layout_stack_move(state, tab_id, block_id, target_block_id, position, activate, correlation_id),
158        // SPEC_864 Phase 4 — queue-append pass-through (the reducer does
159        // not model pendingbackendactions in TabRecord; the persist
160        // subscriber appends to db_layout from the event).
161        Command::LayoutQueueBackendActions {
162            tab_id,
163            actions,
164            correlation_id,
165        } => layout::handle_layout_queue_backend_actions(state, tab_id, actions, correlation_id),
166        // Phase 3 — the remaining 7 layout-tree arms. Each resolves the
167        // tab, calls the existing pure fn in `backend::layout`, runs
168        // `balance_node` (matching the frontend's post-action normalize),
169        // reconciles dangling focus/magnify, and emits the granular event.
170        Command::LayoutMoveNode {
171            tab_id,
172            node_id,
173            new_parent_id,
174            index,
175            correlation_id,
176        } => layout::handle_layout_move_node(
177            state,
178            tab_id,
179            node_id,
180            new_parent_id,
181            index,
182            correlation_id,
183        ),
184        Command::LayoutSwapNodes {
185            tab_id,
186            node1_id,
187            node2_id,
188            correlation_id,
189        } => layout::handle_layout_swap_nodes(state, tab_id, node1_id, node2_id, correlation_id),
190        Command::LayoutResizeNodes {
191            tab_id,
192            ops,
193            correlation_id,
194        } => layout::handle_layout_resize_nodes(state, tab_id, ops, correlation_id),
195        Command::LayoutReplaceNode {
196            tab_id,
197            target_id,
198            new_node,
199            focus_after,
200            correlation_id,
201        } => layout::handle_layout_replace_node(
202            state,
203            tab_id,
204            target_id,
205            new_node,
206            focus_after,
207            correlation_id,
208        ),
209        Command::LayoutSplitHorizontal {
210            tab_id,
211            target_id,
212            new_node,
213            position,
214            focus_after,
215            correlation_id,
216        } => layout::handle_layout_split_horizontal(
217            state,
218            tab_id,
219            target_id,
220            new_node,
221            position,
222            focus_after,
223            correlation_id,
224        ),
225        Command::LayoutSplitVertical {
226            tab_id,
227            target_id,
228            new_node,
229            position,
230            focus_after,
231            correlation_id,
232        } => layout::handle_layout_split_vertical(
233            state,
234            tab_id,
235            target_id,
236            new_node,
237            position,
238            focus_after,
239            correlation_id,
240        ),
241        Command::LayoutInsertNodeAtIndex {
242            tab_id,
243            node,
244            index_arr,
245            focus_after,
246            magnify_after,
247            correlation_id,
248        } => layout::handle_layout_insert_node_at_index(
249            state,
250            tab_id,
251            node,
252            index_arr,
253            focus_after,
254            magnify_after,
255            correlation_id,
256        ),
257        Command::CreateWindow {
258            window_id,
259            workspace_id,
260        } => window::handle_create_window(state, window_id, workspace_id),
261        Command::CloseWindowInternal { window_id } => {
262            window::handle_close_window_internal(state, window_id)
263        }
264        Command::SwitchWorkspace {
265            window_id,
266            workspace_id,
267        } => window::handle_switch_workspace(state, window_id, workspace_id),
268        Command::ReorderTabsBulk {
269            workspace_id,
270            tab_ids,
271        } => tab::handle_reorder_tabs_bulk(state, workspace_id, tab_ids),
272        Command::RenameWorkspace { workspace_id, name } => {
273            workspace::handle_rename_workspace(state, workspace_id, name)
274        }
275        Command::RenameTab { tab_id, name } => tab::handle_rename_tab(state, tab_id, name),
276        Command::UpdateWorkspaceMeta {
277            workspace_id,
278            meta_patch,
279        } => workspace::handle_update_workspace_meta(state, workspace_id, meta_patch),
280        Command::UpdateTabMeta {
281            tab_id,
282            meta_patch,
283        } => tab::handle_update_tab_meta(state, tab_id, meta_patch),
284        Command::UpdateBlockMeta {
285            block_id,
286            meta_patch,
287        } => block::handle_update_block_meta(state, block_id, meta_patch),
288        Command::UpdateWindowMeta {
289            window_id,
290            meta_patch,
291        } => window::handle_update_window_meta(state, window_id, meta_patch),
292        Command::MoveTab {
293            tab_id,
294            src_workspace_id,
295            dst_workspace_id,
296            dst_index,
297        } => tab::handle_move_tab(state, tab_id, src_workspace_id, dst_workspace_id, dst_index),
298        Command::MoveBlock {
299            block_id,
300            src_tab_id,
301            dst_tab_id,
302            dst_index,
303        } => block::handle_move_block(state, block_id, src_tab_id, dst_tab_id, dst_index),
304        // Anything else is a non-fatal protocol error. Future
305        // phases (E.2b tabs, E.3 blocks, E.4 layouts) extend this
306        // match by adding new arms above.
307        other => {
308            let v = state.bump_version();
309            vec![Event::Error {
310                code: ErrorCode::InvalidCommand,
311                message: format!("srv reducer does not accept: {:?}", other),
312                fatal: false,
313                version: v,
314            }]
315        }
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use crate::reducer::test_support::*;
323
324    // This test exercises the `update()` dispatch fallback itself (the
325    // catch-all `other => { ... InvalidCommand ... }` arm above) rather
326    // than any single domain submodule's logic, so it stays here instead
327    // of moving to a `reducer::*` submodule.
328    #[test]
329    fn unaccepted_command_returns_invalid_command_error() {
330        let mut state = State::default();
331        let events = update(
332            &mut state,
333            Command::ReportWindowOpened {
334                label: "main".into(),
335                kind: agentmux_common::ipc::WindowKind::FullInstance,
336                parent_label: None,
337            },
338            &ctx(1),
339        );
340        assert!(matches!(
341            &events[0],
342            Event::Error {
343                code: ErrorCode::InvalidCommand,
344                fatal: false,
345                ..
346            }
347        ));
348    }
349}