agentmux_srv/state.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3//
4// Phase E.1b — srv reducer state. Mirrors agentmux-launcher's State
5// pattern: plain data, mutated only by the pure reducer
6// (`crate::reducer::update`). Held inside Arc<Mutex<State>> by the
7// pipe IPC server; mutex held only during reducer dispatch
8// (sub-millisecond).
9//
10// What's here:
11// * `LifecyclePhase` (re-exported from agentmux-common::ipc) — E.1b
12// * `ProcessRecord` — state — E.1b
13// * `ProcessState` — Running / Exited — E.1b
14// * `WorkspaceRecord` — workspace_id, name — E.2
15// * `State` — top-level: lifecycle + process map + workspaces +
16// monotonic counters
17//
18// What's intentionally NOT here yet:
19// * Tab / Block / Layout domain state — E.2b+
20// * `persistence_hwm` field — E.2c (lands with the persist
21// subscriber that mirrors pipe-event effects back to SQLite)
22
23use std::collections::HashMap;
24
25pub use agentmux_common::ipc::LifecyclePhase;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum ProcessState {
29 Running,
30 Exited { code: i32 },
31}
32
33#[derive(Debug, Clone)]
34pub struct ProcessRecord {
35 pub state: ProcessState,
36 #[cfg(test)]
37 pub version: String,
38}
39
40/// Phase E.2 — workspace as held by the srv reducer's canonical
41/// state. Mirrors the persistent `Workspace` struct in
42/// `agentmux_srv::backend::obj::Workspace` but with the reducer-
43/// canonical fields the cross-process events care about.
44///
45/// Phase E.2b extends the record with `tab_ids` (ordered) and
46/// `active_tab_id`. Tabs themselves live in `state.tabs` keyed by
47/// tab_id; the workspace owns the ordering.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct WorkspaceRecord {
50 pub workspace_id: String,
51 pub name: String,
52 /// Ordered list of tab ids in this workspace. Mirrors the
53 /// persistent `Workspace.tabids` field.
54 pub tab_ids: Vec<String>,
55 /// The active tab in this workspace, if any. `None` when the
56 /// workspace has no tabs or has not yet had one selected.
57 pub active_tab_id: Option<String>,
58}
59
60/// Phase E.2b — tab as held by the srv reducer's canonical state.
61/// Tabs are owned by exactly one workspace; the workspace's
62/// `tab_ids` field gives the ordering. E.3 adds `block_ids` so the
63/// tab tracks which blocks live inside it.
64// `Eq` dropped in Phase E.4.B because `LayoutNode.size: f32` precludes
65// it. Nothing in the codebase relies on `TabRecord: Eq` (no HashSet
66// usage, no `==` comparisons in the reducer).
67#[derive(Debug, Clone, PartialEq, Default)]
68pub struct TabRecord {
69 pub tab_id: String,
70 pub workspace_id: String,
71 pub name: String,
72 /// Phase E.3 — ordered list of block ids in this tab. Mirrors
73 /// the persistent `Tab.blockids` field.
74 pub block_ids: Vec<String>,
75 /// Phase E.4 (Option A) — focused layout node id. Empty when no
76 /// pane in this tab is focused. Mirrors
77 /// `LayoutState.focusednodeid` for the tab's layout row. Mutated
78 /// by `Command::SetFocusedNode` and bootstrap-loaded from the
79 /// LayoutState row at startup. The remaining LayoutState fields
80 /// (rootnode/leaforder/pendingbackendactions) stay on the
81 /// existing wcore-direct path until Option B.
82 pub focused_node_id: String,
83 /// Phase E.4 (Option A) — magnified layout node id. Empty when
84 /// no pane is magnified (toggle-off). Mirrors
85 /// `LayoutState.magnifiednodeid`.
86 pub magnified_node_id: String,
87 /// Phase E.4.B (Option B) — layout tree root.
88 ///
89 /// Mirrors the persisted `LayoutState.rootnode`. Mutated by the
90 /// `LayoutClear` / `LayoutSetTree` / `LayoutInsertNode` /
91 /// `LayoutDeleteNode` reducer arms (and the rest of the 11 in
92 /// follow-up PRs). `None` represents an empty tree (no panes).
93 ///
94 /// **Status: scaffolded; bootstrap-loaded.** `persist::
95 /// bootstrap_state_from_mstore` populates this from
96 /// `LayoutState.rootnode` at startup. Production writers still
97 /// go through the wcore-direct path (per
98 /// `srv-phase-e4b-implementation-plan-2026-05-03.md` Phase 7);
99 /// reducer arms mutate this field but no production code
100 /// dispatches to them yet.
101 pub rootnode: Option<agentmux_common::LayoutNode>,
102}
103
104/// Phase E.3 — block as held by the srv reducer's canonical state.
105/// Blocks are owned by exactly one tab; the tab's `block_ids`
106/// field gives the ordering. Block content (view, meta, runtimeopts)
107/// is intentionally not yet tracked — E.3 ships block lifecycle
108/// only; metadata + view land in a follow-up.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct BlockRecord {
111 pub block_id: String,
112 pub tab_id: String,
113}
114
115/// Phase E.5 — window-to-workspace mapping as held by the srv
116/// reducer's canonical state. Mirrors the persistent
117/// `Window.workspaceid` field. Used by sagas (TearOff/Restore/
118/// CreateWindow/CloseWindow) that need to coordinate the
119/// window↔workspace lifecycle atomically.
120///
121/// Note: this is NOT the same as the launcher's `state::Window` —
122/// the launcher tracks CEF window ownership (label, kind, hwnd).
123/// The srv `WindowRecord` is purely "which workspace does this
124/// window currently point at." Both are valid orthogonal projections
125/// of the same on-disk Window row.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct WindowRecord {
128 pub window_id: String,
129 pub workspace_id: String,
130}
131
132#[derive(Debug)]
133pub struct State {
134 pub lifecycle: LifecyclePhase,
135 pub processes: HashMap<u32, ProcessRecord>,
136 pub event_version: u64,
137 pub next_client_id: u64,
138 /// Phase E.2 — workspaces canonical to the srv reducer.
139 /// Bootstrapped from SQLite at startup; subsequent transitions
140 /// flow through `update`. In E.2 the reducer is a session-only
141 /// projection — pipe-originated mutations live only in this
142 /// map until the process restarts. E.2c adds the persist
143 /// subscriber that mirrors changes back to SQLite (idempotent,
144 /// version-gated) and migrates HTTP/WS RPC through the reducer.
145 pub workspaces: HashMap<String, WorkspaceRecord>,
146 /// Phase E.2b — tabs canonical to the srv reducer. Keyed by
147 /// `tab_id`; ordering within a workspace is held in
148 /// `WorkspaceRecord.tab_ids`. Bootstrap-loaded from SQLite at
149 /// startup alongside workspaces.
150 pub tabs: HashMap<String, TabRecord>,
151 /// Phase E.3 — blocks canonical to the srv reducer. Keyed by
152 /// `block_id`; ordering within a tab is held in
153 /// `TabRecord.block_ids`. Bootstrap-loaded from SQLite at startup
154 /// alongside workspaces and tabs.
155 pub blocks: HashMap<String, BlockRecord>,
156 /// Phase E.5 — window→workspace mapping. Bootstrap-loaded from
157 /// SQLite Window rows. Mutated by the saga-driven CreateWindow/
158 /// CloseWindow/SwitchWorkspace commands; sagas use it to keep
159 /// window+workspace lifecycle coherent.
160 pub windows: HashMap<String, WindowRecord>,
161 // `persistence_hwm` deferred to E.2c when the persist subscriber
162 // lands and there's actually something to track.
163}
164
165impl Default for State {
166 fn default() -> Self {
167 Self {
168 lifecycle: LifecyclePhase::Starting,
169 processes: HashMap::new(),
170 event_version: 0,
171 next_client_id: 0,
172 workspaces: HashMap::new(),
173 tabs: HashMap::new(),
174 blocks: HashMap::new(),
175 windows: HashMap::new(),
176 }
177 }
178}
179
180impl State {
181 /// Increment + return the monotonic event-version counter.
182 /// Every emitted Event carries a version; subscribers detect
183 /// gaps for resync (Phase D.3).
184 pub fn bump_version(&mut self) -> u64 {
185 self.event_version = self.event_version.saturating_add(1);
186 self.event_version
187 }
188
189 /// Allocate a fresh client_id for a new Register reply.
190 pub fn alloc_client_id(&mut self) -> u64 {
191 self.next_client_id = self.next_client_id.saturating_add(1);
192 self.next_client_id
193 }
194}