agentmux_srv\reducer/
tab.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use agentmux_common::ipc::{ErrorCode, Event};
5
6use crate::state::State;
7
8
9use crate::state::TabRecord;
10
11/// Phase E.2b — create a tab inside a workspace. Validates the
12/// parent exists; otherwise emits `Event::Error` (non-fatal). On
13/// success: assigns a UUID, appends to the workspace's `tab_ids`,
14/// inserts into `state.tabs`, emits `Event::TabCreated`. If the
15/// workspace had no active tab, the new tab also becomes active
16/// and an `Event::ActiveTabChanged` is emitted alongside.
17///
18/// NOT idempotent on retry (same UUID-assignment caveat as
19/// `handle_create_workspace`).
20pub(super) fn handle_create_tab(state: &mut State, workspace_id: String, name: String) -> Vec<Event> {
21    let Some(workspace_record) = state.workspaces.get(&workspace_id) else {
22        let v = state.bump_version();
23        return vec![Event::Error {
24            code: ErrorCode::InvalidCommand,
25            message: format!("CreateTab: workspace not found: {}", workspace_id),
26            fatal: false,
27            version: v,
28        }];
29    };
30    // codex P2 #622: auto-generate `tabN` when name is empty,
31    // matching `wcore::create_tab`'s default-naming behaviour. The
32    // counter uses the reducer's tab_ids length + 1 (matching the
33    // old SQLite-side count: tabids.len() + pinnedtabids.len() + 1
34    // — pinnedtabids stays at zero in production since pinning
35    // was removed in E.2c.3b, so reducer-only counting matches).
36    let resolved_name = if name.is_empty() {
37        format!("tab{}", workspace_record.tab_ids.len() + 1)
38    } else {
39        name
40    };
41    let tab_id = uuid::Uuid::new_v4().to_string();
42    state.tabs.insert(
43        tab_id.clone(),
44        TabRecord {
45            tab_id: tab_id.clone(),
46            workspace_id: workspace_id.clone(),
47            name: resolved_name.clone(),
48            block_ids: Vec::new(),
49            focused_node_id: String::new(),
50            magnified_node_id: String::new(),
51            rootnode: None,
52        },
53    );
54    let workspace = state.workspaces.get_mut(&workspace_id).expect("checked");
55    workspace.tab_ids.push(tab_id.clone());
56    let activated = if workspace.active_tab_id.is_none() {
57        workspace.active_tab_id = Some(tab_id.clone());
58        true
59    } else {
60        false
61    };
62    let mut events = Vec::with_capacity(2);
63    let v = state.bump_version();
64    events.push(Event::TabCreated {
65        workspace_id: workspace_id.clone(),
66        tab_id: tab_id.clone(),
67        name: resolved_name,
68        version: v,
69    });
70    if activated {
71        let v2 = state.bump_version();
72        events.push(Event::ActiveTabChanged {
73            workspace_id,
74            tab_id: Some(tab_id),
75            version: v2,
76        });
77    }
78    events
79}
80
81/// Phase E.2b — delete a tab from a workspace. Idempotent: deleting
82/// a missing tab is a silent no-op. If the deleted tab was the
83/// active tab, the workspace's active tab becomes the next tab in
84/// `tab_ids` (or the previous one if the deleted was last; or None
85/// if the workspace is now empty), and an `Event::ActiveTabChanged`
86/// is emitted alongside `Event::TabDeleted`.
87pub(super) fn handle_delete_tab(
88    state: &mut State,
89    workspace_id: String,
90    tab_id: String,
91    force: bool,
92) -> Vec<Event> {
93    let Some(workspace) = state.workspaces.get_mut(&workspace_id) else {
94        return Vec::new();
95    };
96    let Some(pos) = workspace.tab_ids.iter().position(|t| t == &tab_id) else {
97        return Vec::new();
98    };
99    // Last-tab guard with `force` bypass (round 4 of PR #633).
100    // History:
101    //   * Round 1: saga pre-check → reagent flagged TOCTOU race.
102    //   * Round 2: moved guard to reducer (atomic) → broke CreateTab
103    //     compensation (codex P1 round 2) and Cmd+W keyboard flow
104    //     (codex P1 round 1).
105    //   * Round 3: removed guard entirely; saga keeps soft pre-check
106    //     → codex P2 round 4 re-flagged the TOCTOU race.
107    //   * Round 4 (this): atomic guard with `force: bool` bypass.
108    //     User-facing flows (CloseTab RPC → DeleteTab saga) pass
109    //     `force: false`; compensation paths (`CreateTab` rollback,
110    //     `PromoteBlockToTab.ctx.compensate`) pass `force: true`.
111    //     Frontend keyboard handler `simpleCloseStaticTab` already
112    //     gates pre-RPC, so the reducer rejection is a defense-in-
113    //     depth backstop that catches automation/race paths.
114    if !force && workspace.tab_ids.len() <= 1 {
115        let v = state.bump_version();
116        return vec![Event::Error {
117            code: ErrorCode::InvalidCommand,
118            message: format!(
119                "DeleteTab: refusing to delete the last tab in workspace {} (would leave empty workspace; pass force=true for compensation paths)",
120                workspace_id,
121            ),
122            fatal: false,
123            version: v,
124        }];
125    }
126    workspace.tab_ids.remove(pos);
127    let active_changed = if workspace.active_tab_id.as_deref() == Some(tab_id.as_str()) {
128        let new_active = workspace
129            .tab_ids
130            .get(pos)
131            .or_else(|| pos.checked_sub(1).and_then(|i| workspace.tab_ids.get(i)))
132            .cloned();
133        workspace.active_tab_id = new_active.clone();
134        Some(new_active)
135    } else {
136        None
137    };
138    let removed_tab = state.tabs.remove(&tab_id);
139    // Phase E.3 — cascade to blocks. Subscribers observing TabDeleted
140    // are expected to drop dependent block state (no per-block
141    // BlockDeleted events emitted; mirrors workspace→tabs cascade
142    // semantics).
143    if let Some(tab) = &removed_tab {
144        for block_id in &tab.block_ids {
145            state.blocks.remove(block_id);
146        }
147    }
148    let mut events = Vec::with_capacity(2);
149    let v = state.bump_version();
150    events.push(Event::TabDeleted {
151        workspace_id: workspace_id.clone(),
152        tab_id,
153        version: v,
154    });
155    if let Some(new_active) = active_changed {
156        let v2 = state.bump_version();
157        events.push(Event::ActiveTabChanged {
158            workspace_id,
159            tab_id: new_active,
160            version: v2,
161        });
162    }
163    events
164}
165
166/// Phase E.2b — set a workspace's active tab. No-op if already
167/// active. Errors (non-fatal) if the workspace doesn't exist or the
168/// tab isn't in that workspace's tab list.
169pub(super) fn handle_set_active_tab(
170    state: &mut State,
171    workspace_id: String,
172    tab_id: String,
173) -> Vec<Event> {
174    let Some(workspace) = state.workspaces.get_mut(&workspace_id) else {
175        let v = state.bump_version();
176        return vec![Event::Error {
177            code: ErrorCode::InvalidCommand,
178            message: format!("SetActiveTab: workspace not found: {}", workspace_id),
179            fatal: false,
180            version: v,
181        }];
182    };
183    if !workspace.tab_ids.iter().any(|t| t == &tab_id) {
184        let v = state.bump_version();
185        return vec![Event::Error {
186            code: ErrorCode::InvalidCommand,
187            message: format!(
188                "SetActiveTab: tab {} not in workspace {}",
189                tab_id, workspace_id
190            ),
191            fatal: false,
192            version: v,
193        }];
194    }
195    if workspace.active_tab_id.as_deref() == Some(tab_id.as_str()) {
196        return Vec::new();
197    }
198    workspace.active_tab_id = Some(tab_id.clone());
199    let v = state.bump_version();
200    vec![Event::ActiveTabChanged {
201        workspace_id,
202        tab_id: Some(tab_id),
203        version: v,
204    }]
205}
206
207/// Phase E.2c.3b — reorder a tab within its workspace's
208/// `tab_ids`. `new_index` is clamped to `tab_ids.len()`. No-op
209/// if the tab is already at that position. Errors (non-fatal) if
210/// the workspace doesn't exist or the tab isn't in its tab list.
211pub(super) fn handle_reorder_tab(
212    state: &mut State,
213    workspace_id: String,
214    tab_id: String,
215    new_index: u32,
216) -> Vec<Event> {
217    let Some(workspace) = state.workspaces.get_mut(&workspace_id) else {
218        let v = state.bump_version();
219        return vec![Event::Error {
220            code: ErrorCode::InvalidCommand,
221            message: format!("ReorderTab: workspace not found: {}", workspace_id),
222            fatal: false,
223            version: v,
224        }];
225    };
226    let Some(current_pos) = workspace.tab_ids.iter().position(|t| t == &tab_id) else {
227        let v = state.bump_version();
228        return vec![Event::Error {
229            code: ErrorCode::InvalidCommand,
230            message: format!(
231                "ReorderTab: tab {} not in workspace {}",
232                tab_id, workspace_id
233            ),
234            fatal: false,
235            version: v,
236        }];
237    };
238    let len = workspace.tab_ids.len();
239    let target = (new_index as usize).min(len.saturating_sub(1));
240    if current_pos == target {
241        return Vec::new();
242    }
243    let id = workspace.tab_ids.remove(current_pos);
244    workspace.tab_ids.insert(target, id);
245    let v = state.bump_version();
246    vec![Event::TabReordered {
247        workspace_id,
248        tab_id,
249        new_index: target as u32,
250        version: v,
251    }]
252}
253
254/// Phase E.5.3 — replace a workspace's `tab_ids` with the given
255/// list. Validates the workspace exists and the new list is a
256/// permutation of the current set (same elements, possibly
257/// different order). No-op if identical.
258pub(super) fn handle_reorder_tabs_bulk(
259    state: &mut State,
260    workspace_id: String,
261    tab_ids: Vec<String>,
262) -> Vec<Event> {
263    // codex P1 #620 carryover: relax membership validation until tab
264    // moves are migrated through the reducer. `MoveTabToWorkspace`
265    // and `PromoteBlockToTab` (planned for PR 4) still write through
266    // wcore without dispatching reducer commands, so the reducer's
267    // view of `workspace.tab_ids` can be stale relative to SQLite.
268    // A subsequent `UpdateTabIds` (now routed through this command)
269    // must not refuse the canonical order just because the reducer
270    // hasn't seen the upstream move yet — that would be a
271    // user-visible regression vs. the prior wcore-direct path.
272    //
273    // Treat the caller's `tab_ids` as authoritative. The remaining
274    // checks are basic sanity: the workspace must exist in the
275    // reducer, and `tab_ids` must not contain duplicates (which would
276    // produce a corrupt persisted ordering with no way for the
277    // subscriber to recover). Length / set comparison against the
278    // reducer's stale view is dropped here; PR 4 reinstates strict
279    // validation once tab moves go through the reducer.
280    if !state.workspaces.contains_key(&workspace_id) {
281        let v = state.bump_version();
282        return vec![Event::Error {
283            code: ErrorCode::InvalidCommand,
284            message: format!("ReorderTabsBulk: workspace not found: {}", workspace_id),
285            fatal: false,
286            version: v,
287        }];
288    }
289    {
290        let mut seen: std::collections::HashSet<&String> =
291            std::collections::HashSet::with_capacity(tab_ids.len());
292        for id in &tab_ids {
293            if !seen.insert(id) {
294                let v = state.bump_version();
295                return vec![Event::Error {
296                    code: ErrorCode::InvalidCommand,
297                    message: format!(
298                        "ReorderTabsBulk: tab_ids contains duplicate entry: {}",
299                        id
300                    ),
301                    fatal: false,
302                    version: v,
303                }];
304            }
305        }
306    }
307    if state.workspaces.get(&workspace_id).expect("checked").tab_ids == tab_ids {
308        return Vec::new();
309    }
310    state.workspaces.get_mut(&workspace_id).expect("checked").tab_ids = tab_ids.clone();
311    let v = state.bump_version();
312    vec![Event::TabsReorderedBulk {
313        workspace_id,
314        tab_ids,
315        version: v,
316    }]
317}
318
319/// Phase E.5.5 — move a tab from `src_workspace_id` to
320/// `dst_workspace_id`, inserting at `dst_index` (clamped to dst's
321/// length). Updates the tab's `workspace_id`, removes it from src's
322/// `tab_ids`, inserts into dst's `tab_ids`. If the tab was src's
323/// `active_tab_id`, src's active reverts to its first remaining
324/// tab (or `None` when empty).
325///
326/// Errors when:
327/// * source / dest workspace not found,
328/// * tab not found,
329/// * `tab.workspace_id != src_workspace_id` (caller-side bug),
330/// * `src_workspace_id == dst_workspace_id` (use `ReorderTab` for
331///   intra-workspace reorders — same-workspace moves through this
332///   path would create ambiguity around `dst_index` semantics).
333pub(super) fn handle_move_tab(
334    state: &mut State,
335    tab_id: String,
336    src_workspace_id: String,
337    dst_workspace_id: String,
338    dst_index: u32,
339) -> Vec<Event> {
340    if src_workspace_id == dst_workspace_id {
341        let v = state.bump_version();
342        return vec![Event::Error {
343            code: ErrorCode::InvalidCommand,
344            message: "MoveTab: src and dst workspaces are identical; use ReorderTab".into(),
345            fatal: false,
346            version: v,
347        }];
348    }
349    // Strict validation (Phase E.4 strict-mode flip): the
350    // migration-tolerant lazy-import fallback (codex P1 round-2
351    // #621) was removed once the soak window closed with no
352    // `lazy-import` warnings observed in production. All reducer-
353    // routed paths now keep `state.tabs` and
354    // `state.workspaces[*].tab_ids` consistent with SQLite, so we
355    // can reject unknown tabs and workspace_id mismatches outright.
356    if !state.workspaces.contains_key(&src_workspace_id) {
357        let v = state.bump_version();
358        return vec![Event::Error {
359            code: ErrorCode::InvalidCommand,
360            message: format!("MoveTab: src workspace not found: {}", src_workspace_id),
361            fatal: false,
362            version: v,
363        }];
364    }
365    if !state.workspaces.contains_key(&dst_workspace_id) {
366        let v = state.bump_version();
367        return vec![Event::Error {
368            code: ErrorCode::InvalidCommand,
369            message: format!("MoveTab: dst workspace not found: {}", dst_workspace_id),
370            fatal: false,
371            version: v,
372        }];
373    }
374    let tab_workspace_id: Option<String> =
375        state.tabs.get(&tab_id).map(|t| t.workspace_id.clone());
376    match tab_workspace_id {
377        None => {
378            let v = state.bump_version();
379            return vec![Event::Error {
380                code: ErrorCode::InvalidCommand,
381                message: format!("MoveTab: tab not found in state: {}", tab_id),
382                fatal: false,
383                version: v,
384            }];
385        }
386        Some(actual_ws) if actual_ws != src_workspace_id => {
387            let v = state.bump_version();
388            return vec![Event::Error {
389                code: ErrorCode::InvalidCommand,
390                message: format!(
391                    "MoveTab: workspace_id mismatch — tab {} belongs to {}, not {}",
392                    tab_id, actual_ws, src_workspace_id
393                ),
394                fatal: false,
395                version: v,
396            }];
397        }
398        Some(_) => {}
399    }
400
401    // Remove from src.
402    let new_src_active_tab_id: Option<String> = {
403        let src = state.workspaces.get_mut(&src_workspace_id).expect("checked");
404        src.tab_ids.retain(|id| id != &tab_id);
405        if src.active_tab_id.as_deref() == Some(tab_id.as_str()) {
406            src.active_tab_id = src.tab_ids.first().cloned();
407        }
408        src.active_tab_id.clone()
409    };
410
411    // Insert into dst at clamped index. Set the moved tab as dst's
412    // new active tab — mirrors wcore::move_tab_to_workspace
413    // behaviour and addresses codex P2 #621 (dst.active_tab_id was
414    // previously left untouched, so a saga-driven tear-off could
415    // produce a destination workspace with no active tab selected).
416    let final_dst_index: u32 = {
417        let dst = state.workspaces.get_mut(&dst_workspace_id).expect("checked");
418        let clamped = (dst_index as usize).min(dst.tab_ids.len());
419        dst.tab_ids.insert(clamped, tab_id.clone());
420        dst.active_tab_id = Some(tab_id.clone());
421        clamped as u32
422    };
423
424    // Update the tab's parent.
425    state
426        .tabs
427        .get_mut(&tab_id)
428        .expect("checked")
429        .workspace_id = dst_workspace_id.clone();
430
431    let v = state.bump_version();
432    vec![Event::TabMoved {
433        tab_id: tab_id.clone(),
434        src_workspace_id,
435        dst_workspace_id,
436        dst_index: final_dst_index,
437        new_src_active_tab_id,
438        new_dst_active_tab_id: Some(tab_id),
439        version: v,
440    }]
441}
442
443/// Phase E.5.3 — rename a tab. Errors if missing; no-op if the
444/// name is unchanged.
445pub(super) fn handle_rename_tab(state: &mut State, tab_id: String, name: String) -> Vec<Event> {
446    let Some(tab) = state.tabs.get_mut(&tab_id) else {
447        let v = state.bump_version();
448        return vec![Event::Error {
449            code: ErrorCode::InvalidCommand,
450            message: format!("RenameTab: tab not found: {}", tab_id),
451            fatal: false,
452            version: v,
453        }];
454    };
455    if tab.name == name {
456        return Vec::new();
457    }
458    tab.name = name.clone();
459    let v = state.bump_version();
460    vec![Event::TabRenamed {
461        tab_id,
462        name,
463        version: v,
464    }]
465}
466
467/// Phase E.5.3 — pass-through for tab meta updates. Same shape as
468/// `handle_update_workspace_meta`.
469pub(super) fn handle_update_tab_meta(
470    state: &mut State,
471    tab_id: String,
472    meta_patch: serde_json::Value,
473) -> Vec<Event> {
474    if !state.tabs.contains_key(&tab_id) {
475        let v = state.bump_version();
476        return vec![Event::Error {
477            code: ErrorCode::InvalidCommand,
478            message: format!("UpdateTabMeta: tab not found: {}", tab_id),
479            fatal: false,
480            version: v,
481        }];
482    }
483    let v = state.bump_version();
484    vec![Event::TabMetaUpdated {
485        tab_id,
486        meta_patch,
487        version: v,
488    }]
489}