agentmux_srv\sagas/
delete_tab.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase E.5.7 (Step 5 PR 1) — DeleteTab saga.
5//
6// Replaces the SQLite-first delete pattern in `service.rs`'s
7// `("workspace", "CloseTab")` handler with a reducer-driven saga.
8// The legacy handler called `wcore::delete_tab` first and then
9// dispatched `Command::DeleteTab` to keep the reducer in sync — a
10// short-circuit that pre-dates the saga coordinator + persist
11// subscriber pattern (closes gap §4 in
12// `docs/retro/reducer-architecture-gaps-2026-05-01.md`).
13//
14// **Steps:**
15// 1. `DeleteTab { workspace_id, tab_id }` — reducer removes the tab
16//    from canonical state (cascading to its blocks in-state) and
17//    emits `Event::TabDeleted` plus optional `Event::ActiveTabChanged`
18//    if the removed tab was active. The persist subscriber writes
19//    SQLite via `wcore::delete_tab` (cascades to blocks + layout +
20//    PTY controllers).
21//
22// **Pre-conditions:**
23// 1. Tab must exist in the reducer state and be in the named
24//    workspace.
25// 2. Tab must NOT be the workspace's last tab. Mirrors `TearOffTab`'s
26//    "cannot tear off last tab" guard. Removing the last tab leaves
27//    an empty workspace whose UI representation is awkward — callers
28//    that want full-workspace teardown should issue
29//    `DeleteWorkspace` (which is its own saga in Step 5 PR 2).
30//
31// **Block controller cascade:** the persist subscriber's
32// `apply_tab_deleted` invokes `wcore::delete_tab` which calls
33// `delete_tab_inner` → `delete_controller(block_id)` for each block.
34// The saga therefore does not need to do explicit controller-kill
35// like `delete_block.rs` does (DeleteBlock's persist path is
36// `wcore::delete_block` which only handles the SQLite + layout
37// prune, not controller teardown).
38//
39// **Compensation:** like DeleteBlock, un-deleting a tab requires
40// reconstructing SQLite rows (Tab + LayoutState + cascaded Blocks)
41// AND re-spawning the PTY controllers — neither feasible from saga
42// state. Pragma per the brief: log warning on failure, no automatic
43// re-create. PR 2's restart-recovery scan will surface partial
44// failures from the durable saga log.
45//
46// **Pre-condition source — reducer vs SQLite:** check reducer state.
47// The legacy CloseTab path was SQLite-first which made SQLite
48// authoritative; the saga inverts this — the reducer's `tab_ids` is
49// the source of truth, with the persist subscriber writing SQLite
50// in response. Tabs that exist in SQLite but not in the reducer
51// indicate a bootstrap-mismatch (separate bug); the saga rejecting
52// them is the correct conservative behaviour.
53
54use agentmux_common::ipc::Command;
55use serde_json::{json, Value};
56
57use super::{
58    alloc_saga_id, classify_run_saga_result, emit_saga_started, emit_terminal, run_saga, SagaCtx,
59};
60use crate::server::AppState;
61
62/// Run the DeleteTab saga. On success returns
63/// `{"workspace_id": "...", "tab_id": "..."}`.
64pub async fn run(
65    state: &AppState,
66    workspace_id: String,
67    tab_id: String,
68) -> Result<Value, String> {
69    // Pre-conditions: tab exists in workspace; not the last tab.
70    //
71    // **Last-tab pre-check is best-effort.** A reducer-level guard
72    // would be atomic but it broke legitimate CreateTab compensation
73    // (codex P1 round 2 #633) and frontend keyboard flow (codex P1
74    // round 1 #633). Round 3 walked back the reducer guard. The
75    // saga keeps a soft pre-check here as a UX guard; the TOCTOU
76    // window (two concurrent CloseTabs on different tabs in a 2-tab
77    // workspace) is reachable in theory but the user-facing call
78    // sites (tabbar close button at `tabbar.tsx:63`, keyboard handler
79    // at `keymodel.ts::simpleCloseStaticTab`) gate with
80    // `if (allTabs.length <= 1) return`, so user-driven concurrent
81    // CloseTabs can't reach this saga. Automated test harnesses
82    // could still race; document the limitation, accept it.
83    {
84        let s = state.srv_state.lock().await;
85        let Some(workspace) = s.workspaces.get(&workspace_id) else {
86            return Err(format!(
87                "DeleteTab: workspace not found: {}",
88                workspace_id
89            ));
90        };
91        if !workspace.tab_ids.iter().any(|t| t == &tab_id) {
92            return Err(format!(
93                "DeleteTab: tab {} is not in workspace {}",
94                tab_id, workspace_id
95            ));
96        }
97        if workspace.tab_ids.len() <= 1 {
98            return Err(format!(
99                "DeleteTab: cannot delete last tab in workspace {}",
100                workspace_id
101            ));
102        }
103        if !s.tabs.contains_key(&tab_id) {
104            // Inconsistent state — workspace.tab_ids references a
105            // tab that's not in state.tabs. Reject; bootstrap-rebuild
106            // gap is a separate concern.
107            return Err(format!("DeleteTab: tab record missing: {}", tab_id));
108        }
109    }
110
111    // Capture the tab's block_ids before dispatch — we'll use these
112    // to clean up PTY controllers if the saga partially succeeds
113    // (reducer dispatched, SQLite apply failed). (codex P2 PR #633
114    // round 3.)
115    let block_ids_to_cleanup: Vec<String> = {
116        let s = state.srv_state.lock().await;
117        s.tabs
118            .get(&tab_id)
119            .map(|t| t.block_ids.clone())
120            .unwrap_or_default()
121    };
122
123    let saga_id = alloc_saga_id(state);
124    if let Err(e) = emit_saga_started(
125        state,
126        saga_id,
127        "delete_tab",
128        json!({
129            "workspace_id": &workspace_id,
130            "tab_id": &tab_id,
131        }),
132    )
133    .await
134    {
135        return Err(e);
136    }
137    let ctx = SagaCtx::new(state, saga_id);
138    let result = run_saga("delete_tab", run_inner(ctx, workspace_id, tab_id.clone())).await;
139    // If the tab is gone from reducer state, the reducer dispatched
140    // (whether or not SQLite apply succeeded). Kill controllers for
141    // every block that was in the tab. Idempotent on missing
142    // controllers. Same pattern as `delete_block::run` (codex P2
143    // round 2 + 3).
144    {
145        let tab_still_in_reducer = state.srv_state.lock().await.tabs.contains_key(&tab_id);
146        if !tab_still_in_reducer {
147            for block_id in &block_ids_to_cleanup {
148                crate::backend::blockcontroller::delete_controller(block_id);
149            }
150        }
151    }
152    emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
153    result
154}
155
156async fn run_inner(
157    ctx: SagaCtx<'_>,
158    workspace_id: String,
159    tab_id: String,
160) -> Result<Value, String> {
161    // Step 1: dispatch DeleteTab through the reducer. The persist
162    // subscriber sees TabDeleted (and optional ActiveTabChanged) and
163    // runs `wcore::delete_tab` which cascades to blocks + layout +
164    // PTY controllers.
165    if let Err(reason) = ctx
166        .dispatch(Command::DeleteTab {
167            workspace_id: workspace_id.clone(),
168            tab_id: tab_id.clone(),
169            // User-facing flow — reducer enforces last-tab guard
170            // atomically (codex P2 round 4 PR #633).
171            force: false,
172        })
173        .await
174    {
175        // No automatic compensation. See module doc-comment for
176        // rationale.
177        tracing::warn!(
178            workspace_id = %workspace_id,
179            tab_id = %tab_id,
180            "[saga] DeleteTab dispatch failed (no automatic compensation): {}",
181            reason
182        );
183        return Err(format!("DeleteTab: {}", reason));
184    }
185
186    Ok(json!({
187        "workspace_id": workspace_id,
188        "tab_id": tab_id,
189    }))
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::backend::obj::{Tab, Workspace};
196    use crate::server::tests::test_state;
197    use agentmux_common::ipc::Event;
198
199    async fn dispatch_apply(
200        state: &crate::server::AppState,
201        cmd: agentmux_common::ipc::Command,
202    ) -> Vec<agentmux_common::ipc::Event> {
203        let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
204        for ev in &events {
205            crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
206        }
207        events
208    }
209
210    /// Seed: a workspace with two tabs (last-tab guard requires >1).
211    /// Returns `(state, ws_id, tab_a_id, tab_b_id)`.
212    async fn seed_workspace_with_two_tabs() -> (
213        crate::server::AppState,
214        String,
215        String,
216        String,
217    ) {
218        let state = test_state();
219        let ws_evs = dispatch_apply(
220            &state,
221            agentmux_common::ipc::Command::CreateWorkspace { name: "w".into() },
222        )
223        .await;
224        let ws_id = ws_evs
225            .iter()
226            .find_map(|e| match e {
227                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
228                _ => None,
229            })
230            .unwrap();
231        let mut tab_ids = Vec::new();
232        for name in &["tab-a", "tab-b"] {
233            let tab_evs = dispatch_apply(
234                &state,
235                agentmux_common::ipc::Command::CreateTab {
236                    workspace_id: ws_id.clone(),
237                    name: name.to_string(),
238                },
239            )
240            .await;
241            tab_ids.push(
242                tab_evs
243                    .iter()
244                    .find_map(|e| match e {
245                        Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
246                        _ => None,
247                    })
248                    .unwrap(),
249            );
250        }
251        (state, ws_id, tab_ids[0].clone(), tab_ids[1].clone())
252    }
253
254    #[tokio::test]
255    async fn happy_path_removes_tab_from_reducer_and_sqlite() {
256        let (state, ws_id, tab_a, tab_b) = seed_workspace_with_two_tabs().await;
257
258        // Sanity: both tabs present pre-delete.
259        {
260            let s = state.srv_state.lock().await;
261            assert_eq!(s.workspaces[&ws_id].tab_ids, vec![tab_a.clone(), tab_b.clone()]);
262            assert!(s.tabs.contains_key(&tab_a));
263        }
264        assert!(state.wstore.get::<Tab>(&tab_a).unwrap().is_some());
265
266        let result = run(&state, ws_id.clone(), tab_a.clone()).await.unwrap();
267        assert_eq!(result["tab_id"], tab_a);
268        assert_eq!(result["workspace_id"], ws_id);
269
270        // Reducer: tab_a gone; workspace.tab_ids has only tab_b.
271        let s = state.srv_state.lock().await;
272        assert!(!s.tabs.contains_key(&tab_a));
273        assert_eq!(s.workspaces[&ws_id].tab_ids, vec![tab_b.clone()]);
274        drop(s);
275
276        // SQLite: tab gone; workspace.tabids reflects.
277        assert!(state.wstore.get::<Tab>(&tab_a).unwrap().is_none());
278        let ws_persist = state.wstore.get::<Workspace>(&ws_id).unwrap().unwrap();
279        assert_eq!(ws_persist.tabids, vec![tab_b]);
280    }
281
282    #[tokio::test]
283    async fn rejects_when_workspace_not_found() {
284        let state = test_state();
285        let err = run(&state, "ghost-ws".into(), "ghost-tab".into())
286            .await
287            .unwrap_err();
288        assert!(err.contains("workspace not found"), "got: {}", err);
289    }
290
291    #[tokio::test]
292    async fn rejects_when_tab_not_in_workspace() {
293        let (state, ws_id, _tab_a, _tab_b) = seed_workspace_with_two_tabs().await;
294        let err = run(&state, ws_id, "ghost-tab".into()).await.unwrap_err();
295        assert!(err.contains("not in workspace"), "got: {}", err);
296    }
297
298    #[tokio::test]
299    async fn rejects_last_tab() {
300        let state = test_state();
301        let ws_evs = dispatch_apply(
302            &state,
303            agentmux_common::ipc::Command::CreateWorkspace { name: "w".into() },
304        )
305        .await;
306        let ws_id = ws_evs
307            .iter()
308            .find_map(|e| match e {
309                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
310                _ => None,
311            })
312            .unwrap();
313        let tab_evs = dispatch_apply(
314            &state,
315            agentmux_common::ipc::Command::CreateTab {
316                workspace_id: ws_id.clone(),
317                name: "only".into(),
318            },
319        )
320        .await;
321        let only_tab = tab_evs
322            .iter()
323            .find_map(|e| match e {
324                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
325                _ => None,
326            })
327            .unwrap();
328        let err = run(&state, ws_id, only_tab).await.unwrap_err();
329        assert!(err.contains("last tab"), "got: {}", err);
330    }
331}