agentmux_srv\sagas/
promote_block_to_tab.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase E.5.7 — PromoteBlockToTab saga.
5//
6// Migrates the reducer-state portion of `wcore::promote_block_to_tab`
7// to a reducer-driven multi-step. The original wcore function does
8// substantial layout work + sets the new tab as active; that work
9// stays wcore-direct in the RPC handler that wraps this saga.
10//
11// **Steps:**
12// 1. `CreateTab { workspace_id, name: "" }` — new tab in the same
13//    workspace.
14// 2. `MoveBlock { block_id, src=source_tab, dst=new_tab, dst_index: 0 }`.
15//
16// **Compensation:**
17// * Step 2 fails after step 1 → `DeleteTab { workspace_id, new_tab_id }`
18//   (cascades through any blocks; new tab has no blocks at this
19//   point since step 2 failed).
20// * Step 1 fails → nothing to compensate.
21//
22// Layout setup (rootnode + leaforder for the new tab) and SetActiveTab
23// stay in the RPC handler post-saga; auto-close empty source tab too.
24
25use agentmux_common::ipc::{Command, Event};
26use serde_json::{json, Value};
27
28use super::{
29    alloc_saga_id, classify_run_saga_result, emit_saga_started, emit_terminal, run_saga, SagaCtx,
30};
31use crate::server::AppState;
32
33/// Run the PromoteBlockToTab saga. On success, returns
34/// `{"new_tab_id": "..."}`.
35pub async fn run(
36    state: &AppState,
37    block_id: String,
38    source_tab_id: String,
39    workspace_id: String,
40) -> Result<Value, String> {
41    // Pre-condition: read SQLite (source of truth during migration
42    // window — wcore-direct paths can leave reducer state stale).
43    {
44        let block = match state.wstore.get::<crate::backend::obj::Block>(&block_id) {
45            Ok(Some(b)) => b,
46            Ok(None) => {
47                return Err(format!("PromoteBlockToTab: block not found: {}", block_id));
48            }
49            Err(e) => return Err(format!("PromoteBlockToTab: block read failed: {}", e)),
50        };
51        let expected_parent = format!("tab:{}", source_tab_id);
52        if block.parentoref != expected_parent {
53            return Err(format!(
54                "PromoteBlockToTab: block {} is in {}, not tab:{}",
55                block_id, block.parentoref, source_tab_id
56            ));
57        }
58        if state
59            .wstore
60            .get::<crate::backend::obj::Workspace>(&workspace_id)
61            .map(|w| w.is_none())
62            .unwrap_or(true)
63        {
64            return Err(format!(
65                "PromoteBlockToTab: workspace not found: {}",
66                workspace_id
67            ));
68        }
69    }
70
71    let saga_id = alloc_saga_id(state);
72    if let Err(e) = emit_saga_started(
73        state,
74        saga_id,
75        "promote_block_to_tab",
76        serde_json::json!({
77            "block_id": &block_id,
78            "source_tab_id": &source_tab_id,
79            "workspace_id": &workspace_id,
80        }),
81    )
82    .await
83    {
84        return Err(e);
85    }
86    let ctx = SagaCtx::new(state, saga_id);
87    let result = run_saga(
88        "promote_block_to_tab",
89        run_inner(ctx, block_id, source_tab_id, workspace_id),
90    )
91    .await;
92    emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
93    result
94}
95
96async fn run_inner(
97    ctx: SagaCtx<'_>,
98    block_id: String,
99    source_tab_id: String,
100    workspace_id: String,
101) -> Result<Value, String> {
102    // Step 1: create the new tab in the same workspace.
103    let create_tab_events = ctx
104        .dispatch(Command::CreateTab {
105            workspace_id: workspace_id.clone(),
106            name: String::new(),
107        })
108        .await
109        .map_err(|e| format!("PromoteBlockToTab step 1 (CreateTab): {}", e))?;
110    let new_tab_id = create_tab_events
111        .iter()
112        .find_map(|e| match e {
113            Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
114            _ => None,
115        })
116        .ok_or_else(|| {
117            "PromoteBlockToTab: CreateTab did not emit TabCreated".to_string()
118        })?;
119
120    // Step 2: move the block into the new tab.
121    if let Err(reason) = ctx
122        .dispatch(Command::MoveBlock {
123            block_id: block_id.clone(),
124            src_tab_id: source_tab_id.clone(),
125            dst_tab_id: new_tab_id.clone(),
126            dst_index: 0,
127        })
128        .await
129    {
130        // Compensate: delete the empty new tab.
131        // force=true so the last-tab guard doesn't reject when the
132        // workspace ends up with this single just-created tab
133        // (codex P1 round 2 PR #633).
134        ctx.compensate(Command::DeleteTab {
135            workspace_id: workspace_id.clone(),
136            tab_id: new_tab_id.clone(),
137            force: true,
138        })
139        .await;
140        return Err(format!("PromoteBlockToTab step 2 (MoveBlock): {}", reason));
141    }
142
143    Ok(json!({ "new_tab_id": new_tab_id }))
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::server::tests::test_state;
150
151    async fn dispatch_apply(
152        state: &crate::server::AppState,
153        cmd: agentmux_common::ipc::Command,
154    ) -> Vec<Event> {
155        let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
156        for ev in &events {
157            crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
158        }
159        events
160    }
161
162    #[tokio::test]
163    async fn happy_path_creates_tab_and_moves_block() {
164        let state = test_state();
165        let ws_evs = dispatch_apply(
166            &state,
167            agentmux_common::ipc::Command::CreateWorkspace { name: "w".into() },
168        )
169        .await;
170        let ws_id = ws_evs
171            .iter()
172            .find_map(|e| match e {
173                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
174                _ => None,
175            })
176            .unwrap();
177        let tab_evs = dispatch_apply(
178            &state,
179            agentmux_common::ipc::Command::CreateTab {
180                workspace_id: ws_id.clone(),
181                name: "src".into(),
182            },
183        )
184        .await;
185        let src_tab = tab_evs
186            .iter()
187            .find_map(|e| match e {
188                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
189                _ => None,
190            })
191            .unwrap();
192        let blk_evs = dispatch_apply(
193            &state,
194            agentmux_common::ipc::Command::CreateBlock {
195                tab_id: src_tab.clone(),
196                meta: serde_json::Value::Null,
197            },
198        )
199        .await;
200        let block_id = blk_evs
201            .iter()
202            .find_map(|e| match e {
203                Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
204                _ => None,
205            })
206            .unwrap();
207
208        let result = run(&state, block_id.clone(), src_tab.clone(), ws_id.clone())
209            .await
210            .unwrap();
211        let new_tab_id = result["new_tab_id"].as_str().unwrap();
212
213        // Reducer: src_tab has no blocks; new_tab has the block.
214        let s = state.srv_state.lock().await;
215        assert!(s.tabs[&src_tab].block_ids.is_empty());
216        assert_eq!(s.tabs[new_tab_id].block_ids, vec![block_id.clone()]);
217        assert_eq!(s.blocks[&block_id].tab_id, new_tab_id);
218        assert!(s.workspaces[&ws_id].tab_ids.contains(&new_tab_id.to_string()));
219    }
220
221    #[tokio::test]
222    async fn rejects_when_block_in_different_tab() {
223        let state = test_state();
224        let ws_evs = dispatch_apply(
225            &state,
226            agentmux_common::ipc::Command::CreateWorkspace { name: "w".into() },
227        )
228        .await;
229        let ws_id = ws_evs
230            .iter()
231            .find_map(|e| match e {
232                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
233                _ => None,
234            })
235            .unwrap();
236        let err = run(&state, "ghost-block".into(), "ghost-tab".into(), ws_id)
237            .await
238            .unwrap_err();
239        assert!(err.contains("not found") || err.contains("not in"), "got: {}", err);
240    }
241}