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}