Skip to main content

agentmux_srv/server/service/
session_restore.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Restore-on-relaunch (Feature 1 of
5//! `docs/specs/SPEC_SESSION_RESTORE_AND_SAVED_LAYOUTS_2026_08_13.md`).
6//!
7//! A graceful window close today deliberately cascades a full delete of the
8//! workspace/tabs/blocks (`e3a6f85c2`, wired to the last-window "main" case
9//! by `4cbf856b7` — see `docs/retro/retro-pane-layout-restore-was-a-leak-not-a-feature-2026-08-13.md`),
10//! so the next cold launch always finds `Client.windowids` empty and reseeds
11//! the hardcoded default 3-pane layout (`window_create::default_three_pane_tree`).
12//!
13//! This module adds an independent, durable "what was open last" record —
14//! written just before that destroy cascade runs (`window_close::handle_close_window`)
15//! and consulted by the cold-launch path (`window_create::handle_create_window`)
16//! before it falls back to the hardcoded seed. It deliberately does NOT touch
17//! the destroy cascade itself (that fix is correct and must stay — orphaned
18//! shell processes were the original bug) and does NOT reuse Pillar 1's
19//! crash-reproject machinery (that's scoped to crash-only recovery from rows
20//! a graceful close is specifically supposed to remove — see
21//! `docs/specs/SPEC_PILLAR1_HOST_REPROJECT_DESIGN_2026_06_30.md` §3).
22//!
23//! Stored as a JSON blob under `Client.meta["session:last_topology"]` —
24//! `Client` is already a durable singleton row with a generic `meta` sidecar
25//! (the same "per-entity loosely-typed sidecar" pattern Pillar 1 Step 2 used
26//! for floating-pane placement on `Block.meta`), so this needs no new
27//! `StoreObj` type, table, or migration.
28
29use std::collections::HashMap;
30
31use agentmux_common::ipc::{Command, Event};
32use agentmux_common::LayoutNode;
33use serde_json::{json, Value};
34
35use crate::backend::obj::{Block, Client, LayoutState, Tab, Workspace};
36use crate::backend::service::{WebCallType, WebReturnType};
37use crate::backend::storage::store::Store;
38use crate::server::AppState;
39
40use super::reducer_helpers::{dispatch_to_reducer, seed_layout_via_reducer};
41
42const SNAPSHOT_META_KEY: &str = "session:last_topology";
43
44pub(crate) fn placeholder(idx: usize) -> String {
45    format!("__snap_block_{idx}__")
46}
47
48fn placeholder_idx(s: &str) -> Option<usize> {
49    s.strip_prefix("__snap_block_")?.strip_suffix("__")?.parse().ok()
50}
51
52/// Read the given workspace's tabs/blocks/layout directly from the store and
53/// build a durable, block-id-independent snapshot (real block ids are
54/// replaced with positional placeholders — `CreateBlock` always assigns a
55/// fresh id, so the snapshot can never reference one that will exist again).
56/// Returns `None` for an empty/unknown workspace (nothing worth persisting).
57pub(crate) fn snapshot_workspace(store: &Store, workspace_id: &str) -> Option<Value> {
58    let workspace = store.get::<Workspace>(workspace_id).ok().flatten()?;
59    let mut tabs = Vec::new();
60    // Position of `workspace.activetabid` WITHIN the successfully-captured
61    // `tabs` array (reagentx P2 on PR #2560: previously never captured at
62    // all, so a restore always focused whichever tab `CreateTab`'s
63    // first-tab-wins default left active — see `wcore/tab.rs`'s
64    // `ws.activetabid.is_empty()` check). Same "index by position in the
65    // list that actually survived" reasoning as blocks below: if the
66    // originally-active tab is one that fails to load, this simply stays
67    // `None` and restore falls back to whatever `CreateTab` leaves active.
68    let mut active_tab_index: Option<usize> = None;
69    // `pinnedtabids` is a legacy field (pinning was removed from AgentMux —
70    // see `tab_lifecycle.rs`); a workspace the reducer hasn't touched yet
71    // (e.g. straight out of `ensure_initial_data`) can still have its one
72    // tab sitting there instead of in `tabids` until the next
73    // `TabsReorderedBulk` event drains it. Read both so a snapshot taken
74    // before that drain doesn't silently see zero tabs.
75    for tab_id in workspace.pinnedtabids.iter().chain(workspace.tabids.iter()) {
76        let Some(tab) = store.get::<Tab>(tab_id).ok().flatten() else {
77            continue;
78        };
79        // Index by position WITHIN `blocks` (the successfully-loaded list),
80        // not position within `tab.blockids` (the full original list) —
81        // reviewer-caught bug (reagentx P2 on PR #2560): a single unreadable
82        // block previously desynced every subsequent placeholder index
83        // against restore's `new_block_ids` (built from the same
84        // successfully-recreated count), silently misattributing later
85        // blocks' layout positions. `blocks.len()` at insert time is always
86        // this block's actual final position, skipped entries included or
87        // not.
88        let mut idx_by_block_id: HashMap<String, usize> = HashMap::new();
89        let mut blocks = Vec::new();
90        for block_id in &tab.blockids {
91            let Some(block) = store.get::<Block>(block_id).ok().flatten() else {
92                continue;
93            };
94            idx_by_block_id.insert(block_id.clone(), blocks.len());
95            blocks.push(json!({ "meta": block.meta }));
96        }
97        if blocks.is_empty() {
98            continue;
99        }
100        let (rootnode, focusednodeid, magnifiednodeid) = if tab.layoutstate.is_empty() {
101            (None, String::new(), String::new())
102        } else {
103            match store.get::<LayoutState>(&tab.layoutstate) {
104                Ok(Some(layout)) => {
105                    let rootnode = layout.rootnode.map(|mut tree| {
106                        placeholderize(&mut tree, &idx_by_block_id);
107                        tree
108                    });
109                    (rootnode, layout.focusednodeid, layout.magnifiednodeid)
110                }
111                _ => (None, String::new(), String::new()),
112            }
113        };
114        if tab_id == &workspace.activetabid {
115            active_tab_index = Some(tabs.len());
116        }
117        tabs.push(json!({
118            "name": tab.name,
119            "blocks": blocks,
120            "rootnode": rootnode,
121            "focusednodeid": focusednodeid,
122            "magnifiednodeid": magnifiednodeid,
123        }));
124    }
125    if tabs.is_empty() {
126        return None;
127    }
128    Some(json!({ "tabs": tabs, "active_tab_index": active_tab_index }))
129}
130
131/// Replace every leaf's real block id (in `data.block_id`, `block_stack`,
132/// `active_block_id`) with a positional placeholder, in place.
133fn placeholderize(node: &mut LayoutNode, idx_by_block_id: &HashMap<String, usize>) {
134    if let Some(data) = node.data.as_mut() {
135        if let Some(&idx) = idx_by_block_id.get(&data.block_id) {
136            data.block_id = placeholder(idx);
137        }
138        for id in data.block_stack.iter_mut() {
139            if let Some(&idx) = idx_by_block_id.get(id.as_str()) {
140                *id = placeholder(idx);
141            }
142        }
143        if let Some(&idx) = idx_by_block_id.get(&data.active_block_id) {
144            data.active_block_id = placeholder(idx);
145        }
146    }
147    for child in node.children.iter_mut() {
148        placeholderize(child, idx_by_block_id);
149    }
150}
151
152/// Replace every leaf's positional placeholder with the corresponding freshly
153/// created block id, in place. A placeholder with no matching entry (should
154/// not happen — `new_ids` is built 1:1 from the same snapshot's `blocks`
155/// array — but tolerated defensively) is left as-is; such a leaf simply won't
156/// resolve to a real block and the frontend already handles unknown block ids
157/// in a layout leaf as an empty pane, matching how a manually-crafted or
158/// corrupted layout degrades today.
159fn resolve_placeholders(node: &mut LayoutNode, new_ids: &[String]) {
160    if let Some(data) = node.data.as_mut() {
161        if let Some(idx) = placeholder_idx(&data.block_id) {
162            if let Some(real) = new_ids.get(idx) {
163                data.block_id = real.clone();
164            }
165        }
166        for id in data.block_stack.iter_mut() {
167            if let Some(idx) = placeholder_idx(id) {
168                if let Some(real) = new_ids.get(idx) {
169                    *id = real.clone();
170                }
171            }
172        }
173        if let Some(idx) = placeholder_idx(&data.active_block_id) {
174            if let Some(real) = new_ids.get(idx) {
175                data.active_block_id = real.clone();
176            }
177        }
178    }
179    for child in node.children.iter_mut() {
180        resolve_placeholders(child, new_ids);
181    }
182}
183
184/// Persist a snapshot as the durable "last session" record, overwriting
185/// whatever was there before. Best-effort: a write failure is logged, never
186/// propagated — this must never block the close it rides alongside.
187///
188/// Reads-then-writes the singleton `Client` row inside a single
189/// `with_tx` (reagentx P1 on PR #2560): the two used to be separate
190/// `store.get_all`/`store.update` calls, unlike `persist_subscriber`'s
191/// window handlers which already wrap this exact shape in a transaction.
192/// A concurrent write to `Client` between the read and the write here
193/// (e.g. another in-flight `CloseWindow` pruning `windowids`, or a second
194/// concurrent snapshot save/clear) would silently lose whichever change
195/// didn't win the race — a classic lost-update. `with_tx` holds the
196/// store's connection lock for the whole read+write, so no other caller
197/// can observe or write an intermediate state.
198/// Returns whether the write actually landed. Callers on the clean-close path
199/// (`window_close.rs`) intentionally discard this — that path is documented
200/// best-effort, and must never let a snapshot failure block the close it is
201/// riding along with. The OS-shutdown path (`handle_save_session_snapshot`
202/// below) is different: it has nothing else to do after this call, so it
203/// uses the result to report whether the flush actually succeeded rather
204/// than reporting success unconditionally.
205pub(crate) fn save_last_session_snapshot(store: &Store, snapshot: Value) -> bool {
206    let result = store.with_tx(|tx| {
207        let clients = tx.get_all::<Client>()?;
208        let Some(mut client) = clients.into_iter().next() else {
209            // No client row to attach the snapshot to — nothing was written.
210            // Not a store error, but still not a successful save: surface it
211            // as one via the Ok(false) below rather than Ok(()) reading as
212            // unconditional success regardless of what actually happened.
213            return Ok(false);
214        };
215        client.meta.insert(SNAPSHOT_META_KEY.to_string(), snapshot);
216        tx.update(&mut client)?;
217        Ok(true)
218    });
219    match result {
220        Ok(saved) => saved,
221        Err(e) => {
222            tracing::warn!(
223                error = %e,
224                "session_restore: failed to persist last-session snapshot"
225            );
226            false
227        }
228    }
229}
230
231fn load_last_session_snapshot(store: &Store) -> Option<Value> {
232    let clients = store.get_all::<Client>().ok()?;
233    let client = clients.into_iter().next()?;
234    client.meta.get(SNAPSHOT_META_KEY).cloned()
235}
236
237/// Consume the durable "last session" record after a fully successful
238/// restore (called only once `window_create::handle_create_window`'s final
239/// window read-back succeeds — see that call site's comment for why not any
240/// earlier). Best-effort, same as `save_last_session_snapshot`: a failure to
241/// clear just means a future stray/misused restore call could replay this
242/// snapshot again, which `client_windowids_empty`'s server-side check
243/// (`handle_create_window`) already independently guards against — this is
244/// defense in depth, not the only thing preventing duplicates.
245pub(crate) fn clear_last_session_snapshot(store: &Store) {
246    // Same `with_tx`-atomicity reasoning as `save_last_session_snapshot`
247    // above (reagentx P1 on PR #2560) — this read-then-write was
248    // previously two separate store calls.
249    let result = store.with_tx(|tx| {
250        let clients = tx.get_all::<Client>()?;
251        let Some(mut client) = clients.into_iter().next() else {
252            return Ok(());
253        };
254        if client.meta.remove(SNAPSHOT_META_KEY).is_none() {
255            return Ok(()); // nothing to clear
256        }
257        tx.update(&mut client)?;
258        Ok(())
259    });
260    if let Err(e) = result {
261        tracing::warn!(
262            error = %e,
263            "session_restore: failed to clear last-session snapshot after restore"
264        );
265    }
266}
267
268fn find_error(events: &[Event]) -> Option<String> {
269    events.iter().find_map(|e| match e {
270        Event::Error { message, .. } => Some(message.clone()),
271        _ => None,
272    })
273}
274
275async fn apply_and_publish(
276    state: &AppState,
277    events: &[Event],
278) -> Result<(), String> {
279    for ev in events {
280        if let Err(e) = crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore) {
281            return Err(format!("SQLite write failed: {}", e));
282        }
283    }
284    Ok(())
285}
286
287/// Attempt to replay the durable "last session" snapshot (if any) into a
288/// fresh workspace: `CreateWorkspace` → per-tab `CreateTab` + per-block
289/// `CreateBlock` (reusing each block's saved `meta`, so a restored agent pane
290/// relaunches the same way opening a fresh one does) → `LayoutSetTree` with
291/// the saved split tree (block ids remapped from placeholders to the newly
292/// created ones).
293///
294/// Returns `Ok(None)` when there's nothing to restore (no snapshot, or every
295/// tab in it failed to recreate) — the caller falls back to the hardcoded
296/// default seed exactly as it did before this feature existed. Returns
297/// `Err` only for a failure in the very first step (creating the workspace
298/// itself); per-tab/per-block failures during replay are logged and skipped
299/// rather than aborting the whole restore, so a partially-stale snapshot
300/// (e.g. one tab's block meta no longer makes sense) still restores
301/// everything else instead of failing shut.
302/// Snapshot the current session WITHOUT tearing anything down.
303///
304/// This is the OS-shutdown flush point (`SPEC_CONTINUOUS_SESSION_PERSISTENCE_2026_09_08.md`
305/// Phase 0). It exists because the only existing writer of the session
306/// snapshot is `window_close.rs`, gated on the close that empties
307/// `Client.windowids` — so a Windows restart, which terminates the process
308/// without ever calling `CloseWindow`, has always left no snapshot at all and
309/// lost the workspace. Verified against a real incident: the killed
310/// instance's DB had `session:last_topology` absent, `windowids` still
311/// populated, and 22 orphaned blocks.
312///
313/// Deliberately does NOT run `delete_workspace`. The OS is about to reclaim
314/// every process anyway, and that saga's 5s-per-shell `KILL_GRACE_SECS` does
315/// not fit inside the OS's shutdown budget (historically ~5s total, and
316/// Windows kills apps that overstay it). Save, then let the process die.
317///
318/// Takes no arguments: during shutdown the caller is a native wndproc with no
319/// convenient window-id/label mapping in hand, and srv already knows its own
320/// current topology. Resolves the workspace the same way the close path does —
321/// via the single `Client`'s first window — so a restore after an OS shutdown
322/// reconstructs exactly what a restore after a clean quit would have.
323///
324/// Idempotent and safe to call repeatedly: it overwrites the same
325/// `Client.meta` key, and a single attempt here never retries or blocks on
326/// failure, so a shutdown flush can never wedge the shutdown it is trying to
327/// protect. That is a statement about *this call itself* staying fast and
328/// single-shot, not about what it reports: unlike the clean-close path,
329/// `save_last_session_snapshot`'s result here IS propagated into the
330/// response (`success: false` on either an unbuildable snapshot or a failed
331/// store write), because the caller — the cef-side shutdown hook — relies on
332/// that to decide whether to re-arm for a genuine retry on the OS's own
333/// second trigger.
334pub(crate) async fn handle_save_session_snapshot(
335    state: &AppState,
336    _call: &WebCallType,
337) -> WebReturnType {
338    let store = &state.mstore;
339
340    let Some(ws_id) = current_workspace_id(store) else {
341        // No client, no window, or no workspace — nothing to capture. Not an
342        // error: a shutdown arriving before the first window exists is
343        // ordinary, and reporting failure would only add noise to a path
344        // that runs while the machine is going down.
345        tracing::debug!("session_restore: shutdown flush found no workspace to snapshot");
346        return WebReturnType::success_empty();
347    };
348
349    // reagentx P1 on #3106: the cef-side client (helpers.rs) parses this
350    // response's `success` field to decide whether to re-arm its
351    // once-per-attempt flush latch for a genuine retry on
352    // WM_ENDSESSION(TRUE). That only works if this handler actually reports
353    // failure for an application-level failure — `snapshot_workspace`
354    // returning `None`, or the store write inside
355    // `save_last_session_snapshot` failing — not just for the transport-level
356    // failures (connection refused/timeout) the client can already detect on
357    // its own. Both paths below now propagate `success: false` instead of
358    // this handler always claiming success once it started running.
359    match snapshot_workspace(store, &ws_id) {
360        Some(snapshot) => {
361            if save_last_session_snapshot(store, snapshot) {
362                tracing::info!(
363                    workspace_id = %ws_id,
364                    "session_restore: snapshot flushed for OS shutdown"
365                );
366                WebReturnType::success_empty()
367            } else {
368                // save_last_session_snapshot already logged the specific
369                // store error (or the no-client case) at warn level; no need
370                // to duplicate the detail here.
371                WebReturnType::error("shutdown flush: snapshot built but the store write failed")
372            }
373        }
374        None => {
375            tracing::warn!(
376                workspace_id = %ws_id,
377                "session_restore: shutdown flush could not build a snapshot"
378            );
379            WebReturnType::error("shutdown flush: could not build a snapshot for the current workspace")
380        }
381    }
382}
383
384/// The workspace backing one of this client's windows, if any.
385///
386/// Mirrors how `window_close.rs` picks the workspace to snapshot, so the
387/// shutdown path and the clean-quit path capture the same thing. With
388/// multiple windows this captures the first *live* one — the same
389/// single-workspace limitation restore-on-relaunch already has, not a new
390/// one introduced here.
391///
392/// Walks `windowids` rather than only checking the first: the repo's own
393/// restart/reprojection paths already tolerate a polluted `windowids` list
394/// (a stale id whose `Window` row no longer exists, or whose `Window` row
395/// exists but references a `Workspace` that's gone), so that state can and
396/// does occur in real persisted data. Bailing out on the first entry alone
397/// — whether it's missing outright or just points at a dead workspace —
398/// would skip the flush entirely whenever that specific entry is stale, even
399/// with a perfectly good workspace sitting at a later id (Codex P2 on
400/// #3106's first fix, then again on the fix itself: the first pass only
401/// checked that the `Window` row existed, not that its `workspaceid` still
402/// resolved to a live `Workspace`).
403fn current_workspace_id(store: &Store) -> Option<String> {
404    let client = store.get_all::<Client>().ok()?.into_iter().next()?;
405    client.windowids.iter().find_map(|window_id| {
406        let window = store
407            .get::<crate::backend::obj::Window>(window_id)
408            .ok()
409            .flatten()?;
410        // A live Window row pointing at a dead Workspace is exactly the
411        // shape `shutdown_flush_survives_a_missing_workspace` exercises for
412        // a single window — here it must not stop the search, only rule out
413        // this one candidate.
414        store
415            .get::<Workspace>(&window.workspaceid)
416            .ok()
417            .flatten()?;
418        Some(window.workspaceid)
419    })
420}
421
422/// One tab to rebuild: its blocks' meta, and a split tree whose leaves name
423/// those blocks by position (`placeholder(i)` ↔ `blocks[i]`).
424pub(crate) struct ReplayTab {
425    pub name: String,
426    pub blocks: Vec<Value>,
427    pub rootnode: Option<LayoutNode>,
428    pub focused: String,
429    pub magnified: String,
430}
431
432/// A tab that `replay_tabs` actually rebuilt, with its blocks' new ids in
433/// the order they were created.
434pub(crate) struct ReplayedTab {
435    pub tab_id: String,
436    pub block_ids: Vec<String>,
437}
438
439/// Rebuild `tabs` inside an existing workspace: per tab `CreateTab`, each
440/// block `CreateBlock` (its saved meta), then `LayoutSetTree` with the tree's
441/// placeholders resolved to the new block ids. Shared by restore-on-relaunch
442/// (into a fresh workspace) and Layouts → Open layout… (into the current
443/// window's) — `SPEC_LAYOUT_FILES_2026_09_25.md` §1.1.
444///
445/// Per-tab and per-block failures are logged and skipped, never fatal: a
446/// partially-stale input still rebuilds everything else. The returned vector
447/// is parallel to `tabs` (`None` = that tab didn't come back). Events are
448/// applied to the store but **not published** — the caller does that.
449pub(crate) async fn replay_tabs(
450    state: &AppState,
451    ws_id: &str,
452    tabs: &[ReplayTab],
453    log_ctx: &str,
454) -> (Vec<Event>, Vec<Option<ReplayedTab>>) {
455    let mut all_events = Vec::new();
456    let mut replayed = Vec::with_capacity(tabs.len());
457    // Incognito tabs in the replay get fresh jars, within the cap; tabs that
458    // shared one still do.
459    let mut fresh_jars = std::collections::HashMap::new();
460    let jars_available = crate::server::browser_identity::jars_available(state);
461    let profiles = crate::server::browser_identity::profile_ids();
462    for tab in tabs {
463        let tab_events = dispatch_to_reducer(
464            state,
465            Command::CreateTab { workspace_id: ws_id.to_string(), name: tab.name.clone() },
466        )
467        .await;
468        if let Some(err) = find_error(&tab_events) {
469            tracing::warn!(error = %err, "{log_ctx}: CreateTab failed — skipping this tab");
470            replayed.push(None);
471            continue;
472        }
473        if apply_and_publish(state, &tab_events).await.is_err() {
474            tracing::warn!("{log_ctx}: CreateTab SQLite write failed — skipping this tab");
475            replayed.push(None);
476            continue;
477        }
478        let Some(tab_id) = tab_events.iter().find_map(|e| match e {
479            Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
480            _ => None,
481        }) else {
482            replayed.push(None);
483            continue;
484        };
485        all_events.extend(tab_events);
486
487        let mut new_block_ids: Vec<String> = Vec::new();
488        for meta in &tab.blocks {
489            // A replayed pane is a new pane: not an agent's, not a popup of
490            // a pane that no longer has this id, no banner from before.
491            let mut meta = meta.clone();
492            if let Some(obj) = meta.as_object_mut() {
493                for key in crate::server::browser_owner::srv_only_keys() {
494                    obj.remove(key);
495                }
496            }
497            if !crate::server::browser_identity::refresh_for_replay(&mut meta, &mut fresh_jars, jars_available, profiles.as_ref()) {
498                continue;
499            }
500            let blk_events = dispatch_to_reducer(
501                state,
502                Command::CreateBlock { tab_id: tab_id.clone(), meta },
503            )
504            .await;
505            if let Some(err) = find_error(&blk_events) {
506                tracing::warn!(error = %err, "{log_ctx}: CreateBlock failed — skipping this block");
507                continue;
508            }
509            if apply_and_publish(state, &blk_events).await.is_err() {
510                tracing::warn!("{log_ctx}: CreateBlock SQLite write failed — skipping this block");
511                continue;
512            }
513            if let Some(block_id) = blk_events.iter().find_map(|e| match e {
514                Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
515                _ => None,
516            }) {
517                new_block_ids.push(block_id);
518            }
519            all_events.extend(blk_events);
520        }
521
522        if new_block_ids.is_empty() {
523            replayed.push(None);
524            continue;
525        }
526
527        if let Some(mut tree) = tab.rootnode.clone() {
528            resolve_placeholders(&mut tree, &new_block_ids);
529            if let Err(e) =
530                seed_layout_via_reducer(state, &tab_id, tree, tab.focused.clone(), Vec::new(), tab.magnified.clone())
531                    .await
532            {
533                tracing::warn!(tab_id = %tab_id, error = %e, "{log_ctx}: layout write failed — tab restored blank");
534            }
535        }
536        replayed.push(Some(ReplayedTab { tab_id, block_ids: new_block_ids }));
537    }
538    (all_events, replayed)
539}
540
541pub(crate) async fn restore_last_session(
542    state: &AppState,
543) -> Result<Option<(String, Vec<Event>)>, String> {
544    let Some(snapshot) = load_last_session_snapshot(&state.mstore) else {
545        return Ok(None);
546    };
547    let tabs_json = snapshot
548        .get("tabs")
549        .and_then(|v| v.as_array())
550        .cloned()
551        .unwrap_or_default();
552    if tabs_json.is_empty() {
553        return Ok(None);
554    }
555
556    let ws_events = dispatch_to_reducer(
557        state,
558        Command::CreateWorkspace { name: String::new() },
559    )
560    .await;
561    if let Some(err) = find_error(&ws_events) {
562        return Err(format!("restore_last_session: CreateWorkspace failed: {}", err));
563    }
564    apply_and_publish(state, &ws_events).await?;
565    let Some(ws_id) = ws_events.iter().find_map(|e| match e {
566        Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
567        _ => None,
568    }) else {
569        return Err("restore_last_session: CreateWorkspace produced no WorkspaceCreated event".into());
570    };
571
572    let replay: Vec<ReplayTab> = tabs_json
573        .iter()
574        .map(|tab_json| ReplayTab {
575            name: tab_json.get("name").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
576            blocks: tab_json
577                .get("blocks")
578                .and_then(|v| v.as_array())
579                .map(|blocks| blocks.iter().map(|b| b.get("meta").cloned().unwrap_or(Value::Null)).collect())
580                .unwrap_or_default(),
581            rootnode: tab_json
582                .get("rootnode")
583                .filter(|v| !v.is_null())
584                .and_then(|v| serde_json::from_value::<LayoutNode>(v.clone()).ok()),
585            focused: tab_json.get("focusednodeid").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
586            // `magnifiednodeid` refers to a LayoutNode.id, not a block id —
587            // like `focusednodeid`, it survives the snapshot round-trip
588            // verbatim (only block ids are placeholder-remapped). Previously
589            // never captured/passed at all (reagentx P2 on PR #2560), so a tab
590            // closed with a pane magnified relaunched showing the full tree.
591            magnified: tab_json.get("magnifiednodeid").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
592        })
593        .collect();
594    let (tab_events, replayed) = replay_tabs(state, &ws_id, &replay, "restore_last_session").await;
595    let mut all_events = ws_events;
596    all_events.extend(tab_events);
597    let restored_any_tab = replayed.iter().any(Option::is_some);
598    // Maps this tab's position in the ORIGINAL snapshot (`tabs_json`) to its
599    // freshly-created id, for tabs that actually made it through restore —
600    // resolves `active_tab_index` below by position among what survived,
601    // not raw snapshot index (a tab that fails to restore must not desync
602    // this lookup for every tab after it).
603    let new_tab_ids_by_snapshot_index: HashMap<usize, String> = replayed
604        .iter()
605        .enumerate()
606        .filter_map(|(i, r)| r.as_ref().map(|r| (i, r.tab_id.clone())))
607        .collect();
608
609    if !restored_any_tab {
610        // Nothing actually came back (every tab's CreateTab/CreateBlock
611        // failed) — compensate the now-empty workspace so we don't leave a
612        // stray row behind, and let the caller fall back to the default seed.
613        let comp = dispatch_to_reducer(
614            state,
615            Command::DeleteWorkspace { workspace_id: ws_id.clone(), force: false },
616        )
617        .await;
618        let _ = apply_and_publish(state, &comp).await;
619        super::reducer_helpers::publish_events(state, &comp);
620        return Ok(None);
621    }
622
623    // Restore which tab was active (reagentx P2 on PR #2560: previously
624    // never captured/restored at all — every relaunch focused whichever tab
625    // `CreateTab`'s own first-tab-wins default (`wcore/tab.rs`) happened to
626    // leave active, i.e. always the first). `None` (no index in the
627    // snapshot, or that index's tab didn't survive restore) leaves that
628    // default in place rather than erroring — an unset/stale active tab is
629    // a cosmetic miss, not a reason to fail the whole restore.
630    if let Some(active_snapshot_idx) = snapshot.get("active_tab_index").and_then(|v| v.as_u64()) {
631        if let Some(new_active_tab_id) = new_tab_ids_by_snapshot_index.get(&(active_snapshot_idx as usize)) {
632            let active_events = dispatch_to_reducer(
633                state,
634                Command::SetActiveTab {
635                    workspace_id: ws_id.clone(),
636                    tab_id: new_active_tab_id.clone(),
637                },
638            )
639            .await;
640            if let Some(err) = find_error(&active_events) {
641                tracing::warn!(error = %err, "restore_last_session: SetActiveTab failed — leaving default active tab");
642            } else if apply_and_publish(state, &active_events).await.is_err() {
643                tracing::warn!("restore_last_session: SetActiveTab SQLite write failed — leaving default active tab");
644            } else {
645                all_events.extend(active_events);
646            }
647        }
648    }
649
650    Ok(Some((ws_id, all_events)))
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656    use crate::server::tests::test_state;
657
658    async fn dispatch_apply(state: &AppState, cmd: Command) -> Vec<Event> {
659        let events = dispatch_to_reducer(state, cmd).await;
660        for ev in &events {
661            crate::persist_subscriber::apply_event_to_mstore(ev, &state.mstore).unwrap();
662        }
663        events
664    }
665
666    #[tokio::test]
667    async fn no_snapshot_means_nothing_to_load() {
668        let state = test_state();
669        assert!(load_last_session_snapshot(&state.mstore).is_none());
670    }
671
672    #[tokio::test]
673    async fn save_then_load_round_trips() {
674        let state = test_state();
675        let snap = json!({ "tabs": [{"name": "t", "blocks": [{"meta": {"view": "agent"}}]}] });
676        save_last_session_snapshot(&state.mstore, snap.clone());
677        assert_eq!(load_last_session_snapshot(&state.mstore), Some(snap));
678    }
679
680    #[tokio::test]
681    async fn save_overwrites_prior_snapshot() {
682        let state = test_state();
683        save_last_session_snapshot(&state.mstore, json!({"tabs": [{"name": "old"}]}));
684        save_last_session_snapshot(&state.mstore, json!({"tabs": [{"name": "new"}]}));
685        let loaded = load_last_session_snapshot(&state.mstore).unwrap();
686        assert_eq!(loaded["tabs"][0]["name"], "new");
687    }
688
689    #[tokio::test]
690    async fn snapshot_workspace_captures_tab_and_block_meta() {
691        let state = test_state();
692        let ws_evs = dispatch_apply(&state, Command::CreateWorkspace { name: "w".into() }).await;
693        let ws_id = ws_evs
694            .iter()
695            .find_map(|e| match e {
696                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
697                _ => None,
698            })
699            .unwrap();
700        let tab_evs = dispatch_apply(
701            &state,
702            Command::CreateTab { workspace_id: ws_id.clone(), name: "mytab".into() },
703        )
704        .await;
705        let tab_id = tab_evs
706            .iter()
707            .find_map(|e| match e {
708                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
709                _ => None,
710            })
711            .unwrap();
712        dispatch_apply(
713            &state,
714            Command::CreateBlock {
715                tab_id: tab_id.clone(),
716                meta: json!({ "view": "agent", "agent:name": "test-agent" }),
717            },
718        )
719        .await;
720
721        let snap = snapshot_workspace(&state.mstore, &ws_id).expect("snapshot should be produced");
722        let tabs = snap["tabs"].as_array().unwrap();
723        assert_eq!(tabs.len(), 1);
724        assert_eq!(tabs[0]["name"], "mytab");
725        let blocks = tabs[0]["blocks"].as_array().unwrap();
726        assert_eq!(blocks.len(), 1);
727        assert_eq!(blocks[0]["meta"]["agent:name"], "test-agent");
728    }
729
730    #[tokio::test]
731    async fn snapshot_of_unknown_workspace_is_none() {
732        let state = test_state();
733        assert!(snapshot_workspace(&state.mstore, "no-such-workspace").is_none());
734    }
735
736    #[tokio::test]
737    async fn restore_with_no_snapshot_returns_none() {
738        let state = test_state();
739        let result = restore_last_session(&state).await.unwrap();
740        assert!(result.is_none());
741    }
742
743    #[tokio::test]
744    async fn round_trip_snapshot_then_restore_recreates_tabs_and_blocks() {
745        let state = test_state();
746        let ws_evs = dispatch_apply(&state, Command::CreateWorkspace { name: "w".into() }).await;
747        let ws_id = ws_evs
748            .iter()
749            .find_map(|e| match e {
750                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
751                _ => None,
752            })
753            .unwrap();
754        let tab_evs = dispatch_apply(
755            &state,
756            Command::CreateTab { workspace_id: ws_id.clone(), name: "mytab".into() },
757        )
758        .await;
759        let tab_id = tab_evs
760            .iter()
761            .find_map(|e| match e {
762                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
763                _ => None,
764            })
765            .unwrap();
766        let blk1 = dispatch_apply(
767            &state,
768            Command::CreateBlock { tab_id: tab_id.clone(), meta: json!({"view": "agent"}) },
769        )
770        .await
771        .iter()
772        .find_map(|e| match e {
773            Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
774            _ => None,
775        })
776        .unwrap();
777        let blk2 = dispatch_apply(
778            &state,
779            Command::CreateBlock { tab_id: tab_id.clone(), meta: json!({"view": "sysinfo"}) },
780        )
781        .await
782        .iter()
783        .find_map(|e| match e {
784            Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
785            _ => None,
786        })
787        .unwrap();
788        // Give the tab a real split layout referencing both blocks.
789        let tree = agentmux_common::LayoutNode {
790            id: "root".into(),
791            flex_direction: agentmux_common::FlexDirection::Row,
792            size: 10.0,
793            children: vec![
794                agentmux_common::LayoutNode {
795                    id: "left".into(),
796                    size: 5.0,
797                    data: Some(agentmux_common::LayoutNodeData { block_id: blk1.clone(), ..Default::default() }),
798                    ..Default::default()
799                },
800                agentmux_common::LayoutNode {
801                    id: "right".into(),
802                    size: 5.0,
803                    data: Some(agentmux_common::LayoutNodeData { block_id: blk2.clone(), ..Default::default() }),
804                    ..Default::default()
805                },
806            ],
807            data: None,
808            extra: Default::default(),
809        };
810        seed_layout_via_reducer(&state, &tab_id, tree, "left".into(), Vec::new(), String::new())
811            .await
812            .unwrap();
813
814        // Snapshot + save exactly like the close hook does.
815        let snap = snapshot_workspace(&state.mstore, &ws_id).expect("snapshot should be produced");
816        save_last_session_snapshot(&state.mstore, snap);
817
818        // Restore into a brand-new workspace.
819        let (new_ws_id, _events) = restore_last_session(&state)
820            .await
821            .unwrap()
822            .expect("restore should recreate the tab");
823        assert_ne!(new_ws_id, ws_id, "restore creates a NEW workspace, not the deleted one");
824
825        let new_workspace = state.mstore.get::<Workspace>(&new_ws_id).unwrap().unwrap();
826        assert_eq!(new_workspace.tabids.len(), 1);
827        let new_tab = state.mstore.get::<Tab>(&new_workspace.tabids[0]).unwrap().unwrap();
828        assert_eq!(new_tab.name, "mytab");
829        assert_eq!(new_tab.blockids.len(), 2);
830
831        let new_blk1 = state.mstore.get::<Block>(&new_tab.blockids[0]).unwrap().unwrap();
832        assert_eq!(new_blk1.meta.get("view").and_then(|v| v.as_str()), Some("agent"));
833        let new_blk2 = state.mstore.get::<Block>(&new_tab.blockids[1]).unwrap().unwrap();
834        assert_eq!(new_blk2.meta.get("view").and_then(|v| v.as_str()), Some("sysinfo"));
835
836        // Layout tree was restored with the NEW block ids, not the old ones
837        // or leftover placeholders.
838        let new_layout = state
839            .mstore
840            .get::<LayoutState>(&new_tab.layoutstate)
841            .unwrap()
842            .unwrap();
843        let rootnode = new_layout.rootnode.expect("layout tree restored");
844        let leaf_block_ids: Vec<String> = rootnode
845            .children
846            .iter()
847            .filter_map(|c| c.data.as_ref().map(|d| d.block_id.clone()))
848            .collect();
849        assert_eq!(leaf_block_ids, vec![new_tab.blockids[0].clone(), new_tab.blockids[1].clone()]);
850        assert!(!leaf_block_ids.iter().any(|id| id.starts_with("__snap_block_")));
851    }
852
853    // reagentx P2 on PR #2560: `LayoutState.magnifiednodeid` was never
854    // captured/restored, so a tab closed with a pane magnified always
855    // relaunched showing the full split tree instead of the magnified pane.
856    #[tokio::test]
857    async fn snapshot_and_restore_preserves_magnifiednodeid() {
858        let state = test_state();
859        let ws_evs = dispatch_apply(&state, Command::CreateWorkspace { name: "w".into() }).await;
860        let ws_id = ws_evs
861            .iter()
862            .find_map(|e| match e {
863                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
864                _ => None,
865            })
866            .unwrap();
867        let tab_evs = dispatch_apply(
868            &state,
869            Command::CreateTab { workspace_id: ws_id.clone(), name: "mytab".into() },
870        )
871        .await;
872        let tab_id = tab_evs
873            .iter()
874            .find_map(|e| match e {
875                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
876                _ => None,
877            })
878            .unwrap();
879        let blk1 = dispatch_apply(
880            &state,
881            Command::CreateBlock { tab_id: tab_id.clone(), meta: json!({"view": "agent"}) },
882        )
883        .await
884        .iter()
885        .find_map(|e| match e {
886            Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
887            _ => None,
888        })
889        .unwrap();
890        let blk2 = dispatch_apply(
891            &state,
892            Command::CreateBlock { tab_id: tab_id.clone(), meta: json!({"view": "sysinfo"}) },
893        )
894        .await
895        .iter()
896        .find_map(|e| match e {
897            Event::BlockCreated { block_id, .. } => Some(block_id.clone()),
898            _ => None,
899        })
900        .unwrap();
901        let tree = agentmux_common::LayoutNode {
902            id: "root".into(),
903            flex_direction: agentmux_common::FlexDirection::Row,
904            size: 10.0,
905            children: vec![
906                agentmux_common::LayoutNode {
907                    id: "left".into(),
908                    size: 5.0,
909                    data: Some(agentmux_common::LayoutNodeData { block_id: blk1.clone(), ..Default::default() }),
910                    ..Default::default()
911                },
912                agentmux_common::LayoutNode {
913                    id: "right".into(),
914                    size: 5.0,
915                    data: Some(agentmux_common::LayoutNodeData { block_id: blk2.clone(), ..Default::default() }),
916                    ..Default::default()
917                },
918            ],
919            data: None,
920            extra: Default::default(),
921        };
922        // "left" pane is magnified.
923        seed_layout_via_reducer(&state, &tab_id, tree, "left".into(), Vec::new(), "left".into())
924            .await
925            .unwrap();
926
927        let snap = snapshot_workspace(&state.mstore, &ws_id).expect("snapshot should be produced");
928        assert_eq!(snap["tabs"][0]["magnifiednodeid"], "left", "magnifiednodeid captured in snapshot");
929        save_last_session_snapshot(&state.mstore, snap);
930
931        let (new_ws_id, _events) = restore_last_session(&state).await.unwrap().unwrap();
932        let new_workspace = state.mstore.get::<Workspace>(&new_ws_id).unwrap().unwrap();
933        let new_tab = state.mstore.get::<Tab>(&new_workspace.tabids[0]).unwrap().unwrap();
934        let new_layout = state.mstore.get::<LayoutState>(&new_tab.layoutstate).unwrap().unwrap();
935        assert_eq!(
936            new_layout.magnifiednodeid, "left",
937            "magnifiednodeid restored — LayoutNode.id values aren't placeholder-remapped, \
938             only block ids are, so this survives verbatim"
939        );
940    }
941
942    // reagentx P2 on PR #2560: `Workspace.activetabid` was never
943    // captured/restored, so a relaunch always focused the first tab
944    // regardless of which was actually active at close.
945    #[tokio::test]
946    async fn snapshot_and_restore_preserves_active_tab_when_not_the_first() {
947        let state = test_state();
948        let ws_evs = dispatch_apply(&state, Command::CreateWorkspace { name: "w".into() }).await;
949        let ws_id = ws_evs
950            .iter()
951            .find_map(|e| match e {
952                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
953                _ => None,
954            })
955            .unwrap();
956
957        // tab1 is created first (and would be the default active tab per
958        // wcore/tab.rs's first-tab-wins rule) but tab2 is the one the user
959        // actually left active.
960        let tab1_evs = dispatch_apply(
961            &state,
962            Command::CreateTab { workspace_id: ws_id.clone(), name: "tab-one".into() },
963        )
964        .await;
965        let tab1_id = tab1_evs
966            .iter()
967            .find_map(|e| match e {
968                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
969                _ => None,
970            })
971            .unwrap();
972        dispatch_apply(
973            &state,
974            Command::CreateBlock { tab_id: tab1_id.clone(), meta: json!({"view": "agent"}) },
975        )
976        .await;
977
978        let tab2_evs = dispatch_apply(
979            &state,
980            Command::CreateTab { workspace_id: ws_id.clone(), name: "tab-two".into() },
981        )
982        .await;
983        let tab2_id = tab2_evs
984            .iter()
985            .find_map(|e| match e {
986                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
987                _ => None,
988            })
989            .unwrap();
990        dispatch_apply(
991            &state,
992            Command::CreateBlock { tab_id: tab2_id.clone(), meta: json!({"view": "sysinfo"}) },
993        )
994        .await;
995
996        dispatch_apply(
997            &state,
998            Command::SetActiveTab { workspace_id: ws_id.clone(), tab_id: tab2_id.clone() },
999        )
1000        .await;
1001        let workspace_before = state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap();
1002        assert_eq!(workspace_before.activetabid, tab2_id, "precondition: tab-two is active");
1003
1004        let snap = snapshot_workspace(&state.mstore, &ws_id).expect("snapshot should be produced");
1005        assert_eq!(snap["active_tab_index"], 1, "active tab is the SECOND captured tab");
1006        save_last_session_snapshot(&state.mstore, snap);
1007
1008        let (new_ws_id, _events) = restore_last_session(&state).await.unwrap().unwrap();
1009        let new_workspace = state.mstore.get::<Workspace>(&new_ws_id).unwrap().unwrap();
1010        assert_eq!(new_workspace.tabids.len(), 2);
1011        let new_tab2 = state.mstore.get::<Tab>(&new_workspace.tabids[1]).unwrap().unwrap();
1012        assert_eq!(new_tab2.name, "tab-two");
1013        assert_eq!(
1014            new_workspace.activetabid, new_tab2.oid,
1015            "the restored workspace's active tab is the NEW tab-two, not whichever \
1016             CreateTab's first-tab-wins default would otherwise leave active"
1017        );
1018    }
1019
1020    #[tokio::test]
1021    async fn restore_is_idempotent_source_snapshot_survives() {
1022        // A second restore attempt (e.g. the app crashed mid-restore and
1023        // relaunched again) must still find and replay the same snapshot —
1024        // restoring does not consume/clear it.
1025        let state = test_state();
1026        let ws_evs = dispatch_apply(&state, Command::CreateWorkspace { name: "w".into() }).await;
1027        let ws_id = ws_evs
1028            .iter()
1029            .find_map(|e| match e {
1030                Event::WorkspaceCreated { workspace_id, .. } => Some(workspace_id.clone()),
1031                _ => None,
1032            })
1033            .unwrap();
1034        let tab_evs = dispatch_apply(
1035            &state,
1036            Command::CreateTab { workspace_id: ws_id.clone(), name: "t".into() },
1037        )
1038        .await;
1039        let tab_id = tab_evs
1040            .iter()
1041            .find_map(|e| match e {
1042                Event::TabCreated { tab_id, .. } => Some(tab_id.clone()),
1043                _ => None,
1044            })
1045            .unwrap();
1046        dispatch_apply(
1047            &state,
1048            Command::CreateBlock { tab_id: tab_id.clone(), meta: json!({"view": "agent"}) },
1049        )
1050        .await;
1051        let snap = snapshot_workspace(&state.mstore, &ws_id).unwrap();
1052        save_last_session_snapshot(&state.mstore, snap);
1053
1054        let (first_ws, _) = restore_last_session(&state).await.unwrap().unwrap();
1055        let (second_ws, _) = restore_last_session(&state).await.unwrap().unwrap();
1056        assert_ne!(first_ws, second_ws, "each restore call creates its own fresh workspace");
1057        assert!(state.mstore.get::<Workspace>(&first_ws).unwrap().is_some());
1058        assert!(state.mstore.get::<Workspace>(&second_ws).unwrap().is_some());
1059    }
1060
1061
1062    // --- Phase 0: OS-shutdown snapshot flush ---------------------------------
1063    // SPEC_CONTINUOUS_SESSION_PERSISTENCE_2026_09_08.md
1064
1065    fn shutdown_call() -> WebCallType {
1066        WebCallType {
1067            service: "window".to_string(),
1068            method: "SaveSessionSnapshot".to_string(),
1069            args: vec![],
1070            uicontext: None,
1071        }
1072    }
1073
1074    /// The workspace the shutdown flush will actually capture: the one behind
1075    /// the client's FIRST window. `test_state()` bootstraps a client, window,
1076    /// workspace and a default tab via `ensure_initial_data`, so this is the
1077    /// bootstrapped session rather than anything the test invents — which is
1078    /// the point, since it mirrors what a real running instance looks like.
1079    fn bootstrapped_workspace(state: &AppState) -> String {
1080        current_workspace_id(&state.mstore).expect("test_state seeds a live session")
1081    }
1082
1083    /// The whole point of Phase 0: an OS shutdown captures the session even
1084    /// though `CloseWindow` never runs. Before this, a restart left
1085    /// `session:last_topology` absent and the workspace was lost — confirmed
1086    /// against a real incident (§1.1 of the spec), where the killed instance's
1087    /// DB had the key missing, `windowids` still populated, and 22 orphaned
1088    /// blocks.
1089    ///
1090    /// Asserts against the bootstrapped session rather than a test-built one:
1091    /// `ensure_initial_data` seeds the store directly, so the reducer has no
1092    /// knowledge of that workspace and reducer-routed `CreateTab` cannot add
1093    /// to it. That divergence is a property of the test harness, not of the
1094    /// shutdown path — and the bootstrapped session is a faithful stand-in for
1095    /// what a real instance has open.
1096    #[tokio::test]
1097    async fn shutdown_flush_writes_a_snapshot_without_a_close() {
1098        let state = test_state();
1099        let ws_id = bootstrapped_workspace(&state);
1100        let ws = state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap();
1101        // Count BOTH lists: a workspace the reducer has not touched yet
1102        // (straight out of `ensure_initial_data`) still has its tab in the
1103        // legacy `pinnedtabids` until a `TabsReorderedBulk` drains it — which
1104        // is exactly why `snapshot_workspace` reads both (see its loop).
1105        let open_tabs = ws.pinnedtabids.len() + ws.tabids.len();
1106        assert!(open_tabs > 0, "precondition: the session has a tab open");
1107        assert!(
1108            load_last_session_snapshot(&state.mstore).is_none(),
1109            "precondition: nothing saved before the flush"
1110        );
1111
1112        handle_save_session_snapshot(&state, &shutdown_call()).await;
1113
1114        let snap = load_last_session_snapshot(&state.mstore)
1115            .expect("shutdown flush must persist a snapshot with no CloseWindow");
1116        let tabs = snap["tabs"].as_array().expect("snapshot carries a tabs array");
1117        assert_eq!(
1118            tabs.len(),
1119            open_tabs,
1120            "every open tab must be captured, not just the active one"
1121        );
1122    }
1123
1124    /// Phase 0 must NOT tear down. The OS is about to reclaim everything, and
1125    /// `delete_workspace`'s per-shell grace does not fit the shutdown budget —
1126    /// running it would risk being killed mid-cascade for no benefit.
1127    #[tokio::test]
1128    async fn shutdown_flush_leaves_the_workspace_intact() {
1129        let state = test_state();
1130        let ws_id = bootstrapped_workspace(&state);
1131        let before = state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap();
1132
1133        handle_save_session_snapshot(&state, &shutdown_call()).await;
1134
1135        let after = state
1136            .mstore
1137            .get::<Workspace>(&ws_id)
1138            .unwrap()
1139            .expect("shutdown flush must not delete the workspace");
1140        assert_eq!(
1141            (before.pinnedtabids, before.tabids),
1142            (after.pinnedtabids, after.tabids),
1143            "tabs must survive the flush untouched"
1144        );
1145    }
1146
1147    /// Repeated flushes are safe. The cef-side hook dedupes process-wide, but
1148    /// both `WM_QUERYENDSESSION` and `WM_ENDSESSION` can fire, and a
1149    /// cancelled-then-real shutdown produces two rounds — so the server side
1150    /// must tolerate it rather than rely on the caller being careful.
1151    #[tokio::test]
1152    async fn shutdown_flush_is_idempotent() {
1153        let state = test_state();
1154        let _ = bootstrapped_workspace(&state);
1155
1156        handle_save_session_snapshot(&state, &shutdown_call()).await;
1157        let first = load_last_session_snapshot(&state.mstore).unwrap();
1158        handle_save_session_snapshot(&state, &shutdown_call()).await;
1159        let second = load_last_session_snapshot(&state.mstore).unwrap();
1160
1161        assert_eq!(first, second, "a second flush must not corrupt the snapshot");
1162    }
1163
1164    /// Defensive path: the client's window points at a workspace that is gone.
1165    /// "Survives" means the handler returns promptly with no panic and no
1166    /// partial/corrupt write. Final reasoning, after two rounds of revision
1167    /// on #3106 (see git history if the "why" ever seems arbitrary):
1168    ///
1169    /// A single window whose workspace is dead is now caught by
1170    /// `current_workspace_id` itself (`shutdown_flush_skips_a_window_whose_
1171    /// workspace_is_gone`'s fix) — it's no longer "found a workspace id that
1172    /// then failed to snapshot," it's "found no live workspace among any
1173    /// window," the exact same bucket as
1174    /// `shutdown_flush_with_no_window_at_all_is_not_an_error`. Retrying a
1175    /// genuinely-deleted workspace can't produce a different outcome, so
1176    /// treating this as ordinary (not a failure worth the retry logic
1177    /// acting on) is correct, not a gap: the two tests are no longer "two
1178    /// sides of a fork," they're two different inputs that correctly
1179    /// converge on the same output.
1180    #[tokio::test]
1181    async fn shutdown_flush_survives_a_missing_workspace() {
1182        let state = test_state();
1183        let ws_id = bootstrapped_workspace(&state);
1184        state.mstore.delete::<Workspace>(&ws_id).unwrap();
1185
1186        let ret = handle_save_session_snapshot(&state, &shutdown_call()).await;
1187
1188        assert!(
1189            ret.error.is_none(),
1190            "no live workspace among any window is ordinary, not a failure"
1191        );
1192        assert!(
1193            load_last_session_snapshot(&state.mstore).is_none(),
1194            "nothing capturable means nothing written — not a partial snapshot"
1195        );
1196    }
1197
1198    /// The failure path `handle_save_session_snapshot` actually exists to
1199    /// report (reagentx P1): `current_workspace_id` finds a genuinely live
1200    /// window+workspace, but `snapshot_workspace` still can't build anything
1201    /// from it — every tab it walked was skipped (here: a tab with zero
1202    /// blocks, `snapshot_workspace`'s own `if blocks.is_empty() { continue }`
1203    /// followed by `if tabs.is_empty() { return None }`). Unlike the two
1204    /// tests above, this is NOT "nothing exists" — a real workspace is
1205    /// sitting right there — so this is exactly the "something existed but
1206    /// couldn't be captured" case the retry logic is meant to act on, and it
1207    /// must report failure.
1208    #[tokio::test]
1209    async fn shutdown_flush_reports_failure_when_the_workspace_has_no_capturable_tabs() {
1210        let state = test_state();
1211        let ws_id = bootstrapped_workspace(&state);
1212        let workspace = state.mstore.get::<Workspace>(&ws_id).unwrap().unwrap();
1213        for tab_id in workspace.pinnedtabids.iter().chain(workspace.tabids.iter()) {
1214            let mut tab = state.mstore.get::<Tab>(tab_id).unwrap().unwrap();
1215            for block_id in tab.blockids.drain(..) {
1216                let _ = state.mstore.delete::<Block>(&block_id);
1217            }
1218            state.mstore.update(&mut tab).unwrap();
1219        }
1220
1221        let ret = handle_save_session_snapshot(&state, &shutdown_call()).await;
1222
1223        assert!(
1224            ret.error.is_some(),
1225            "a live workspace with nothing capturable in it must report failure, not silent success"
1226        );
1227        assert!(
1228            load_last_session_snapshot(&state.mstore).is_none(),
1229            "nothing capturable means nothing written — not a partial snapshot"
1230        );
1231    }
1232
1233    /// A shutdown arriving before the first window exists at all (no
1234    /// client, or a client with no live window) is ordinary, not a failure —
1235    /// reporting error here would just be noise on every fresh launch that
1236    /// happens to shut down early. Same outcome and same reasoning as
1237    /// `shutdown_flush_survives_a_missing_workspace` now that
1238    /// `current_workspace_id` filters dead-workspace windows itself — kept
1239    /// as its own test anyway so a future change to either input shape is
1240    /// still checked independently.
1241    #[tokio::test]
1242    async fn shutdown_flush_with_no_window_at_all_is_not_an_error() {
1243        let state = test_state();
1244        let _ = bootstrapped_workspace(&state);
1245        let window_id = state
1246            .mstore
1247            .get_all::<Client>()
1248            .unwrap()
1249            .into_iter()
1250            .next()
1251            .unwrap()
1252            .windowids
1253            .first()
1254            .cloned()
1255            .expect("test_state seeds exactly one window");
1256        state
1257            .mstore
1258            .delete::<crate::backend::obj::Window>(&window_id)
1259            .unwrap();
1260
1261        let ret = handle_save_session_snapshot(&state, &shutdown_call()).await;
1262
1263        assert!(
1264            ret.error.is_none(),
1265            "no window to resolve a workspace from is ordinary, not a failure"
1266        );
1267        assert!(
1268            load_last_session_snapshot(&state.mstore).is_none(),
1269            "nothing was captured, so nothing should be written"
1270        );
1271    }
1272
1273    /// reagentx/Codex P2 on #3106: `current_workspace_id` used to check only
1274    /// `windowids[0]` and give up entirely if that one row was stale, even
1275    /// with a perfectly good workspace behind a later entry. The repo's own
1276    /// restart/reprojection paths already tolerate a polluted `windowids`
1277    /// list, so this is real reachable state, not a hypothetical.
1278    #[tokio::test]
1279    async fn shutdown_flush_skips_a_stale_leading_window_id() {
1280        let state = test_state();
1281        let ws_id = bootstrapped_workspace(&state);
1282
1283        // Prepend a window id with no backing Window row — the exact
1284        // "stale leading entry" shape the finding describes.
1285        let mut client = state
1286            .mstore
1287            .get_all::<Client>()
1288            .unwrap()
1289            .into_iter()
1290            .next()
1291            .unwrap();
1292        client
1293            .windowids
1294            .insert(0, "stale-window-id-no-row".to_string());
1295        state.mstore.update(&mut client).unwrap();
1296
1297        let ret = handle_save_session_snapshot(&state, &shutdown_call()).await;
1298
1299        assert!(
1300            ret.error.is_none(),
1301            "a stale leading window id must not fail the whole flush"
1302        );
1303        let snapshot = load_last_session_snapshot(&state.mstore)
1304            .expect("the live workspace behind the second window id must still be captured");
1305        let expected = snapshot_workspace(&state.mstore, &ws_id)
1306            .expect("the real workspace is independently snapshot-able");
1307        assert_eq!(
1308            snapshot, expected,
1309            "must resolve to and capture the real workspace, not silently skip it"
1310        );
1311    }
1312
1313    /// Codex's follow-up P2 on the fix above: a stale *entry* isn't only "no
1314    /// `Window` row at all" — a `Window` row can exist and be perfectly
1315    /// readable while its `workspaceid` points at a `Workspace` that's gone
1316    /// (exactly the single-window shape
1317    /// `shutdown_flush_survives_a_missing_workspace` exercises). The first
1318    /// pass at walking `windowids` only checked that the `Window` row
1319    /// existed, so it would accept that dead workspace id as the answer and
1320    /// stop, even with a live workspace behind a later window.
1321    #[tokio::test]
1322    async fn shutdown_flush_skips_a_window_whose_workspace_is_gone() {
1323        let state = test_state();
1324        let live_ws_id = bootstrapped_workspace(&state);
1325
1326        let mut client = state
1327            .mstore
1328            .get_all::<Client>()
1329            .unwrap()
1330            .into_iter()
1331            .next()
1332            .unwrap();
1333        let live_window_id = client
1334            .windowids
1335            .first()
1336            .cloned()
1337            .expect("test_state seeds exactly one window");
1338
1339        // A second Window row, readable and legitimate, pointing at a
1340        // workspace id that was never created — indistinguishable from one
1341        // that existed and was later deleted, which is the real-world shape
1342        // this guards against.
1343        let mut dead_window = crate::backend::obj::Window {
1344            oid: "dead-window-with-deleted-workspace".to_string(),
1345            workspaceid: "workspace-that-was-deleted".to_string(),
1346            ..Default::default()
1347        };
1348        state.mstore.insert(&mut dead_window).unwrap();
1349
1350        // Leading: the resolver must not stop here.
1351        client.windowids.insert(0, dead_window.oid.clone());
1352        state.mstore.update(&mut client).unwrap();
1353
1354        let ret = handle_save_session_snapshot(&state, &shutdown_call()).await;
1355
1356        assert!(
1357            ret.error.is_none(),
1358            "a dead-workspace window must not fail the whole flush when a live one follows"
1359        );
1360        let snapshot = load_last_session_snapshot(&state.mstore)
1361            .expect("the live workspace behind the second window must still be captured");
1362        let expected = snapshot_workspace(&state.mstore, &live_ws_id)
1363            .expect("the real workspace is independently snapshot-able");
1364        assert_eq!(
1365            snapshot, expected,
1366            "must resolve to and capture the live workspace, not the dead one it's paired with"
1367        );
1368        // Sanity: prove the two windows really do disagree, so a bug that
1369        // silently ignored windowids order couldn't pass by accident.
1370        assert_ne!(live_window_id, dead_window.oid);
1371    }
1372}