agentmux_srv\sagas/
tear_off_tab.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase E.5.5 — TearOffTab saga.
5//
6// Migrates the tear-off-tab flow from `wcore::tear_off_tab` (which
7// did two non-transactional SQLite writes outside the reducer) to a
8// reducer-driven multi-step that the persist subscriber writes to
9// SQLite event-by-event. Closes the smoke regression where the new
10// workspace existed in SQLite but the reducer didn't know about it,
11// so the new window's `CreateTab` call would fail.
12//
13// **Steps (mirrors `wcore::tear_off_tab`'s behaviour, NOT the
14// `SPEC_PHASE_E_SAGAS_2026-04-30.md` §6.1 step 3 `CreateWindow`):**
15//
16// 1. `CreateWorkspace { name: "" }` — empty-name matches wcore's
17//    behaviour; user renames after.
18// 2. `MoveTab { tab_id, src=source_ws, dst=new_ws, dst_index: 0 }`.
19//
20// The frontend separately calls the host's `tear_off_pool_promote`
21// to open the CEF window, then the new window's renderer registers
22// the (window_id, workspace_id) mapping via `WindowService.CreateWindow`.
23// Including `Command::CreateWindow` in the saga (per spec §6.1) is
24// deferred — it would require pre-assigning the window_id before
25// the CEF window opens, which the existing host pool-promote and
26// app-init.ts flow doesn't accommodate. See
27// `docs/retro/saga-coordinator-location-analysis-2026-04-30.md`
28// §4.2 — host CEF window creation stays outside the saga.
29//
30// **Pre-condition:** the source workspace has more than one tab.
31// `wcore::tear_off_tab` enforced this; the reducer's `MoveTab`
32// doesn't (intentionally — the same command supports legitimate
33// "drain workspace" flows). The saga checks before issuing any
34// commands so the user-visible error is clear.
35//
36// **Compensation:**
37// * Step 2 fails after step 1 succeeded → `DeleteWorkspace { new_ws_id }`
38//   (the reducer cascades any tabs in it; the new workspace will be
39//   empty here since step 2 failed before moving the tab).
40// * Step 1 fails → nothing to compensate (reducer rejected without
41//   mutating).
42
43use agentmux_common::ipc::{Command, Event};
44use serde_json::{json, Value};
45
46use super::{
47    alloc_saga_id, classify_run_saga_result, emit_saga_started, emit_terminal, run_saga, SagaCtx,
48};
49use crate::server::AppState;
50
51/// Run the TearOffTab saga. On success, returns
52/// `{"new_workspace_id": "..."}`. On failure, the source workspace
53/// is unchanged (compensation has reverted any partial state).
54pub async fn run(
55    state: &AppState,
56    tab_id: String,
57    source_workspace_id: String,
58) -> Result<Value, String> {
59    // Pre-condition: source workspace must have more than one tab.
60    // We're about to move one out; if it's the only tab, we'd leave
61    // an empty workspace behind, which the UI doesn't represent
62    // gracefully. Mirrors `wcore::tear_off_tab`'s "cannot tear off
63    // last tab" guard.
64    //
65    // **Read SQLite, not reducer state.** During the migration
66    // window, some tab-creating/moving paths (e.g.
67    // `PromoteBlockToTab`) are still wcore-direct — their writes
68    // don't flow through the reducer, so `state.workspaces[ws].tab_ids`
69    // can lag SQLite. A reducer-state pre-check would falsely reject
70    // valid tear-off requests (codex P1 round-2 #621). SQLite is the
71    // source of truth; check there. Also include `pinnedtabids` in
72    // the membership check — bootstrap merges them into the reducer's
73    // `tab_ids`, but legacy SQLite rows may still carry the entry.
74    {
75        let src_ws = match state.wstore.get::<crate::backend::obj::Workspace>(&source_workspace_id) {
76            Ok(Some(ws)) => ws,
77            Ok(None) => {
78                return Err(format!(
79                    "TearOffTab: source workspace not found: {}",
80                    source_workspace_id
81                ));
82            }
83            Err(e) => return Err(format!("TearOffTab: workspace read failed: {}", e)),
84        };
85        let in_workspace = src_ws.tabids.iter().any(|id| id == &tab_id)
86            || src_ws.pinnedtabids.iter().any(|id| id == &tab_id);
87        if !in_workspace {
88            return Err(format!(
89                "TearOffTab: tab {} is not in workspace {}",
90                tab_id, source_workspace_id
91            ));
92        }
93        let total_tabs = src_ws.tabids.len() + src_ws.pinnedtabids.len();
94        if total_tabs <= 1 {
95            return Err(format!(
96                "TearOffTab: cannot tear off last tab from workspace {}",
97                source_workspace_id
98            ));
99        }
100    }
101
102    let saga_id = alloc_saga_id(state);
103    if let Err(e) = emit_saga_started(
104        state,
105        saga_id,
106        "tear_off_tab",
107        serde_json::json!({
108            "tab_id": &tab_id,
109            "source_workspace_id": &source_workspace_id,
110        }),
111    )
112    .await
113    {
114        return Err(e);
115    }
116    let ctx = SagaCtx::new(state, saga_id);
117    let result = run_saga("tear_off_tab", run_inner(ctx, tab_id, source_workspace_id)).await;
118    emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
119    result
120}
121
122async fn run_inner(
123    ctx: SagaCtx<'_>,
124    tab_id: String,
125    source_workspace_id: String,
126) -> Result<Value, String> {
127    // Step 1: create the new workspace.
128    let create_events = ctx
129        .dispatch(Command::CreateWorkspace {
130            name: String::new(),
131        })
132        .await
133        .map_err(|e| format!("TearOffTab step 1 (CreateWorkspace): {}", e))?;
134    let new_workspace_id = create_events
135        .iter()
136        .find_map(|e| match e {
137            Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
138            _ => None,
139        })
140        .ok_or_else(|| {
141            "TearOffTab: CreateWorkspace did not emit WorkspaceCreated".to_string()
142        })?;
143
144    // Step 2: move the tab.
145    if let Err(reason) = ctx
146        .dispatch(Command::MoveTab {
147            tab_id: tab_id.clone(),
148            src_workspace_id: source_workspace_id.clone(),
149            dst_workspace_id: new_workspace_id.clone(),
150            dst_index: 0,
151        })
152        .await
153    {
154        // Compensate: delete the empty workspace we just created.
155        // `force: false` — internal compensation, not a saga-driven
156        // cascade. (Step 5 PR 2 added the `force` flag for
157        // `delete_workspace` saga provenance.)
158        ctx.compensate(Command::DeleteWorkspace {
159            workspace_id: new_workspace_id.clone(),
160            force: false,
161        })
162        .await;
163        return Err(format!("TearOffTab step 2 (MoveTab): {}", reason));
164    }
165
166    Ok(json!({ "new_workspace_id": new_workspace_id }))
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::backend::obj::Workspace;
173    use crate::server::tests::test_state;
174
175    /// Boot a workspace + 2 tabs in the reducer + SQLite, mirroring
176    /// what bootstrap would do at startup. Returns `(state, ws_id,
177    /// tab_a_id, tab_b_id)`.
178    async fn seed_workspace_with_two_tabs() -> (
179        crate::server::AppState,
180        String,
181        String,
182        String,
183    ) {
184        let state = test_state();
185        // Use the reducer to create a workspace + 2 tabs so they end
186        // up in both reducer state and SQLite (the saga's pre-condition
187        // checks read reducer state).
188        let ws_events = crate::server::service::dispatch_to_reducer(
189            &state,
190            agentmux_common::ipc::Command::CreateWorkspace { name: "src".into() },
191        )
192        .await;
193        for ev in &ws_events {
194            crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
195        }
196        let ws_id = ws_events
197            .iter()
198            .find_map(|e| match e {
199                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
200                _ => None,
201            })
202            .unwrap();
203        let mut tab_ids = Vec::new();
204        for name in &["tab-a", "tab-b"] {
205            let tab_events = crate::server::service::dispatch_to_reducer(
206                &state,
207                agentmux_common::ipc::Command::CreateTab {
208                    workspace_id: ws_id.clone(),
209                    name: name.to_string(),
210                },
211            )
212            .await;
213            for ev in &tab_events {
214                crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
215            }
216            tab_ids.push(
217                tab_events
218                    .iter()
219                    .find_map(|e| match e {
220                        Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
221                        _ => None,
222                    })
223                    .unwrap(),
224            );
225        }
226        (state, ws_id, tab_ids[0].clone(), tab_ids[1].clone())
227    }
228
229    #[tokio::test]
230    async fn happy_path_creates_new_workspace_and_moves_tab() {
231        let (state, src_ws, tab_a, tab_b) = seed_workspace_with_two_tabs().await;
232        let result = run(&state, tab_a.clone(), src_ws.clone()).await.unwrap();
233        let new_ws_id = result["new_workspace_id"].as_str().unwrap();
234
235        // Reducer view: src has just tab_b; new_ws has tab_a.
236        let s = state.srv_state.lock().await;
237        assert_eq!(s.workspaces[&src_ws].tab_ids, vec![tab_b.clone()]);
238        assert_eq!(s.workspaces[new_ws_id].tab_ids, vec![tab_a.clone()]);
239        assert_eq!(s.tabs[&tab_a].workspace_id, new_ws_id);
240
241        // SQLite view: same.
242        let src_persist = state.wstore.get::<Workspace>(&src_ws).unwrap().unwrap();
243        let new_persist = state.wstore.get::<Workspace>(new_ws_id).unwrap().unwrap();
244        assert_eq!(src_persist.tabids, vec![tab_b]);
245        assert_eq!(new_persist.tabids, vec![tab_a]);
246    }
247
248    #[tokio::test]
249    async fn rejects_when_source_workspace_missing() {
250        let state = test_state();
251        let err = run(&state, "tab-1".into(), "no-such-ws".into()).await.unwrap_err();
252        assert!(err.contains("source workspace not found"), "got: {}", err);
253    }
254
255    #[tokio::test]
256    async fn rejects_last_tab() {
257        let state = test_state();
258        let ws_events = crate::server::service::dispatch_to_reducer(
259            &state,
260            agentmux_common::ipc::Command::CreateWorkspace { name: "src".into() },
261        )
262        .await;
263        for ev in &ws_events {
264            crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
265        }
266        let ws_id = ws_events
267            .iter()
268            .find_map(|e| match e {
269                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
270                _ => None,
271            })
272            .unwrap();
273        let tab_events = crate::server::service::dispatch_to_reducer(
274            &state,
275            agentmux_common::ipc::Command::CreateTab {
276                workspace_id: ws_id.clone(),
277                name: "only".into(),
278            },
279        )
280        .await;
281        for ev in &tab_events {
282            crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
283        }
284        let only_tab = tab_events
285            .iter()
286            .find_map(|e| match e {
287                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
288                _ => None,
289            })
290            .unwrap();
291        let err = run(&state, only_tab, ws_id).await.unwrap_err();
292        assert!(err.contains("last tab"), "got: {}", err);
293    }
294}