Skip to main content

agentmux_srv/server/service/
tab_lifecycle.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `workspace` service handlers — tab CRUD (`CreateTab`, `SetActiveTab`,
5//! `CloseTab`, `UpdateTabIds`, `ReorderTab`). Split out of `workspace.rs`;
6//! see that file's dispatcher for the full method list.
7
8use crate::backend::obj::*;
9use crate::backend::service::{self, CloseTabRtnType, WebCallType, WebReturnType};
10
11use super::super::AppState;
12use super::reducer_helpers::{compensate_via_reducer, dispatch_to_reducer, publish_events};
13
14// Phase E.2c.3b — CreateTab dispatches through the reducer.
15// The `pinned` argument from older clients is ignored:
16// pinning was a Waveterm feature removed from AgentMux.
17// Legacy SQLite databases may still have entries in
18// `Workspace.pinnedtabids`; bootstrap merges them into
19// `tab_ids` so they behave as regular tabs.
20pub(crate) async fn handle_create_tab(state: &AppState, call: &WebCallType) -> WebReturnType {
21    let store = &state.mstore;
22    let args = &call.args;
23    let ws_id: String = match service::get_arg(args, 0) {
24        Ok(v) => v,
25        Err(e) => return WebReturnType::error(e),
26    };
27    let tab_name: String = service::get_arg(args, 1).unwrap_or_default();
28    let activate: bool = service::get_arg(args, 2).unwrap_or(true);
29    // args[3] (`pinned`) intentionally ignored.
30    // Auto-generate a `Tab {N}` name when the caller passed
31    // empty so behaviour matches the prior wcore path. Counts
32    // both `tabids` and any leftover `pinnedtabids` from
33    // legacy data so the numbering doesn't collide with
34    // pre-removal entries that bootstrap will surface as
35    // regular tabs.
36    let resolved_name = if tab_name.is_empty() {
37        match store.get::<Workspace>(&ws_id) {
38            Ok(Some(ws)) => {
39                format!("Tab {}", ws.tabids.len() + ws.pinnedtabids.len() + 1)
40            }
41            _ => "Tab 1".to_string(),
42        }
43    } else {
44        tab_name.clone()
45    };
46    let events = dispatch_to_reducer(
47        state,
48        agentmux_common::ipc::Command::CreateTab {
49            workspace_id: ws_id.clone(),
50            name: resolved_name,
51        },
52    )
53    .await;
54    // Surface reducer Error events (e.g., workspace not
55    // found) before any persistence work — they're not
56    // bug events, they're caller-visible failures, and the
57    // generic "did not emit TabCreated" message below would
58    // mask the real reason. Matches the SetActiveTab pattern.
59    // (reagent P1 #616.)
60    if let Some(err_msg) = events.iter().find_map(|e| match e {
61        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
62        _ => None,
63    }) {
64        return WebReturnType::error(err_msg);
65    }
66    let tab_id = events.iter().find_map(|e| match e {
67        agentmux_common::ipc::Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
68        _ => None,
69    });
70    // Apply synchronously to mstore (forward+compensate on
71    // failure — same pattern as CreateWorkspace in E.2c.2).
72    let mut apply_err: Option<String> = None;
73    for ev in &events {
74        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
75            apply_err = Some(e.to_string());
76            break;
77        }
78    }
79    if let Some(err) = apply_err {
80        if let Some(tid) = tab_id.as_ref() {
81            compensate_via_reducer(
82                state,
83                agentmux_common::ipc::Command::DeleteTab {
84                    workspace_id: ws_id.clone(),
85                    tab_id: tid.clone(),
86                    // Compensation must bypass the last-tab
87                    // guard to roll back a just-created sole
88                    // tab when its persist failed (codex P1
89                    // round 2 + P2 round 4 PR #633).
90                    force: true,
91                },
92                store,
93            )
94            .await;
95        }
96        return WebReturnType::error(format!("CreateTab: SQLite write failed: {}", err));
97    }
98    publish_events(state, &events);
99    // If `activate=true` and the reducer didn't auto-activate
100    // this as the first tab, dispatch SetActiveTab.
101    let auto_activated = events
102        .iter()
103        .any(|e| matches!(e, agentmux_common::ipc::Event::ActiveTabChanged { .. }));
104    if activate && !auto_activated {
105        if let Some(tid) = tab_id.as_ref() {
106            let active_events = dispatch_to_reducer(
107                state,
108                agentmux_common::ipc::Command::SetActiveTab {
109                    workspace_id: ws_id.clone(),
110                    tab_id: tid.clone(),
111                },
112            )
113            .await;
114            let mut active_err: Option<String> = None;
115            for ev in &active_events {
116                if let Err(e) =
117                    crate::persist_subscriber::apply_event_to_mstore(ev, store)
118                {
119                    active_err = Some(e.to_string());
120                    break;
121                }
122            }
123            if active_err.is_none() {
124                publish_events(state, &active_events);
125            }
126            // SetActiveTab failure is non-fatal here — the
127            // tab exists; activation can be retried by the
128            // caller. Log if it happened.
129            if let Some(err) = active_err {
130                tracing::warn!(
131                    "CreateTab: post-create activate failed: {}",
132                    err
133                );
134            }
135        }
136    }
137    match tab_id {
138        Some(id) => {
139            let mut updates = vec![];
140            if let Ok(tab) = store.must_get::<Tab>(&id) {
141                updates.push(MuxObjUpdate {
142                    updatetype: "update".into(),
143                    otype: OTYPE_TAB.to_string(),
144                    oid: id.clone(),
145                    obj: Some(mux_obj_to_value(&tab)),
146                });
147            }
148            if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
149                updates.push(MuxObjUpdate {
150                    updatetype: "update".into(),
151                    otype: OTYPE_WORKSPACE.to_string(),
152                    oid: ws_id.clone(),
153                    obj: Some(mux_obj_to_value(&ws)),
154                });
155            }
156            WebReturnType::success_data_updates(
157                serde_json::to_value(&id).unwrap_or_default(),
158                updates,
159            )
160        }
161        None => WebReturnType::error(
162            "CreateTab: reducer did not emit TabCreated".to_string(),
163        ),
164    }
165}
166
167// Phase E.2c.3 — SetActiveTab routes through the reducer.
168// Read-through reads (e.g., GetWorkspace) still hit mstore
169// during the migration window, so the synchronous
170// apply-to-mstore keeps them consistent.
171pub(crate) async fn handle_set_active_tab(state: &AppState, call: &WebCallType) -> WebReturnType {
172    let store = &state.mstore;
173    let args = &call.args;
174    let ws_id: String = match service::get_arg(args, 0) {
175        Ok(v) => v,
176        Err(e) => return WebReturnType::error(e),
177    };
178    let tab_id: String = match service::get_arg(args, 1) {
179        Ok(v) => v,
180        Err(e) => return WebReturnType::error(e),
181    };
182    // Phase E.2c.3b — pinning was removed from AgentMux
183    // (Waveterm legacy). All tabs are regular; dispatch
184    // straight through the reducer. Bootstrap merges any
185    // legacy `pinnedtabids` into the reducer's `tab_ids` so
186    // tabs from older databases are reachable as normal tabs.
187    let events = dispatch_to_reducer(
188        state,
189        agentmux_common::ipc::Command::SetActiveTab {
190            workspace_id: ws_id.clone(),
191            tab_id: tab_id.clone(),
192        },
193    )
194    .await;
195    // Reducer emits Event::Error on unknown workspace/tab —
196    // surface as RPC error.
197    if let Some(err_msg) = events.iter().find_map(|e| match e {
198        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
199        _ => None,
200    }) {
201        return WebReturnType::error(err_msg);
202    }
203    // Apply synchronously. SetActiveTab is reversible at the
204    // reducer level (just write back the previous active id),
205    // but we don't track the previous id here; if SQLite
206    // fails, return the error and accept short-lived
207    // divergence on this RPC path. (Acceptable: SetActiveTab
208    // is a UI-driven action; the user can retry.)
209    let mut apply_err: Option<String> = None;
210    for ev in &events {
211        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
212            apply_err = Some(e.to_string());
213            break;
214        }
215    }
216    if let Some(err) = apply_err {
217        return WebReturnType::error(format!("SetActiveTab: SQLite write failed: {}", err));
218    }
219    publish_events(state, &events);
220    if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
221        let update = MuxObjUpdate {
222            updatetype: "update".into(),
223            otype: OTYPE_WORKSPACE.to_string(),
224            oid: ws_id.clone(),
225            obj: Some(mux_obj_to_value(&ws)),
226        };
227        WebReturnType::success_with_updates(vec![update])
228    } else {
229        WebReturnType::success_empty()
230    }
231}
232
233// Phase E.5.7 (Step 5 PR 1) — CloseTab via DeleteTab saga.
234// Replaces the legacy SQLite-first pattern (wcore::delete_tab
235// followed by reducer-sync dispatch) with a saga-driven
236// reducer + persist-subscriber flow. The saga also enforces
237// the not-the-last-tab pre-condition mirrored from
238// TearOffTab; user-facing CloseTab now refuses to drain a
239// workspace to zero tabs (callers wanting full teardown
240// should issue DeleteWorkspace instead — that path migrates
241// in Step 5 PR 2).
242pub(crate) async fn handle_close_tab(state: &AppState, call: &WebCallType) -> WebReturnType {
243    let store = &state.mstore;
244    let args = &call.args;
245    let ws_id: String = match service::get_arg(args, 0) {
246        Ok(v) => v,
247        Err(e) => return WebReturnType::error(e),
248    };
249    let tab_id: String = match service::get_arg(args, 1) {
250        Ok(v) => v,
251        Err(e) => return WebReturnType::error(e),
252    };
253    if let Err(reason) =
254        crate::sagas::delete_tab::run(state, ws_id.clone(), tab_id.clone()).await
255    {
256        return WebReturnType::error(reason);
257    }
258    let rtn = CloseTabRtnType {
259        closewindow: false,
260        newactivetabid: String::new(),
261    };
262    let mut updates = vec![MuxObjUpdate {
263        updatetype: "delete".into(),
264        otype: OTYPE_TAB.to_string(),
265        oid: tab_id.clone(),
266        obj: None,
267    }];
268    if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
269        updates.push(MuxObjUpdate {
270            updatetype: "update".into(),
271            otype: OTYPE_WORKSPACE.to_string(),
272            oid: ws_id.clone(),
273            obj: Some(mux_obj_to_value(&ws)),
274        });
275    }
276    WebReturnType::success_data_updates(
277        serde_json::to_value(&rtn).unwrap_or_default(),
278        updates,
279    )
280}
281
282// Phase E.5.3 — UpdateTabIds migrated to ReorderTabsBulk
283// through the reducer. The legacy `pinned_tab_ids` arg is
284// ignored: pinning was a Waveterm feature removed from
285// AgentMux. Bootstrap merged any legacy `pinnedtabids` into
286// the reducer's `tab_ids`. The subscriber's
287// `apply_tabs_reordered_bulk` rewrites `Workspace.tabids`
288// and drains any leftover `Workspace.pinnedtabids` so the
289// UI's `[...pinnedtabids, ...tabids]` combine never
290// double-counts a tab once a workspace's tabs are
291// reordered through the reducer.
292pub(crate) async fn handle_update_tab_ids(state: &AppState, call: &WebCallType) -> WebReturnType {
293    let store = &state.mstore;
294    let args = &call.args;
295    let ws_id: String = match service::get_arg(args, 0) {
296        Ok(v) => v,
297        Err(e) => return WebReturnType::error(e),
298    };
299    let tab_ids: Vec<String> = match service::get_arg(args, 1) {
300        Ok(v) => v,
301        Err(e) => return WebReturnType::error(e),
302    };
303    // args[2] (pinned_tab_ids) intentionally ignored.
304    let events = dispatch_to_reducer(
305        state,
306        agentmux_common::ipc::Command::ReorderTabsBulk {
307            workspace_id: ws_id.clone(),
308            tab_ids,
309        },
310    )
311    .await;
312    if let Some(err_msg) = events.iter().find_map(|e| match e {
313        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
314        _ => None,
315    }) {
316        return WebReturnType::error(err_msg);
317    }
318    for ev in &events {
319        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
320            return WebReturnType::error(format!(
321                "UpdateTabIds: SQLite write failed: {}",
322                e
323            ));
324        }
325    }
326    publish_events(state, &events);
327    if let Ok(updated_ws) = store.must_get::<Workspace>(&ws_id) {
328        let update = MuxObjUpdate {
329            updatetype: "update".into(),
330            otype: OTYPE_WORKSPACE.to_string(),
331            oid: ws_id.clone(),
332            obj: Some(mux_obj_to_value(&updated_ws)),
333        };
334        return WebReturnType::success_with_updates(vec![update]);
335    }
336    WebReturnType::success_empty()
337}
338
339// Phase E.2c.3b — ReorderTab dispatches through the reducer.
340// Forward+compensate isn't useful here (reorder is its own
341// inverse), so on SQLite apply failure we just surface the
342// error and the reducer state ends up ahead of disk for the
343// remainder of the session — converges back at next restart
344// via bootstrap.
345pub(crate) async fn handle_reorder_tab(state: &AppState, call: &WebCallType) -> WebReturnType {
346    let store = &state.mstore;
347    let args = &call.args;
348    let ws_id: String = match service::get_arg(args, 0) {
349        Ok(v) => v,
350        Err(e) => return WebReturnType::error(e),
351    };
352    let tab_id: String = match service::get_arg(args, 1) {
353        Ok(v) => v,
354        Err(e) => return WebReturnType::error(e),
355    };
356    let new_index: usize = match service::get_arg(args, 2) {
357        Ok(v) => v,
358        Err(e) => return WebReturnType::error(e),
359    };
360    tracing::info!(ws_id = %ws_id, tab_id = %tab_id, new_index = %new_index, "[dnd:svc] ReorderTab");
361    // Clamp to u32::MAX rather than truncating via `as u32`.
362    // The reducer further clamps to `tab_ids.len() - 1` so an
363    // absurd usize ends up at the last position — matching
364    // the prior `wcore::reorder_tab` behaviour where any
365    // out-of-range usize clamped to the end. (codex P3 #617.)
366    let new_index_u32 = u32::try_from(new_index).unwrap_or(u32::MAX);
367    let events = dispatch_to_reducer(
368        state,
369        agentmux_common::ipc::Command::ReorderTab {
370            workspace_id: ws_id.clone(),
371            tab_id: tab_id.clone(),
372            new_index: new_index_u32,
373        },
374    )
375    .await;
376    // Surface reducer Error events.
377    if let Some(err_msg) = events.iter().find_map(|e| match e {
378        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
379        _ => None,
380    }) {
381        return WebReturnType::error(err_msg);
382    }
383    let mut apply_err: Option<String> = None;
384    for ev in &events {
385        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
386            apply_err = Some(e.to_string());
387            break;
388        }
389    }
390    if let Some(err) = apply_err {
391        return WebReturnType::error(format!("ReorderTab: SQLite write failed: {}", err));
392    }
393    publish_events(state, &events);
394    if let Ok(ws) = store.must_get::<Workspace>(&ws_id) {
395        let update = MuxObjUpdate {
396            updatetype: "update".into(),
397            otype: OTYPE_WORKSPACE.to_string(),
398            oid: ws_id.clone(),
399            obj: Some(mux_obj_to_value(&ws)),
400        };
401        WebReturnType::success_with_updates(vec![update])
402    } else {
403        WebReturnType::success_empty()
404    }
405}