1use 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
33pub async fn run(
36 state: &AppState,
37 block_id: String,
38 source_tab_id: String,
39 workspace_id: String,
40) -> Result<Value, String> {
41 {
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 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 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 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 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}