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}