Skip to main content

agentmux_srv/server/service/
window_create.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `window` service handler — `CreateWindow`. Split out of `window.rs`;
5//! see that file's dispatcher for the full method list.
6
7use crate::backend::obj::*;
8use crate::backend::service::{self, WebCallType, WebReturnType};
9
10use super::super::AppState;
11use super::reducer_helpers::{dispatch_to_reducer, publish_events};
12
13// Phase E.5.8 — CreateWindow migrated through the reducer.
14// Two paths: (1) empty workspace_id → CreateWorkspace +
15// CreateTab + CreateWindow as a multi-step dispatch (mirrors
16// wcore::create_window_full's "fresh workspace" path); (2)
17// existing workspace_id → just CreateWindow. The subscriber's
18// apply_srv_window_opened handles `Client.windowids` updates
19// and Window-row creation. Layout setup for the new tab uses
20// the apply_tab_created provisioning (E.4 layout migration is
21// separate; default rootnode = None matches wcore behaviour).
22pub(crate) async fn handle_create_window(state: &AppState, call: &WebCallType) -> WebReturnType {
23    let store = &state.mstore;
24    let args = &call.args;
25    let requested_ws_id: String = service::get_arg(args, 1).unwrap_or_default();
26    // Restore-on-relaunch (SPEC_SESSION_RESTORE_AND_SAVED_LAYOUTS_2026_08_13
27    // Feature 1) — arg 3 is set only by the frontend's genuine cold-start
28    // call site (`Client.windowids` was empty). Every other caller
29    // (tear-off, "Open New Window", the workspace-missing error-recovery
30    // fallback) omits it and gets the pre-existing default-seed behavior,
31    // completely unchanged, below.
32    //
33    // The client's own claim isn't trusted alone (reagentx P2 on PR #2560):
34    // re-check `Client.windowids` server-side too, so a stray/duplicate/
35    // misused call with `restoreIfAvailable: true` mid-session (when other
36    // windows are legitimately still open) can't resurrect the last snapshot
37    // into a spurious extra workspace.
38    let client_windowids_empty = store
39        .get_all::<Client>()
40        .ok()
41        .and_then(|cs| cs.into_iter().next())
42        .map(|c| c.windowids.is_empty())
43        .unwrap_or(true);
44    let restore_if_available: bool =
45        service::get_arg(args, 3).unwrap_or(false) && client_windowids_empty;
46    // "Open in New Window" from a widget's context menu (arg 4/5, optional,
47    // same additive pattern as restoreIfAvailable above) — when present,
48    // seed the fresh tab with ONLY this one block instead of the default
49    // 3-pane agent/swarm/sysinfo layout. Ignored on the tear-off / existing-
50    // workspace path (the `else` arm below), same as the default seed is.
51    let seed_view: Option<String> = service::get_optional_arg(args, 4).unwrap_or(None);
52    let seed_meta: Option<serde_json::Value> = service::get_optional_arg(args, 5).unwrap_or(None);
53    // Set when a restore actually replays the last snapshot — gates clearing
54    // it (see the final read-back below) so a genuinely successful restore
55    // can't be replayed again by a later call, while a crash mid-restore
56    // (this flag set, but the function never reaches that final clear)
57    // leaves the snapshot intact for the next launch to retry.
58    let mut did_restore = false;
59    // Resolve / create the workspace.
60    let (ws_id, fresh_workspace_events): (String, Vec<agentmux_common::ipc::Event>) =
61        if requested_ws_id.is_empty() {
62            let restored_from_last_session = if restore_if_available {
63                match super::session_restore::restore_last_session(state).await {
64                    Ok(Some(result)) => Some(result),
65                    Ok(None) => None,
66                    Err(e) => {
67                        tracing::warn!(
68                            error = %e,
69                            "CreateWindow: session restore failed — falling back to default seed"
70                        );
71                        None
72                    }
73                }
74            } else {
75                None
76            };
77            if let Some(result) = restored_from_last_session {
78                did_restore = true;
79                result
80            } else {
81            // Step 1: create workspace.
82            let ws_events = dispatch_to_reducer(
83                state,
84                agentmux_common::ipc::Command::CreateWorkspace {
85                    name: String::new(),
86                },
87            )
88            .await;
89            if let Some(err_msg) = ws_events.iter().find_map(|e| match e {
90                agentmux_common::ipc::Event::Error { message, .. } => {
91                    Some(message.clone())
92                }
93                _ => None,
94            }) {
95                return WebReturnType::error(err_msg);
96            }
97            for ev in &ws_events {
98                if let Err(e) =
99                    crate::persist_subscriber::apply_event_to_mstore(ev, store)
100                {
101                    return WebReturnType::error(format!(
102                        "CreateWindow: SQLite write failed: {}",
103                        e
104                    ));
105                }
106            }
107            let new_ws_id = ws_events
108                .iter()
109                .find_map(|e| match e {
110                    agentmux_common::ipc::Event::WorkspaceCreated {
111                        workspace_id, ..
112                    } => Some(workspace_id.clone()),
113                    _ => None,
114                })
115                .unwrap_or_default();
116            // Step 2: create tab.
117            let tab_events = dispatch_to_reducer(
118                state,
119                agentmux_common::ipc::Command::CreateTab {
120                    workspace_id: new_ws_id.clone(),
121                    name: String::new(),
122                },
123            )
124            .await;
125            if let Some(err_msg) = tab_events.iter().find_map(|e| match e {
126                agentmux_common::ipc::Event::Error { message, .. } => {
127                    Some(message.clone())
128                }
129                _ => None,
130            }) {
131                // Compensate: delete the empty workspace.
132                let comp = dispatch_to_reducer(
133                    state,
134                    agentmux_common::ipc::Command::DeleteWorkspace {
135                        workspace_id: new_ws_id.clone(),
136                        // Internal compensation path — not
137                        // saga-driven (Step 5 PR 2 added the
138                        // `force` flag for saga provenance).
139                        force: false,
140                    },
141                )
142                .await;
143                for ev in &comp {
144                    let _ = crate::persist_subscriber::apply_event_to_mstore(ev, store);
145                }
146                publish_events(state, &comp);
147                return WebReturnType::error(err_msg);
148            }
149            for ev in &tab_events {
150                if let Err(e) =
151                    crate::persist_subscriber::apply_event_to_mstore(ev, store)
152                {
153                    return WebReturnType::error(format!(
154                        "CreateWindow: SQLite write failed: {}",
155                        e
156                    ));
157                }
158            }
159            // Seed the fresh tab so "Open another window" matches first
160            // launch instead of opening blank: the default 3-pane launch
161            // layout (agent | sysinfo / swarm), UNLESS `seed_view` (arg 4)
162            // was supplied — then seed a single pane with just that view
163            // instead (a widget's "Open in New Window"). Only this
164            // fresh-workspace branch seeds; tear-off (existing workspace,
165            // the `else` arm) reattaches its populated workspace as-is.
166            // Non-fatal: a seed failure leaves an empty tab (the prior
167            // behaviour) rather than failing window creation.
168            // See docs/retro/retro-blank-new-window-2026-06-21.md.
169            //
170            // 2nd-window-tear-off desync fix (#1681): the seed blocks
171            // MUST be created through the reducer (CreateBlock command),
172            // NOT via the store-only `seed_default_layout`/`create_block`
173            // path. This handler runs AFTER bootstrap, so anything
174            // written straight to SQLite is invisible to the in-memory
175            // reducer `srv_state` until the next restart. A new window
176            // seeded store-only renders fine (frontend reads SQLite) but
177            // its blocks are absent from `srv_state`, so a later
178            // `TearOffBlock` from that window is rejected "block not
179            // found" (ws/tab exist — they went through the reducer — but
180            // the block did not). Dispatch CreateBlock per pane so the
181            // blocks land in BOTH srv_state and (via the subscriber)
182            // SQLite, then write the shared layout referencing them.
183            let mut block_seed_events: Vec<agentmux_common::ipc::Event> = Vec::new();
184            if let Some(new_tab_id) = tab_events.iter().find_map(|e| match e {
185                agentmux_common::ipc::Event::TabCreated { tab_id, .. } => {
186                    Some(tab_id.clone())
187                }
188                _ => None,
189            }) {
190                let seed_views: Vec<String> = match &seed_view {
191                    Some(v) => vec![v.clone()],
192                    None => vec!["agent".to_string(), "swarm".to_string(), "sysinfo".to_string()],
193                };
194                // Dispatch the seed block(s) through the reducer. For the
195                // default 3-pane case, order is the one
196                // `default_three_pane_tree` expects positionally (agent,
197                // swarm, sysinfo) — NOT top-to-bottom display order, since
198                // sysinfo renders above swarm.
199                let mut seeded_ids: Vec<String> = Vec::new();
200                for view in &seed_views {
201                    let meta = if seed_view.is_some() {
202                        seed_meta.clone().unwrap_or_else(|| serde_json::json!({ "view": view }))
203                    } else {
204                        serde_json::json!({ "view": view })
205                    };
206                    let evs = dispatch_to_reducer(
207                        state,
208                        agentmux_common::ipc::Command::CreateBlock {
209                            tab_id: new_tab_id.clone(),
210                            meta,
211                        },
212                    )
213                    .await;
214                    if let Some(err_msg) = evs.iter().find_map(|e| match e {
215                        agentmux_common::ipc::Event::Error { message, .. } => {
216                            Some(message.clone())
217                        }
218                        _ => None,
219                    }) {
220                        tracing::warn!(
221                            tab_id = %new_tab_id,
222                            view = %view,
223                            error = %err_msg,
224                            "CreateWindow: seed block create failed — opening blank tab"
225                        );
226                        break;
227                    }
228                    for ev in &evs {
229                        if let Err(e) =
230                            crate::persist_subscriber::apply_event_to_mstore(ev, store)
231                        {
232                            tracing::warn!(
233                                tab_id = %new_tab_id,
234                                error = %e,
235                                "CreateWindow: seed block SQLite write failed"
236                            );
237                        }
238                    }
239                    if let Some(block_id) = evs.iter().find_map(|e| match e {
240                        agentmux_common::ipc::Event::BlockCreated { block_id, .. } => {
241                            Some(block_id.clone())
242                        }
243                        _ => None,
244                    }) {
245                        seeded_ids.push(block_id);
246                    }
247                    block_seed_events.extend(evs);
248                }
249
250                let tree_and_focus = if seed_view.is_some() && seeded_ids.len() == 1 {
251                    // "Open in New Window" from a widget — single-pane seed,
252                    // no split. See single_leaf_tree's doc comment.
253                    Some(crate::backend::wcore::single_leaf_tree(&seeded_ids[0]))
254                } else if seed_view.is_none() && seeded_ids.len() == 3 {
255                    // SPEC_864 Phase 3 — post-bootstrap seed routes
256                    // through the reducer (single writer of
257                    // db_layout); the tree shape is shared with the
258                    // pre-bootstrap store-direct first-launch seed
259                    // via `default_three_pane_tree`.
260                    Some(crate::backend::wcore::default_three_pane_tree(
261                        &seeded_ids[0],
262                        &seeded_ids[1],
263                        &seeded_ids[2],
264                    ))
265                } else {
266                    None
267                };
268
269                if let Some((tree, focused, leaforder)) = tree_and_focus {
270                    if let Err(e) = super::reducer_helpers::seed_layout_via_reducer(
271                        state,
272                        &new_tab_id,
273                        tree,
274                        focused,
275                        leaforder,
276                        String::new(),
277                    )
278                    .await
279                    {
280                        tracing::warn!(
281                            tab_id = %new_tab_id,
282                            error = %e,
283                            "CreateWindow: default layout write failed — opening blank tab"
284                        );
285                    }
286                }
287            }
288            let mut combined = ws_events;
289            combined.extend(tab_events);
290            combined.extend(block_seed_events);
291            (new_ws_id, combined)
292            }
293        } else {
294            // Existing workspace — verify it's in the reducer
295            // (or SQLite), but no creation needed.
296            let exists_in_sqlite = match store.get::<Workspace>(&requested_ws_id) {
297                Ok(opt) => opt.is_some(),
298                Err(e) => {
299                    return WebReturnType::error(format!(
300                        "CreateWindow: workspace lookup failed: {}",
301                        e
302                    ));
303                }
304            };
305            if !exists_in_sqlite {
306                return WebReturnType::error(format!(
307                    "CreateWindow: workspace not found: {}",
308                    requested_ws_id
309                ));
310            }
311            (requested_ws_id, Vec::new())
312        };
313
314    // Step 3: register the window in the reducer.
315    let window_id = uuid::Uuid::new_v4().to_string();
316    let win_events = dispatch_to_reducer(
317        state,
318        agentmux_common::ipc::Command::CreateWindow {
319            window_id: window_id.clone(),
320            workspace_id: ws_id.clone(),
321        },
322    )
323    .await;
324    if let Some(err_msg) = win_events.iter().find_map(|e| match e {
325        agentmux_common::ipc::Event::Error { message, .. } => Some(message.clone()),
326        _ => None,
327    }) {
328        // Compensate the fresh workspace if we created one.
329        if !fresh_workspace_events.is_empty() {
330            let comp = dispatch_to_reducer(
331                state,
332                agentmux_common::ipc::Command::DeleteWorkspace {
333                    workspace_id: ws_id.clone(),
334                    // Internal compensation path (Step 5 PR 2).
335                    force: false,
336                },
337            )
338            .await;
339            for ev in &comp {
340                let _ = crate::persist_subscriber::apply_event_to_mstore(ev, store);
341            }
342            publish_events(state, &comp);
343        }
344        return WebReturnType::error(err_msg);
345    }
346    for ev in &win_events {
347        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
348            return WebReturnType::error(format!(
349                "CreateWindow: SQLite write failed: {}",
350                e
351            ));
352        }
353    }
354    // Mark the window as `isnew` so the host's first-paint
355    // signaling logic still applies — wcore::create_window_full
356    // set this; the subscriber's default is `isnew: false`.
357    if let Ok(mut win) = store.must_get::<Window>(&window_id) {
358        if !win.isnew {
359            win.isnew = true;
360            let _ = store.update(&mut win);
361        }
362    }
363    // SPEC_POOL_ADOPTION_AND_WINDOW_ROW_CRUMB_2026_07_11 Residual 2 —
364    // persist the caller's host window label as a meta crumb,
365    // atomically with row creation (optional arg 2; old callers send
366    // two args and skip this). The crumb exists so cleanup paths can
367    // resolve label → window row WITHOUT the host-side registration
368    // chain (`register_backend_window` → launcher shadow), which is
369    // exactly what's missing in the early-close race shape (#2088)
370    // and after a host crash. It is a HINT, not an identity — labels
371    // recur across host restarts — so consumers must treat a miss or
372    // duplicate as "fall back to current behavior," never delete on
373    // crumb evidence alone. Non-fatal on failure: an unattributed
374    // row is the pre-crumb status quo.
375    let host_label: String = service::get_arg(args, 2).unwrap_or_default();
376    let crumb_events = if host_label.is_empty() {
377        Vec::new()
378    } else {
379        let evs = dispatch_to_reducer(
380            state,
381            agentmux_common::ipc::Command::UpdateWindowMeta {
382                window_id: window_id.clone(),
383                meta_patch: serde_json::json!({ "host:label": host_label }),
384            },
385        )
386        .await;
387        for ev in &evs {
388            if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, store) {
389                tracing::warn!(
390                    window_id = %window_id,
391                    error = %e,
392                    "CreateWindow: host:label crumb write failed — row stays unattributed"
393                );
394            }
395        }
396        evs
397    };
398    // SPEC_PER_WINDOW_THEME_AND_OPACITY_2026_10_12 §4.6 — a reloaded window
399    // other than the main one gets a new row each load (optional arg 6 is
400    // the row it had before), so its own theme and opacity come across.
401    let previous_window_id: Option<String> = service::get_optional_arg(args, 6).unwrap_or(None);
402    let mut reload_events = match previous_window_id.filter(|p| !p.is_empty() && *p != window_id) {
403        Some(prev) => super::window_inherit::apply_inheritance(state, &prev, &window_id).await,
404        None => Vec::new(),
405    };
406    // SPEC_PER_WINDOW_THEME_AND_OPACITY_2026_10_12 §6 — the window that
407    // opened this one may already have asked for its appearance to be
408    // copied here (`InheritWindowAppearance`, keyed by host label). Taken
409    // only after the row is recorded, so a request arriving in between
410    // finds the row instead (see window_inherit.rs).
411    let inherit_events = match (!host_label.is_empty())
412        .then(|| {
413            let row_seq = super::window_inherit::record_created(&host_label, &window_id);
414            super::window_inherit::take_pending(&host_label, row_seq)
415        })
416        .flatten()
417    {
418        Some(source) => super::window_inherit::apply_inheritance(state, &source, &window_id).await,
419        None => Vec::new(),
420    };
421    // Publish all events from this multi-step (workspace + tab + window).
422    let mut all_events = fresh_workspace_events;
423    all_events.extend(win_events);
424    all_events.extend(crumb_events);
425    reload_events.extend(inherit_events);
426    all_events.extend(reload_events);
427    publish_events(state, &all_events);
428    // Return the Window struct (matches the prior RPC contract).
429    match store.must_get::<Window>(&window_id) {
430        Ok(win) => {
431            // Restore-on-relaunch (SPEC_SESSION_RESTORE_AND_SAVED_LAYOUTS_2026_08_13
432            // Feature 1) — only clear the snapshot once the restore it
433            // produced has fully, successfully completed (this read-back is
434            // the last step of that path). A crash between
435            // `restore_last_session` returning and reaching this line never
436            // executes this clear, so the next launch's `Client.windowids`
437            // is still empty and retries against the same intact snapshot —
438            // deliberately not cleared any earlier than this. Reviewer-caught
439            // (reagentx P2 on PR #2560): without this, a stray repeat call
440            // could replay the same snapshot into a second duplicate
441            // workspace even after a genuinely successful restore.
442            if did_restore {
443                super::session_restore::clear_last_session_snapshot(store);
444            }
445            WebReturnType::success(serde_json::to_value(&win).unwrap_or_default())
446        }
447        Err(e) => WebReturnType::error(format!(
448            "CreateWindow: window read-back failed: {}",
449            e
450        )),
451    }
452}
453
454#[cfg(test)]
455mod create_window_seed_tests {
456    use super::super::window::handle_window_service;
457    use crate::backend::obj::{Block, LayoutState, Tab};
458    use crate::backend::service::WebCallType;
459    use crate::server::tests::test_state;
460
461    fn create_window_call(workspace_id: &str) -> WebCallType {
462        WebCallType {
463            service: "window".to_string(),
464            method: "CreateWindow".to_string(),
465            uicontext: None,
466            // arg0 is ignored by the handler; arg1 is the (optional) workspace
467            // to reattach. Empty → fresh-workspace seed path.
468            args: vec![
469                serde_json::Value::Null,
470                serde_json::Value::String(workspace_id.to_string()),
471            ],
472        }
473    }
474
475    /// Regression for the 2nd-window-tear-off desync (#1681).
476    ///
477    /// "Open another window" used to seed its default blocks straight
478    /// into SQLite (`seed_default_layout` → `create_block`), bypassing the
479    /// reducer. The handler runs after bootstrap, so those blocks never
480    /// reached the in-memory `srv_state`. The frontend rendered them (it reads
481    /// SQLite) but a subsequent `TearOffBlock` from that window was rejected
482    /// "block not found" — the workspace/tab existed (they went through the
483    /// reducer) but the block did not. This asserts the seed blocks now land in
484    /// `srv_state` AND that a tear-off from the new window succeeds end-to-end.
485    #[tokio::test]
486    async fn new_window_seed_blocks_are_in_reducer_state_and_tear_off_succeeds() {
487        let state = test_state();
488        let blocks_before = state.srv_state.lock().await.blocks.len();
489
490        let ret = handle_window_service(&state, &create_window_call("")).await;
491        assert!(ret.success, "CreateWindow failed: {:?}", ret.error);
492
493        let win = ret.data.expect("CreateWindow returns the Window");
494        let workspace_id = win
495            .get("workspaceid")
496            .and_then(|v| v.as_str())
497            .expect("window has a workspaceid")
498            .to_string();
499
500        // The fix: the seed blocks are present in the in-memory reducer
501        // state, attached to the new window's tab.
502        let (tab_id, block_id) = {
503            let s = state.srv_state.lock().await;
504            assert_eq!(
505                s.blocks.len(),
506                blocks_before + 3,
507                "the 3 seed blocks must be tracked in srv_state, not only SQLite"
508            );
509            let ws = s
510                .workspaces
511                .get(&workspace_id)
512                .expect("new workspace is in the reducer");
513            let tab_id = ws.tab_ids.first().expect("new workspace has a tab").clone();
514            let tab = s.tabs.get(&tab_id).expect("new tab is in the reducer");
515            assert_eq!(
516                tab.block_ids.len(),
517                3,
518                "the new window's tab must hold its 3 seed blocks in the reducer"
519            );
520            (tab_id, tab.block_ids[0].clone())
521        };
522
523        // End-to-end: tearing a block off the freshly-created window no longer
524        // hits the "block not found" pre-condition.
525        let result =
526            crate::sagas::tear_off_block::run(&state, block_id, tab_id, workspace_id).await;
527        assert!(
528            result.is_ok(),
529            "tear-off from a freshly-created window must succeed, got: {:?}",
530            result.err()
531        );
532    }
533
534    /// A widget's "Open in New Window" (arg 4 `seed_view` / arg 5
535    /// `seed_meta`) must seed the new tab with ONLY that one pane — not the
536    /// default 3-pane layout plus the widget as a 4th split. Regression: the
537    /// pre-existing frontend-only `initial_view` mechanism opened the
538    /// default 3-pane layout via CreateWindow and then called `pane.open`
539    /// on top of it, which only ever ADDED a pane rather than replacing the
540    /// seed.
541    #[tokio::test]
542    async fn new_window_with_seed_view_seeds_a_single_pane_not_the_default_three() {
543        let state = test_state();
544
545        let call = WebCallType {
546            service: "window".to_string(),
547            method: "CreateWindow".to_string(),
548            uicontext: None,
549            args: vec![
550                serde_json::Value::Null,                        // winSize
551                serde_json::Value::String(String::new()),       // workspaceId — fresh
552                serde_json::Value::Null,                        // hostLabel
553                serde_json::Value::Bool(false),                 // restoreIfAvailable
554                serde_json::Value::String("browser".to_string()), // seedView
555                serde_json::json!({ "view": "browser", "url": "https://example.com" }), // seedMeta
556            ],
557        };
558
559        let ret = handle_window_service(&state, &call).await;
560        assert!(ret.success, "CreateWindow failed: {:?}", ret.error);
561
562        let win = ret.data.expect("CreateWindow returns the Window");
563        let workspace_id = win
564            .get("workspaceid")
565            .and_then(|v| v.as_str())
566            .expect("window has a workspaceid")
567            .to_string();
568
569        let tab_id = {
570            let s = state.srv_state.lock().await;
571            let ws = s.workspaces.get(&workspace_id).expect("new workspace is in the reducer");
572            assert_eq!(ws.tab_ids.len(), 1, "one tab");
573            let tab_id = ws.tab_ids.first().unwrap().clone();
574            let tab = s.tabs.get(&tab_id).expect("new tab is in the reducer");
575            assert_eq!(
576                tab.block_ids.len(),
577                1,
578                "seeded with exactly ONE block, not the default 3-pane layout"
579            );
580            tab_id
581        };
582
583        let store = &state.mstore;
584        let tab = store.must_get::<Tab>(&tab_id).unwrap();
585        assert_eq!(tab.blockids.len(), 1);
586        let block = store.must_get::<Block>(&tab.blockids[0]).unwrap();
587        assert_eq!(block.meta.get("view").and_then(|v| v.as_str()), Some("browser"));
588        assert_eq!(
589            block.meta.get("url").and_then(|v| v.as_str()),
590            Some("https://example.com"),
591            "the full seedMeta must be used, not just a bare {{view}} object"
592        );
593
594        // Single-leaf tree — no split, root node itself wraps the block.
595        let layout = store.must_get::<LayoutState>(&tab.layoutstate).unwrap();
596        let root = layout.rootnode.as_ref().expect("layout must be populated");
597        assert!(root.children.is_empty(), "single pane — no split children");
598        assert_eq!(root.data.as_ref().unwrap().block_id, tab.blockids[0]);
599        assert_eq!(layout.leaforder.as_ref().unwrap().len(), 1);
600    }
601}