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