/** * Convenience reducer-driven state store, mirroring the Swift * `AHPStateMirror` and the Rust reducers example. * * Tracks root, session, terminal, changeset, automation catalogue, and * automation-run state. Apply {@link Snapshot}s and {@link ActionEnvelope}s and * the mirror keeps those resources up to date via the generated reducers. * * Useful for simple clients. Larger apps will usually keep their own * state and call the reducers directly. * * @module client/state-mirror */ import type { ActionEnvelope } from '../types/common/actions.js'; import type { Snapshot, URI } from '../types/common/state.js'; import type { ChangesetAction, AutomationAction, AutomationRunAction, RootAction, SessionAction, TerminalAction, } from '../types/action-origin.generated.js'; import type { ChangesetState } from '../types/channels-changeset/state.js'; import type { RootState } from '../types/channels-root/state.js'; import type { SessionState } from '../types/channels-session/state.js'; import type { TerminalState } from '../types/channels-terminal/state.js'; import type { AutomationEntry, AutomationState } from '../types/channels-automation/state.js'; import type { AutomationRunState } from '../types/channels-automation-run/state.js'; import { changesetReducer } from '../types/channels-changeset/reducer.js'; import { rootReducer } from '../types/channels-root/reducer.js'; import { sessionReducer } from '../types/channels-session/reducer.js'; import { terminalReducer } from '../types/channels-terminal/reducer.js'; import { automationReducer } from '../types/channels-automation/reducer.js'; import { automationRunReducer } from '../types/channels-automation-run/reducer.js'; const ROOT_URI = 'ahp-root://' as const; const AUTOMATIONS_URI = 'ahp-automations://' as const; const INITIAL_ROOT: RootState = { agents: [] }; const INITIAL_AUTOMATION_CATALOG: AutomationState = { entries: [] }; /** Reducer-driven state container synchronised with server events. */ export class AhpStateMirror { private rootState: RootState = INITIAL_ROOT; private readonly sessionsMap = new Map(); private readonly terminalsMap = new Map(); private readonly changesetsMap = new Map(); private automationCatalogState: AutomationState = INITIAL_AUTOMATION_CATALOG; private readonly automationsMap = new Map(); private readonly automationRunsMap = new Map(); /** Current root state. */ get root(): RootState { return this.rootState; } /** All known sessions keyed by URI. */ get sessions(): ReadonlyMap { return this.sessionsMap; } /** All known terminals keyed by URI. */ get terminals(): ReadonlyMap { return this.terminalsMap; } /** All known changesets keyed by URI. */ get changesets(): ReadonlyMap { return this.changesetsMap; } /** Current automation catalogue state. */ get automationCatalog(): AutomationState { return this.automationCatalogState; } /** All catalogued automations keyed by their stable resource URI. */ get automations(): ReadonlyMap { return this.automationsMap; } get automationRuns(): ReadonlyMap { return this.automationRunsMap; } /** Look up a session by URI. */ getSession(uri: URI): SessionState | undefined { return this.sessionsMap.get(uri); } /** Look up a terminal by URI. */ getTerminal(uri: URI): TerminalState | undefined { return this.terminalsMap.get(uri); } /** Look up a catalogued automation by URI. */ getAutomation(uri: URI): AutomationEntry | undefined { return this.automationsMap.get(uri); } /** * Apply a server snapshot, replacing the state for the resource the * snapshot covers. */ applySnapshot(snapshot: Snapshot): void { const resource = snapshot.resource; if (resource === ROOT_URI) { this.rootState = snapshot.state as RootState; return; } if (resource.startsWith('ahp-session:')) { this.sessionsMap.set(resource, snapshot.state as SessionState); return; } if (resource.startsWith('ahp-terminal:')) { this.terminalsMap.set(resource, snapshot.state as TerminalState); return; } if (resource.startsWith('ahp-changeset:')) { this.changesetsMap.set(resource, snapshot.state as ChangesetState); return; } if (resource === AUTOMATIONS_URI) { this.setAutomationCatalog(snapshot.state as AutomationState); return; } if (resource.startsWith('ahp-automation-run:')) { this.automationRunsMap.set(resource, snapshot.state as AutomationRunState); return; } } /** * Apply a server-pushed {@link ActionEnvelope}, routing through the * matching reducer. Unknown channels are ignored. * * The channel-based routing here discriminates which reducer applies; * the action is cast to the appropriate per-channel subset (the * `RootAction` / `SessionAction` / `TerminalAction` / `ChangesetAction` * unions generated from `@clientDispatchable` annotations) because * TypeScript cannot infer that narrowing from the channel string alone. */ apply(envelope: ActionEnvelope): void { const { channel, action } = envelope; if (channel === ROOT_URI) { this.rootState = rootReducer(this.rootState, action as RootAction); return; } if (channel.startsWith('ahp-session:')) { const current = this.sessionsMap.get(channel); if (!current) return; this.sessionsMap.set(channel, sessionReducer(current, action as SessionAction)); return; } if (channel.startsWith('ahp-terminal:')) { const current = this.terminalsMap.get(channel); if (!current) return; this.terminalsMap.set(channel, terminalReducer(current, action as TerminalAction)); return; } if (channel.startsWith('ahp-changeset:')) { const current = this.changesetsMap.get(channel); if (!current) return; this.changesetsMap.set(channel, changesetReducer(current, action as ChangesetAction)); return; } if (channel === AUTOMATIONS_URI) { this.setAutomationCatalog(automationReducer(this.automationCatalogState, action as AutomationAction)); return; } if (channel.startsWith('ahp-automation-run:')) { const current = this.automationRunsMap.get(channel); if (!current) return; this.automationRunsMap.set(channel, automationRunReducer(current, action as AutomationRunAction)); return; } } private setAutomationCatalog(state: AutomationState): void { this.automationCatalogState = state; this.automationsMap.clear(); for (const automation of state.entries) { this.automationsMap.set(automation.resource, automation); } } }