Skip to main content

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}