Skip to main content

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}