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}