agentmux_srv/sagas/delete_workspace.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase E.5.7 (Step 5 PR 2) — DeleteWorkspace saga.
5//
6// Replaces the inline cascade-on-dispatch pattern in
7// `service.rs::("workspace", "DeleteWorkspace")` with a saga that
8// records lifecycle brackets in the durable saga log. Closes the
9// remaining gap in the Step 5 series:
10//
11// * PR 1 already migrated `DeleteBlock` and `DeleteTab` to sagas.
12// * The `("workspace", "DeleteWorkspace")` RPC handler still issued
13// a single `Command::DeleteWorkspace` whose reducer arm cascaded
14// through tabs+blocks atomically, but a crash mid-cascade left
15// inconsistent state with no durable record of WHAT was deleted.
16//
17// **Saga-as-narrator pattern.** The reducer's `handle_delete_workspace`
18// remains the canonical mutator (it cascades through tabs+blocks and
19// drops window mappings in a single in-memory step). The saga's
20// contribution is durability: it walks the workspace's tabs first via
21// per-tab `Command::DeleteTab { force: true }` dispatches, recording
22// each in the saga log, then issues the final
23// `Command::DeleteWorkspace { force: true }` to drop the (now-empty)
24// workspace + window mappings.
25//
26// This decomposition means crash recovery (`recovery::compensate_unresolved`,
27// merged in #636) sees per-tab progress markers and can mark the saga
28// `failed_compensation` for operator review if the cascade was
29// interrupted — versus the legacy single-shot `DeleteWorkspace` which
30// either fully applied or left the reducer state untouched, with no
31// durable trace either way.
32//
33// **Steps:**
34// 1. Snapshot the workspace's tabs+blocks (read-only) for the saga
35// log's `input` field. Provenance for `--diag sagas`: which entities
36// existed at saga start, so an operator can reason about a partial
37// cascade later.
38// 2. For each tab (in `tab_ids` order): dispatch
39// `Command::DeleteTab { force: true }`. The reducer cascades the
40// tab's blocks atomically; the persist subscriber writes SQLite
41// via `wcore::delete_tab` (which kills PTY controllers via
42// `delete_tab_inner` → `delete_controller(block_id)`).
43// `force: true` bypasses the reducer's last-tab guard — we're
44// intentionally draining the workspace.
45// 3. Final dispatch: `Command::DeleteWorkspace { force: true }`. The
46// workspace is empty by this point (step 2 deleted every tab), so
47// the reducer's cascade only removes the workspace record + drops
48// window mappings (emitting `SrvWindowClosed` per affected window).
49// The `force: true` flag is provenance-only — the reducer's
50// behaviour is identical regardless.
51//
52// **Compensation.** Delete sagas are awkward to compensate by design:
53// once a tab + its blocks are gone (SQLite rows deleted, controllers
54// killed), reconstruction would require persisting the pre-state, which
55// no current saga does. Per the brief: compensation is **record-only**.
56// If a step fails mid-cascade we rely on `classify_run_saga_result` to
57// classify the error path:
58//
59// * `Err` (non-timeout) → `Compensated` — the saga's per-step log
60// rows already record what was deleted; subsequent crash-recovery
61// does NOT replay (Delete commands have no derivable inverse in
62// `recovery::derive_inverse_command`, by design).
63// * `Err` containing `"timed out"` → `Failed` — the saga timed out
64// before completing; recovery will see `running` lifecycle + the
65// succeeded step prefix and mark it `failed_compensation` (since
66// Delete has no derivable inverse, the operator must reconcile).
67// * `Ok` → `Completed` — clean cascade.
68//
69// **Pre-condition:** the workspace must exist in the reducer state OR
70// in SQLite. Bootstrap loads SQLite into reducer at startup, so they
71// normally match; we accept either to handle migration-window flows
72// where SQLite-direct writes haven't yet flowed through the reducer.
73
74use agentmux_common::ipc::Command;
75use serde_json::{json, Value};
76
77use super::{
78 alloc_saga_id, classify_run_saga_result, emit_saga_started, emit_terminal, run_saga, SagaCtx,
79};
80use crate::server::AppState;
81
82/// Run the DeleteWorkspace saga. On success returns
83/// `{"workspace_id": "...", "deleted_tab_count": N, "deleted_block_count": M}`.
84pub async fn run(state: &AppState, workspace_id: String) -> Result<Value, String> {
85 // Pre-condition + snapshot: read the workspace's tabs+blocks
86 // before any dispatch. We need the tab list to drive step 2's
87 // per-tab DeleteTab dispatches, and we record block ids in the
88 // saga log so `--diag sagas` can show what was destroyed.
89 let (tab_ids, block_count, block_ids) = {
90 let s = state.srv_state.lock().await;
91 let Some(workspace) = s.workspaces.get(&workspace_id) else {
92 return Err(format!(
93 "DeleteWorkspace: workspace not found: {}",
94 workspace_id
95 ));
96 };
97 let tab_ids: Vec<String> = workspace.tab_ids.clone();
98 let block_count: usize = tab_ids
99 .iter()
100 .map(|tid| s.tabs.get(tid).map(|t| t.block_ids.len()).unwrap_or(0))
101 .sum();
102 let block_ids: Vec<String> = tab_ids
103 .iter()
104 .filter_map(|tid| s.tabs.get(tid))
105 .flat_map(|t| t.block_ids.iter().cloned())
106 .collect();
107 (tab_ids, block_count, block_ids)
108 };
109
110 let saga_id = alloc_saga_id(state);
111 if let Err(e) = emit_saga_started(
112 state,
113 saga_id,
114 "delete_workspace",
115 json!({
116 "workspace_id": &workspace_id,
117 "tab_ids": &tab_ids,
118 "block_count": block_count,
119 }),
120 )
121 .await
122 {
123 return Err(e);
124 }
125 // Stop every agent in every tab of the workspace up front — gracefully,
126 // concurrently, under one deadline — before any record goes (pane-close
127 // spec §9.5). The per-tab cascade below then finds nothing left to stop.
128 super::agent_teardown::run_many(state, &block_ids, super::agent_teardown::Policy::close()).await;
129 let ctx = SagaCtx::new(state, saga_id);
130 let result = run_saga(
131 "delete_workspace",
132 run_inner(ctx, workspace_id.clone(), tab_ids.clone(), block_count),
133 )
134 .await;
135 for block_id in &block_ids {
136 super::close_pane::finish_close(state, block_id).await;
137 }
138 emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
139 result
140}
141
142async fn run_inner(
143 ctx: SagaCtx<'_>,
144 workspace_id: String,
145 tab_ids: Vec<String>,
146 block_count: usize,
147) -> Result<Value, String> {
148 // Step 2: per-tab DeleteTab dispatch. `force: true` bypasses the
149 // reducer's last-tab guard — the saga is intentionally draining
150 // the workspace, the guard exists to protect user-facing CloseTab
151 // flows from emptying a workspace by accident. Every agent was
152 // already closed gracefully above (`agent_teardown::run_many`); the
153 // persist subscriber's `wcore::delete_tab` → `delete_tab_inner` →
154 // `agent_teardown::discard` is only the backstop and finds nothing.
155 //
156 // **No compensation.** If a tab's DeleteTab dispatch fails mid-
157 // cascade, the already-deleted prefix is gone — we can't
158 // reconstruct the SQLite rows or respawn the PTY controllers.
159 // Returning `Err` records the failure in the saga log (per-step
160 // succeeded rows for the prefix + a failed row for the rejecting
161 // tab). `classify_run_saga_result` maps non-timeout `Err` to
162 // `Compensated`, which is technically inaccurate (nothing was
163 // un-done) but matches the saga framework's convention for
164 // non-timeout errors and avoids surfacing the saga to recovery's
165 // `failed`-state replay path (Delete has no derivable inverse
166 // anyway, so recovery would just mark `failed_compensation` —
167 // which is fine; either outcome surfaces in `--diag sagas`).
168 for (i, tab_id) in tab_ids.iter().enumerate() {
169 if let Err(reason) = ctx
170 .dispatch(Command::DeleteTab {
171 workspace_id: workspace_id.clone(),
172 tab_id: tab_id.clone(),
173 // Saga is draining the workspace; bypass the
174 // user-facing last-tab guard. (Saga's own pre-checks
175 // already validated the workspace exists.)
176 force: true,
177 })
178 .await
179 {
180 tracing::warn!(
181 workspace_id = %workspace_id,
182 tab_id = %tab_id,
183 step = i,
184 "[saga] DeleteWorkspace step 2 (DeleteTab): dispatch failed: {} — succeeded prefix already gone, no compensation possible",
185 reason,
186 );
187 return Err(format!(
188 "DeleteWorkspace step 2 (DeleteTab {}): {}",
189 tab_id, reason
190 ));
191 }
192 }
193
194 // Step 3: drop the (now-empty) workspace + cascade window mappings.
195 // The reducer's `handle_delete_workspace` removes the workspace
196 // record + emits `SrvWindowClosed` per affected window; the
197 // persist subscriber's `apply_workspace_deleted` runs
198 // `wcore::delete_workspace` for SQLite (idempotent if the
199 // workspace was already removed by the per-tab cascade in step 2).
200 if let Err(reason) = ctx
201 .dispatch(Command::DeleteWorkspace {
202 workspace_id: workspace_id.clone(),
203 // Saga-driven dispatch — provenance flag for the durable
204 // log. Reducer behaviour is identical for both values.
205 force: true,
206 })
207 .await
208 {
209 tracing::warn!(
210 workspace_id = %workspace_id,
211 "[saga] DeleteWorkspace step 3 (DeleteWorkspace): dispatch failed: {} — tabs already gone, workspace partially deleted",
212 reason,
213 );
214 return Err(format!("DeleteWorkspace step 3 (DeleteWorkspace): {}", reason));
215 }
216
217 Ok(json!({
218 "workspace_id": workspace_id,
219 "deleted_tab_count": tab_ids.len(),
220 "deleted_block_count": block_count,
221 }))
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
227 use crate::backend::obj::{Block, Tab, Workspace};
228 use crate::server::tests::test_state;
229 use agentmux_common::ipc::Event;
230
231 async fn dispatch_apply(
232 state: &crate::server::AppState,
233 cmd: Command,
234 ) -> Vec<Event> {
235 let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
236 for ev in &events {
237 crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
238 }
239 events
240 }
241
242 /// Seed a workspace with N tabs, each containing one block.
243 /// Returns `(state, workspace_id, tab_ids, block_ids)`.
244 async fn seed_workspace_with_tabs_and_blocks(
245 n: usize,
246 ) -> (
247 crate::server::AppState,
248 String,
249 Vec<String>,
250 Vec<String>,
251 ) {
252 let state = test_state();
253 let ws_evs = dispatch_apply(
254 &state,
255 Command::CreateWorkspace { name: "w".into() },
256 )
257 .await;
258 let ws_id = ws_evs
259 .iter()
260 .find_map(|e| match e {
261 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
262 _ => None,
263 })
264 .unwrap();
265 let mut tab_ids = Vec::new();
266 let mut block_ids = Vec::new();
267 for i in 0..n {
268 let tab_evs = dispatch_apply(
269 &state,
270 Command::CreateTab {
271 workspace_id: ws_id.clone(),
272 name: format!("tab-{}", i),
273 },
274 )
275 .await;
276 let tab_id = tab_evs
277 .iter()
278 .find_map(|e| match e {
279 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
280 _ => None,
281 })
282 .unwrap();
283 let blk_evs = dispatch_apply(
284 &state,
285 Command::CreateBlock {
286 tab_id: tab_id.clone(),
287 meta: serde_json::Value::Null,
288 },
289 )
290 .await;
291 let block_id = blk_evs
292 .iter()
293 .find_map(|e| match e {
294 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
295 _ => None,
296 })
297 .unwrap();
298 tab_ids.push(tab_id);
299 block_ids.push(block_id);
300 }
301 (state, ws_id, tab_ids, block_ids)
302 }
303
304 #[tokio::test]
305 async fn happy_path_cascades_tabs_and_blocks() {
306 let (state, ws_id, tab_ids, block_ids) =
307 seed_workspace_with_tabs_and_blocks(2).await;
308
309 // Sanity: pre-state has workspace + tabs + blocks in both
310 // reducer state and SQLite.
311 {
312 let s = state.srv_state.lock().await;
313 assert!(s.workspaces.contains_key(&ws_id));
314 assert_eq!(s.workspaces[&ws_id].tab_ids.len(), 2);
315 assert_eq!(s.tabs.len(), 2);
316 assert_eq!(s.blocks.len(), 2);
317 }
318 for tab_id in &tab_ids {
319 assert!(state.mstore.get::<Tab>(tab_id).unwrap().is_some());
320 }
321 for block_id in &block_ids {
322 assert!(state.mstore.get::<Block>(block_id).unwrap().is_some());
323 }
324
325 let result = run(&state, ws_id.clone()).await.unwrap();
326 assert_eq!(result["workspace_id"], ws_id);
327 assert_eq!(result["deleted_tab_count"], 2);
328 assert_eq!(result["deleted_block_count"], 2);
329
330 // Reducer: workspace + all tabs + all blocks gone.
331 let s = state.srv_state.lock().await;
332 assert!(!s.workspaces.contains_key(&ws_id));
333 assert!(s.tabs.is_empty());
334 assert!(s.blocks.is_empty());
335 drop(s);
336
337 // SQLite: matches.
338 assert!(state.mstore.get::<Workspace>(&ws_id).unwrap().is_none());
339 for tab_id in &tab_ids {
340 assert!(state.mstore.get::<Tab>(tab_id).unwrap().is_none());
341 }
342 for block_id in &block_ids {
343 assert!(state.mstore.get::<Block>(block_id).unwrap().is_none());
344 }
345 }
346
347 #[tokio::test]
348 async fn rejects_when_workspace_not_found() {
349 let state = test_state();
350 let err = run(&state, "ghost-ws".into()).await.unwrap_err();
351 assert!(err.contains("workspace not found"), "got: {}", err);
352 }
353
354 #[tokio::test]
355 async fn empty_workspace_succeeds() {
356 // Workspace exists but has zero tabs — saga should skip the
357 // per-tab loop and proceed directly to step 3 (DeleteWorkspace).
358 let state = test_state();
359 let ws_evs = dispatch_apply(
360 &state,
361 Command::CreateWorkspace { name: "empty".into() },
362 )
363 .await;
364 let ws_id = ws_evs
365 .iter()
366 .find_map(|e| match e {
367 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
368 _ => None,
369 })
370 .unwrap();
371
372 let result = run(&state, ws_id.clone()).await.unwrap();
373 assert_eq!(result["deleted_tab_count"], 0);
374 assert_eq!(result["deleted_block_count"], 0);
375
376 let s = state.srv_state.lock().await;
377 assert!(!s.workspaces.contains_key(&ws_id));
378 }
379
380 #[tokio::test]
381 async fn writes_lifecycle_brackets_to_saga_log() {
382 // Verify the saga records `start_saga` + per-step `succeeded`
383 // rows + a terminal `completed` lifecycle row. This is what
384 // PR 2's `--diag sagas` and `compensate_unresolved` rely on.
385 let (state, ws_id, _tab_ids, _block_ids) =
386 seed_workspace_with_tabs_and_blocks(1).await;
387 run(&state, ws_id.clone()).await.unwrap();
388
389 let snap = state.saga_log.snapshot_recent(10).unwrap();
390 let saga = snap
391 .iter()
392 .find(|s| s.name == "delete_workspace")
393 .expect("delete_workspace saga should appear in snapshot");
394 assert_eq!(saga.state, "completed", "saga should terminate completed");
395 // 1 DeleteTab step + 1 DeleteWorkspace step = 2 forward steps.
396 assert!(
397 saga.step_count >= 2,
398 "expected >= 2 steps, got {}",
399 saga.step_count
400 );
401 // No unresolved sagas — recovery shouldn't pick this up.
402 let unresolved = state.saga_log.unresolved_sagas().unwrap();
403 assert!(
404 unresolved.iter().all(|s| s.saga_id != saga.saga_id),
405 "saga should not be unresolved post-completion"
406 );
407 }
408
409 #[tokio::test]
410 async fn cascade_drops_window_mappings() {
411 // Seed a workspace mapped to a window; saga should cascade
412 // window-removal via the reducer's existing
413 // handle_delete_workspace logic (which emits SrvWindowClosed
414 // per affected window).
415 let (state, ws_id, _tab_ids, _block_ids) =
416 seed_workspace_with_tabs_and_blocks(1).await;
417 let win_id = "win-test".to_string();
418 let _ = dispatch_apply(
419 &state,
420 Command::CreateWindow {
421 window_id: win_id.clone(),
422 workspace_id: ws_id.clone(),
423 },
424 )
425 .await;
426 {
427 let s = state.srv_state.lock().await;
428 assert!(s.windows.contains_key(&win_id));
429 }
430
431 run(&state, ws_id.clone()).await.unwrap();
432
433 let s = state.srv_state.lock().await;
434 assert!(!s.windows.contains_key(&win_id));
435 }
436}