agentmux_srv\sagas/
redock_floating_pane.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// RedockFloatingPane saga — moves a block from a floating pane's
5// (single-block) workspace into an existing tab in a different
6// workspace. The inverse of `tear_off_block`.
7//
8// Used by the floating-pane re-dock flow (Phase 4a per
9// `docs/specs/SPEC_FLOATING_PANE_REDOCK_2026-05-27.md`): the user
10// drops a floater over another agentmux window's tile area; that
11// window's frontend computes the drop and calls this RPC.
12//
13// **Steps:**
14// 1. `MoveBlock { block_id, src=source_tab, dst=target_tab,
15//    dst_index: <position in target> }`
16//
17// After the move:
18// * Source workspace's tab is empty → frontend's empty-tab watcher
19//   (PR #1089) closes the floating window.
20// * Target tab's `blockids` now includes the moved block. The frontend
21//   target window updates its layout local-state to add a leaf and
22//   persists via the standard `UpdateObject` path. The saga itself
23//   does not touch LayoutState — same pattern as within-window pane
24//   drag (frontend mutates layout locally, persists, backend just
25//   stores).
26//
27// **Compensation:** single-step saga, so no in-saga compensation
28// chain needed. If MoveBlock fails, the reducer's validation rejects
29// up-front and nothing has changed yet.
30
31use agentmux_common::ipc::Command;
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 RedockFloatingPane saga. On success, returns
40/// `{ "block_id": "...", "target_tab_id": "..." }`.
41///
42/// `dst_index` defaults to the end of the target tab's blockids if
43/// callers don't have a specific drop position (Phase 4a MVP). Phase
44/// 4b polish will let callers specify a drop position relative to a
45/// target leaf + direction (left/right/top/bottom/into).
46pub async fn run(
47    state: &AppState,
48    block_id: String,
49    source_tab_id: String,
50    source_workspace_id: String,
51    target_tab_id: String,
52    target_workspace_id: String,
53    dst_index: Option<u32>,
54) -> Result<Value, String> {
55    // Pre-conditions: block exists and belongs to source_tab; source
56    // tab is in source_workspace; target tab is in target_workspace;
57    // source != target (callers should already check, but a no-op
58    // re-dock is a programmer mistake worth surfacing).
59    let dst_index_resolved = {
60        let s = state.srv_state.lock().await;
61        match s.blocks.get(&block_id) {
62            None => {
63                return Err(format!(
64                    "RedockFloatingPane: block not found: {}",
65                    block_id
66                ));
67            }
68            Some(block) if block.tab_id != source_tab_id => {
69                return Err(format!(
70                    "RedockFloatingPane: block {} is in tab {}, not {}",
71                    block_id, block.tab_id, source_tab_id
72                ));
73            }
74            _ => {}
75        }
76        match s.tabs.get(&source_tab_id) {
77            None => {
78                return Err(format!(
79                    "RedockFloatingPane: source tab not found: {}",
80                    source_tab_id
81                ));
82            }
83            Some(tab) if tab.workspace_id != source_workspace_id => {
84                return Err(format!(
85                    "RedockFloatingPane: source tab {} is in workspace {}, not {}",
86                    source_tab_id, tab.workspace_id, source_workspace_id
87                ));
88            }
89            _ => {}
90        }
91        let target_tab = match s.tabs.get(&target_tab_id) {
92            None => {
93                return Err(format!(
94                    "RedockFloatingPane: target tab not found: {}",
95                    target_tab_id
96                ));
97            }
98            Some(tab) if tab.workspace_id != target_workspace_id => {
99                return Err(format!(
100                    "RedockFloatingPane: target tab {} is in workspace {}, not {}",
101                    target_tab_id, tab.workspace_id, target_workspace_id
102                ));
103            }
104            Some(tab) => tab,
105        };
106        if source_tab_id == target_tab_id {
107            return Err(
108                "RedockFloatingPane: source and target are the same tab — use MoveBlockToTab"
109                    .to_string(),
110            );
111        }
112        // Default insertion: append at the end of the target tab's
113        // blockids.
114        dst_index.unwrap_or(target_tab.block_ids.len() as u32)
115    };
116
117    let saga_id = alloc_saga_id(state);
118    if let Err(e) = emit_saga_started(
119        state,
120        saga_id,
121        "redock_floating_pane",
122        json!({
123            "block_id": &block_id,
124            "source_tab_id": &source_tab_id,
125            "target_tab_id": &target_tab_id,
126            "dst_index": dst_index_resolved,
127        }),
128    )
129    .await
130    {
131        return Err(e);
132    }
133    let ctx = SagaCtx::new(state, saga_id);
134    let result = run_saga(
135        "redock_floating_pane",
136        run_inner(
137            ctx,
138            block_id,
139            source_tab_id,
140            target_tab_id,
141            dst_index_resolved,
142        ),
143    )
144    .await;
145    emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
146    result
147}
148
149async fn run_inner(
150    ctx: SagaCtx<'_>,
151    block_id: String,
152    source_tab_id: String,
153    target_tab_id: String,
154    dst_index: u32,
155) -> Result<Value, String> {
156    ctx.dispatch(Command::MoveBlock {
157        block_id: block_id.clone(),
158        src_tab_id: source_tab_id,
159        dst_tab_id: target_tab_id.clone(),
160        dst_index,
161    })
162    .await
163    .map_err(|e| format!("RedockFloatingPane MoveBlock: {}", e))?;
164
165    Ok(json!({
166        "block_id": block_id,
167        "target_tab_id": target_tab_id,
168        "dst_index": dst_index,
169    }))
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::backend::obj::{Block, Tab};
176    use crate::server::tests::test_state;
177    use agentmux_common::ipc::Event;
178
179    async fn dispatch_apply(
180        state: &crate::server::AppState,
181        cmd: agentmux_common::ipc::Command,
182    ) -> Vec<agentmux_common::ipc::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    async fn seed_workspace_with_block(
191        state: &crate::server::AppState,
192        ws_name: &str,
193    ) -> (String, String, String) {
194        let ws_evs = dispatch_apply(
195            state,
196            Command::CreateWorkspace {
197                name: ws_name.into(),
198            },
199        )
200        .await;
201        let ws_id = ws_evs
202            .iter()
203            .find_map(|e| match e {
204                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
205                _ => None,
206            })
207            .unwrap();
208        let tab_evs = dispatch_apply(
209            state,
210            Command::CreateTab {
211                workspace_id: ws_id.clone(),
212                name: "t".into(),
213            },
214        )
215        .await;
216        let tab_id = tab_evs
217            .iter()
218            .find_map(|e| match e {
219                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
220                _ => None,
221            })
222            .unwrap();
223        let blk_evs = dispatch_apply(
224            state,
225            Command::CreateBlock {
226                tab_id: tab_id.clone(),
227                meta: Value::Null,
228            },
229        )
230        .await;
231        let block_id = blk_evs
232            .iter()
233            .find_map(|e| match e {
234                Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
235                _ => None,
236            })
237            .unwrap();
238        (ws_id, tab_id, block_id)
239    }
240
241    #[tokio::test]
242    async fn happy_path_moves_block_between_workspaces() {
243        let state = test_state();
244        let (src_ws, src_tab, block_id) = seed_workspace_with_block(&state, "src").await;
245        // Seed a destination workspace with one tab (no blocks yet).
246        let dst_ws_evs = dispatch_apply(
247            &state,
248            Command::CreateWorkspace { name: "dst".into() },
249        )
250        .await;
251        let dst_ws = dst_ws_evs
252            .iter()
253            .find_map(|e| match e {
254                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
255                _ => None,
256            })
257            .unwrap();
258        let dst_tab_evs = dispatch_apply(
259            &state,
260            Command::CreateTab {
261                workspace_id: dst_ws.clone(),
262                name: "dt".into(),
263            },
264        )
265        .await;
266        let dst_tab = dst_tab_evs
267            .iter()
268            .find_map(|e| match e {
269                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
270                _ => None,
271            })
272            .unwrap();
273
274        let result = run(
275            &state,
276            block_id.clone(),
277            src_tab.clone(),
278            src_ws.clone(),
279            dst_tab.clone(),
280            dst_ws.clone(),
281            None,
282        )
283        .await
284        .unwrap();
285        assert_eq!(result["block_id"], block_id);
286        assert_eq!(result["target_tab_id"], dst_tab);
287
288        // Reducer: source tab is empty; target tab has the block.
289        let s = state.srv_state.lock().await;
290        assert!(s.tabs[&src_tab].block_ids.is_empty());
291        assert_eq!(s.tabs[&dst_tab].block_ids, vec![block_id.clone()]);
292        assert_eq!(s.blocks[&block_id].tab_id, dst_tab);
293
294        // SQLite: matches.
295        drop(s);
296        let dst_tab_obj = state.wstore.get::<Tab>(&dst_tab).unwrap().unwrap();
297        assert_eq!(dst_tab_obj.blockids, vec![block_id.clone()]);
298        let block = state.wstore.get::<Block>(&block_id).unwrap().unwrap();
299        assert_eq!(block.parentoref, format!("tab:{}", dst_tab));
300    }
301
302    #[tokio::test]
303    async fn rejects_when_source_and_target_are_same_tab() {
304        let state = test_state();
305        let (ws, tab, block_id) = seed_workspace_with_block(&state, "w").await;
306        let err = run(
307            &state,
308            block_id,
309            tab.clone(),
310            ws.clone(),
311            tab,
312            ws,
313            None,
314        )
315        .await
316        .unwrap_err();
317        assert!(
318            err.contains("source and target are the same tab"),
319            "got: {}",
320            err
321        );
322    }
323
324    #[tokio::test]
325    async fn rejects_when_block_not_in_source_tab() {
326        let state = test_state();
327        let (ws, tab, _block_id) = seed_workspace_with_block(&state, "w").await;
328        let dst_ws_evs = dispatch_apply(
329            &state,
330            Command::CreateWorkspace { name: "dst".into() },
331        )
332        .await;
333        let dst_ws = dst_ws_evs
334            .iter()
335            .find_map(|e| match e {
336                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
337                _ => None,
338            })
339            .unwrap();
340        let dst_tab_evs = dispatch_apply(
341            &state,
342            Command::CreateTab {
343                workspace_id: dst_ws.clone(),
344                name: "dt".into(),
345            },
346        )
347        .await;
348        let dst_tab = dst_tab_evs
349            .iter()
350            .find_map(|e| match e {
351                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
352                _ => None,
353            })
354            .unwrap();
355        let err = run(
356            &state,
357            "ghost-block".into(),
358            tab,
359            ws,
360            dst_tab,
361            dst_ws,
362            None,
363        )
364        .await
365        .unwrap_err();
366        assert!(err.contains("block not found"), "got: {}", err);
367    }
368}