Skip to main content

agentmux_srv/backend/
layout_file.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Layout files — the `agentmux.layout` v1 format and its writer
5//! (`docs/specs/SPEC_LAYOUT_FILES_2026_09_25.md`, Phase 1).
6//!
7//! A layout file describes a window's tabs, their split trees (as ratios)
8//! and each pane's typed views, so it can be read, diffed, shared and — in
9//! Phase 2 — reopened. It is **not** a dump of the store: every view is
10//! written through a per-type allowlist ([`view_from_block`]), never by
11//! copying block meta. The session-restore snapshot
12//! (`server/service/session_restore.rs`) copies meta verbatim, which is fine
13//! for an internal blob and wrong for a file a user hands to someone else:
14//! session ids, instance ids, absolute paths and env would all leak.
15//!
16//! Every struct carries an `extra` map, so a reader keeps fields it doesn't
17//! know and writes them back unchanged (spec §3.1).
18
19use std::collections::{BTreeMap, HashMap};
20use std::path::{Path, PathBuf};
21
22use serde::{Deserialize, Serialize};
23use serde_json::{Map, Value};
24
25use agentmux_common::layout_types::LayoutNode;
26
27use crate::backend::obj::{Block, LayoutState, MetaMapType, Tab, Window, Workspace};
28use crate::backend::storage::store::Store;
29
30/// The `format` discriminator. A reader refuses any other value.
31pub const LAYOUT_FORMAT: &str = "agentmux.layout";
32/// The format version this build writes and the newest it reads.
33pub const LAYOUT_VERSION: u32 = 1;
34/// Every layout file ends with this (spec §3.1): filterable in dialogs,
35/// highlighted as JSON in any editor.
36pub const LAYOUT_EXTENSION: &str = ".agentmux-layout.json";
37
38// ── The file format (spec §3) ──
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct LayoutDoc {
42    pub format: String,
43    pub version: u32,
44    #[serde(default)]
45    pub name: String,
46    #[serde(default, skip_serializing_if = "String::is_empty")]
47    pub saved_at: String,
48    #[serde(default)]
49    pub saved_by: SavedBy,
50    #[serde(default)]
51    pub scope: String,
52    #[serde(default)]
53    pub windows: Vec<LayoutWindow>,
54    #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
55    pub extra: Map<String, Value>,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
59pub struct SavedBy {
60    #[serde(default)]
61    pub app: String,
62    #[serde(default)]
63    pub app_version: String,
64    /// This install's WAN instance id (public), so Phase 2 can tell "saved
65    /// here" from "came from elsewhere". Absent without a `wan.db`.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub instance: Option<String>,
68    #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
69    pub extra: Map<String, Value>,
70}
71
72#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
73pub struct LayoutWindow {
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub active_tab: Option<usize>,
76    #[serde(default)]
77    pub tabs: Vec<LayoutTab>,
78    #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
79    pub extra: Map<String, Value>,
80}
81
82#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
83pub struct LayoutTab {
84    #[serde(default)]
85    pub name: String,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub focused: Option<String>,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub magnified: Option<String>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub root: Option<TreeNode>,
92    /// Keyed by file-local pane id (`p1`, `p2`, …) — never an OID.
93    #[serde(default)]
94    pub panes: BTreeMap<String, LayoutPane>,
95    #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
96    pub extra: Map<String, Value>,
97}
98
99/// A split (`{split, children}`) or a leaf (`{pane}`).
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(untagged)]
102pub enum TreeNode {
103    Split {
104        /// `row` | `column` — the tree's own `flexDirection` values.
105        split: String,
106        children: Vec<TreeChild>,
107        #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
108        extra: Map<String, Value>,
109    },
110    Leaf {
111        pane: String,
112        #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
113        extra: Map<String, Value>,
114    },
115}
116
117#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
118pub struct TreeChild {
119    /// Share of the parent split; siblings sum to 1.
120    pub ratio: f64,
121    pub node: TreeNode,
122}
123
124#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
125pub struct LayoutPane {
126    /// The pane's tabs, in order.
127    pub views: Vec<LayoutView>,
128    /// Index into `views`.
129    #[serde(default)]
130    pub active: usize,
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub minimized: Option<bool>,
133    #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
134    pub extra: Map<String, Value>,
135}
136
137#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
138pub struct LayoutView {
139    #[serde(rename = "type")]
140    pub view_type: String,
141    #[serde(default, skip_serializing_if = "Map::is_empty")]
142    pub config: Map<String, Value>,
143    /// Opt-in, machine-local resume references (spec §3.3). Absent unless the
144    /// user asked for them at save time.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub resume: Option<Map<String, Value>>,
147    #[serde(flatten, default, skip_serializing_if = "Map::is_empty")]
148    pub extra: Map<String, Value>,
149}
150
151// ── Export ──
152
153/// Everything the exporter reads from outside the store, injected so tests
154/// are deterministic.
155pub struct ExportContext {
156    /// The user's home dir; paths under it are written `~/…`.
157    pub home: Option<PathBuf>,
158    pub app_version: String,
159    pub instance: Option<String>,
160    /// RFC 3339.
161    pub saved_at: String,
162    /// Write the opt-in `resume` references (spec §3.3; off by default).
163    pub include_resume: bool,
164}
165
166impl ExportContext {
167    /// The live context: real home dir, this build's version, this install's
168    /// WAN instance id if it has one, now.
169    pub fn live(include_resume: bool) -> Self {
170        Self {
171            home: dirs::home_dir(),
172            app_version: env!("CARGO_PKG_VERSION").to_string(),
173            instance: crate::backend::storage::wan_identity::global()
174                .and_then(|w| w.instance_ensure(&crate::backend::reactive::registry::local_host_label()).ok())
175                .map(|i| i.instance_id),
176            saved_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
177            include_resume,
178        }
179    }
180}
181
182/// The result of exporting: the document, plus anything the user should be
183/// told before it is written (spec §3.4).
184#[derive(Debug, Clone, PartialEq)]
185pub struct Exported {
186    pub doc: LayoutDoc,
187    pub warnings: Vec<String>,
188}
189
190/// Export one window — every tab of its workspace — as a layout document.
191pub fn export_window(store: &Store, window_id: &str, name: &str, ctx: &ExportContext) -> Result<Exported, String> {
192    let window = store
193        .get::<Window>(window_id)
194        .map_err(|e| format!("could not read window {window_id}: {e}"))?
195        .ok_or_else(|| format!("no such window: {window_id}"))?;
196    let workspace = store
197        .get::<Workspace>(&window.workspaceid)
198        .map_err(|e| format!("could not read the window's workspace: {e}"))?
199        .ok_or_else(|| "the window has no workspace".to_string())?;
200
201    let mut warnings = Vec::new();
202    let mut tabs = Vec::new();
203    let mut active_tab = None;
204    // `pinnedtabids` is legacy but can still hold a fresh workspace's only
205    // tab — read both, as `session_restore::snapshot_workspace` does.
206    for tab_id in workspace.pinnedtabids.iter().chain(workspace.tabids.iter()) {
207        let Some(tab) = store.get::<Tab>(tab_id).ok().flatten() else { continue };
208        let Some(exported) = export_tab(store, &tab, ctx, &mut warnings) else { continue };
209        if tab_id == &workspace.activetabid {
210            active_tab = Some(tabs.len());
211        }
212        tabs.push(exported);
213    }
214    if tabs.is_empty() {
215        return Err("the window has no tabs with panes to save".to_string());
216    }
217    Ok(Exported {
218        doc: LayoutDoc {
219            format: LAYOUT_FORMAT.to_string(),
220            version: LAYOUT_VERSION,
221            name: name.to_string(),
222            saved_at: ctx.saved_at.clone(),
223            saved_by: SavedBy {
224                app: "agentmux".to_string(),
225                app_version: ctx.app_version.clone(),
226                instance: ctx.instance.clone(),
227                extra: Map::new(),
228            },
229            scope: "window".to_string(),
230            windows: vec![LayoutWindow { active_tab, tabs, extra: Map::new() }],
231            extra: Map::new(),
232        },
233        warnings,
234    })
235}
236
237/// Pane ids are minted per tab in tree order; node ids are the tree's own,
238/// used only to map focus/magnify onto them.
239struct TabBuilder<'a> {
240    store: &'a Store,
241    ctx: &'a ExportContext,
242    blocks: HashMap<String, Block>,
243    panes: BTreeMap<String, LayoutPane>,
244    pane_by_node: HashMap<String, String>,
245    warnings: &'a mut Vec<String>,
246}
247
248fn export_tab(store: &Store, tab: &Tab, ctx: &ExportContext, warnings: &mut Vec<String>) -> Option<LayoutTab> {
249    let layout = if tab.layoutstate.is_empty() {
250        None
251    } else {
252        store.get::<LayoutState>(&tab.layoutstate).ok().flatten()
253    };
254    let rootnode = layout.as_ref().and_then(|l| l.rootnode.clone())?;
255    let blocks = tab
256        .blockids
257        .iter()
258        .filter_map(|id| store.get::<Block>(id).ok().flatten().map(|b| (id.clone(), b)))
259        .collect();
260    let mut builder = TabBuilder {
261        store,
262        ctx,
263        blocks,
264        panes: BTreeMap::new(),
265        pane_by_node: HashMap::new(),
266        warnings,
267    };
268    let root = builder.node(&rootnode)?;
269    let layout = layout?;
270    let focused = builder.pane_by_node.get(&layout.focusednodeid).cloned();
271    let magnified = builder.pane_by_node.get(&layout.magnifiednodeid).cloned();
272    Some(LayoutTab {
273        name: tab.name.clone(),
274        focused,
275        magnified,
276        root: Some(root),
277        panes: builder.panes,
278        extra: Map::new(),
279    })
280}
281
282impl TabBuilder<'_> {
283    /// Convert one tree node; `None` if nothing under it resolves to a view
284    /// (a pane whose blocks are gone is dropped, not written empty).
285    fn node(&mut self, node: &LayoutNode) -> Option<TreeNode> {
286        if node.children.is_empty() {
287            return self.leaf(node);
288        }
289        let mut kept: Vec<(f64, TreeNode)> = Vec::new();
290        for child in &node.children {
291            if let Some(converted) = self.node(child) {
292                kept.push((f64::from(child.size.max(0.0)), converted));
293            }
294        }
295        match kept.len() {
296            0 => None,
297            // A split with one survivor is just that survivor.
298            1 => kept.pop().map(|(_, n)| n),
299            _ => Some(TreeNode::Split {
300                split: match node.flex_direction {
301                    agentmux_common::layout_types::FlexDirection::Column => "column",
302                    _ => "row",
303                }
304                .to_string(),
305                children: ratios(&kept.iter().map(|(s, _)| *s).collect::<Vec<_>>())
306                    .into_iter()
307                    .zip(kept.into_iter().map(|(_, n)| n))
308                    .map(|(ratio, node)| TreeChild { ratio, node })
309                    .collect(),
310                extra: Map::new(),
311            }),
312        }
313    }
314
315    fn leaf(&mut self, node: &LayoutNode) -> Option<TreeNode> {
316        let data = node.data.as_ref()?;
317        let stack: Vec<&String> = if data.block_stack.is_empty() {
318            vec![&data.block_id]
319        } else {
320            data.block_stack.iter().collect()
321        };
322        let active_block = if data.active_block_id.is_empty() { &data.block_id } else { &data.active_block_id };
323        let mut views = Vec::new();
324        let mut active = 0;
325        for block_id in stack {
326            let Some(block) = self.blocks.get(block_id) else { continue };
327            if block_id == active_block {
328                active = views.len();
329            }
330            views.push(view_from_block(self.store, &block.meta, self.ctx, self.warnings));
331        }
332        if views.is_empty() {
333            return None;
334        }
335        let pane_id = format!("p{}", self.panes.len() + 1);
336        self.pane_by_node.insert(node.id.clone(), pane_id.clone());
337        self.panes.insert(
338            pane_id.clone(),
339            LayoutPane {
340                views,
341                active,
342                minimized: node.extra.get("minimized").and_then(Value::as_bool).filter(|m| *m),
343                extra: Map::new(),
344            },
345        );
346        Some(TreeNode::Leaf { pane: pane_id, extra: Map::new() })
347    }
348}
349
350/// Flex sizes → ratios summing to exactly 1, rounded to 4 places for a
351/// readable file (the last sibling absorbs the rounding). Non-positive sizes
352/// share equally.
353fn ratios(sizes: &[f64]) -> Vec<f64> {
354    let total: f64 = sizes.iter().sum();
355    let n = sizes.len();
356    let raw: Vec<f64> = if total > 0.0 {
357        sizes.iter().map(|s| s / total).collect()
358    } else {
359        vec![1.0 / n as f64; n]
360    };
361    let mut out: Vec<f64> = raw.iter().map(|r| (r * 10_000.0).round() / 10_000.0).collect();
362    if let Some(last) = out.last_mut() {
363        let rest: f64 = raw.iter().take(n - 1).map(|r| (r * 10_000.0).round() / 10_000.0).sum();
364        *last = ((1.0 - rest) * 10_000.0).round() / 10_000.0;
365    }
366    out
367}
368
369/// Where a saved Armory pane goes, by its section (and, for the old Memory
370/// section, its Global/Personal subsection): its pane, `connectors` or
371/// `memory`, and that pane's section. The same mapping m0037_retire_old_pane_names.rs applied to
372/// stored blocks; layout files live outside AgentMux, so their import still needs it.
373fn armory_target(section: Option<&str>, subsection: Option<&str>) -> (&'static str, &'static str) {
374    match section {
375        Some("mcp") => ("connectors", "mcp"),
376        Some("memory") if subsection == Some("personal") => ("memory", "personal"),
377        Some("memory") => ("memory", "global"),
378        Some("native_memory") => ("memory", "personal"),
379        Some("skills") => ("memory", "skills"),
380        Some("bundles") => ("memory", "bundles"),
381        _ => ("connectors", "accounts"),
382    }
383}
384
385fn meta_str<'a>(meta: &'a MetaMapType, key: &str) -> Option<&'a str> {
386    meta.get(key).and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty())
387}
388
389/// A path under `home` as `~/…` (forward slashes); anything else unchanged.
390fn home_relative(path: &str, home: Option<&Path>) -> String {
391    let Some(home) = home.and_then(|h| h.to_str()) else { return path.to_string() };
392    let home = home.trim_end_matches(['/', '\\']);
393    // `get`, not `[..]`: the home dir's byte length can fall inside a
394    // multi-byte character of `path`, and slicing there panics (ReAgent P1 on
395    // #3787). Such a path can't be under home anyway.
396    let (Some(prefix), Some(rest)) = (path.get(..home.len()), path.get(home.len()..)) else {
397        return path.to_string();
398    };
399    let matches_prefix = if cfg!(windows) { prefix.eq_ignore_ascii_case(home) } else { prefix == home };
400    if !matches_prefix {
401        return path.to_string();
402    }
403    if rest.is_empty() {
404        return "~".to_string();
405    }
406    if !rest.starts_with(['/', '\\']) {
407        return path.to_string(); // `/home/alice2` is not under `/home/alice`
408    }
409    format!("~{}", rest.replace('\\', "/"))
410}
411
412/// A URL with its query string and fragment removed (spec §3.3/§3.4) —
413/// they are where tokens, session ids and tracking live.
414fn strip_query(url: &str) -> String {
415    url.split(['?', '#']).next().unwrap_or("").to_string()
416}
417
418/// One pane tab, written from the per-type allowlist (spec §3.3). Anything
419/// not named here — session ids, instance ids, env, runtime state, CLI
420/// paths, failure records — is never written. An unknown view type is
421/// written as `{type}` alone.
422fn view_from_block(store: &Store, meta: &MetaMapType, ctx: &ExportContext, warnings: &mut Vec<String>) -> LayoutView {
423    let mut view_type = meta_str(meta, "view").unwrap_or("unknown").to_string();
424    let home = ctx.home.as_deref();
425    let mut config = Map::new();
426    let mut resume = None;
427    // A saved Armory block the app hasn't moved to its new pane yet (it does
428    // when the block loads) is written as that pane.
429    if matches!(view_type.as_str(), "armory" | "trust") {
430        let (pane, section) = armory_target(meta_str(meta, "armory:section"), meta_str(meta, "armory:memory:subsection"));
431        view_type = pane.to_string();
432        config.insert("section".into(), Value::String(section.to_string()));
433    }
434    // Memory was named Knowledge; a block saved then still says so, and keeps
435    // its section under the old key until it's next changed.
436    if view_type == "knowledge" {
437        view_type = "memory".to_string();
438        if let Some(section) = meta_str(meta, "memory:section").or_else(|| meta_str(meta, "knowledge:section")) {
439            config.insert("section".into(), Value::String(section.to_string()));
440        }
441    }
442    // Remotes was a pane of its own until it became a section of Connectors.
443    if view_type == "remotes" {
444        view_type = "connectors".to_string();
445        config.insert("section".into(), Value::String("remotes".to_string()));
446    }
447    match view_type.as_str() {
448        "agent" => {
449            // The definition's portable identity, never its local row id.
450            let def = meta_str(meta, "agentId").and_then(|id| store.agent_def_get(id).ok().flatten());
451            let slug = def.as_ref().map(|d| d.slug.clone()).filter(|s| !s.is_empty());
452            let name = def
453                .as_ref()
454                .map(|d| d.name.clone())
455                .filter(|s| !s.is_empty())
456                .or_else(|| meta_str(meta, "agentName").map(str::to_string));
457            let provider = def
458                .as_ref()
459                .map(|d| d.provider.clone())
460                .filter(|s| !s.is_empty())
461                .or_else(|| meta_str(meta, "agentProvider").map(str::to_string));
462            if let Some(slug) = slug {
463                config.insert("agent".into(), Value::String(slug));
464            }
465            if let Some(name) = name {
466                config.insert("name".into(), Value::String(name));
467            }
468            if let Some(provider) = provider {
469                config.insert("provider".into(), Value::String(provider));
470            }
471            if ctx.include_resume {
472                if let Some(session) = meta_str(meta, "agent:sessionid") {
473                    let mut r = Map::new();
474                    r.insert("session_id".into(), Value::String(session.to_string()));
475                    resume = Some(r);
476                }
477            }
478        }
479        "term" => {
480            if let Some(cwd) = meta_str(meta, "cmd:cwd") {
481                config.insert("cwd".into(), Value::String(home_relative(cwd, home)));
482            }
483            if let Some(cmd) = meta_str(meta, "cmd") {
484                let args: Vec<String> = meta
485                    .get("cmd:args")
486                    .and_then(Value::as_array)
487                    .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
488                    .unwrap_or_default();
489                let full = std::iter::once(cmd.to_string()).chain(args.iter().cloned()).collect::<Vec<_>>().join(" ");
490                if crate::backend::reactive::sanitize::is_sensitive_message(&full) {
491                    warnings.push(format!(
492                        "A terminal command looks like it may contain a credential or a destructive operation: `{}` — check the file before sharing it.",
493                        crate::backend::reactive::sanitize::sanitize_message(&full)
494                    ));
495                }
496                config.insert("command".into(), Value::String(cmd.to_string()));
497                if !args.is_empty() {
498                    config.insert("args".into(), Value::Array(args.into_iter().map(Value::String).collect()));
499                }
500            }
501        }
502        "browser" => {
503            if let Some(url) = meta_str(meta, "url") {
504                config.insert("url".into(), Value::String(strip_query(url)));
505            }
506        }
507        "editor" | "codeeditor" | "preview" => {
508            if let Some(file) = meta_str(meta, "file") {
509                config.insert("file".into(), Value::String(home_relative(file, home)));
510            }
511        }
512        "media" => {
513            if let Some(path) = meta_str(meta, "media:path") {
514                config.insert("path".into(), Value::String(home_relative(path, home)));
515            }
516        }
517        "files" => {
518            if let Some(path) = meta_str(meta, "files:path") {
519                config.insert("path".into(), Value::String(home_relative(path, home)));
520            }
521        }
522        "sysinfo" => {
523            if let Some(kind) = meta_str(meta, "sysinfo:type") {
524                config.insert("sysinfo_type".into(), Value::String(kind.to_string()));
525            }
526        }
527        "connectors" | "memory" => {
528            if let Some(section) = meta_str(meta, &format!("{view_type}:section")) {
529                config.insert("section".into(), Value::String(section.to_string()));
530            }
531        }
532        // Singleton views and anything this build doesn't know: the type
533        // alone is enough to reopen them, and nothing else is safe to assume.
534        _ => {}
535    }
536    LayoutView { view_type, config, resume, extra: Map::new() }
537}
538
539// ── Write ──
540
541/// Whether `path` is somewhere a layout may be written: absolute, and named
542/// `*.agentmux-layout.json`.
543pub fn is_layout_path(path: &Path) -> bool {
544    path.is_absolute()
545        && path
546            .file_name()
547            .and_then(|n| n.to_str())
548            .is_some_and(|n| n.to_ascii_lowercase().ends_with(LAYOUT_EXTENSION) && n.len() > LAYOUT_EXTENSION.len())
549}
550
551/// Write `doc` to `path` atomically: a temp file beside it, then a rename,
552/// so a crash or a full disk never leaves half a layout behind.
553pub fn write_layout_file(path: &Path, doc: &LayoutDoc) -> Result<(), String> {
554    if !is_layout_path(path) {
555        return Err(format!(
556            "a layout must be saved to an absolute path ending in {LAYOUT_EXTENSION}: {}",
557            path.display()
558        ));
559    }
560    let dir = path.parent().ok_or_else(|| format!("no parent directory: {}", path.display()))?;
561    std::fs::create_dir_all(dir).map_err(|e| format!("could not create {}: {e}", dir.display()))?;
562    let mut json = serde_json::to_string_pretty(doc).map_err(|e| format!("could not encode the layout: {e}"))?;
563    json.push('\n');
564    let tmp = dir.join(format!(".{}.tmp-{}", path.file_name().and_then(|n| n.to_str()).unwrap_or("layout"), uuid::Uuid::new_v4()));
565    std::fs::write(&tmp, json).map_err(|e| format!("could not write {}: {e}", tmp.display()))?;
566    std::fs::rename(&tmp, path).map_err(|e| {
567        let _ = std::fs::remove_file(&tmp);
568        format!("could not save {}: {e}", path.display())
569    })
570}
571
572/// Parse a layout document, refusing another format or a newer version
573/// (spec §3.1). Unknown fields are kept in `extra`.
574pub fn parse_layout(json: &str) -> Result<LayoutDoc, String> {
575    let doc: LayoutDoc = serde_json::from_str(json).map_err(|e| format!("not a layout file: {e}"))?;
576    if doc.format != LAYOUT_FORMAT {
577        return Err(format!("not an AgentMux layout (format is \"{}\")", doc.format));
578    }
579    if doc.version > LAYOUT_VERSION {
580        return Err(format!(
581            "this layout was saved by a newer AgentMux (format version {}; this build reads up to {LAYOUT_VERSION})",
582            doc.version
583        ));
584    }
585    Ok(doc)
586}
587
588// ── Apply (spec §3.5, Phase 2) ──
589
590/// Block meta key naming the agent definition a layout's agent pane should
591/// launch. Set on the empty `{view:"agent"}` placeholder the plan creates;
592/// `agent_open::open_agent_into_block` launches into it and clears the key.
593pub const META_LAYOUT_AGENT: &str = "layout:agent";
594
595/// A layout file larger than this is refused unread — real ones are a few KB.
596pub const MAX_LAYOUT_FILE_BYTES: u64 = 1024 * 1024;
597const MAX_TABS: usize = 50;
598const MAX_PANES_PER_TAB: usize = 64;
599const MAX_TREE_DEPTH: usize = 32;
600
601/// Views that need no config to open, beyond their type.
602const PLAIN_VIEWS: &[&str] = &[
603    "swarm", "settings", "help", "drone", "workflows", "warden", "toolchain", "launcher", "identity",
604    "tower",
605];
606
607/// How to turn a file into panes.
608pub struct PlanOptions {
609    /// The user's home dir; `~/…` paths in the file resolve under it.
610    pub home: Option<PathBuf>,
611    /// Start terminal commands. Off unless the file came from this install or
612    /// the user said so in the preview (spec §3.5 "Trust").
613    pub run_commands: bool,
614}
615
616/// One tab ready for `session_restore::replay_tabs`: block metas, and a
617/// tree whose leaves name them by `session_restore::placeholder(i)`.
618#[derive(Debug, Clone)]
619pub struct PlanTab {
620    pub name: String,
621    pub blocks: Vec<Value>,
622    pub rootnode: Option<LayoutNode>,
623    pub focused: String,
624    pub magnified: String,
625    /// One human-readable line per pane tab, for the preview.
626    pub summary: Vec<String>,
627}
628
629/// A layout file turned into panes, plus everything the user should see
630/// before it opens.
631#[derive(Debug, Clone, Default)]
632pub struct ApplyPlan {
633    pub name: String,
634    pub tabs: Vec<PlanTab>,
635    pub active_tab: Option<usize>,
636    /// Terminal commands in the file, whether or not they will run.
637    pub commands: Vec<String>,
638    /// What couldn't be reproduced here, and what was left out.
639    pub notes: Vec<String>,
640}
641
642/// Read and parse a layout file, refusing anything too large to be one.
643pub fn read_layout_file(path: &Path) -> Result<LayoutDoc, String> {
644    let meta = std::fs::metadata(path).map_err(|e| format!("could not read {}: {e}", path.display()))?;
645    if meta.len() > MAX_LAYOUT_FILE_BYTES {
646        return Err(format!("{} is too large to be a layout file", path.display()));
647    }
648    let text = std::fs::read_to_string(path).map_err(|e| format!("could not read {}: {e}", path.display()))?;
649    parse_layout(&text)
650}
651
652/// `~` / `~/x` resolved under `home`; anything else unchanged.
653fn expand_home(path: &str, home: Option<&Path>) -> PathBuf {
654    match (path.strip_prefix('~'), home) {
655        (Some(""), Some(h)) => h.to_path_buf(),
656        (Some(rest), Some(h)) if rest.starts_with(['/', '\\']) => h.join(rest.trim_start_matches(['/', '\\'])),
657        _ => PathBuf::from(path),
658    }
659}
660
661fn cfg_str<'a>(view: &'a LayoutView, key: &str) -> Option<&'a str> {
662    view.config.get(key).and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty())
663}
664
665/// A user agent (not a template) matching the file's slug, else its display
666/// name — only if exactly one matches, so a name shared by two agents never
667/// launches the wrong one.
668fn resolve_agent<'a>(
669    agents: &'a [crate::backend::storage::agents::AgentDefinition],
670    slug: Option<&str>,
671    name: Option<&str>,
672) -> Option<&'a crate::backend::storage::agents::AgentDefinition> {
673    fn unique<'a, T>(mut it: impl Iterator<Item = &'a T>) -> Option<&'a T> {
674        let first = it.next()?;
675        it.next().is_none().then_some(first)
676    }
677    let users = || agents.iter().filter(|a| a.is_seeded == 0);
678    slug.and_then(|s| unique(users().filter(|a| a.slug.eq_ignore_ascii_case(s))))
679        .or_else(|| name.and_then(|n| unique(users().filter(|a| a.name.eq_ignore_ascii_case(n)))))
680}
681
682/// Build the plan. Never touches the store beyond reading agent definitions.
683pub fn plan_from_doc(store: &Store, doc: &LayoutDoc, opts: &PlanOptions) -> ApplyPlan {
684    let agents = store.agent_def_list().unwrap_or_default();
685    let mut plan = ApplyPlan { name: doc.name.clone(), ..Default::default() };
686    let Some(window) = doc.windows.first() else {
687        plan.notes.push("The file has no windows.".to_string());
688        return plan;
689    };
690    if doc.windows.len() > 1 {
691        plan.notes.push(format!("The file has {} windows; only the first is opened.", doc.windows.len()));
692    }
693    if window.tabs.len() > MAX_TABS {
694        plan.notes.push(format!("Only the first {MAX_TABS} of {} tabs are opened.", window.tabs.len()));
695    }
696    for (i, tab) in window.tabs.iter().take(MAX_TABS).enumerate() {
697        let mut builder = PlanBuilder {
698            doc_tab: tab,
699            agents: &agents,
700            opts,
701            blocks: Vec::new(),
702            summary: Vec::new(),
703            node_by_pane: HashMap::new(),
704            panes_used: 0,
705            truncated: Vec::new(),
706            commands: &mut plan.commands,
707            notes: &mut plan.notes,
708        };
709        let rootnode = tab.root.as_ref().and_then(|root| builder.node(root, 100.0, 0));
710        let (blocks, summary, node_by_pane) = (builder.blocks, builder.summary, builder.node_by_pane);
711        if blocks.is_empty() {
712            plan.notes.push(format!("Tab “{}” has no panes that can be opened, so it is skipped.", tab.name));
713            continue;
714        }
715        if window.active_tab == Some(i) {
716            plan.active_tab = Some(plan.tabs.len());
717        }
718        let node_for = |pane: &Option<String>| pane.as_ref().and_then(|p| node_by_pane.get(p)).cloned().unwrap_or_default();
719        plan.tabs.push(PlanTab {
720            name: tab.name.clone(),
721            blocks,
722            rootnode,
723            focused: node_for(&tab.focused),
724            magnified: node_for(&tab.magnified),
725            summary,
726        });
727    }
728    plan
729}
730
731struct PlanBuilder<'a> {
732    doc_tab: &'a LayoutTab,
733    agents: &'a [crate::backend::storage::agents::AgentDefinition],
734    opts: &'a PlanOptions,
735    blocks: Vec<Value>,
736    summary: Vec<String>,
737    node_by_pane: HashMap<String, String>,
738    panes_used: usize,
739    truncated: Vec<Truncated>,
740    commands: &'a mut Vec<String>,
741    notes: &'a mut Vec<String>,
742}
743
744/// A per-tab limit the file hit. Each is noted once per tab, however many
745/// panes or branches it cuts off.
746#[derive(PartialEq)]
747enum Truncated {
748    Depth,
749    Panes,
750}
751
752impl PlanBuilder<'_> {
753    fn note_once(&mut self, what: Truncated) {
754        if self.truncated.contains(&what) {
755            return;
756        }
757        let tab = &self.doc_tab.name;
758        self.notes.push(match what {
759            Truncated::Depth => format!("Tab “{tab}” nests too deeply; the rest is skipped."),
760            Truncated::Panes => format!("Tab “{tab}” has more than {MAX_PANES_PER_TAB} panes; only the first {MAX_PANES_PER_TAB} are opened."),
761        });
762        self.truncated.push(what);
763    }
764
765    fn node(&mut self, node: &TreeNode, size: f64, depth: usize) -> Option<LayoutNode> {
766        if depth > MAX_TREE_DEPTH {
767            self.note_once(Truncated::Depth);
768            return None;
769        }
770        match node {
771            TreeNode::Split { split, children, .. } => {
772                let kids: Vec<LayoutNode> = children
773                    .iter()
774                    .filter_map(|c| self.node(&c.node, c.ratio.clamp(0.0, 1.0) * 100.0, depth + 1))
775                    .collect();
776                match kids.len() {
777                    0 => None,
778                    1 => kids.into_iter().next().map(|mut only| {
779                        only.size = size as f32;
780                        only
781                    }),
782                    _ => Some(LayoutNode {
783                        id: uuid::Uuid::new_v4().to_string(),
784                        flex_direction: if split == "column" {
785                            agentmux_common::layout_types::FlexDirection::Column
786                        } else {
787                            agentmux_common::layout_types::FlexDirection::Row
788                        },
789                        size: size as f32,
790                        children: kids,
791                        ..Default::default()
792                    }),
793                }
794            }
795            TreeNode::Leaf { pane, .. } => {
796                if self.panes_used >= MAX_PANES_PER_TAB {
797                    self.note_once(Truncated::Panes);
798                    return None;
799                }
800                let tab = &self.doc_tab.name;
801                let Some(doc_pane) = self.doc_tab.panes.get(pane) else {
802                    self.notes.push(format!("Tab “{tab}” refers to a pane “{pane}” the file doesn't define; it's left out."));
803                    return None;
804                };
805                if doc_pane.views.is_empty() {
806                    self.notes.push(format!("Pane “{pane}” in tab “{tab}” has nothing to show; it's left out."));
807                    return None;
808                }
809                let mut placeholders = Vec::new();
810                for view in &doc_pane.views {
811                    let meta = self.view_meta(view);
812                    placeholders.push(crate::server::service::session_restore::placeholder(self.blocks.len()));
813                    self.blocks.push(meta);
814                }
815                self.panes_used += 1;
816                let active = placeholders[doc_pane.active.min(placeholders.len() - 1)].clone();
817                let id = uuid::Uuid::new_v4().to_string();
818                self.node_by_pane.insert(pane.clone(), id.clone());
819                let mut extra = Map::new();
820                if doc_pane.minimized == Some(true) {
821                    extra.insert("minimized".into(), Value::Bool(true));
822                }
823                let multi = placeholders.len() > 1;
824                Some(LayoutNode {
825                    id,
826                    size: size as f32,
827                    data: Some(agentmux_common::layout_types::LayoutNodeData {
828                        block_id: active.clone(),
829                        block_stack: if multi { placeholders } else { Vec::new() },
830                        active_block_id: if multi { active } else { String::new() },
831                        ..Default::default()
832                    }),
833                    extra,
834                    ..Default::default()
835                })
836            }
837        }
838    }
839
840    /// The reverse of the §3.3 allowlist: a fresh pane's meta from a view.
841    fn view_meta(&mut self, view: &LayoutView) -> Value {
842        let home = self.opts.home.as_deref();
843        let mut meta = Map::new();
844        let kind = view.view_type.as_str();
845        match kind {
846            "agent" => {
847                meta.insert("view".into(), "agent".into());
848                let slug = cfg_str(view, "agent");
849                let name = cfg_str(view, "name");
850                let label = name.or(slug).unwrap_or("agent");
851                match resolve_agent(self.agents, slug, name) {
852                    Some(def) => {
853                        meta.insert(META_LAYOUT_AGENT.into(), Value::String(def.id.clone()));
854                        self.summary.push(format!("Agent: {}", def.name));
855                    }
856                    None => {
857                        self.notes.push(format!("There's no agent “{label}” here — its pane opens the agent picker."));
858                        self.summary.push(format!("Agent: {label} (not found — picker)"));
859                    }
860                }
861            }
862            "term" => {
863                meta.insert("view".into(), "term".into());
864                let cwd = cfg_str(view, "cwd").map(|c| expand_home(c, home));
865                let mut where_ = String::new();
866                if let Some(cwd) = cwd {
867                    if cwd.is_dir() {
868                        where_ = format!(" in {}", cwd.display());
869                        meta.insert("cmd:cwd".into(), Value::String(cwd.to_string_lossy().to_string()));
870                    } else {
871                        self.notes.push(format!("Folder {} doesn't exist here — that terminal starts in the default folder.", cwd.display()));
872                    }
873                }
874                let command = cfg_str(view, "command").map(|c| {
875                    let args: Vec<String> = view
876                        .config
877                        .get("args")
878                        .and_then(Value::as_array)
879                        .map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect())
880                        .unwrap_or_default();
881                    (c.to_string(), args)
882                });
883                match command {
884                    Some((cmd, args)) => {
885                        let full = std::iter::once(cmd.clone()).chain(args.iter().cloned()).collect::<Vec<_>>().join(" ");
886                        self.commands.push(full.clone());
887                        if self.opts.run_commands {
888                            meta.insert("controller".into(), "cmd".into());
889                            meta.insert("cmd".into(), Value::String(cmd));
890                            if !args.is_empty() {
891                                meta.insert("cmd:args".into(), Value::Array(args.into_iter().map(Value::String).collect()));
892                            }
893                            self.summary.push(format!("Terminal{where_}: runs `{full}`"));
894                        } else {
895                            meta.insert("controller".into(), "shell".into());
896                            self.summary.push(format!("Terminal{where_} (command not run: `{full}`)"));
897                        }
898                    }
899                    None => {
900                        meta.insert("controller".into(), "shell".into());
901                        self.summary.push(format!("Terminal{where_}"));
902                    }
903                }
904            }
905            "browser" => {
906                meta.insert("view".into(), "browser".into());
907                match cfg_str(view, "url") {
908                    Some(url) if url.starts_with("https://") || url.starts_with("http://") => {
909                        meta.insert("url".into(), Value::String(url.to_string()));
910                        self.summary.push(format!("Browser: {url}"));
911                    }
912                    Some(url) => {
913                        self.notes.push(format!("Skipped a browser address that isn't http(s): {url}"));
914                        self.summary.push("Browser".to_string());
915                    }
916                    None => self.summary.push("Browser".to_string()),
917                }
918            }
919            "editor" | "codeeditor" | "preview" => {
920                meta.insert("view".into(), "editor".into());
921                match cfg_str(view, "file").map(|f| expand_home(f, home)) {
922                    Some(file) => {
923                        if !file.exists() {
924                            self.notes.push(format!("File {} doesn't exist here.", file.display()));
925                        }
926                        self.summary.push(format!("Editor: {}", file.display()));
927                        meta.insert("file".into(), Value::String(file.to_string_lossy().to_string()));
928                    }
929                    None => {
930                        meta.insert("editor:scratch".into(), Value::Bool(true));
931                        self.summary.push("Editor".to_string());
932                    }
933                }
934            }
935            "media" => {
936                meta.insert("view".into(), "media".into());
937                if let Some(path) = cfg_str(view, "path").map(|p| expand_home(p, home)) {
938                    self.summary.push(format!("Media: {}", path.display()));
939                    meta.insert("media:path".into(), Value::String(path.to_string_lossy().to_string()));
940                } else {
941                    self.summary.push("Media".to_string());
942                }
943            }
944            "files" => {
945                meta.insert("view".into(), "files".into());
946                if let Some(path) = cfg_str(view, "path").map(|p| expand_home(p, home)) {
947                    self.summary.push(format!("Hangar: {}", path.display()));
948                    meta.insert("files:path".into(), Value::String(path.to_string_lossy().to_string()));
949                } else {
950                    self.summary.push("Hangar".to_string());
951                }
952            }
953            "sysinfo" => {
954                meta.insert("view".into(), "sysinfo".into());
955                if let Some(k) = cfg_str(view, "sysinfo_type") {
956                    meta.insert("sysinfo:type".into(), Value::String(k.to_string()));
957                }
958                self.summary.push("System info".to_string());
959            }
960            // Files saved before the Armory was split say "armory", ones saved
961            // before Memory was renamed say "knowledge", and ones saved before
962            // Remotes became a section of Connectors say "remotes".
963            "connectors" | "memory" | "knowledge" | "armory" | "remotes" => {
964                let (pane, section) = match kind {
965                    "armory" => {
966                        let (pane, section) = armory_target(cfg_str(view, "section"), None);
967                        (pane, Some(section))
968                    }
969                    "knowledge" => ("memory", cfg_str(view, "section")),
970                    "remotes" => ("connectors", Some("remotes")),
971                    pane => (pane, cfg_str(view, "section")),
972                };
973                meta.insert("view".into(), pane.into());
974                if let Some(s) = section {
975                    meta.insert(format!("{pane}:section"), Value::String(s.to_string()));
976                }
977                self.summary.push(if pane == "connectors" { "Connectors" } else { "Memory" }.to_string());
978            }
979            k if PLAIN_VIEWS.contains(&k) => {
980                meta.insert("view".into(), Value::String(k.to_string()));
981                self.summary.push(k.to_string());
982            }
983            other => {
984                // Keep the type so the pane's header names it; the body shows
985                // the app's own "no view" placeholder.
986                let shown: String = other.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_').take(40).collect();
987                self.notes.push(format!("Pane type “{shown}” isn't known to this version — it opens as an empty pane."));
988                meta.insert("view".into(), Value::String(shown.clone()));
989                self.summary.push(format!("{shown} (unknown)"));
990            }
991        }
992        Value::Object(meta)
993    }
994}
995
996/// Whether a file counts as trusted (spec §3.5): saved by this install and
997/// sitting in its default layouts folder. Only trusted files start terminal
998/// commands without the user's say-so.
999pub fn is_trusted(doc: &LayoutDoc, path: &Path, own_instance: Option<&str>, layouts_dir: Option<&Path>) -> bool {
1000    let saved_here = own_instance.is_some_and(|own| doc.saved_by.instance.as_deref() == Some(own));
1001    let in_folder = layouts_dir.is_some_and(|dir| path.parent().is_some_and(|p| p == dir));
1002    saved_here && in_folder
1003}
1004
1005#[cfg(test)]
1006mod tests;