agentmux_srv/
persist.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase E.2 — bootstrap helper: load SQLite-persistent state into
5// the srv reducer at startup. The reducer's state is a SESSION-only
6// projection in E.2/E.2b — it's populated from SQLite at boot,
7// mutated by pipe-originated commands during the session, and
8// discarded on restart (the next bootstrap re-reads SQLite).
9//
10// HTTP/WS RPC continues to write to SQLite directly via wcore. So
11// SQLite stays authoritative for the duration of the session even
12// though the reducer's view diverges as soon as a pipe command
13// runs. That's intentional: pipe-originated commands have no
14// client populating them yet (saga coordinator is empty in E.1a;
15// E.5+ adds saga consumers). Once those exist, E.2c adds the
16// persist subscriber that mirrors pipe-event effects back to SQLite.
17//
18// This module DOES NOT define a persist subscriber. The HWM /
19// broadcast-lag concerns codex flagged are deferred to E.2c when
20// the subscriber actually exists.
21
22use std::sync::Arc;
23
24use tokio::sync::Mutex;
25
26use crate::backend::obj::{Block, Tab, Window, Workspace};
27use crate::backend::storage::wstore::WaveStore;
28use crate::state::{BlockRecord, State, TabRecord, WindowRecord, WorkspaceRecord};
29
30/// Phase E.2 / E.2b — load workspaces and their tabs from SQLite
31/// into the reducer state. Called once at srv startup before the
32/// IPC server starts accepting commands. Async because we're inside
33/// the tokio runtime.
34///
35/// Errors are logged but non-fatal: if a SQLite read fails (fresh
36/// install, empty DB, transient I/O), the reducer starts with
37/// whatever loaded successfully. Workspace and tab loads are
38/// independent — a workspace-load failure does not prevent the tab
39/// load from being attempted (and vice versa), since pipe commands
40/// later in the session can populate either map.
41pub async fn bootstrap_state_from_wstore(state: &Arc<Mutex<State>>, wstore: &WaveStore) {
42    let workspaces = wstore.get_all::<Workspace>().unwrap_or_else(|e| {
43        tracing::warn!(
44            target: "srv-persist",
45            "[srv-persist] bootstrap: failed to load workspaces from wstore: {} — workspaces start empty",
46            e
47        );
48        Vec::new()
49    });
50    let tabs = wstore.get_all::<Tab>().unwrap_or_else(|e| {
51        tracing::warn!(
52            target: "srv-persist",
53            "[srv-persist] bootstrap: failed to load tabs from wstore: {} — tabs start empty",
54            e
55        );
56        Vec::new()
57    });
58    let blocks = wstore.get_all::<Block>().unwrap_or_else(|e| {
59        tracing::warn!(
60            target: "srv-persist",
61            "[srv-persist] bootstrap: failed to load blocks from wstore: {} — blocks start empty",
62            e
63        );
64        Vec::new()
65    });
66    let mut state = state.lock().await;
67    for ws in &workspaces {
68        // The persistent `Workspace` carries two ordered lists:
69        // `tabids` (regular tabs) and `pinnedtabids` (sticky tabs).
70        // Both are equally "owned by this workspace" for reducer
71        // purposes — only their UX semantics differ. Concatenate
72        // pinned-then-regular (pinning convention puts pinned tabs
73        // first), then filter against the tabs we actually loaded
74        // to drop dangling references defensively. (codex P1 #612.)
75        let tab_ids: Vec<String> = ws
76            .pinnedtabids
77            .iter()
78            .chain(ws.tabids.iter())
79            .filter(|tid| tabs.iter().any(|t| &t.oid == *tid))
80            .cloned()
81            .collect();
82        let active_tab_id = if !ws.activetabid.is_empty()
83            && tab_ids.iter().any(|tid| tid == &ws.activetabid)
84        {
85            Some(ws.activetabid.clone())
86        } else {
87            None
88        };
89        state.workspaces.insert(
90            ws.oid.clone(),
91            WorkspaceRecord {
92                workspace_id: ws.oid.clone(),
93                name: ws.name.clone(),
94                tab_ids,
95                active_tab_id,
96            },
97        );
98    }
99    for tab in &tabs {
100        // Each tab needs to know its parent workspace_id, which the
101        // persistent `Tab` struct doesn't carry directly — we recover
102        // it from whichever workspace lists this tab id in EITHER
103        // `tabids` OR `pinnedtabids`. Tabs whose parent isn't loaded
104        // are skipped (orphans). (codex P1 #612.)
105        let Some(workspace_id) = workspaces
106            .iter()
107            .find(|ws| {
108                ws.tabids
109                    .iter()
110                    .chain(ws.pinnedtabids.iter())
111                    .any(|tid| tid == &tab.oid)
112            })
113            .map(|ws| ws.oid.clone())
114        else {
115            tracing::warn!(
116                target: "srv-persist",
117                "[srv-persist] bootstrap: tab {} has no parent workspace — skipping",
118                tab.oid
119            );
120            continue;
121        };
122        // Phase E.3 — filter dangling block ids on the same defensive
123        // basis we apply to tabs above.
124        let block_ids: Vec<String> = tab
125            .blockids
126            .iter()
127            .filter(|bid| blocks.iter().any(|b| &b.oid == *bid))
128            .cloned()
129            .collect();
130        // Phase E.4 (Option A) — bootstrap focused/magnified node from
131        // the tab's LayoutState row so the reducer's view matches what
132        // the user last saw on disk. Empty when the layout row isn't
133        // found or has no value (matches `LayoutState` defaults).
134        //
135        // Phase E.4.B — also bootstrap `rootnode` (the layout tree).
136        // Until the wcore-direct writers migrate to dispatch through
137        // the new layout reducer arms (Phase 7), the reducer's
138        // rootnode is a passive shadow — refreshed on startup, never
139        // diverging from disk because no reducer-arm writers exist
140        // yet at runtime.
141        let (focused_node_id, magnified_node_id, rootnode) = if tab.layoutstate.is_empty() {
142            (String::new(), String::new(), None)
143        } else {
144            match wstore.get::<crate::backend::obj::LayoutState>(&tab.layoutstate) {
145                Ok(Some(layout)) => (
146                    layout.focusednodeid,
147                    layout.magnifiednodeid,
148                    layout.rootnode,
149                ),
150                _ => (String::new(), String::new(), None),
151            }
152        };
153        state.tabs.insert(
154            tab.oid.clone(),
155            TabRecord {
156                tab_id: tab.oid.clone(),
157                workspace_id,
158                name: tab.name.clone(),
159                block_ids,
160                focused_node_id,
161                magnified_node_id,
162                rootnode,
163            },
164        );
165    }
166    for block in &blocks {
167        // Phase E.3 — recover each block's parent tab via reverse
168        // lookup against `state.tabs` (NOT raw `tabs` from SQLite):
169        // tabs orphaned in the prior loop (no parent workspace) are
170        // not in `state.tabs`, so blocks under them must also be
171        // dropped to keep reducer state consistent. (reagent P1 #613.)
172        let Some(tab_id) = state
173            .tabs
174            .values()
175            .find(|t| t.block_ids.iter().any(|bid| bid == &block.oid))
176            .map(|t| t.tab_id.clone())
177        else {
178            tracing::warn!(
179                target: "srv-persist",
180                "[srv-persist] bootstrap: block {} has no parent tab in reducer state — skipping",
181                block.oid
182            );
183            continue;
184        };
185        state.blocks.insert(
186            block.oid.clone(),
187            BlockRecord {
188                block_id: block.oid.clone(),
189                tab_id,
190            },
191        );
192    }
193    // Phase E.5 — load Window→Workspace mappings. Used by sagas
194    // (TearOff/Restore/CreateWindow/CloseWindow) that coordinate
195    // window+workspace lifecycle. Skip windows whose `workspaceid`
196    // refers to a workspace that didn't load — those are dangling
197    // refs, same defensive treatment as orphan tabs/blocks.
198    let windows = wstore.get_all::<Window>().unwrap_or_else(|e| {
199        tracing::warn!(
200            target: "srv-persist",
201            "[srv-persist] bootstrap: failed to load windows from wstore: {} — windows start empty",
202            e
203        );
204        Vec::new()
205    });
206    for window in &windows {
207        if window.workspaceid.is_empty() {
208            tracing::warn!(
209                target: "srv-persist",
210                "[srv-persist] bootstrap: window {} has empty workspaceid — skipping",
211                window.oid
212            );
213            continue;
214        }
215        if !state.workspaces.contains_key(&window.workspaceid) {
216            tracing::warn!(
217                target: "srv-persist",
218                "[srv-persist] bootstrap: window {} points at unknown workspace {} — skipping",
219                window.oid, window.workspaceid
220            );
221            continue;
222        }
223        state.windows.insert(
224            window.oid.clone(),
225            WindowRecord {
226                window_id: window.oid.clone(),
227                workspace_id: window.workspaceid.clone(),
228            },
229        );
230    }
231    tracing::info!(
232        target: "srv-persist",
233        "[srv-persist] bootstrap loaded {} workspace(s) + {} tab(s) + {} block(s) + {} window(s) from wstore",
234        state.workspaces.len(),
235        state.tabs.len(),
236        state.blocks.len(),
237        state.windows.len()
238    );
239}