Skip to main content

agentmux_srv/reducer/
window.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::WindowRecord;
10
11/// Phase E.5 — record a new window→workspace mapping. Validates
12/// the parent workspace exists; otherwise emits `Event::Error`
13/// (non-fatal). Idempotent on duplicate `window_id`: re-issuing
14/// for the same window updates the workspace pointer if it
15/// changed, or no-ops if identical.
16pub(super) fn handle_create_window(
17    state: &mut State,
18    window_id: String,
19    workspace_id: String,
20) -> Vec<Event> {
21    if !state.workspaces.contains_key(&workspace_id) {
22        let v = state.bump_version();
23        return vec![Event::Error {
24            code: ErrorCode::InvalidCommand,
25            message: format!("CreateWindow: workspace not found: {}", workspace_id),
26            fatal: false,
27            version: v,
28        }];
29    }
30    if let Some(existing) = state.windows.get(&window_id) {
31        if existing.workspace_id == workspace_id {
32            return Vec::new();
33        }
34    }
35    state.windows.insert(
36        window_id.clone(),
37        WindowRecord {
38            window_id: window_id.clone(),
39            workspace_id: workspace_id.clone(),
40        },
41    );
42    let v = state.bump_version();
43    vec![Event::SrvWindowOpened {
44        window_id,
45        workspace_id,
46        version: v,
47    }]
48}
49
50/// Phase E.5 — remove a window's workspace mapping. Idempotent
51/// silent no-op on missing.
52pub(super) fn handle_close_window_internal(state: &mut State, window_id: String) -> Vec<Event> {
53    if state.windows.remove(&window_id).is_none() {
54        return Vec::new();
55    }
56    let v = state.bump_version();
57    vec![Event::SrvWindowClosed {
58        window_id,
59        version: v,
60    }]
61}
62
63/// Phase E.5 — change which workspace a window points at. Errors
64/// (non-fatal) if the window or destination workspace is unknown.
65/// No-op if the window is already pointing at the destination.
66pub(super) fn handle_switch_workspace(
67    state: &mut State,
68    window_id: String,
69    workspace_id: String,
70) -> Vec<Event> {
71    if !state.workspaces.contains_key(&workspace_id) {
72        let v = state.bump_version();
73        return vec![Event::Error {
74            code: ErrorCode::InvalidCommand,
75            message: format!(
76                "SwitchWorkspace: destination workspace not found: {}",
77                workspace_id
78            ),
79            fatal: false,
80            version: v,
81        }];
82    }
83    let Some(window) = state.windows.get_mut(&window_id) else {
84        let v = state.bump_version();
85        return vec![Event::Error {
86            code: ErrorCode::InvalidCommand,
87            message: format!("SwitchWorkspace: window not found: {}", window_id),
88            fatal: false,
89            version: v,
90        }];
91    };
92    if window.workspace_id == workspace_id {
93        return Vec::new();
94    }
95    window.workspace_id = workspace_id.clone();
96    let v = state.bump_version();
97    vec![Event::SrvWindowWorkspaceChanged {
98        window_id,
99        workspace_id,
100        version: v,
101    }]
102}
103
104/// Phase E.5.x (issue #855) — apply a meta-patch to a window. Pass-
105/// through to `Event::WindowMetaUpdated`; the persist subscriber
106/// performs the merge against mstore. Same shape as
107/// `handle_update_workspace_meta` — reducer state does NOT track
108/// window meta, the migration property is "every mutation goes
109/// through the reducer's broadcast bus" so the MuxObjUpdate bridge
110/// can fan out to the frontend.
111///
112/// Validates the window exists, matching its three sibling meta arms
113/// (`handle_update_workspace_meta` / `handle_update_tab_meta` /
114/// `handle_update_block_meta`) — this arm used to be the sole outlier
115/// with no guard, which made `POST /api/v1/window/name` report success
116/// for well-formed-but-nonexistent window ids (the persist subscriber's
117/// `apply_window_meta_updated` silently no-ops on a mstore miss, so
118/// nothing downstream caught it either). The guard is safe because
119/// `state.windows` reliably mirrors real windows: every runtime
120/// creation goes through `handle_create_window`, and
121/// `persist::bootstrap_state_from_mstore` hydrates pre-existing windows
122/// (including the wcore-seeded first-launch window, created before
123/// hydration runs) at startup. The old "wcore-direct paths won't appear
124/// here" caveat this comment used to carry predates that hydration.
125/// SPEC_WINDOW_NAME_API_HARDENING_2026_08_08.md §3.1.
126pub(super) fn handle_update_window_meta(
127    state: &mut State,
128    window_id: String,
129    meta_patch: serde_json::Value,
130) -> Vec<Event> {
131    if !state.windows.contains_key(&window_id) {
132        let v = state.bump_version();
133        return vec![Event::Error {
134            code: ErrorCode::InvalidCommand,
135            message: format!("UpdateWindowMeta: window not found: {}", window_id),
136            fatal: false,
137            version: v,
138        }];
139    }
140    let v = state.bump_version();
141    vec![Event::WindowMetaUpdated {
142        window_id,
143        meta_patch,
144        version: v,
145    }]
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use crate::reducer::test_support::*;
152    use crate::reducer::update;
153    use agentmux_common::ipc::Command;
154
155    #[test]
156    fn create_window_validates_workspace_exists() {
157        let mut state = State::default();
158        let events = update(
159            &mut state,
160            Command::CreateWindow {
161                window_id: "win-1".into(),
162                workspace_id: "no-such-ws".into(),
163            },
164            &ctx(1),
165        );
166        assert!(matches!(&events[0], Event::Error { .. }));
167        assert!(state.windows.is_empty());
168    }
169
170    #[test]
171    fn create_window_inserts_record_and_emits_event() {
172        let mut state = State::default();
173        let ws_id = create_workspace(&mut state, "w");
174        let events = update(
175            &mut state,
176            Command::CreateWindow {
177                window_id: "win-1".into(),
178                workspace_id: ws_id.clone(),
179            },
180            &ctx(2),
181        );
182        assert!(matches!(
183            &events[0],
184            Event::SrvWindowOpened { window_id, workspace_id, .. }
185                if window_id == "win-1" && *workspace_id == ws_id
186        ));
187        assert_eq!(state.windows["win-1"].workspace_id, ws_id);
188    }
189
190    #[test]
191    fn create_window_idempotent_on_same_workspace() {
192        let mut state = State::default();
193        let ws_id = create_workspace(&mut state, "w");
194        let _ = update(
195            &mut state,
196            Command::CreateWindow {
197                window_id: "win-1".into(),
198                workspace_id: ws_id.clone(),
199            },
200            &ctx(2),
201        );
202        let events = update(
203            &mut state,
204            Command::CreateWindow {
205                window_id: "win-1".into(),
206                workspace_id: ws_id,
207            },
208            &ctx(3),
209        );
210        assert!(events.is_empty());
211    }
212
213    #[test]
214    fn close_window_internal_removes_record() {
215        let mut state = State::default();
216        let ws_id = create_workspace(&mut state, "w");
217        let _ = update(
218            &mut state,
219            Command::CreateWindow {
220                window_id: "win-1".into(),
221                workspace_id: ws_id,
222            },
223            &ctx(2),
224        );
225        let events = update(
226            &mut state,
227            Command::CloseWindowInternal {
228                window_id: "win-1".into(),
229            },
230            &ctx(3),
231        );
232        assert!(matches!(&events[0], Event::SrvWindowClosed { .. }));
233        assert!(state.windows.is_empty());
234    }
235
236    #[test]
237    fn close_window_internal_silent_on_missing() {
238        let mut state = State::default();
239        let events = update(
240            &mut state,
241            Command::CloseWindowInternal {
242                window_id: "ghost".into(),
243            },
244            &ctx(1),
245        );
246        assert!(events.is_empty());
247    }
248
249    #[test]
250    fn update_window_meta_errors_on_unknown_window() {
251        // Regression for SPEC_WINDOW_NAME_API_HARDENING_2026_08_08.md §2.1:
252        // this arm used to emit WindowMetaUpdated unconditionally, so a
253        // well-formed-but-nonexistent window id sailed through to a silent
254        // persist no-op and POST /api/v1/window/name reported success.
255        let mut state = State::default();
256        let events = update(
257            &mut state,
258            Command::UpdateWindowMeta {
259                window_id: "00000000-dead-beef-0000-000000000000".into(),
260                meta_patch: serde_json::json!({ "window:displayname": "phantom" }),
261            },
262            &ctx(1),
263        );
264        assert!(
265            matches!(&events[0], Event::Error { message, .. } if message.contains("window not found")),
266            "expected window-not-found error, got {:?}",
267            events
268        );
269    }
270
271    #[test]
272    fn update_window_meta_emits_event_for_known_window() {
273        let mut state = State::default();
274        let ws_id = create_workspace(&mut state, "a");
275        let _ = update(
276            &mut state,
277            Command::CreateWindow {
278                window_id: "win-1".into(),
279                workspace_id: ws_id,
280            },
281            &ctx(2),
282        );
283        let events = update(
284            &mut state,
285            Command::UpdateWindowMeta {
286                window_id: "win-1".into(),
287                meta_patch: serde_json::json!({ "window:displayname": "named" }),
288            },
289            &ctx(3),
290        );
291        assert!(
292            matches!(&events[0], Event::WindowMetaUpdated { window_id, .. } if window_id == "win-1"),
293            "expected WindowMetaUpdated, got {:?}",
294            events
295        );
296    }
297
298    #[test]
299    fn switch_workspace_updates_window_pointer() {
300        let mut state = State::default();
301        let ws_a = create_workspace(&mut state, "a");
302        let ws_b = create_workspace(&mut state, "b");
303        let _ = update(
304            &mut state,
305            Command::CreateWindow {
306                window_id: "win-1".into(),
307                workspace_id: ws_a.clone(),
308            },
309            &ctx(2),
310        );
311        let events = update(
312            &mut state,
313            Command::SwitchWorkspace {
314                window_id: "win-1".into(),
315                workspace_id: ws_b.clone(),
316            },
317            &ctx(3),
318        );
319        assert!(matches!(
320            &events[0],
321            Event::SrvWindowWorkspaceChanged { workspace_id, .. } if *workspace_id == ws_b
322        ));
323        assert_eq!(state.windows["win-1"].workspace_id, ws_b);
324    }
325
326    #[test]
327    fn switch_workspace_validates_window_and_destination() {
328        let mut state = State::default();
329        let ws_id = create_workspace(&mut state, "a");
330        // Unknown window.
331        let events = update(
332            &mut state,
333            Command::SwitchWorkspace {
334                window_id: "ghost".into(),
335                workspace_id: ws_id.clone(),
336            },
337            &ctx(2),
338        );
339        assert!(matches!(&events[0], Event::Error { .. }));
340        // Known window, unknown workspace.
341        let _ = update(
342            &mut state,
343            Command::CreateWindow {
344                window_id: "win-1".into(),
345                workspace_id: ws_id,
346            },
347            &ctx(3),
348        );
349        let events = update(
350            &mut state,
351            Command::SwitchWorkspace {
352                window_id: "win-1".into(),
353                workspace_id: "no-such-ws".into(),
354            },
355            &ctx(4),
356        );
357        assert!(matches!(&events[0], Event::Error { .. }));
358    }
359
360    #[test]
361    fn switch_workspace_no_op_when_already_pointing() {
362        let mut state = State::default();
363        let ws_id = create_workspace(&mut state, "a");
364        let _ = update(
365            &mut state,
366            Command::CreateWindow {
367                window_id: "win-1".into(),
368                workspace_id: ws_id.clone(),
369            },
370            &ctx(2),
371        );
372        let events = update(
373            &mut state,
374            Command::SwitchWorkspace {
375                window_id: "win-1".into(),
376                workspace_id: ws_id,
377            },
378            &ctx(3),
379        );
380        assert!(events.is_empty());
381    }
382}