agentmux_srv\sagas/
restore_torn_off_tab.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase E.5.6 — RestoreTornOffTab saga.
5//
6// Drops the torn-off tab back into the destination workspace and,
7// if the source workspace became empty, deletes it (cascade).
8//
9// **Steps:**
10// 1. `MoveTab { tab_id, src=source_ws, dst=dest_ws, dst_index }`
11// 2. If post-move state shows source workspace's `tab_ids` is
12//    empty → `DeleteWorkspace { source_ws }` (cascade is built in;
13//    no surviving tabs to handle).
14//    Otherwise → done.
15//
16// **Compensation:**
17// * Step 1 fails → nothing to compensate (reducer rejected without
18//   mutating; tab is still in source).
19// * Step 2 fails → tab is already restored to dest; the orphan
20//   source workspace persists. Log the failure but return success
21//   (the user-visible operation succeeded; the orphan is a soft
22//   cleanup gap that the next user action — close window, etc. —
23//   will catch).
24//
25// **Pinning note:** the legacy `was_pinned` arg of `RestoreTornOffTab`
26// is ignored. Pinning was a Waveterm feature removed from AgentMux
27// (per E.2c.3b). Restored tabs always land in `tab_ids`.
28
29use agentmux_common::ipc::Command;
30use serde_json::{json, Value};
31
32use super::{
33    alloc_saga_id, classify_run_saga_result, emit_saga_started, emit_terminal, run_saga, SagaCtx,
34};
35use crate::server::AppState;
36
37/// Run the RestoreTornOffTab saga. On success, returns
38/// `{"source_workspace_deleted": <bool>}`.
39pub async fn run(
40    state: &AppState,
41    tab_id: String,
42    source_workspace_id: String,
43    dest_workspace_id: String,
44    insert_index: Option<u32>,
45) -> Result<Value, String> {
46    // Pre-condition checks read SQLite, not reducer state. During
47    // the migration window, wcore-direct paths (PromoteBlockToTab,
48    // etc.) leave reducer.tabs / workspaces stale relative to disk;
49    // a reducer-state pre-check would falsely reject valid restores
50    // (codex P1 round-2 #621).
51    {
52        let src_ws = match state.wstore.get::<crate::backend::obj::Workspace>(&source_workspace_id) {
53            Ok(Some(ws)) => ws,
54            Ok(None) => {
55                return Err(format!(
56                    "RestoreTornOffTab: source workspace not found: {}",
57                    source_workspace_id
58                ));
59            }
60            Err(e) => {
61                return Err(format!(
62                    "RestoreTornOffTab: workspace read failed: {}",
63                    e
64                ));
65            }
66        };
67        if state
68            .wstore
69            .get::<crate::backend::obj::Workspace>(&dest_workspace_id)
70            .map(|w| w.is_none())
71            .unwrap_or(true)
72        {
73            return Err(format!(
74                "RestoreTornOffTab: dest workspace not found: {}",
75                dest_workspace_id
76            ));
77        }
78        let in_workspace = src_ws.tabids.iter().any(|id| id == &tab_id)
79            || src_ws.pinnedtabids.iter().any(|id| id == &tab_id);
80        if !in_workspace {
81            return Err(format!(
82                "RestoreTornOffTab: tab {} is not in workspace {}",
83                tab_id, source_workspace_id
84            ));
85        }
86    }
87
88    let dst_index = insert_index.unwrap_or(u32::MAX);
89
90    let saga_id = alloc_saga_id(state);
91    if let Err(e) = emit_saga_started(
92        state,
93        saga_id,
94        "restore_torn_off_tab",
95        serde_json::json!({
96            "tab_id": &tab_id,
97            "source_workspace_id": &source_workspace_id,
98            "dest_workspace_id": &dest_workspace_id,
99            "insert_index": dst_index,
100        }),
101    )
102    .await
103    {
104        return Err(e);
105    }
106    let ctx = SagaCtx::new(state, saga_id);
107    let result = run_saga(
108        "restore_torn_off_tab",
109        run_inner(ctx, tab_id, source_workspace_id, dest_workspace_id, dst_index),
110    )
111    .await;
112    emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
113    result
114}
115
116async fn run_inner(
117    ctx: SagaCtx<'_>,
118    tab_id: String,
119    source_workspace_id: String,
120    dest_workspace_id: String,
121    dst_index: u32,
122) -> Result<Value, String> {
123    // Step 1: move the tab.
124    ctx.dispatch(Command::MoveTab {
125        tab_id: tab_id.clone(),
126        src_workspace_id: source_workspace_id.clone(),
127        dst_workspace_id: dest_workspace_id.clone(),
128        dst_index,
129    })
130    .await
131    .map_err(|e| format!("RestoreTornOffTab step 1 (MoveTab): {}", e))?;
132
133    // Step 2: check post-move state, conditionally delete source.
134    let source_now_empty = {
135        let s = ctx.state_lock().await;
136        s.workspaces
137            .get(&source_workspace_id)
138            .map(|ws| ws.tab_ids.is_empty())
139            .unwrap_or(true)
140    };
141
142    let mut source_deleted = false;
143    if source_now_empty {
144        // Best-effort delete; log + soft-fail on reducer rejection
145        // (tab is already restored, which is what the user asked for).
146        match ctx
147            .dispatch(Command::DeleteWorkspace {
148                workspace_id: source_workspace_id.clone(),
149                // `force: false` — sub-step within restore_torn_off_tab,
150                // not the dedicated `delete_workspace` saga (Step 5 PR 2).
151                // The workspace is already empty here so cascade-vs-saga
152                // distinction is moot; the flag is provenance-only.
153                force: false,
154            })
155            .await
156        {
157            Ok(_) => source_deleted = true,
158            Err(e) => {
159                tracing::warn!(
160                    saga_id = ctx.saga_id(),
161                    "[saga] RestoreTornOffTab: source workspace cleanup failed: {} (orphan workspace {})",
162                    e,
163                    source_workspace_id
164                );
165            }
166        }
167    }
168
169    Ok(json!({ "source_workspace_deleted": source_deleted }))
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use agentmux_common::ipc::Event;
176    use crate::backend::obj::Workspace;
177    use crate::server::tests::test_state;
178
179    async fn dispatch_apply(
180        state: &crate::server::AppState,
181        cmd: agentmux_common::ipc::Command,
182    ) -> Vec<Event> {
183        let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
184        for ev in &events {
185            crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
186        }
187        events
188    }
189
190    /// Seeds a "torn-off" workspace (single tab) and a destination
191    /// workspace (its own tab). Returns (state, torn_ws, torn_tab,
192    /// dest_ws, dest_tab).
193    async fn seed_torn_off_state() -> (crate::server::AppState, String, String, String, String) {
194        let state = test_state();
195        let mut ws_ids = Vec::new();
196        let mut tab_ids = Vec::new();
197        for ws_name in &["torn", "dest"] {
198            let ws_evs = dispatch_apply(
199                &state,
200                agentmux_common::ipc::Command::CreateWorkspace {
201                    name: ws_name.to_string(),
202                },
203            )
204            .await;
205            let ws_id = ws_evs
206                .iter()
207                .find_map(|e| match e {
208                    Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
209                    _ => None,
210                })
211                .unwrap();
212            let tab_evs = dispatch_apply(
213                &state,
214                agentmux_common::ipc::Command::CreateTab {
215                    workspace_id: ws_id.clone(),
216                    name: format!("{}-tab", ws_name),
217                },
218            )
219            .await;
220            let tab_id = tab_evs
221                .iter()
222                .find_map(|e| match e {
223                    Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
224                    _ => None,
225                })
226                .unwrap();
227            ws_ids.push(ws_id);
228            tab_ids.push(tab_id);
229        }
230        (
231            state,
232            ws_ids[0].clone(),
233            tab_ids[0].clone(),
234            ws_ids[1].clone(),
235            tab_ids[1].clone(),
236        )
237    }
238
239    #[tokio::test]
240    async fn happy_path_moves_tab_back_and_deletes_empty_source() {
241        let (state, torn_ws, torn_tab, dest_ws, _dest_tab) = seed_torn_off_state().await;
242        let result = run(&state, torn_tab.clone(), torn_ws.clone(), dest_ws.clone(), Some(0))
243            .await
244            .unwrap();
245        assert_eq!(result["source_workspace_deleted"], true);
246
247        // Reducer: source workspace gone; tab is in dest.
248        let s = state.srv_state.lock().await;
249        assert!(!s.workspaces.contains_key(&torn_ws));
250        assert!(s.workspaces[&dest_ws].tab_ids.contains(&torn_tab));
251        assert_eq!(s.tabs[&torn_tab].workspace_id, dest_ws);
252        drop(s);
253
254        // SQLite: source workspace row gone too.
255        assert!(state.wstore.get::<Workspace>(&torn_ws).unwrap().is_none());
256    }
257
258    #[tokio::test]
259    async fn skips_delete_when_source_still_has_tabs() {
260        let (state, torn_ws, torn_tab, dest_ws, _) = seed_torn_off_state().await;
261        // Add a second tab to torn so it doesn't become empty.
262        let _ = dispatch_apply(
263            &state,
264            agentmux_common::ipc::Command::CreateTab {
265                workspace_id: torn_ws.clone(),
266                name: "extra".into(),
267            },
268        )
269        .await;
270
271        let result = run(&state, torn_tab, torn_ws.clone(), dest_ws, Some(0))
272            .await
273            .unwrap();
274        assert_eq!(
275            result["source_workspace_deleted"], false,
276            "source ws should survive when it still has tabs"
277        );
278        // Source still in reducer.
279        let s = state.srv_state.lock().await;
280        assert!(s.workspaces.contains_key(&torn_ws));
281    }
282}