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}