Skip to main content

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. Block/workspace existence checks read
42    // the store directly rather than reducer state — unlike tab_ids
43    // (SPEC_864 Phase 5 verified every mutator of that field is
44    // reducer-routed), block and workspace migration status wasn't part
45    // of this pass, so SQLite stays the read here.
46    {
47        let block = match state.mstore.get::<crate::backend::obj::Block>(&block_id) {
48            Ok(Some(b)) => b,
49            Ok(None) => {
50                return Err(format!("PromoteBlockToTab: block not found: {}", block_id));
51            }
52            Err(e) => return Err(format!("PromoteBlockToTab: block read failed: {}", e)),
53        };
54        let expected_parent = format!("tab:{}", source_tab_id);
55        if block.parentoref != expected_parent {
56            return Err(format!(
57                "PromoteBlockToTab: block {} is in {}, not tab:{}",
58                block_id, block.parentoref, source_tab_id
59            ));
60        }
61        if state
62            .mstore
63            .get::<crate::backend::obj::Workspace>(&workspace_id)
64            .map(|w| w.is_none())
65            .unwrap_or(true)
66        {
67            return Err(format!(
68                "PromoteBlockToTab: workspace not found: {}",
69                workspace_id
70            ));
71        }
72    }
73
74    let saga_id = alloc_saga_id(state);
75    if let Err(e) = emit_saga_started(
76        state,
77        saga_id,
78        "promote_block_to_tab",
79        serde_json::json!({
80            "block_id": &block_id,
81            "source_tab_id": &source_tab_id,
82            "workspace_id": &workspace_id,
83        }),
84    )
85    .await
86    {
87        return Err(e);
88    }
89    let ctx = SagaCtx::new(state, saga_id);
90    let result = run_saga(
91        "promote_block_to_tab",
92        run_inner(ctx, block_id, source_tab_id, workspace_id),
93    )
94    .await;
95    emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
96    result
97}
98
99async fn run_inner(
100    ctx: SagaCtx<'_>,
101    block_id: String,
102    source_tab_id: String,
103    workspace_id: String,
104) -> Result<Value, String> {
105    // Step 1: create the new tab in the same workspace.
106    let create_tab_events = ctx
107        .dispatch(Command::CreateTab {
108            workspace_id: workspace_id.clone(),
109            name: String::new(),
110        })
111        .await
112        .map_err(|e| format!("PromoteBlockToTab step 1 (CreateTab): {}", e))?;
113    let new_tab_id = create_tab_events
114        .iter()
115        .find_map(|e| match e {
116            Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
117            _ => None,
118        })
119        .ok_or_else(|| {
120            "PromoteBlockToTab: CreateTab did not emit TabCreated".to_string()
121        })?;
122
123    // Step 2: move the block into the new tab.
124    if let Err(reason) = ctx
125        .dispatch(Command::MoveBlock {
126            block_id: block_id.clone(),
127            src_tab_id: source_tab_id.clone(),
128            dst_tab_id: new_tab_id.clone(),
129            dst_index: 0,
130        })
131        .await
132    {
133        // Compensate: delete the empty new tab.
134        // force=true so the last-tab guard doesn't reject when the
135        // workspace ends up with this single just-created tab
136        // (codex P1 round 2 PR #633).
137        ctx.compensate(Command::DeleteTab {
138            workspace_id: workspace_id.clone(),
139            tab_id: new_tab_id.clone(),
140            force: true,
141        })
142        .await;
143        return Err(format!("PromoteBlockToTab step 2 (MoveBlock): {}", reason));
144    }
145
146    Ok(json!({ "new_tab_id": new_tab_id }))
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::server::tests::test_state;
153
154    async fn dispatch_apply(
155        state: &crate::server::AppState,
156        cmd: agentmux_common::ipc::Command,
157    ) -> Vec<Event> {
158        let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
159        for ev in &events {
160            crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
161        }
162        events
163    }
164
165    #[tokio::test]
166    async fn happy_path_creates_tab_and_moves_block() {
167        let state = test_state();
168        let ws_evs = dispatch_apply(
169            &state,
170            agentmux_common::ipc::Command::CreateWorkspace { name: "w".into() },
171        )
172        .await;
173        let ws_id = ws_evs
174            .iter()
175            .find_map(|e| match e {
176                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
177                _ => None,
178            })
179            .unwrap();
180        let tab_evs = dispatch_apply(
181            &state,
182            agentmux_common::ipc::Command::CreateTab {
183                workspace_id: ws_id.clone(),
184                name: "src".into(),
185            },
186        )
187        .await;
188        let src_tab = tab_evs
189            .iter()
190            .find_map(|e| match e {
191                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
192                _ => None,
193            })
194            .unwrap();
195        let blk_evs = dispatch_apply(
196            &state,
197            agentmux_common::ipc::Command::CreateBlock {
198                tab_id: src_tab.clone(),
199                meta: serde_json::Value::Null,
200            },
201        )
202        .await;
203        let block_id = blk_evs
204            .iter()
205            .find_map(|e| match e {
206                Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
207                _ => None,
208            })
209            .unwrap();
210
211        let result = run(&state, block_id.clone(), src_tab.clone(), ws_id.clone())
212            .await
213            .unwrap();
214        let new_tab_id = result["new_tab_id"].as_str().unwrap();
215
216        // Reducer: src_tab has no blocks; new_tab has the block.
217        let s = state.srv_state.lock().await;
218        assert!(s.tabs[&src_tab].block_ids.is_empty());
219        assert_eq!(s.tabs[new_tab_id].block_ids, vec![block_id.clone()]);
220        assert_eq!(s.blocks[&block_id].tab_id, new_tab_id);
221        assert!(s.workspaces[&ws_id].tab_ids.contains(&new_tab_id.to_string()));
222    }
223
224    #[tokio::test]
225    async fn rejects_when_block_in_different_tab() {
226        let state = test_state();
227        let ws_evs = dispatch_apply(
228            &state,
229            agentmux_common::ipc::Command::CreateWorkspace { name: "w".into() },
230        )
231        .await;
232        let ws_id = ws_evs
233            .iter()
234            .find_map(|e| match e {
235                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
236                _ => None,
237            })
238            .unwrap();
239        let err = run(&state, "ghost-block".into(), "ghost-tab".into(), ws_id)
240            .await
241            .unwrap_err();
242        assert!(err.contains("not found") || err.contains("not in"), "got: {}", err);
243    }
244}