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 {
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 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 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 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 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}