Skip to main content

agentmux_srv/server/service/
window_mutate.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `window` service handlers — window mutation RPCs (`SwitchWorkspace`,
5//! `SetWindowPosAndSize`, `SetWindowOpacity`, `SetWindowTopology`). Split
6//! out of `window.rs`; see that file's dispatcher for the full method list.
7
8use crate::backend::obj::*;
9use crate::backend::service::{self, WebCallType, WebReturnType};
10
11use super::super::AppState;
12use super::reducer_helpers::{dispatch_to_reducer, publish_events};
13
14// Phase E.5.8 — SwitchWorkspace migrated to single-step
15// reducer dispatch. The reducer validates window + workspace
16// both exist + emits SrvWindowWorkspaceChanged; subscriber
17// writes Window.workspaceid in SQLite.
18pub(crate) async fn handle_switch_workspace(state: &AppState, call: &WebCallType) -> WebReturnType {
19    let store = &state.mstore;
20    let args = &call.args;
21    let window_id: String = match service::get_arg(args, 0) {
22        Ok(v) => v,
23        Err(e) => return WebReturnType::error(e),
24    };
25    let ws_id: String = match service::get_arg(args, 1) {
26        Ok(v) => v,
27        Err(e) => return WebReturnType::error(e),
28    };
29    let events = dispatch_to_reducer(
30        state,
31        agentmux_common::ipc::Command::SwitchWorkspace {
32            window_id: window_id.clone(),
33            workspace_id: ws_id.clone(),
34        },
35    )
36    .await;
37    if let Some(err_msg) = events.iter().find_map(|e| match e {
38        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
39        _ => None,
40    }) {
41        return WebReturnType::error(err_msg);
42    }
43    for ev in &events {
44        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
45            return WebReturnType::error(format!(
46                "SwitchWorkspace: SQLite write failed: {}",
47                e
48            ));
49        }
50    }
51    publish_events(state, &events);
52    WebReturnType::success_empty()
53}
54
55pub(crate) async fn handle_set_window_pos_and_size(state: &AppState, call: &WebCallType) -> WebReturnType {
56    let store = &state.mstore;
57    let args = &call.args;
58    let window_id: String = match service::get_arg(args, 0) {
59        Ok(v) => v,
60        Err(e) => return WebReturnType::error(e),
61    };
62    let pos: Option<Point> = service::get_optional_arg(args, 1).unwrap_or(None);
63    let size: Option<WinSize> = service::get_optional_arg(args, 2).unwrap_or(None);
64    match store.must_get::<Window>(&window_id) {
65        Ok(mut win) => {
66            if let Some(p) = pos {
67                win.pos = p;
68            }
69            if let Some(s) = size {
70                win.winsize = s;
71            }
72            match store.update(&mut win) {
73                Ok(_) => WebReturnType::success_empty(),
74                Err(e) => WebReturnType::error(e.to_string()),
75            }
76        }
77        Err(e) => WebReturnType::error(e.to_string()),
78    }
79}
80
81// SPEC_PILLAR1_STEP2 (Slice A, Phase 1) — durable mirror of the
82// host's per-window opacity so a crashed/restarted host can
83// restore it (Phase 2, not yet wired: nothing calls this arm
84// today). Direct store read-modify-write, same shape as
85// SetWindowPosAndSize just above — window opacity isn't
86// reducer-tracked state (see `state::WindowRecord`), so there's
87// no split-brain risk to route around the way #864's layout
88// tree had. `opacity: None` clears back to fully-opaque/unset —
89// that's a real, destructive state change (unlike pos/size's
90// `None` in SetWindowPosAndSize, which just means "skip this
91// field"), so unlike that handler a malformed (non-null,
92// non-f32) argument must surface as an error, not get silently
93// treated as an explicit clear. (reagent P1.)
94pub(crate) async fn handle_set_window_opacity(state: &AppState, call: &WebCallType) -> WebReturnType {
95    let store = &state.mstore;
96    let args = &call.args;
97    let window_id: String = match service::get_arg(args, 0) {
98        Ok(v) => v,
99        Err(e) => return WebReturnType::error(e),
100    };
101    let opacity: Option<f32> = match service::get_optional_arg(args, 1) {
102        Ok(v) => v,
103        Err(e) => return WebReturnType::error(e),
104    };
105    if let Some(o) = opacity {
106        if !(0.0..=1.0).contains(&o) {
107            return WebReturnType::error(format!(
108                "SetWindowOpacity: opacity must be in 0.0..=1.0, got {o}"
109            ));
110        }
111    }
112    match store.must_get::<Window>(&window_id) {
113        Ok(mut win) => {
114            win.opacity = opacity;
115            match store.update(&mut win) {
116                Ok(_) => WebReturnType::success_empty(),
117                Err(e) => WebReturnType::error(e.to_string()),
118            }
119        }
120        Err(e) => WebReturnType::error(e.to_string()),
121    }
122}
123
124// SPEC_PILLAR1_STEP3 — durable mirror of a window's kind + parent
125// linkage, so a future reproject can tell which native-window
126// creation path to drive for each window in `Client.windowids`.
127// Direct store read-modify-write, same shape as SetWindowOpacity
128// just above — kind/parent aren't reducer-tracked state (see
129// `state::WindowRecord`), so there's no split-brain risk to route
130// around.
131pub(crate) async fn handle_set_window_topology(state: &AppState, call: &WebCallType) -> WebReturnType {
132    let store = &state.mstore;
133    let args = &call.args;
134    let window_id: String = match service::get_arg(args, 0) {
135        Ok(v) => v,
136        Err(e) => return WebReturnType::error(e),
137    };
138    let kind: Option<String> = match service::get_optional_arg(args, 1) {
139        Ok(v) => v,
140        Err(e) => return WebReturnType::error(e),
141    };
142    let parent_window_id: Option<String> = match service::get_optional_arg(args, 2) {
143        Ok(v) => v,
144        Err(e) => return WebReturnType::error(e),
145    };
146    if let Some(k) = &kind {
147        if k != "full_instance" && k != "subwindow" {
148            return WebReturnType::error(format!(
149                "SetWindowTopology: kind must be 'full_instance' or 'subwindow', got '{k}'"
150            ));
151        }
152        if k == "subwindow" && parent_window_id.is_none() {
153            return WebReturnType::error(
154                "SetWindowTopology: kind='subwindow' requires a non-null parent_window_id"
155                    .to_string(),
156            );
157        }
158    }
159    // reagent P1: parent_window_id must only ever be set alongside
160    // kind='subwindow' — the doc comment's invariant ("None for a
161    // full_instance ... or an unset/legacy row") was previously only
162    // enforced in one direction (subwindow requires a parent), not
163    // the reverse (full_instance/None must NOT carry a parent).
164    if parent_window_id.is_some() && kind.as_deref() != Some("subwindow") {
165        return WebReturnType::error(
166            "SetWindowTopology: parent_window_id must only be set when kind='subwindow'"
167                .to_string(),
168        );
169    }
170    // reagent P2: a subwindow's parent must reference a real,
171    // DIFFERENT window — otherwise a dangling or self-referential
172    // parent_window_id silently persists undetected.
173    if let Some(parent_id) = &parent_window_id {
174        if parent_id == &window_id {
175            return WebReturnType::error(
176                "SetWindowTopology: parent_window_id must not equal window_id".to_string(),
177            );
178        }
179        if store.must_get::<Window>(parent_id).is_err() {
180            return WebReturnType::error(format!(
181                "SetWindowTopology: parent_window_id '{parent_id}' does not reference an existing window"
182            ));
183        }
184    }
185    match store.must_get::<Window>(&window_id) {
186        Ok(mut win) => {
187            win.kind = kind;
188            win.parent_window_id = parent_window_id;
189            match store.update(&mut win) {
190                Ok(_) => WebReturnType::success_empty(),
191                Err(e) => WebReturnType::error(e.to_string()),
192            }
193        }
194        Err(e) => WebReturnType::error(e.to_string()),
195    }
196}
197
198/// SPEC_PILLAR1_STEP2 Slice A Phase 1 — `SetWindowOpacity` unit tests.
199#[cfg(test)]
200mod set_window_opacity_tests {
201    use super::super::window::handle_window_service;
202    use crate::backend::obj::Window;
203    use crate::backend::service::WebCallType;
204    use crate::server::tests::test_state;
205
206    fn call(window_id: &str, opacity: Option<f32>) -> WebCallType {
207        WebCallType {
208            service: "window".to_string(),
209            method: "SetWindowOpacity".to_string(),
210            uicontext: None,
211            args: vec![
212                serde_json::Value::String(window_id.to_string()),
213                opacity
214                    .map(|o| serde_json::json!(o))
215                    .unwrap_or(serde_json::Value::Null),
216            ],
217        }
218    }
219
220    async fn seeded_window_id(state: &crate::server::AppState) -> String {
221        let ret = handle_window_service(
222            state,
223            &WebCallType {
224                service: "window".to_string(),
225                method: "CreateWindow".to_string(),
226                uicontext: None,
227                args: vec![
228                    serde_json::Value::Null,
229                    serde_json::Value::String(String::new()),
230                ],
231            },
232        )
233        .await;
234        assert!(ret.success, "CreateWindow failed: {:?}", ret.error);
235        ret.data
236            .expect("CreateWindow returns the Window")
237            .get("oid")
238            .and_then(|v| v.as_str())
239            .expect("window has an oid")
240            .to_string()
241    }
242
243    #[tokio::test]
244    async fn sets_and_persists_opacity() {
245        let state = test_state();
246        let window_id = seeded_window_id(&state).await;
247
248        let ret = handle_window_service(&state, &call(&window_id, Some(0.85))).await;
249        assert!(ret.success, "SetWindowOpacity failed: {:?}", ret.error);
250
251        let win = state.mstore.must_get::<Window>(&window_id).unwrap();
252        assert_eq!(win.opacity, Some(0.85));
253    }
254
255    #[tokio::test]
256    async fn null_opacity_clears_it() {
257        let state = test_state();
258        let window_id = seeded_window_id(&state).await;
259
260        handle_window_service(&state, &call(&window_id, Some(0.5)))
261            .await;
262        let ret = handle_window_service(&state, &call(&window_id, None)).await;
263        assert!(ret.success, "SetWindowOpacity (clear) failed: {:?}", ret.error);
264
265        let win = state.mstore.must_get::<Window>(&window_id).unwrap();
266        assert_eq!(win.opacity, None, "None must clear back to unset/opaque");
267    }
268
269    #[tokio::test]
270    async fn rejects_out_of_range_opacity() {
271        let state = test_state();
272        let window_id = seeded_window_id(&state).await;
273
274        for bad in [-0.1_f32, 1.1_f32] {
275            let ret = handle_window_service(&state, &call(&window_id, Some(bad))).await;
276            assert!(!ret.success, "opacity {bad} must be rejected");
277        }
278        // Rejected — window's opacity must stay unset.
279        let win = state.mstore.must_get::<Window>(&window_id).unwrap();
280        assert_eq!(win.opacity, None);
281    }
282
283    #[tokio::test]
284    async fn errors_on_unknown_window() {
285        let state = test_state();
286        let ret = handle_window_service(&state, &call("ghost-window", Some(0.5))).await;
287        assert!(!ret.success, "unknown window must error");
288    }
289
290    /// reagent P1: a malformed (non-null, non-f32) opacity argument must
291    /// surface as an error, not be silently swallowed into an explicit
292    /// clear — `None` here means "wipe the persisted opacity," a real
293    /// state change, so a parse failure must not be conflated with it.
294    #[tokio::test]
295    async fn malformed_opacity_errors_and_does_not_clear() {
296        let state = test_state();
297        let window_id = seeded_window_id(&state).await;
298
299        let set_ret = handle_window_service(&state, &call(&window_id, Some(0.6))).await;
300        assert!(set_ret.success);
301
302        let bad_call = WebCallType {
303            service: "window".to_string(),
304            method: "SetWindowOpacity".to_string(),
305            uicontext: None,
306            args: vec![
307                serde_json::Value::String(window_id.clone()),
308                serde_json::Value::String("not-a-number".to_string()),
309            ],
310        };
311        let ret = handle_window_service(&state, &bad_call).await;
312        assert!(!ret.success, "malformed opacity must error, not be treated as clear");
313
314        let win = state.mstore.must_get::<Window>(&window_id).unwrap();
315        assert_eq!(
316            win.opacity,
317            Some(0.6),
318            "a rejected malformed argument must not wipe the previously-set opacity"
319        );
320    }
321
322    #[tokio::test]
323    async fn boundary_values_are_accepted() {
324        let state = test_state();
325        let window_id = seeded_window_id(&state).await;
326
327        for boundary in [0.0_f32, 1.0_f32] {
328            let ret = handle_window_service(&state, &call(&window_id, Some(boundary))).await;
329            assert!(ret.success, "boundary opacity {boundary} must be accepted");
330            let win = state.mstore.must_get::<Window>(&window_id).unwrap();
331            assert_eq!(win.opacity, Some(boundary));
332        }
333    }
334}
335
336/// SPEC_PILLAR1_STEP3 — `SetWindowTopology` unit tests.
337#[cfg(test)]
338mod set_window_topology_tests {
339    use super::super::window::handle_window_service;
340    use crate::backend::obj::Window;
341    use crate::backend::service::WebCallType;
342    use crate::server::tests::test_state;
343
344    fn call(window_id: &str, kind: Option<&str>, parent_window_id: Option<&str>) -> WebCallType {
345        WebCallType {
346            service: "window".to_string(),
347            method: "SetWindowTopology".to_string(),
348            uicontext: None,
349            args: vec![
350                serde_json::Value::String(window_id.to_string()),
351                kind.map(|k| serde_json::json!(k)).unwrap_or(serde_json::Value::Null),
352                parent_window_id
353                    .map(|p| serde_json::json!(p))
354                    .unwrap_or(serde_json::Value::Null),
355            ],
356        }
357    }
358
359    async fn seeded_window_id(state: &crate::server::AppState) -> String {
360        let ret = handle_window_service(
361            state,
362            &WebCallType {
363                service: "window".to_string(),
364                method: "CreateWindow".to_string(),
365                uicontext: None,
366                args: vec![
367                    serde_json::Value::Null,
368                    serde_json::Value::String(String::new()),
369                ],
370            },
371        )
372        .await;
373        assert!(ret.success, "CreateWindow failed: {:?}", ret.error);
374        ret.data
375            .expect("CreateWindow returns the Window")
376            .get("oid")
377            .and_then(|v| v.as_str())
378            .expect("window has an oid")
379            .to_string()
380    }
381
382    #[tokio::test]
383    async fn sets_and_persists_full_instance() {
384        let state = test_state();
385        let window_id = seeded_window_id(&state).await;
386
387        let ret = handle_window_service(&state, &call(&window_id, Some("full_instance"), None)).await;
388        assert!(ret.success, "SetWindowTopology failed: {:?}", ret.error);
389
390        let win = state.mstore.must_get::<Window>(&window_id).unwrap();
391        assert_eq!(win.kind, Some("full_instance".to_string()));
392        assert_eq!(win.parent_window_id, None);
393    }
394
395    #[tokio::test]
396    async fn sets_and_persists_subwindow_with_parent() {
397        let state = test_state();
398        let parent_id = seeded_window_id(&state).await;
399        let window_id = seeded_window_id(&state).await;
400
401        let ret =
402            handle_window_service(&state, &call(&window_id, Some("subwindow"), Some(&parent_id))).await;
403        assert!(ret.success, "SetWindowTopology failed: {:?}", ret.error);
404
405        let win = state.mstore.must_get::<Window>(&window_id).unwrap();
406        assert_eq!(win.kind, Some("subwindow".to_string()));
407        assert_eq!(win.parent_window_id, Some(parent_id));
408    }
409
410    #[tokio::test]
411    async fn rejects_subwindow_without_parent() {
412        let state = test_state();
413        let window_id = seeded_window_id(&state).await;
414
415        let ret = handle_window_service(&state, &call(&window_id, Some("subwindow"), None)).await;
416        assert!(!ret.success, "subwindow with no parent_window_id must be rejected");
417
418        let win = state.mstore.must_get::<Window>(&window_id).unwrap();
419        assert_eq!(win.kind, None, "rejected write must not persist");
420    }
421
422    #[tokio::test]
423    async fn rejects_unknown_kind() {
424        let state = test_state();
425        let window_id = seeded_window_id(&state).await;
426
427        let ret = handle_window_service(&state, &call(&window_id, Some("floating"), None)).await;
428        assert!(!ret.success, "unknown kind must be rejected");
429    }
430
431    /// reagent P1: a full_instance (or unset/None kind) must never carry a
432    /// parent_window_id — only the reverse (subwindow requires a parent)
433    /// was originally enforced.
434    #[tokio::test]
435    async fn rejects_full_instance_with_parent() {
436        let state = test_state();
437        let parent_id = seeded_window_id(&state).await;
438        let window_id = seeded_window_id(&state).await;
439
440        let ret =
441            handle_window_service(&state, &call(&window_id, Some("full_instance"), Some(&parent_id)))
442                .await;
443        assert!(!ret.success, "full_instance with a parent_window_id must be rejected");
444
445        let win = state.mstore.must_get::<Window>(&window_id).unwrap();
446        assert_eq!(win.kind, None, "rejected write must not persist");
447    }
448
449    /// reagent P1: same rule when kind is omitted entirely (None) but a
450    /// parent_window_id is supplied anyway.
451    #[tokio::test]
452    async fn rejects_none_kind_with_parent() {
453        let state = test_state();
454        let parent_id = seeded_window_id(&state).await;
455        let window_id = seeded_window_id(&state).await;
456
457        let ret = handle_window_service(&state, &call(&window_id, None, Some(&parent_id))).await;
458        assert!(!ret.success, "kind=None with a parent_window_id must be rejected");
459    }
460
461    /// reagent P2: parent_window_id must reference a real window row, not a
462    /// dangling id.
463    #[tokio::test]
464    async fn rejects_dangling_parent_window_id() {
465        let state = test_state();
466        let window_id = seeded_window_id(&state).await;
467
468        let ret =
469            handle_window_service(&state, &call(&window_id, Some("subwindow"), Some("ghost-parent")))
470                .await;
471        assert!(!ret.success, "a parent_window_id that doesn't exist must be rejected");
472
473        let win = state.mstore.must_get::<Window>(&window_id).unwrap();
474        assert_eq!(win.kind, None, "rejected write must not persist");
475    }
476
477    /// reagent P2: a window cannot be its own parent.
478    #[tokio::test]
479    async fn rejects_self_referential_parent() {
480        let state = test_state();
481        let window_id = seeded_window_id(&state).await;
482
483        let ret =
484            handle_window_service(&state, &call(&window_id, Some("subwindow"), Some(&window_id))).await;
485        assert!(!ret.success, "a window referencing itself as parent must be rejected");
486    }
487
488    #[tokio::test]
489    async fn null_kind_clears_it() {
490        let state = test_state();
491        let window_id = seeded_window_id(&state).await;
492
493        handle_window_service(&state, &call(&window_id, Some("full_instance"), None)).await;
494        let ret = handle_window_service(&state, &call(&window_id, None, None)).await;
495        assert!(ret.success, "SetWindowTopology (clear) failed: {:?}", ret.error);
496
497        let win = state.mstore.must_get::<Window>(&window_id).unwrap();
498        assert_eq!(win.kind, None);
499        assert_eq!(win.parent_window_id, None);
500    }
501
502    #[tokio::test]
503    async fn errors_on_unknown_window() {
504        let state = test_state();
505        let ret = handle_window_service(&state, &call("ghost-window", Some("full_instance"), None)).await;
506        assert!(!ret.success, "unknown window must error");
507    }
508}