Skip to main content

agentmux_srv/server/app_api/
layout.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `layout.save` — write one window as an `agentmux.layout` file
5//! (docs/specs/SPEC_LAYOUT_FILES_2026_09_25.md §6.1). The walk and the
6//! per-view allowlist live in `backend::layout_file`; this is the RPC shell.
7//!
8//! The destination normally comes from the host's Save dialog. The handler
9//! still refuses anything but an absolute `*.agentmux-layout.json` path: a
10//! caller holding the auth key could otherwise use this to overwrite any
11//! file the user can write.
12
13use super::*;
14use crate::backend::layout_file;
15
16pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
17    let mstore = state.mstore.clone();
18    engine.register_typed(COMMAND_LAYOUT_SAVE, move |req: CommandLayoutSaveData, _ctx| {
19        let mstore = mstore.clone();
20        async move {
21            // Store reads and a file write: off the async worker.
22            tokio::task::spawn_blocking(move || layout_save_impl(&mstore, req))
23                .await
24                .map_err(|e| format!("layout.save: task: {e}"))?
25        }
26    });
27
28    let mstore = state.mstore.clone();
29    engine.register_typed(COMMAND_LAYOUT_PREVIEW, move |req: CommandLayoutPreviewData, _ctx| {
30        let mstore = mstore.clone();
31        async move {
32            tokio::task::spawn_blocking(move || layout_preview_impl(&mstore, &req.path, &live_trust_inputs()))
33                .await
34                .map_err(|e| format!("layout.preview: task: {e}"))?
35        }
36    });
37
38    let open_state = state.clone();
39    engine.register_typed(COMMAND_LAYOUT_OPEN, move |req: CommandLayoutOpenData, _ctx| {
40        let state = open_state.clone();
41        async move { layout_open_impl(&state, req, &live_trust_inputs()).await }
42    });
43}
44
45/// What decides whether a file is trusted (spec §3.5), injected for tests.
46pub(super) struct TrustInputs {
47    pub own_instance: Option<String>,
48    pub layouts_dir: Option<std::path::PathBuf>,
49    pub home: Option<std::path::PathBuf>,
50}
51
52fn live_trust_inputs() -> TrustInputs {
53    TrustInputs {
54        own_instance: crate::backend::storage::wan_identity::global()
55            .and_then(|w| w.instance_ensure(&crate::backend::reactive::registry::local_host_label()).ok())
56            .map(|i| i.instance_id),
57        layouts_dir: agentmux_common::DataPaths::from_env().map(|p| p.shared_dir.join("layouts")),
58        home: dirs::home_dir(),
59    }
60}
61
62/// Parse the file and build its plan. `run_commands` = `None` means "as
63/// trusted".
64fn load_plan(
65    store: &Store,
66    path: &str,
67    trust: &TrustInputs,
68    run_commands: Option<bool>,
69) -> Result<(layout_file::ApplyPlan, bool), String> {
70    let path = std::path::PathBuf::from(path);
71    if !layout_file::is_layout_path(&path) {
72        return Err(format!("the path must be absolute and end in {}", layout_file::LAYOUT_EXTENSION));
73    }
74    let doc = layout_file::read_layout_file(&path)?;
75    let trusted = layout_file::is_trusted(&doc, &path, trust.own_instance.as_deref(), trust.layouts_dir.as_deref());
76    let plan = layout_file::plan_from_doc(
77        store,
78        &doc,
79        &layout_file::PlanOptions { home: trust.home.clone(), run_commands: run_commands.unwrap_or(trusted) },
80    );
81    Ok((plan, trusted))
82}
83
84pub(super) fn layout_preview_impl(store: &Store, path: &str, trust: &TrustInputs) -> Result<LayoutPreviewResult, String> {
85    let (plan, trusted) = load_plan(store, path, trust, None).map_err(|e| format!("layout.preview: {e}"))?;
86    Ok(LayoutPreviewResult {
87        name: plan.name,
88        trusted,
89        tabs: plan
90            .tabs
91            .into_iter()
92            .map(|t| LayoutPreviewTab { name: t.name, panes: t.summary })
93            .collect(),
94        commands: plan.commands,
95        notes: plan.notes,
96    })
97}
98
99/// Add the file's tabs to `window_id`'s workspace — or, with `new_window`,
100/// to a new workspace for the caller to open a window onto — then launch
101/// each agent into its placeholder through `agent.open` (spec §3.5). Nothing
102/// already open is touched. An agent that can't start (not installed, live
103/// elsewhere, …) leaves its pane as the agent picker and adds a note.
104pub(super) async fn layout_open_impl(
105    state: &AppState,
106    req: CommandLayoutOpenData,
107    trust: &TrustInputs,
108) -> Result<LayoutOpenResult, String> {
109    let store = state.mstore.clone();
110    let path = req.path.clone();
111    let run = req.run_commands;
112    let trust_copy =
113        TrustInputs { own_instance: trust.own_instance.clone(), layouts_dir: trust.layouts_dir.clone(), home: trust.home.clone() };
114    let (plan, _trusted) = tokio::task::spawn_blocking(move || load_plan(&store, &path, &trust_copy, run))
115        .await
116        .map_err(|e| format!("layout.open: task: {e}"))?
117        .map_err(|e| format!("layout.open: {e}"))?;
118    if plan.tabs.is_empty() {
119        return Err("layout.open: the layout has no tabs that can be opened".to_string());
120    }
121    let new_window = req.new_window.unwrap_or(false);
122    let ws_id = if new_window {
123        // Blank falls back to "Layout", as `layout.save` and the preview do.
124        let name = plan.name.trim();
125        create_empty_workspace(state, if name.is_empty() { "Layout" } else { name }).await?
126    } else {
127        state
128            .mstore
129            .get::<obj::Window>(&req.window_id)
130            .map_err(|e| format!("layout.open: {e}"))?
131            .ok_or_else(|| format!("layout.open: no such window: {}", req.window_id))?
132            .workspaceid
133    };
134
135    let replay: Vec<crate::server::service::session_restore::ReplayTab> = plan
136        .tabs
137        .iter()
138        .map(|t| crate::server::service::session_restore::ReplayTab {
139            name: t.name.clone(),
140            blocks: t.blocks.clone(),
141            rootnode: t.rootnode.clone(),
142            focused: t.focused.clone(),
143            magnified: t.magnified.clone(),
144        })
145        .collect();
146    let (events, replayed) =
147        crate::server::service::session_restore::replay_tabs(state, &ws_id, &replay, "layout.open").await;
148    crate::server::service::publish_events(state, &events);
149
150    let mut notes = plan.notes;
151    let tab_ids: Vec<String> = replayed.iter().flatten().map(|r| r.tab_id.clone()).collect();
152    if new_window && tab_ids.is_empty() {
153        // Nothing for the new window to show: don't leave an empty workspace.
154        let events = crate::server::service::dispatch_to_reducer(
155            state,
156            agentmux_common::ipc::Command::DeleteWorkspace { workspace_id: ws_id, force: false },
157        )
158        .await;
159        for ev in &events {
160            let _ = crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore);
161        }
162        crate::server::service::publish_events(state, &events);
163        return Err("layout.open: none of the layout's tabs could be created".to_string());
164    }
165    if tab_ids.len() < plan.tabs.len() {
166        notes.push(format!("{} of {} tabs couldn't be created.", plan.tabs.len() - tab_ids.len(), plan.tabs.len()));
167    }
168
169    // The layout's active tab becomes the workspace's active tab.
170    if let Some(active) = plan.active_tab.and_then(|i| replayed.get(i)).and_then(|r| r.as_ref()) {
171        let events = crate::server::service::dispatch_to_reducer(
172            state,
173            agentmux_common::ipc::Command::SetActiveTab { workspace_id: ws_id.clone(), tab_id: active.tab_id.clone() },
174        )
175        .await;
176        for ev in &events {
177            let _ = crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore);
178        }
179        crate::server::service::publish_events(state, &events);
180    }
181
182    // Launch each agent into its placeholder pane.
183    for tab in replayed.iter().flatten() {
184        for block_id in &tab.block_ids {
185            let Some(block) = state.mstore.get::<obj::Block>(block_id).ok().flatten() else { continue };
186            let def_id = obj::meta_get_string(&block.meta, layout_file::META_LAYOUT_AGENT, "");
187            if def_id.is_empty() {
188                continue;
189            }
190            if let Err(e) = super::agent_open::open_agent_into_block(state, &def_id, &tab.tab_id, block_id).await {
191                let name = obj::meta_get_string(&block.meta, "agentName", "");
192                let who = if name.is_empty() { def_id.clone() } else { name };
193                notes.push(format!("An agent ({who}) didn't start, so its pane shows the agent picker: {e}"));
194            }
195        }
196    }
197
198    Ok(LayoutOpenResult { workspace_id: ws_id, tab_ids, notes })
199}
200
201/// A workspace with no tabs yet, for "Open in a new window": unlike
202/// `CreateWindow`'s fresh workspace it gets no default tab, so the new window
203/// shows only the layout. The caller opens the window onto it afterwards
204/// (the host's `open_new_window { workspace_id }`, the tear-off attach path).
205async fn create_empty_workspace(state: &AppState, name: &str) -> Result<String, String> {
206    use agentmux_common::ipc::{Command, Event};
207    let events = crate::server::service::dispatch_to_reducer(state, Command::CreateWorkspace { name: name.to_string() }).await;
208    if let Some(message) = events.iter().find_map(|e| match e {
209        Event::Error { message, .. } => Some(message.clone()),
210        _ => None,
211    }) {
212        return Err(format!("layout.open: {message}"));
213    }
214    for ev in &events {
215        crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).map_err(|e| format!("layout.open: {e}"))?;
216    }
217    crate::server::service::publish_events(state, &events);
218    events
219        .iter()
220        .find_map(|e| match e {
221            Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
222            _ => None,
223        })
224        .ok_or_else(|| "layout.open: no workspace was created".to_string())
225}
226
227pub(super) fn layout_save_impl(store: &Store, req: CommandLayoutSaveData) -> Result<LayoutSaveResult, String> {
228    let path = std::path::PathBuf::from(&req.path);
229    if !layout_file::is_layout_path(&path) {
230        return Err(format!(
231            "layout.save: the path must be absolute and end in {}",
232            layout_file::LAYOUT_EXTENSION
233        ));
234    }
235    let name = req.name.trim();
236    let name = if name.is_empty() { "Layout" } else { name };
237    let ctx = layout_file::ExportContext::live(req.include_resume.unwrap_or(false));
238    let exported =
239        layout_file::export_window(store, &req.window_id, name, &ctx).map_err(|e| format!("layout.save: {e}"))?;
240    layout_file::write_layout_file(&path, &exported.doc).map_err(|e| format!("layout.save: {e}"))?;
241    Ok(LayoutSaveResult { path: path.to_string_lossy().to_string(), warnings: exported.warnings })
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use crate::backend::obj::{Block, LayoutState, Tab, Window, Workspace};
248    use agentmux_common::layout_types::{LayoutNode, LayoutNodeData};
249
250    fn one_pane_window(store: &Store) {
251        let mut b = Block { oid: "b1".into(), ..Default::default() };
252        b.meta.insert("view".into(), serde_json::json!("sysinfo"));
253        store.insert(&mut b).unwrap();
254        let mut l = LayoutState {
255            oid: "l1".into(),
256            rootnode: Some(LayoutNode {
257                id: "n1".into(),
258                data: Some(LayoutNodeData { block_id: "b1".into(), ..Default::default() }),
259                ..Default::default()
260            }),
261            ..Default::default()
262        };
263        store.insert(&mut l).unwrap();
264        let mut t = Tab { oid: "t1".into(), name: "T".into(), layoutstate: "l1".into(), blockids: vec!["b1".into()], ..Default::default() };
265        store.insert(&mut t).unwrap();
266        let mut ws = Workspace { oid: "ws1".into(), tabids: vec!["t1".into()], activetabid: "t1".into(), ..Default::default() };
267        store.insert(&mut ws).unwrap();
268        let mut w = Window { oid: "w1".into(), workspaceid: "ws1".into(), ..Default::default() };
269        store.insert(&mut w).unwrap();
270    }
271
272    fn req(path: &std::path::Path, name: &str) -> CommandLayoutSaveData {
273        CommandLayoutSaveData {
274            window_id: "w1".into(),
275            name: name.into(),
276            path: path.to_string_lossy().to_string(),
277            include_resume: None,
278        }
279    }
280
281    #[test]
282    fn saves_the_window_to_the_requested_file() {
283        let store = Store::open_in_memory().unwrap();
284        one_pane_window(&store);
285        let dir = tempfile::tempdir().unwrap();
286        let path = dir.path().join("mine.agentmux-layout.json");
287        let out = layout_save_impl(&store, req(&path, "  Mine  ")).unwrap();
288        assert!(out.warnings.is_empty());
289        let doc = layout_file::parse_layout(&std::fs::read_to_string(&path).unwrap()).unwrap();
290        assert_eq!(doc.name, "Mine", "the name is trimmed");
291        assert_eq!(doc.windows[0].tabs[0].name, "T");
292    }
293
294    #[test]
295    fn a_blank_name_becomes_layout() {
296        let store = Store::open_in_memory().unwrap();
297        one_pane_window(&store);
298        let dir = tempfile::tempdir().unwrap();
299        let path = dir.path().join("x.agentmux-layout.json");
300        layout_save_impl(&store, req(&path, "  ")).unwrap();
301        assert_eq!(layout_file::parse_layout(&std::fs::read_to_string(&path).unwrap()).unwrap().name, "Layout");
302    }
303
304    #[test]
305    fn refuses_any_other_destination_and_writes_nothing() {
306        let store = Store::open_in_memory().unwrap();
307        one_pane_window(&store);
308        let dir = tempfile::tempdir().unwrap();
309        let target = dir.path().join("settings.json");
310        std::fs::write(&target, "keep me").unwrap();
311        assert!(layout_save_impl(&store, req(&target, "x")).is_err());
312        assert_eq!(std::fs::read_to_string(&target).unwrap(), "keep me");
313    }
314
315    // ── layout.preview / layout.open (spec §3.5, Phase 2) ──
316
317    use crate::server::tests::test_state;
318    use agentmux_common::ipc::{Command, Event};
319
320    const OWN: &str = "gr2q7gf5lh6pzfdnurnkvputhm";
321
322    /// A layout file with one tab: an agent pane (whose definition uses a
323    /// provider that doesn't exist — `agent.open` refuses it before anything
324    /// is spawned, even inside a real AgentMux) beside a terminal with a
325    /// command, and a sysinfo pane as a second pane tab of the terminal.
326    fn write_layout(dir: &std::path::Path, instance: &str) -> std::path::PathBuf {
327        let path = dir.join("work.agentmux-layout.json");
328        let doc = serde_json::json!({
329            "format": "agentmux.layout", "version": 1, "name": "Work",
330            "saved_by": { "app": "agentmux", "app_version": "x", "instance": instance },
331            "windows": [{ "active_tab": 0, "tabs": [{
332                "name": "Work",
333                "focused": "p2",
334                "root": { "split": "row", "children": [
335                    { "ratio": 0.5, "node": { "pane": "p1" } },
336                    { "ratio": 0.5, "node": { "pane": "p2" } }
337                ]},
338                "panes": {
339                    "p1": { "views": [{ "type": "agent", "config": { "agent": "rev", "name": "Rev" } }] },
340                    "p2": { "views": [
341                        { "type": "term", "config": { "command": "echo", "args": ["hi"] } },
342                        { "type": "sysinfo" }
343                    ], "active": 0 }
344                }
345            }]}]
346        });
347        std::fs::write(&path, doc.to_string()).unwrap();
348        path
349    }
350
351    async fn window_with_workspace(state: &AppState) -> (String, String) {
352        let events = crate::server::service::dispatch_to_reducer(state, Command::CreateWorkspace { name: "w".into() }).await;
353        for ev in &events {
354            crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
355        }
356        let ws_id = events
357            .iter()
358            .find_map(|e| match e {
359                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
360                _ => None,
361            })
362            .unwrap();
363        let mut w = Window { oid: "win-layout".into(), workspaceid: ws_id.clone(), ..Default::default() };
364        state.mstore.insert(&mut w).unwrap();
365        (w.oid, ws_id)
366    }
367
368    fn with_agent(state: &AppState) {
369        let mut d = crate::backend::storage::agents::test_agent_def("def-rev", "Rev", "no-such-provider", "agent", 1, "");
370        d.slug = "rev".into();
371        state.mstore.agent_def_insert(&mut d).unwrap();
372    }
373
374    fn trust(dir: &std::path::Path, own: Option<&str>) -> TrustInputs {
375        TrustInputs { own_instance: own.map(str::to_string), layouts_dir: Some(dir.to_path_buf()), home: Some(dir.to_path_buf()) }
376    }
377
378    #[tokio::test]
379    async fn preview_describes_without_opening() {
380        let state = test_state();
381        with_agent(&state);
382        let dir = tempfile::tempdir().unwrap();
383        let path = write_layout(dir.path(), "someone-else");
384        let before = state.mstore.get_all::<Tab>().unwrap().len();
385        let p = layout_preview_impl(&state.mstore, &path.to_string_lossy(), &trust(dir.path(), Some(OWN))).unwrap();
386        assert_eq!(p.name, "Work");
387        assert!(!p.trusted, "saved by another install");
388        assert_eq!(p.commands, ["echo hi"]);
389        assert_eq!(p.tabs.len(), 1);
390        assert_eq!(p.tabs[0].panes, ["Agent: Rev", "Terminal (command not run: `echo hi`)", "System info"]);
391        assert_eq!(state.mstore.get_all::<Tab>().unwrap().len(), before, "a preview creates nothing");
392    }
393
394    #[tokio::test]
395    async fn open_adds_the_tabs_to_the_window_and_holds_an_untrusted_command() {
396        let state = test_state();
397        with_agent(&state);
398        let (window_id, ws_id) = window_with_workspace(&state).await;
399        let dir = tempfile::tempdir().unwrap();
400        let path = write_layout(dir.path(), "someone-else");
401        let out = layout_open_impl(
402            &state,
403            CommandLayoutOpenData { path: path.to_string_lossy().to_string(), window_id, run_commands: None, new_window: None },
404            &trust(dir.path(), Some(OWN)),
405        )
406        .await
407        .unwrap();
408
409        assert_eq!(out.tab_ids.len(), 1);
410        assert_eq!(out.workspace_id, ws_id);
411        let ws = state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap();
412        assert!(ws.tabids.contains(&out.tab_ids[0]), "the tab lands in the window's own workspace");
413        let tab = state.mstore.get::<Tab>(&out.tab_ids[0]).unwrap().unwrap();
414        assert_eq!(tab.name, "Work");
415        assert_eq!(tab.blockids.len(), 3);
416
417        let blocks: Vec<Block> = tab.blockids.iter().map(|b| state.mstore.get::<Block>(b).unwrap().unwrap()).collect();
418        let view = |b: &Block| obj::meta_get_string(&b.meta, "view", "");
419        let term = blocks.iter().find(|b| view(b) == "term").unwrap();
420        assert_eq!(obj::meta_get_string(&term.meta, "controller", ""), "shell", "untrusted: the command is held");
421        assert!(!term.meta.contains_key("cmd"));
422        // The agent couldn't start (no such provider): its pane stays the picker.
423        let agent = blocks.iter().find(|b| view(b) == "agent").unwrap();
424        assert!(obj::meta_get_string(&agent.meta, "agentId", "").is_empty());
425        assert!(out.notes.iter().any(|n| n.contains("didn't start")), "{:?}", out.notes);
426
427        // The saved tree came back with real block ids.
428        let layout = state.mstore.get::<LayoutState>(&tab.layoutstate).unwrap().unwrap();
429        let json = serde_json::to_string(&layout.rootnode).unwrap();
430        assert!(!json.contains("__snap_block_"), "every placeholder resolved: {json}");
431        for b in &tab.blockids {
432            assert!(json.contains(b.as_str()), "block {b} is in the tree");
433        }
434        assert_eq!(layout.rootnode.as_ref().unwrap().children.len(), 2);
435    }
436
437    #[tokio::test]
438    async fn a_trusted_file_runs_its_command_and_an_explicit_choice_wins() {
439        let state = test_state();
440        let (window_id, _) = window_with_workspace(&state).await;
441        let dir = tempfile::tempdir().unwrap();
442        let path = write_layout(dir.path(), OWN);
443        let term_controller = |state: &AppState, tab_id: &str| {
444            let tab = state.mstore.get::<Tab>(tab_id).unwrap().unwrap();
445            tab.blockids
446                .iter()
447                .map(|b| state.mstore.get::<Block>(b).unwrap().unwrap())
448                .find(|b| obj::meta_get_string(&b.meta, "view", "") == "term")
449                .map(|b| obj::meta_get_string(&b.meta, "controller", ""))
450                .unwrap()
451        };
452
453        let trusted = layout_open_impl(
454            &state,
455            CommandLayoutOpenData { path: path.to_string_lossy().to_string(), window_id: window_id.clone(), run_commands: None, new_window: None },
456            &trust(dir.path(), Some(OWN)),
457        )
458        .await
459        .unwrap();
460        assert_eq!(term_controller(&state, &trusted.tab_ids[0]), "cmd");
461
462        let declined = layout_open_impl(
463            &state,
464            CommandLayoutOpenData { path: path.to_string_lossy().to_string(), window_id, run_commands: Some(false), new_window: None },
465            &trust(dir.path(), Some(OWN)),
466        )
467        .await
468        .unwrap();
469        assert_eq!(term_controller(&state, &declined.tab_ids[0]), "shell");
470    }
471
472    #[tokio::test]
473    async fn open_in_a_new_window_builds_its_own_workspace_and_leaves_this_one_alone() {
474        let state = test_state();
475        with_agent(&state);
476        let (_, ws_id) = window_with_workspace(&state).await;
477        let before = state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap().tabids;
478        let dir = tempfile::tempdir().unwrap();
479        let path = write_layout(dir.path(), "someone-else");
480        let out = layout_open_impl(
481            &state,
482            // No window is needed: the new one is opened afterwards.
483            CommandLayoutOpenData {
484                path: path.to_string_lossy().to_string(),
485                window_id: String::new(),
486                run_commands: None,
487                new_window: Some(true),
488            },
489            &trust(dir.path(), Some(OWN)),
490        )
491        .await
492        .unwrap();
493
494        assert_ne!(out.workspace_id, ws_id);
495        assert_eq!(state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap().tabids, before, "the current window is untouched");
496        let ws = state.mstore.get::<Workspace>(&out.workspace_id).unwrap().unwrap();
497        assert_eq!(ws.name, "Work", "named after the layout");
498        assert_eq!(ws.tabids, out.tab_ids, "only the layout's tabs, no default tab");
499        assert_eq!(ws.activetabid, out.tab_ids[0]);
500        assert_eq!(
501            state.srv_state.lock().await.workspaces.get(&out.workspace_id).map(|w| w.tab_ids.clone()),
502            Some(out.tab_ids.clone()),
503            "the reducer knows the workspace, so a window can attach to it"
504        );
505        assert!(
506            state.mstore.get_all::<Window>().unwrap().iter().all(|w| w.workspaceid != out.workspace_id),
507            "no window yet; the caller opens one"
508        );
509
510        // The same rules as adding tabs: the untrusted command is held.
511        let tab = state.mstore.get::<Tab>(&out.tab_ids[0]).unwrap().unwrap();
512        let term = tab
513            .blockids
514            .iter()
515            .map(|b| state.mstore.get::<Block>(b).unwrap().unwrap())
516            .find(|b| obj::meta_get_string(&b.meta, "view", "") == "term")
517            .unwrap();
518        assert_eq!(obj::meta_get_string(&term.meta, "controller", ""), "shell");
519    }
520
521    #[tokio::test]
522    async fn a_new_windows_workspace_is_called_layout_when_the_file_has_no_name() {
523        let state = test_state();
524        let dir = tempfile::tempdir().unwrap();
525        let path = write_layout(dir.path(), OWN);
526        let mut doc: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
527        doc["name"] = serde_json::json!("   ");
528        std::fs::write(&path, doc.to_string()).unwrap();
529        let out = layout_open_impl(
530            &state,
531            CommandLayoutOpenData {
532                path: path.to_string_lossy().to_string(),
533                window_id: String::new(),
534                run_commands: Some(false),
535                new_window: Some(true),
536            },
537            &trust(dir.path(), Some(OWN)),
538        )
539        .await
540        .unwrap();
541        assert_eq!(state.mstore.get::<Workspace>(&out.workspace_id).unwrap().unwrap().name, "Layout");
542    }
543
544    #[tokio::test]
545    async fn open_refuses_a_non_layout_path_and_an_unknown_window() {
546        let state = test_state();
547        let dir = tempfile::tempdir().unwrap();
548        let bad = dir.path().join("settings.json");
549        std::fs::write(&bad, "{}").unwrap();
550        assert!(layout_preview_impl(&state.mstore, &bad.to_string_lossy(), &trust(dir.path(), None)).is_err());
551        let path = write_layout(dir.path(), OWN);
552        let err = layout_open_impl(
553            &state,
554            CommandLayoutOpenData { path: path.to_string_lossy().to_string(), window_id: "nope".into(), run_commands: None, new_window: None },
555            &trust(dir.path(), None),
556        )
557        .await
558        .unwrap_err();
559        assert!(err.contains("no such window"), "{err}");
560    }
561
562    #[test]
563    fn deserializes_the_documented_request_shape() {
564        let r: CommandLayoutSaveData = serde_json::from_value(serde_json::json!({
565            "window_id": "w1", "name": "n", "path": "/tmp/a.agentmux-layout.json"
566        }))
567        .unwrap();
568        assert_eq!(r.include_resume, None);
569    }
570}