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        // R3 diagnostic (#1681): a tear-off that references a workspace/tab/block
54        // this srv never created points at a frontend↔backend desync (a window
55        // bootstrapped a workspace that was never persisted), NOT a simple
56        // structural mismatch. The pre-condition used to reject SILENTLY (no log
57        // line — only an http-perf 0.05ms blip), which is why the 2nd-window
58        // tear-off failure was so hard to attribute. Log the full state context
59        // at ERROR on every rejection so a single repro is conclusive: does the
60        // source workspace exist in srv at all?
61        let reject = |reason: &str, s: &crate::state::State| {
62            tracing::error!(
63                target: "dnd:svc",
64                reason = %reason,
65                block_id = %block_id,
66                source_tab_id = %source_tab_id,
67                source_workspace_id = %source_workspace_id,
68                source_ws_exists = s.workspaces.contains_key(&source_workspace_id),
69                source_tab_exists = s.tabs.contains_key(&source_tab_id),
70                block_exists = s.blocks.contains_key(&block_id),
71                known_workspaces = s.workspaces.len(),
72                known_tabs = s.tabs.len(),
73                known_blocks = s.blocks.len(),
74                "[dnd:svc] TearOffBlock REJECTED — possible frontend/backend workspace desync"
75            );
76        };
77        match s.blocks.get(&block_id) {
78            None => {
79                reject("block_not_found", &s);
80                return Err(format!("TearOffBlock: block not found: {}", block_id));
81            }
82            Some(block) if block.tab_id != source_tab_id => {
83                reject("block_in_other_tab", &s);
84                return Err(format!(
85                    "TearOffBlock: block {} is in tab {}, not {}",
86                    block_id, block.tab_id, source_tab_id
87                ));
88            }
89            _ => {}
90        }
91        match s.tabs.get(&source_tab_id) {
92            None => {
93                reject("source_tab_not_found", &s);
94                return Err(format!(
95                    "TearOffBlock: source tab not found: {}",
96                    source_tab_id
97                ));
98            }
99            Some(tab) if tab.workspace_id != source_workspace_id => {
100                reject("tab_in_other_workspace", &s);
101                return Err(format!(
102                    "TearOffBlock: tab {} is in workspace {}, not {}",
103                    source_tab_id, tab.workspace_id, source_workspace_id
104                ));
105            }
106            _ => {}
107        }
108    }
109
110    let saga_id = alloc_saga_id(state);
111    if let Err(e) = emit_saga_started(
112        state,
113        saga_id,
114        "tear_off_block",
115        serde_json::json!({
116            "block_id": &block_id,
117            "source_tab_id": &source_tab_id,
118        }),
119    )
120    .await
121    {
122        return Err(e);
123    }
124    let ctx = SagaCtx::new(state, saga_id);
125    let result = run_saga("tear_off_block", run_inner(ctx, block_id, source_tab_id)).await;
126    emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
127    result
128}
129
130async fn run_inner(
131    ctx: SagaCtx<'_>,
132    block_id: String,
133    source_tab_id: String,
134) -> Result<Value, String> {
135    // Step 1: new workspace.
136    let create_ws_events = ctx
137        .dispatch(Command::CreateWorkspace {
138            name: String::new(),
139        })
140        .await
141        .map_err(|e| format!("TearOffBlock step 1 (CreateWorkspace): {}", e))?;
142    let new_workspace_id = create_ws_events
143        .iter()
144        .find_map(|e| match e {
145            Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
146            _ => None,
147        })
148        .ok_or_else(|| {
149            "TearOffBlock: CreateWorkspace did not emit WorkspaceCreated".to_string()
150        })?;
151
152    // Step 2: new tab in the new workspace.
153    let create_tab_events = match ctx
154        .dispatch(Command::CreateTab {
155            workspace_id: new_workspace_id.clone(),
156            name: String::new(),
157        })
158        .await
159    {
160        Ok(evs) => evs,
161        Err(reason) => {
162            // `force: false` — internal compensation (Step 5 PR 2).
163            ctx.compensate(Command::DeleteWorkspace {
164                workspace_id: new_workspace_id.clone(),
165                force: false,
166            })
167            .await;
168            return Err(format!("TearOffBlock step 2 (CreateTab): {}", reason));
169        }
170    };
171    let new_tab_id = create_tab_events
172        .iter()
173        .find_map(|e| match e {
174            Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
175            _ => None,
176        })
177        .ok_or_else(|| {
178            "TearOffBlock: CreateTab did not emit TabCreated".to_string()
179        })?;
180
181    // Step 3: move the block.
182    if let Err(reason) = ctx
183        .dispatch(Command::MoveBlock {
184            block_id: block_id.clone(),
185            src_tab_id: source_tab_id.clone(),
186            dst_tab_id: new_tab_id.clone(),
187            dst_index: 0,
188        })
189        .await
190    {
191        // Compensate: delete the workspace (cascades the empty tab).
192        // `force: false` — internal compensation (Step 5 PR 2).
193        ctx.compensate(Command::DeleteWorkspace {
194            workspace_id: new_workspace_id.clone(),
195            force: false,
196        })
197        .await;
198        return Err(format!("TearOffBlock step 3 (MoveBlock): {}", reason));
199    }
200
201    Ok(json!({
202        "new_workspace_id": new_workspace_id,
203        "new_tab_id": new_tab_id,
204    }))
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::backend::obj::{Block, Tab};
211    use crate::server::tests::test_state;
212
213    async fn dispatch_apply(
214        state: &crate::server::AppState,
215        cmd: agentmux_common::ipc::Command,
216    ) -> Vec<agentmux_common::ipc::Event> {
217        let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
218        for ev in &events {
219            crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
220        }
221        events
222    }
223
224    #[tokio::test]
225    async fn happy_path_creates_workspace_tab_and_moves_block() {
226        let state = test_state();
227        // Seed: workspace with one tab containing a block.
228        let ws_evs = dispatch_apply(
229            &state,
230            agentmux_common::ipc::Command::CreateWorkspace { name: "src".into() },
231        )
232        .await;
233        let ws_id = ws_evs
234            .iter()
235            .find_map(|e| match e {
236                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
237                _ => None,
238            })
239            .unwrap();
240        let tab_evs = dispatch_apply(
241            &state,
242            agentmux_common::ipc::Command::CreateTab {
243                workspace_id: ws_id.clone(),
244                name: "t".into(),
245            },
246        )
247        .await;
248        let tab_id = tab_evs
249            .iter()
250            .find_map(|e| match e {
251                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
252                _ => None,
253            })
254            .unwrap();
255        let blk_evs = dispatch_apply(
256            &state,
257            agentmux_common::ipc::Command::CreateBlock {
258                tab_id: tab_id.clone(),
259                meta: serde_json::Value::Null,
260            },
261        )
262        .await;
263        let block_id = blk_evs
264            .iter()
265            .find_map(|e| match e {
266                Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
267                _ => None,
268            })
269            .unwrap();
270
271        let result = run(&state, block_id.clone(), tab_id.clone(), ws_id.clone())
272            .await
273            .unwrap();
274        let new_ws_id = result["new_workspace_id"].as_str().unwrap();
275        let new_tab_id = result["new_tab_id"].as_str().unwrap();
276
277        // Reducer: source tab has no blocks; new tab has the block.
278        let s = state.srv_state.lock().await;
279        assert!(s.tabs[&tab_id].block_ids.is_empty());
280        assert_eq!(
281            s.tabs[new_tab_id].block_ids,
282            vec![block_id.clone()],
283            "block should be in new tab"
284        );
285        assert_eq!(s.blocks[&block_id].tab_id, new_tab_id);
286        assert_eq!(s.workspaces[new_ws_id].tab_ids, vec![new_tab_id.to_string()]);
287
288        // SQLite: matches.
289        drop(s);
290        let new_tab = state.wstore.get::<Tab>(new_tab_id).unwrap().unwrap();
291        assert_eq!(new_tab.blockids, vec![block_id.clone()]);
292        let block = state.wstore.get::<Block>(&block_id).unwrap().unwrap();
293        assert_eq!(block.parentoref, format!("tab:{}", new_tab_id));
294    }
295
296    #[tokio::test]
297    async fn rejects_when_block_not_in_source_tab() {
298        let state = test_state();
299        let ws_evs = dispatch_apply(
300            &state,
301            agentmux_common::ipc::Command::CreateWorkspace { name: "w".into() },
302        )
303        .await;
304        let ws_id = ws_evs
305            .iter()
306            .find_map(|e| match e {
307                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
308                _ => None,
309            })
310            .unwrap();
311        let _ = dispatch_apply(
312            &state,
313            agentmux_common::ipc::Command::CreateTab {
314                workspace_id: ws_id.clone(),
315                name: "t".into(),
316            },
317        )
318        .await;
319        let err = run(
320            &state,
321            "ghost-block".into(),
322            "ghost-tab".into(),
323            ws_id,
324        )
325        .await
326        .unwrap_err();
327        assert!(err.contains("block not found") || err.contains("source tab not found"), "got: {}", err);
328    }
329}