1use agentmux_common::ipc::{Command, Event};
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
39pub async fn run(
42 state: &AppState,
43 block_id: String,
44 source_tab_id: String,
45 source_workspace_id: String,
46) -> Result<Value, String> {
47 {
52 let s = state.srv_state.lock().await;
53 match s.blocks.get(&block_id) {
54 None => {
55 return Err(format!("TearOffBlock: block not found: {}", block_id));
56 }
57 Some(block) if block.tab_id != source_tab_id => {
58 return Err(format!(
59 "TearOffBlock: block {} is in tab {}, not {}",
60 block_id, block.tab_id, source_tab_id
61 ));
62 }
63 _ => {}
64 }
65 match s.tabs.get(&source_tab_id) {
66 None => {
67 return Err(format!(
68 "TearOffBlock: source tab not found: {}",
69 source_tab_id
70 ));
71 }
72 Some(tab) if tab.workspace_id != source_workspace_id => {
73 return Err(format!(
74 "TearOffBlock: tab {} is in workspace {}, not {}",
75 source_tab_id, tab.workspace_id, source_workspace_id
76 ));
77 }
78 _ => {}
79 }
80 }
81
82 let saga_id = alloc_saga_id(state);
83 if let Err(e) = emit_saga_started(
84 state,
85 saga_id,
86 "tear_off_block",
87 serde_json::json!({
88 "block_id": &block_id,
89 "source_tab_id": &source_tab_id,
90 }),
91 )
92 .await
93 {
94 return Err(e);
95 }
96 let ctx = SagaCtx::new(state, saga_id);
97 let result = run_saga("tear_off_block", run_inner(ctx, block_id, source_tab_id)).await;
98 emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
99 result
100}
101
102async fn run_inner(
103 ctx: SagaCtx<'_>,
104 block_id: String,
105 source_tab_id: String,
106) -> Result<Value, String> {
107 let create_ws_events = ctx
109 .dispatch(Command::CreateWorkspace {
110 name: String::new(),
111 })
112 .await
113 .map_err(|e| format!("TearOffBlock step 1 (CreateWorkspace): {}", e))?;
114 let new_workspace_id = create_ws_events
115 .iter()
116 .find_map(|e| match e {
117 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
118 _ => None,
119 })
120 .ok_or_else(|| {
121 "TearOffBlock: CreateWorkspace did not emit WorkspaceCreated".to_string()
122 })?;
123
124 let create_tab_events = match ctx
126 .dispatch(Command::CreateTab {
127 workspace_id: new_workspace_id.clone(),
128 name: String::new(),
129 })
130 .await
131 {
132 Ok(evs) => evs,
133 Err(reason) => {
134 ctx.compensate(Command::DeleteWorkspace {
136 workspace_id: new_workspace_id.clone(),
137 force: false,
138 })
139 .await;
140 return Err(format!("TearOffBlock step 2 (CreateTab): {}", reason));
141 }
142 };
143 let new_tab_id = create_tab_events
144 .iter()
145 .find_map(|e| match e {
146 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
147 _ => None,
148 })
149 .ok_or_else(|| {
150 "TearOffBlock: CreateTab did not emit TabCreated".to_string()
151 })?;
152
153 if let Err(reason) = ctx
155 .dispatch(Command::MoveBlock {
156 block_id: block_id.clone(),
157 src_tab_id: source_tab_id.clone(),
158 dst_tab_id: new_tab_id.clone(),
159 dst_index: 0,
160 })
161 .await
162 {
163 ctx.compensate(Command::DeleteWorkspace {
166 workspace_id: new_workspace_id.clone(),
167 force: false,
168 })
169 .await;
170 return Err(format!("TearOffBlock step 3 (MoveBlock): {}", reason));
171 }
172
173 Ok(json!({
174 "new_workspace_id": new_workspace_id,
175 "new_tab_id": new_tab_id,
176 }))
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182 use crate::backend::obj::{Block, Tab};
183 use crate::server::tests::test_state;
184
185 async fn dispatch_apply(
186 state: &crate::server::AppState,
187 cmd: agentmux_common::ipc::Command,
188 ) -> Vec<agentmux_common::ipc::Event> {
189 let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
190 for ev in &events {
191 crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
192 }
193 events
194 }
195
196 #[tokio::test]
197 async fn happy_path_creates_workspace_tab_and_moves_block() {
198 let state = test_state();
199 let ws_evs = dispatch_apply(
201 &state,
202 agentmux_common::ipc::Command::CreateWorkspace { name: "src".into() },
203 )
204 .await;
205 let ws_id = ws_evs
206 .iter()
207 .find_map(|e| match e {
208 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
209 _ => None,
210 })
211 .unwrap();
212 let tab_evs = dispatch_apply(
213 &state,
214 agentmux_common::ipc::Command::CreateTab {
215 workspace_id: ws_id.clone(),
216 name: "t".into(),
217 },
218 )
219 .await;
220 let tab_id = tab_evs
221 .iter()
222 .find_map(|e| match e {
223 Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
224 _ => None,
225 })
226 .unwrap();
227 let blk_evs = dispatch_apply(
228 &state,
229 agentmux_common::ipc::Command::CreateBlock {
230 tab_id: tab_id.clone(),
231 meta: serde_json::Value::Null,
232 },
233 )
234 .await;
235 let block_id = blk_evs
236 .iter()
237 .find_map(|e| match e {
238 Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
239 _ => None,
240 })
241 .unwrap();
242
243 let result = run(&state, block_id.clone(), tab_id.clone(), ws_id.clone())
244 .await
245 .unwrap();
246 let new_ws_id = result["new_workspace_id"].as_str().unwrap();
247 let new_tab_id = result["new_tab_id"].as_str().unwrap();
248
249 let s = state.srv_state.lock().await;
251 assert!(s.tabs[&tab_id].block_ids.is_empty());
252 assert_eq!(
253 s.tabs[new_tab_id].block_ids,
254 vec![block_id.clone()],
255 "block should be in new tab"
256 );
257 assert_eq!(s.blocks[&block_id].tab_id, new_tab_id);
258 assert_eq!(s.workspaces[new_ws_id].tab_ids, vec![new_tab_id.to_string()]);
259
260 drop(s);
262 let new_tab = state.wstore.get::<Tab>(new_tab_id).unwrap().unwrap();
263 assert_eq!(new_tab.blockids, vec![block_id.clone()]);
264 let block = state.wstore.get::<Block>(&block_id).unwrap().unwrap();
265 assert_eq!(block.parentoref, format!("tab:{}", new_tab_id));
266 }
267
268 #[tokio::test]
269 async fn rejects_when_block_not_in_source_tab() {
270 let state = test_state();
271 let ws_evs = dispatch_apply(
272 &state,
273 agentmux_common::ipc::Command::CreateWorkspace { name: "w".into() },
274 )
275 .await;
276 let ws_id = ws_evs
277 .iter()
278 .find_map(|e| match e {
279 Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
280 _ => None,
281 })
282 .unwrap();
283 let _ = dispatch_apply(
284 &state,
285 agentmux_common::ipc::Command::CreateTab {
286 workspace_id: ws_id.clone(),
287 name: "t".into(),
288 },
289 )
290 .await;
291 let err = run(
292 &state,
293 "ghost-block".into(),
294 "ghost-tab".into(),
295 ws_id,
296 )
297 .await
298 .unwrap_err();
299 assert!(err.contains("block not found") || err.contains("source tab not found"), "got: {}", err);
300 }
301}