1use agentmux_common::ipc::Command;
30use serde_json::{json, Value};
31
32use super::{
33 alloc_saga_id, classify_run_saga_result, emit_saga_started, emit_terminal, run_saga, SagaCtx,
34};
35use crate::server::AppState;
36
37pub async fn run(
40 state: &AppState,
41 tab_id: String,
42 source_workspace_id: String,
43 dest_workspace_id: String,
44 insert_index: Option<u32>,
45) -> Result<Value, String> {
46 {
52 let src_ws = match state.wstore.get::<crate::backend::obj::Workspace>(&source_workspace_id) {
53 Ok(Some(ws)) => ws,
54 Ok(None) => {
55 return Err(format!(
56 "RestoreTornOffTab: source workspace not found: {}",
57 source_workspace_id
58 ));
59 }
60 Err(e) => {
61 return Err(format!(
62 "RestoreTornOffTab: workspace read failed: {}",
63 e
64 ));
65 }
66 };
67 if state
68 .wstore
69 .get::<crate::backend::obj::Workspace>(&dest_workspace_id)
70 .map(|w| w.is_none())
71 .unwrap_or(true)
72 {
73 return Err(format!(
74 "RestoreTornOffTab: dest workspace not found: {}",
75 dest_workspace_id
76 ));
77 }
78 let in_workspace = src_ws.tabids.iter().any(|id| id == &tab_id)
79 || src_ws.pinnedtabids.iter().any(|id| id == &tab_id);
80 if !in_workspace {
81 return Err(format!(
82 "RestoreTornOffTab: tab {} is not in workspace {}",
83 tab_id, source_workspace_id
84 ));
85 }
86 }
87
88 let dst_index = insert_index.unwrap_or(u32::MAX);
89
90 let saga_id = alloc_saga_id(state);
91 if let Err(e) = emit_saga_started(
92 state,
93 saga_id,
94 "restore_torn_off_tab",
95 serde_json::json!({
96 "tab_id": &tab_id,
97 "source_workspace_id": &source_workspace_id,
98 "dest_workspace_id": &dest_workspace_id,
99 "insert_index": dst_index,
100 }),
101 )
102 .await
103 {
104 return Err(e);
105 }
106 let ctx = SagaCtx::new(state, saga_id);
107 let result = run_saga(
108 "restore_torn_off_tab",
109 run_inner(ctx, tab_id, source_workspace_id, dest_workspace_id, dst_index),
110 )
111 .await;
112 emit_terminal(state, saga_id, classify_run_saga_result(&result)).await;
113 result
114}
115
116async fn run_inner(
117 ctx: SagaCtx<'_>,
118 tab_id: String,
119 source_workspace_id: String,
120 dest_workspace_id: String,
121 dst_index: u32,
122) -> Result<Value, String> {
123 ctx.dispatch(Command::MoveTab {
125 tab_id: tab_id.clone(),
126 src_workspace_id: source_workspace_id.clone(),
127 dst_workspace_id: dest_workspace_id.clone(),
128 dst_index,
129 })
130 .await
131 .map_err(|e| format!("RestoreTornOffTab step 1 (MoveTab): {}", e))?;
132
133 let source_now_empty = {
135 let s = ctx.state_lock().await;
136 s.workspaces
137 .get(&source_workspace_id)
138 .map(|ws| ws.tab_ids.is_empty())
139 .unwrap_or(true)
140 };
141
142 let mut source_deleted = false;
143 if source_now_empty {
144 match ctx
147 .dispatch(Command::DeleteWorkspace {
148 workspace_id: source_workspace_id.clone(),
149 force: false,
154 })
155 .await
156 {
157 Ok(_) => source_deleted = true,
158 Err(e) => {
159 tracing::warn!(
160 saga_id = ctx.saga_id(),
161 "[saga] RestoreTornOffTab: source workspace cleanup failed: {} (orphan workspace {})",
162 e,
163 source_workspace_id
164 );
165 }
166 }
167 }
168
169 Ok(json!({ "source_workspace_deleted": source_deleted }))
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use agentmux_common::ipc::Event;
176 use crate::backend::obj::Workspace;
177 use crate::server::tests::test_state;
178
179 async fn dispatch_apply(
180 state: &crate::server::AppState,
181 cmd: agentmux_common::ipc::Command,
182 ) -> Vec<Event> {
183 let events = crate::server::service::dispatch_to_reducer(state, cmd).await;
184 for ev in &events {
185 crate::persist_subscriber::apply_event_to_wstore(ev, &state.wstore).unwrap();
186 }
187 events
188 }
189
190 async fn seed_torn_off_state() -> (crate::server::AppState, String, String, String, String) {
194 let state = test_state();
195 let mut ws_ids = Vec::new();
196 let mut tab_ids = Vec::new();
197 for ws_name in &["torn", "dest"] {
198 let ws_evs = dispatch_apply(
199 &state,
200 agentmux_common::ipc::Command::CreateWorkspace {
201 name: ws_name.to_string(),
202 },
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: format!("{}-tab", ws_name),
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 ws_ids.push(ws_id);
228 tab_ids.push(tab_id);
229 }
230 (
231 state,
232 ws_ids[0].clone(),
233 tab_ids[0].clone(),
234 ws_ids[1].clone(),
235 tab_ids[1].clone(),
236 )
237 }
238
239 #[tokio::test]
240 async fn happy_path_moves_tab_back_and_deletes_empty_source() {
241 let (state, torn_ws, torn_tab, dest_ws, _dest_tab) = seed_torn_off_state().await;
242 let result = run(&state, torn_tab.clone(), torn_ws.clone(), dest_ws.clone(), Some(0))
243 .await
244 .unwrap();
245 assert_eq!(result["source_workspace_deleted"], true);
246
247 let s = state.srv_state.lock().await;
249 assert!(!s.workspaces.contains_key(&torn_ws));
250 assert!(s.workspaces[&dest_ws].tab_ids.contains(&torn_tab));
251 assert_eq!(s.tabs[&torn_tab].workspace_id, dest_ws);
252 drop(s);
253
254 assert!(state.wstore.get::<Workspace>(&torn_ws).unwrap().is_none());
256 }
257
258 #[tokio::test]
259 async fn skips_delete_when_source_still_has_tabs() {
260 let (state, torn_ws, torn_tab, dest_ws, _) = seed_torn_off_state().await;
261 let _ = dispatch_apply(
263 &state,
264 agentmux_common::ipc::Command::CreateTab {
265 workspace_id: torn_ws.clone(),
266 name: "extra".into(),
267 },
268 )
269 .await;
270
271 let result = run(&state, torn_tab, torn_ws.clone(), dest_ws, Some(0))
272 .await
273 .unwrap();
274 assert_eq!(
275 result["source_workspace_deleted"], false,
276 "source ws should survive when it still has tabs"
277 );
278 let s = state.srv_state.lock().await;
280 assert!(s.workspaces.contains_key(&torn_ws));
281 }
282}