agentmux_srv\sagas/
tear_off_block.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase E.5.5 — TearOffBlock saga.
5//
6// Migrates the reducer-state portion of `wcore::tear_off_block` to
7// a reducer-driven multi-step. The original wcore function does
8// substantial layout work (rebuilds the new tab's `LayoutState`
9// rootnode + leaforder, queues a layout-delete action on the source
10// tab's pendingbackendactions) — that's E.4 territory and stays
11// wcore-direct in the RPC handler that wraps this saga. The
12// reducer/SQLite portion is what fixes the smoke regression
13// (the new workspace was invisible to the reducer because wcore
14// bypassed it).
15//
16// **Steps:**
17// 1. `CreateWorkspace { name: "" }`
18// 2. `CreateTab { workspace_id: new_ws_id, name: "" }`
19// 3. `MoveBlock { block_id, src=source_tab, dst=new_tab, dst_index: 0 }`
20//
21// Auto-close-source-tab and layout setup are NOT here — see the
22// RPC handler in `service.rs` for those steps.
23//
24// **Compensation:**
25// * Step 3 fails after step 1+2 → `DeleteWorkspace { new_ws_id }`
26//   (cascades through tabs to blocks; no blocks landed in the new
27//   tab at this point).
28// * Step 2 fails after step 1 → `DeleteWorkspace { new_ws_id }`.
29// * Step 1 fails → nothing to compensate.
30
31use agentmux_common::ipc::{Command, Event};
32use serde_json::{json, Value};
33
34use super::{
35    alloc_saga_id, classify_run_saga_result, emit_saga_started, emit_terminal, run_saga, SagaCtx,
36};
37use crate::server::AppState;
38
39/// Run the TearOffBlock saga. On success, returns
40/// `{"new_workspace_id": "...", "new_tab_id": "..."}`.
41pub async fn run(
42    state: &AppState,
43    block_id: String,
44    source_tab_id: String,
45    source_workspace_id: String,
46) -> Result<Value, String> {
47    // Pre-condition: block exists and belongs to source_tab; source
48    // tab is in source_workspace. Reducer would catch the structural
49    // mismatch via MoveBlock validation, but check up-front so we
50    // don't allocate a workspace + tab and then have to compensate.
51    {
52        let s = state.srv_state.lock().await;
53        match s.blocks.get(&block_id) {
54            None => {
55                return Err(format!("TearOffBlock: block not found: {}", block_id));
56            }
57            Some(block) if block.tab_id != source_tab_id => {
58                return Err(format!(
59                    "TearOffBlock: block {} is in tab {}, not {}",
60                    block_id, block.tab_id, source_tab_id
61                ));
62            }
63            _ => {}
64        }
65        match s.tabs.get(&source_tab_id) {
66            None => {
67                return Err(format!(
68                    "TearOffBlock: source tab not found: {}",
69                    source_tab_id
70                ));
71            }
72            Some(tab) if tab.workspace_id != source_workspace_id => {
73                return Err(format!(
74                    "TearOffBlock: tab {} is in workspace {}, not {}",
75                    source_tab_id, tab.workspace_id, source_workspace_id
76                ));
77            }
78            _ => {}
79        }
80    }
81
82    let saga_id = alloc_saga_id(state);
83    if let Err(e) = emit_saga_started(
84        state,
85        saga_id,
86        "tear_off_block",
87        serde_json::json!({
88            "block_id": &block_id,
89            "source_tab_id": &source_tab_id,
90        }),
91    )
92    .await
93    {
94        return Err(e);
95    }
96    let ctx = SagaCtx::new(state, saga_id);
97    let result = run_saga("tear_off_block", run_inner(ctx, block_id, source_tab_id)).await;
98    emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
99    result
100}
101
102async fn run_inner(
103    ctx: SagaCtx<'_>,
104    block_id: String,
105    source_tab_id: String,
106) -> Result<Value, String> {
107    // Step 1: new workspace.
108    let create_ws_events = ctx
109        .dispatch(Command::CreateWorkspace {
110            name: String::new(),
111        })
112        .await
113        .map_err(|e| format!("TearOffBlock step 1 (CreateWorkspace): {}", e))?;
114    let new_workspace_id = create_ws_events
115        .iter()
116        .find_map(|e| match e {
117            Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
118            _ => None,
119        })
120        .ok_or_else(|| {
121            "TearOffBlock: CreateWorkspace did not emit WorkspaceCreated".to_string()
122        })?;
123
124    // Step 2: new tab in the new workspace.
125    let create_tab_events = match ctx
126        .dispatch(Command::CreateTab {
127            workspace_id: new_workspace_id.clone(),
128            name: String::new(),
129        })
130        .await
131    {
132        Ok(evs) => evs,
133        Err(reason) => {
134            // `force: false` — internal compensation (Step 5 PR 2).
135            ctx.compensate(Command::DeleteWorkspace {
136                workspace_id: new_workspace_id.clone(),
137                force: false,
138            })
139            .await;
140            return Err(format!("TearOffBlock step 2 (CreateTab): {}", reason));
141        }
142    };
143    let new_tab_id = create_tab_events
144        .iter()
145        .find_map(|e| match e {
146            Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
147            _ => None,
148        })
149        .ok_or_else(|| {
150            "TearOffBlock: CreateTab did not emit TabCreated".to_string()
151        })?;
152
153    // Step 3: move the block.
154    if let Err(reason) = ctx
155        .dispatch(Command::MoveBlock {
156            block_id: block_id.clone(),
157            src_tab_id: source_tab_id.clone(),
158            dst_tab_id: new_tab_id.clone(),
159            dst_index: 0,
160        })
161        .await
162    {
163        // Compensate: delete the workspace (cascades the empty tab).
164        // `force: false` — internal compensation (Step 5 PR 2).
165        ctx.compensate(Command::DeleteWorkspace {
166            workspace_id: new_workspace_id.clone(),
167            force: false,
168        })
169        .await;
170        return Err(format!("TearOffBlock step 3 (MoveBlock): {}", reason));
171    }
172
173    Ok(json!({
174        "new_workspace_id": new_workspace_id,
175        "new_tab_id": new_tab_id,
176    }))
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::backend::obj::{Block, Tab};
183    use crate::server::tests::test_state;
184
185    async fn dispatch_apply(
186        state: &crate::server::AppState,
187        cmd: agentmux_common::ipc::Command,
188    ) -> Vec<agentmux_common::ipc::Event> {
189        let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
190        for ev in &events {
191            crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
192        }
193        events
194    }
195
196    #[tokio::test]
197    async fn happy_path_creates_workspace_tab_and_moves_block() {
198        let state = test_state();
199        // Seed: workspace with one tab containing a block.
200        let ws_evs = dispatch_apply(
201            &state,
202            agentmux_common::ipc::Command::CreateWorkspace { name: "src".into() },
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: "t".into(),
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        let blk_evs = dispatch_apply(
228            &state,
229            agentmux_common::ipc::Command::CreateBlock {
230                tab_id: tab_id.clone(),
231                meta: serde_json::Value::Null,
232            },
233        )
234        .await;
235        let block_id = blk_evs
236            .iter()
237            .find_map(|e| match e {
238                Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
239                _ => None,
240            })
241            .unwrap();
242
243        let result = run(&state, block_id.clone(), tab_id.clone(), ws_id.clone())
244            .await
245            .unwrap();
246        let new_ws_id = result["new_workspace_id"].as_str().unwrap();
247        let new_tab_id = result["new_tab_id"].as_str().unwrap();
248
249        // Reducer: source tab has no blocks; new tab has the block.
250        let s = state.srv_state.lock().await;
251        assert!(s.tabs[&tab_id].block_ids.is_empty());
252        assert_eq!(
253            s.tabs[new_tab_id].block_ids,
254            vec![block_id.clone()],
255            "block should be in new tab"
256        );
257        assert_eq!(s.blocks[&block_id].tab_id, new_tab_id);
258        assert_eq!(s.workspaces[new_ws_id].tab_ids, vec![new_tab_id.to_string()]);
259
260        // SQLite: matches.
261        drop(s);
262        let new_tab = state.wstore.get::<Tab>(new_tab_id).unwrap().unwrap();
263        assert_eq!(new_tab.blockids, vec![block_id.clone()]);
264        let block = state.wstore.get::<Block>(&block_id).unwrap().unwrap();
265        assert_eq!(block.parentoref, format!("tab:{}", new_tab_id));
266    }
267
268    #[tokio::test]
269    async fn rejects_when_block_not_in_source_tab() {
270        let state = test_state();
271        let ws_evs = dispatch_apply(
272            &state,
273            agentmux_common::ipc::Command::CreateWorkspace { name: "w".into() },
274        )
275        .await;
276        let ws_id = ws_evs
277            .iter()
278            .find_map(|e| match e {
279                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
280                _ => None,
281            })
282            .unwrap();
283        let _ = dispatch_apply(
284            &state,
285            agentmux_common::ipc::Command::CreateTab {
286                workspace_id: ws_id.clone(),
287                name: "t".into(),
288            },
289        )
290        .await;
291        let err = run(
292            &state,
293            "ghost-block".into(),
294            "ghost-tab".into(),
295            ws_id,
296        )
297        .await
298        .unwrap_err();
299        assert!(err.contains("block not found") || err.contains("source tab not found"), "got: {}", err);
300    }
301}