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