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}