Skip to main content

agentmux_srv/reducer/
workspace.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use agentmux_common::ipc::{ErrorCode, Event};
5
6use crate::state::State;
7
8
9use crate::state::WorkspaceRecord;
10
11/// Phase E.2 — create a new workspace. Reducer assigns the OID
12/// (UUID), inserts into canonical state, emits WorkspaceCreated.
13/// NOT idempotent on retry: each invocation generates a fresh UUID
14/// and inserts a new row, so a saga that double-fires CreateWorkspace
15/// would create two distinct workspaces. Saga-side dedup (correlation
16/// IDs / saga state machine) is responsible for at-most-once delivery
17/// when sagas land in E.5+.
18pub(super) fn handle_create_workspace(state: &mut State, name: String) -> Vec<Event> {
19    let workspace_id = uuid::Uuid::new_v4().to_string();
20    state.workspaces.insert(
21        workspace_id.clone(),
22        WorkspaceRecord {
23            workspace_id: workspace_id.clone(),
24            name: name.clone(),
25            tab_ids: Vec::new(),
26            active_tab_id: None,
27        },
28    );
29    let v = state.bump_version();
30    vec![Event::WorkspaceCreated {
31        workspace_id,
32        name,
33        version: v,
34    }]
35}
36
37/// Phase E.2 — delete a workspace from canonical state. Idempotent:
38/// deleting a missing workspace is a silent no-op. Cascades to the
39/// workspace's tabs (E.2b) and through to each tab's blocks (E.3):
40/// every tab whose `workspace_id` matches is removed from
41/// `state.tabs`, and each block in those tabs is removed from
42/// `state.blocks`, before the workspace itself goes away. Cascade
43/// events are NOT emitted individually — subscribers observing
44/// `WorkspaceDeleted` are expected to drop dependent state (mirrors
45/// how `wcore::delete_workspace` cascades in SQLite).
46///
47/// The `force` parameter (Step 5 PR 2) is provenance-only: it carries
48/// through to the durable saga log when the saga drives this dispatch
49/// (`force = true`), and is ignored by the reducer's cascade logic.
50/// The reducer is a pure mutator — it must always cascade to keep
51/// in-memory state consistent regardless of whether a saga or a
52/// legacy/internal path is calling.
53pub(super) fn handle_delete_workspace(
54    state: &mut State,
55    workspace_id: String,
56    _force: bool,
57) -> Vec<Event> {
58    let Some(removed) = state.workspaces.remove(&workspace_id) else {
59        return Vec::new();
60    };
61    // `cascaded_block_ids` rides the WorkspaceDeleted event below so the
62    // host can tear down any browser-pane renderer whose block was never
63    // live/loaded in a window (issue #2218, B.4) — same rationale as
64    // TabDeleted's `block_ids` (reducer/tab.rs::handle_delete_tab).
65    let mut cascaded_block_ids: Vec<String> = Vec::new();
66    for tab_id in &removed.tab_ids {
67        if let Some(tab) = state.tabs.remove(tab_id) {
68            cascaded_block_ids.extend(tab.block_ids.iter().cloned());
69            for block_id in &tab.block_ids {
70                state.blocks.remove(block_id);
71            }
72        }
73    }
74    // Phase E.5 — drop window mappings that point at the deleted
75    // workspace AND emit `SrvWindowClosed` for each so the persist
76    // subscriber prunes the SQLite Window row + Client.windowids.
77    // The original cascade (E.5.1+2) was silent, leaving downstream
78    // projections out of sync. (codex P1 follow-up to #619.)
79    let dropped_window_ids: Vec<String> = state
80        .windows
81        .iter()
82        .filter(|(_, w)| w.workspace_id == workspace_id)
83        .map(|(id, _)| id.clone())
84        .collect();
85    for id in &dropped_window_ids {
86        state.windows.remove(id);
87    }
88    let mut events = Vec::with_capacity(1 + dropped_window_ids.len());
89    let v = state.bump_version();
90    events.push(Event::WorkspaceDeleted {
91        workspace_id,
92        block_ids: cascaded_block_ids,
93        version: v,
94    });
95    for window_id in dropped_window_ids {
96        let v = state.bump_version();
97        events.push(Event::SrvWindowClosed { window_id, version: v });
98    }
99    events
100}
101
102/// Phase E.5.3 — rename a workspace. Errors if missing; no-op if
103/// the name is unchanged.
104pub(super) fn handle_rename_workspace(state: &mut State, workspace_id: String, name: String) -> Vec<Event> {
105    let Some(workspace) = state.workspaces.get_mut(&workspace_id) else {
106        let v = state.bump_version();
107        return vec![Event::Error {
108            code: ErrorCode::InvalidCommand,
109            message: format!("RenameWorkspace: workspace not found: {}", workspace_id),
110            fatal: false,
111            version: v,
112        }];
113    };
114    if workspace.name == name {
115        return Vec::new();
116    }
117    workspace.name = name.clone();
118    let v = state.bump_version();
119    vec![Event::WorkspaceRenamed {
120        workspace_id,
121        name,
122        version: v,
123    }]
124}
125
126/// Phase E.5.3 — pass-through validation + emit for workspace
127/// meta updates. The reducer does NOT mutate meta in state (it
128/// doesn't track meta in WorkspaceRecord); the persist subscriber
129/// applies the patch directly to mstore. This keeps the reducer's
130/// state shape unchanged while still routing every meta mutation
131/// through the broadcast bus for observers.
132pub(super) fn handle_update_workspace_meta(
133    state: &mut State,
134    workspace_id: String,
135    meta_patch: serde_json::Value,
136) -> Vec<Event> {
137    if !state.workspaces.contains_key(&workspace_id) {
138        let v = state.bump_version();
139        return vec![Event::Error {
140            code: ErrorCode::InvalidCommand,
141            message: format!(
142                "UpdateWorkspaceMeta: workspace not found: {}",
143                workspace_id
144            ),
145            fatal: false,
146            version: v,
147        }];
148    }
149    let v = state.bump_version();
150    vec![Event::WorkspaceMetaUpdated {
151        workspace_id,
152        meta_patch,
153        version: v,
154    }]
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160    use crate::reducer::test_support::*;
161    use crate::reducer::update;
162    use agentmux_common::ipc::Command;
163
164    #[test]
165    fn create_workspace_inserts_record_and_emits_event() {
166        let mut state = State::default();
167        let events = update(
168            &mut state,
169            Command::CreateWorkspace {
170                name: "myws".into(),
171            },
172            &ctx(1),
173        );
174        assert_eq!(events.len(), 1);
175        let Event::WorkspaceCreated {
176            workspace_id, name, ..
177        } = &events[0]
178        else {
179            panic!("expected WorkspaceCreated, got {:?}", events[0]);
180        };
181        assert_eq!(name, "myws");
182        assert!(state.workspaces.contains_key(workspace_id));
183        assert_eq!(state.workspaces[workspace_id].name, "myws");
184    }
185
186    #[test]
187    fn delete_workspace_removes_record_and_emits_event() {
188        let mut state = State::default();
189        let events = update(
190            &mut state,
191            Command::CreateWorkspace {
192                name: "to-delete".into(),
193            },
194            &ctx(1),
195        );
196        let Event::WorkspaceCreated { workspace_id, .. } = &events[0] else {
197            panic!();
198        };
199        let ws_id = workspace_id.clone();
200        let events = update(
201            &mut state,
202            Command::DeleteWorkspace {
203                workspace_id: ws_id.clone(),
204                force: false,
205            },
206            &ctx(2),
207        );
208        assert_eq!(events.len(), 1);
209        assert!(matches!(
210            &events[0],
211            Event::WorkspaceDeleted { workspace_id, .. } if workspace_id == &ws_id
212        ));
213        assert!(!state.workspaces.contains_key(&ws_id));
214    }
215
216    #[test]
217    fn delete_workspace_unknown_is_silent_no_op() {
218        let mut state = State::default();
219        let events = update(
220            &mut state,
221            Command::DeleteWorkspace {
222                workspace_id: "does-not-exist".into(),
223                force: false,
224            },
225            &ctx(1),
226        );
227        assert!(events.is_empty());
228    }
229
230    #[test]
231    fn delete_workspace_cascades_tabs() {
232        let mut state = State::default();
233        let ws_id = create_workspace(&mut state, "w");
234        let _ = update(
235            &mut state,
236            Command::CreateTab {
237                workspace_id: ws_id.clone(),
238                name: "t1".into(),
239            },
240            &ctx(1),
241        );
242        let _ = update(
243            &mut state,
244            Command::CreateTab {
245                workspace_id: ws_id.clone(),
246                name: "t2".into(),
247            },
248            &ctx(2),
249        );
250        assert_eq!(state.tabs.len(), 2);
251        let events = update(
252            &mut state,
253            Command::DeleteWorkspace {
254                workspace_id: ws_id.clone(),
255                force: false,
256            },
257            &ctx(3),
258        );
259        assert!(matches!(&events[0], Event::WorkspaceDeleted { .. }));
260        assert!(state.tabs.is_empty());
261        assert!(!state.workspaces.contains_key(&ws_id));
262    }
263
264    #[test]
265    fn delete_workspace_emits_cascaded_block_ids_across_all_tabs() {
266        let mut state = State::default();
267        let ws_id = create_workspace(&mut state, "w");
268        let tab1_id = create_tab(&mut state, &ws_id, "t1");
269        let tab2_id = create_tab(&mut state, &ws_id, "t2");
270        let mut expected = Vec::new();
271        for (i, tab_id) in [&tab1_id, &tab2_id].into_iter().enumerate() {
272            for j in 0..2 {
273                let block_id = match &update(
274                    &mut state,
275                    Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
276                    &ctx(10 + (i * 2 + j) as u64),
277                )[0] {
278                    Event::BlockCreated { block_id, .. } => block_id.clone(),
279                    other => panic!("expected BlockCreated, got {:?}", other),
280                };
281                expected.push(block_id);
282            }
283        }
284        assert_eq!(expected.len(), 4);
285        let events = update(
286            &mut state,
287            Command::DeleteWorkspace { workspace_id: ws_id.clone(), force: false },
288            &ctx(20),
289        );
290        match &events[0] {
291            Event::WorkspaceDeleted { block_ids, .. } => {
292                assert_eq!(block_ids.len(), 4);
293                for b in &expected {
294                    assert!(block_ids.contains(b));
295                }
296            }
297            other => panic!("expected WorkspaceDeleted, got {:?}", other),
298        }
299    }
300
301    #[test]
302    fn delete_workspace_cascades_through_tabs_to_blocks() {
303        let mut state = State::default();
304        let ws_id = create_workspace(&mut state, "w");
305        let tab_id = create_tab(&mut state, &ws_id, "t");
306        let _ = update(
307            &mut state,
308            Command::CreateBlock { tab_id: tab_id.clone(), meta: serde_json::Value::Null },
309            &ctx(2),
310        );
311        let _ = update(
312            &mut state,
313            Command::CreateBlock { tab_id, meta: serde_json::Value::Null },
314            &ctx(3),
315        );
316        assert_eq!(state.blocks.len(), 2);
317        let _ = update(
318            &mut state,
319            Command::DeleteWorkspace { workspace_id: ws_id, force: false },
320            &ctx(4),
321        );
322        assert!(state.blocks.is_empty());
323        assert!(state.tabs.is_empty());
324    }
325
326    #[test]
327    fn delete_workspace_drops_pointing_windows_and_emits_events() {
328        let mut state = State::default();
329        let ws_a = create_workspace(&mut state, "a");
330        let ws_b = create_workspace(&mut state, "b");
331        let _ = update(
332            &mut state,
333            Command::CreateWindow {
334                window_id: "win-a".into(),
335                workspace_id: ws_a.clone(),
336            },
337            &ctx(2),
338        );
339        let _ = update(
340            &mut state,
341            Command::CreateWindow {
342                window_id: "win-b".into(),
343                workspace_id: ws_b.clone(),
344            },
345            &ctx(3),
346        );
347        // Delete workspace A; only win-a should be dropped + emit
348        // SrvWindowClosed; win-b survives.
349        let events = update(
350            &mut state,
351            Command::DeleteWorkspace { workspace_id: ws_a, force: false },
352            &ctx(4),
353        );
354        assert!(matches!(&events[0], Event::WorkspaceDeleted { .. }));
355        assert!(events.iter().any(|e| matches!(
356            e,
357            Event::SrvWindowClosed { window_id, .. } if window_id == "win-a"
358        )));
359        // Verify win-b was NOT closed.
360        assert!(!events.iter().any(|e| matches!(
361            e,
362            Event::SrvWindowClosed { window_id, .. } if window_id == "win-b"
363        )));
364        assert!(!state.windows.contains_key("win-a"));
365        assert_eq!(state.windows["win-b"].workspace_id, ws_b);
366    }
367}