Skip to main content

agentmux_srv/server/service/
workspace_lifecycle.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `workspace` service handlers — workspace CRUD (`CreateWorkspace`,
5//! `GetWorkspace`, `DeleteWorkspace`, `UpdateWorkspace`). Split out of
6//! `workspace.rs`; see that file's dispatcher for the full method list.
7
8use crate::backend::service::{self, WebCallType, WebReturnType};
9use crate::backend::wcore;
10
11use super::super::AppState;
12use super::reducer_helpers::{
13    compensate_via_reducer, dispatch_to_reducer, publish_events, mstore_workspace_exists,
14};
15
16pub(crate) async fn handle_create_workspace(state: &AppState, call: &WebCallType) -> WebReturnType {
17    let store = &state.mstore;
18    let args = &call.args;
19    let name: String = service::get_arg(args, 0).unwrap_or_default();
20    let events = dispatch_to_reducer(
21        state,
22        agentmux_common::ipc::Command::CreateWorkspace { name: name.clone() },
23    )
24    .await;
25    let workspace_id = events.iter().find_map(|e| match e {
26        agentmux_common::ipc::Event::WorkspaceCreated { workspace_id, .. } => {
27            Some(workspace_id.clone())
28        }
29        _ => None,
30    });
31    // Apply synchronously to mstore BEFORE publishing or
32    // returning. On SQLite failure, dispatch a compensating
33    // `DeleteWorkspace` so the reducer's session-only state
34    // doesn't carry a ghost workspace that was never
35    // persisted (codex P2 #615).
36    let mut apply_err: Option<String> = None;
37    for ev in &events {
38        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
39            apply_err = Some(e.to_string());
40            break;
41        }
42    }
43    if let Some(err) = apply_err {
44        if let Some(id) = workspace_id.as_ref() {
45            compensate_via_reducer(
46                state,
47                agentmux_common::ipc::Command::DeleteWorkspace {
48                    workspace_id: id.clone(),
49                    // Internal compensation path for failed
50                    // CreateWorkspace SQLite apply — not the
51                    // saga (Step 5 PR 2).
52                    force: false,
53                },
54                store,
55            )
56            .await;
57        }
58        return WebReturnType::error(format!(
59            "CreateWorkspace: SQLite write failed: {}",
60            err
61        ));
62    }
63    publish_events(state, &events);
64    match workspace_id {
65        Some(id) => match wcore::get_workspace(store, &id) {
66            Ok(ws) => {
67                WebReturnType::success(serde_json::to_value(&ws).unwrap_or_default())
68            }
69            Err(e) => WebReturnType::error(format!(
70                "CreateWorkspace: post-write read failed: {}",
71                e
72            )),
73        },
74        None => WebReturnType::error(
75            "CreateWorkspace: reducer did not emit WorkspaceCreated".to_string(),
76        ),
77    }
78}
79
80pub(crate) async fn handle_get_workspace(state: &AppState, call: &WebCallType) -> WebReturnType {
81    let store = &state.mstore;
82    let args = &call.args;
83    let ws_id: String = match service::get_arg(args, 0) {
84        Ok(v) => v,
85        Err(e) => return WebReturnType::error(e),
86    };
87    // mstore-direct during the migration window (see
88    // ("workspace", ...) header comment above for the
89    // rationale). Reducer-state reads return on E.2c.3+ once
90    // tabs (and pinned tabs) live in the reducer.
91    match wcore::get_workspace(store, &ws_id) {
92        Ok(ws) => WebReturnType::success(serde_json::to_value(&ws).unwrap_or_default()),
93        Err(e) => WebReturnType::error(e.to_string()),
94    }
95}
96
97pub(crate) async fn handle_delete_workspace(state: &AppState, call: &WebCallType) -> WebReturnType {
98    let store = &state.mstore;
99    let args = &call.args;
100    let ws_id: String = match service::get_arg(args, 0) {
101        Ok(v) => v,
102        Err(e) => return WebReturnType::error(e),
103    };
104    // Step 5 PR 2 — route the user-initiated DeleteWorkspace
105    // through the `delete_workspace` saga. The saga:
106    //   1. Snapshots the workspace's tabs+blocks for
107    //      provenance in the durable saga log.
108    //   2. Dispatches per-tab `DeleteTab { force: true }`
109    //      through the reducer (cascades blocks; persist
110    //      subscriber writes SQLite + kills controllers via
111    //      `wcore::delete_tab_inner`).
112    //   3. Dispatches the final
113    //      `DeleteWorkspace { force: true }` which removes
114    //      the (now-empty) workspace + window mappings.
115    //
116    // The legacy SQLite-first path here (wcore::delete_workspace
117    // followed by Command::DeleteWorkspace dispatch) is replaced
118    // by the saga because the durable lifecycle bracket gives
119    // crash-recovery a chance to retry/compensate via
120    // `recovery::compensate_unresolved` if the cascade is
121    // interrupted. Cascade behaviour is preserved 1:1.
122    //
123    // Pre-condition: workspace must exist (in reducer or
124    // SQLite). The saga runs its own existence check; we mirror
125    // the legacy NotFound semantics here for backward-compat
126    // error messages.
127    let exists_in_mstore = match mstore_workspace_exists(store, &ws_id) {
128        Ok(v) => v,
129        Err(e) => {
130            return WebReturnType::error(format!(
131                "DeleteWorkspace: SQLite read failed: {}",
132                e
133            ))
134        }
135    };
136    if !exists_in_mstore {
137        let exists_in_state = state
138            .srv_state
139            .lock()
140            .await
141            .workspaces
142            .contains_key(&ws_id);
143        if !exists_in_state {
144            return WebReturnType::error(format!(
145                "DeleteWorkspace: workspace not found: {}",
146                ws_id
147            ));
148        }
149    }
150    match crate::sagas::delete_workspace::run(state, ws_id.clone()).await {
151        Ok(_) => WebReturnType::success_empty(),
152        Err(e) => WebReturnType::error(format!("DeleteWorkspace failed: {}", e)),
153    }
154}
155
156// Phase E.5.3 — UpdateWorkspace migrated through the reducer.
157// Currently only handles rename (the only field this RPC ever
158// mutated). Meta-only updates are dispatched as
159// UpdateWorkspaceMeta separately by frontends.
160pub(crate) async fn handle_update_workspace(state: &AppState, call: &WebCallType) -> WebReturnType {
161    let store = &state.mstore;
162    let args = &call.args;
163    let ws_id: String = match service::get_arg(args, 0) {
164        Ok(v) => v,
165        Err(e) => return WebReturnType::error(e),
166    };
167    let name: Option<String> = service::get_optional_arg(args, 1).unwrap_or(None);
168    let Some(name) = name else {
169        return WebReturnType::success_empty();
170    };
171    let events = dispatch_to_reducer(
172        state,
173        agentmux_common::ipc::Command::RenameWorkspace {
174            workspace_id: ws_id.clone(),
175            name,
176        },
177    )
178    .await;
179    if let Some(err_msg) = events.iter().find_map(|e| match e {
180        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
181        _ => None,
182    }) {
183        return WebReturnType::error(err_msg);
184    }
185    for ev in &events {
186        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
187            return WebReturnType::error(format!(
188                "UpdateWorkspace: SQLite write failed: {}",
189                e
190            ));
191        }
192    }
193    publish_events(state, &events);
194    WebReturnType::success_empty()
195}