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 for tab_id in &removed.tab_ids {
62 if let Some(tab) = state.tabs.remove(tab_id) {
63 for block_id in &tab.block_ids {
64 state.blocks.remove(block_id);
65 }
66 }
67 }
68 // Phase E.5 — drop window mappings that point at the deleted
69 // workspace AND emit `SrvWindowClosed` for each so the persist
70 // subscriber prunes the SQLite Window row + Client.windowids.
71 // The original cascade (E.5.1+2) was silent, leaving downstream
72 // projections out of sync. (codex P1 follow-up to #619.)
73 let dropped_window_ids: Vec<String> = state
74 .windows
75 .iter()
76 .filter(|(_, w)| w.workspace_id == workspace_id)
77 .map(|(id, _)| id.clone())
78 .collect();
79 for id in &dropped_window_ids {
80 state.windows.remove(id);
81 }
82 let mut events = Vec::with_capacity(1 + dropped_window_ids.len());
83 let v = state.bump_version();
84 events.push(Event::WorkspaceDeleted {
85 workspace_id,
86 version: v,
87 });
88 for window_id in dropped_window_ids {
89 let v = state.bump_version();
90 events.push(Event::SrvWindowClosed { window_id, version: v });
91 }
92 events
93}
94
95/// Phase E.5.3 — rename a workspace. Errors if missing; no-op if
96/// the name is unchanged.
97pub(super) fn handle_rename_workspace(state: &mut State, workspace_id: String, name: String) -> Vec<Event> {
98 let Some(workspace) = state.workspaces.get_mut(&workspace_id) else {
99 let v = state.bump_version();
100 return vec![Event::Error {
101 code: ErrorCode::InvalidCommand,
102 message: format!("RenameWorkspace: workspace not found: {}", workspace_id),
103 fatal: false,
104 version: v,
105 }];
106 };
107 if workspace.name == name {
108 return Vec::new();
109 }
110 workspace.name = name.clone();
111 let v = state.bump_version();
112 vec![Event::WorkspaceRenamed {
113 workspace_id,
114 name,
115 version: v,
116 }]
117}
118
119/// Phase E.5.3 — pass-through validation + emit for workspace
120/// meta updates. The reducer does NOT mutate meta in state (it
121/// doesn't track meta in WorkspaceRecord); the persist subscriber
122/// applies the patch directly to wstore. This keeps the reducer's
123/// state shape unchanged while still routing every meta mutation
124/// through the broadcast bus for observers.
125pub(super) fn handle_update_workspace_meta(
126 state: &mut State,
127 workspace_id: String,
128 meta_patch: serde_json::Value,
129) -> Vec<Event> {
130 if !state.workspaces.contains_key(&workspace_id) {
131 let v = state.bump_version();
132 return vec![Event::Error {
133 code: ErrorCode::InvalidCommand,
134 message: format!(
135 "UpdateWorkspaceMeta: workspace not found: {}",
136 workspace_id
137 ),
138 fatal: false,
139 version: v,
140 }];
141 }
142 let v = state.bump_version();
143 vec![Event::WorkspaceMetaUpdated {
144 workspace_id,
145 meta_patch,
146 version: v,
147 }]
148}