/** * Workspace Manager * * Central manager for workspace state synchronization across channels. * Provides a channel-agnostic interface for: * - Two-way sync of transient state (client ↔ gateway) * - Two-way sync of persistent state (client ↔ gateway ↔ container) * - Path-based change triggers (future use) * * Each user session gets one WorkspaceManager instance. * Multiple channels (WebSocket, Telegram, etc.) can attach to the same workspace. */ import type { FastifyBaseLogger } from 'fastify'; import type { Operation as JsonPatchOp } from 'fast-json-patch'; import { SyncRegistry } from './sync-registry.js'; import type { ContainerSync } from './container-sync.js'; import type { StoreConfig, ChannelAdapter, PathTrigger, PathTriggerHandler, PathTriggerContext, WorkspaceStores, } from './types.js'; import { DEFAULT_STORES } from './types.js'; export interface WorkspaceManagerConfig { userId: string; sessionId: string; stores: StoreConfig[]; containerSync?: ContainerSync; logger: FastifyBaseLogger; } /** * Manages workspace state for a user session. */ export class WorkspaceManager { private userId: string; private sessionId: string; private registry: SyncRegistry; private containerSync?: ContainerSync; private logger: FastifyBaseLogger; private stores: StoreConfig[]; // Current channel adapter (WebSocket, Telegram, etc.) private adapter: ChannelAdapter | null = null; // Path triggers for change notifications private triggers: PathTrigger[] = []; // Track which stores are dirty (changed since last container sync) private dirtyStores = new Set(); // Track initialization state private initialized = false; constructor(config: WorkspaceManagerConfig) { this.userId = config.userId; this.sessionId = config.sessionId; this.stores = config.stores; this.containerSync = config.containerSync; this.logger = config.logger.child({ component: 'WorkspaceManager', sessionId: config.sessionId }); this.registry = new SyncRegistry(this.logger); // Register all stores for (const store of this.stores) { this.registry.register(store); } } /** * Initialize workspace - load persistent stores from container. */ async initialize(): Promise { if (this.initialized) { return; } this.logger.info('Initializing workspace'); // Load persistent stores from container if (this.containerSync?.isConnected()) { const persistentStores = this.stores.filter((s) => s.persistent).map((s) => s.name); if (persistentStores.length > 0) { this.logger.debug({ stores: persistentStores }, 'Loading persistent stores from container'); const states = await this.containerSync.loadAllStores(persistentStores); for (const [storeName, state] of states) { this.registry.setState(storeName, state); this.logger.debug({ store: storeName }, 'Loaded persistent store'); } } } else { this.logger.debug('Container sync not available, using initial state for persistent stores'); } this.initialized = true; this.logger.info('Workspace initialized'); } /** * Shutdown workspace - save dirty persistent stores to container. */ async shutdown(): Promise { if (!this.initialized) { return; } this.logger.info('Shutting down workspace'); // Save dirty persistent stores await this.saveDirtyStores(); this.adapter = null; this.initialized = false; this.logger.info('Workspace shut down'); } // =========================================================================== // Channel Adapter Management // =========================================================================== /** * Set the channel adapter for sending messages. * Only one adapter can be active at a time. */ setAdapter(adapter: ChannelAdapter): void { this.adapter = adapter; this.logger.debug('Channel adapter set'); } /** * Clear the channel adapter. */ clearAdapter(): void { this.adapter = null; this.logger.debug('Channel adapter cleared'); } /** * Check if an adapter is connected. */ hasAdapter(): boolean { return this.adapter !== null; } // =========================================================================== // Sync Protocol Handlers (called by channel adapters) // =========================================================================== /** * Handle hello message from client. * Sends snapshots or catchup patches for all stores. */ async handleHello(clientSeqs: Record): Promise { if (!this.adapter) { this.logger.warn('No adapter connected, cannot respond to hello'); return; } this.logger.debug({ clientSeqs }, 'Handling hello'); const messages = this.registry.syncClient(clientSeqs); for (const msg of messages) { if (msg.type === 'snapshot') { this.adapter.sendSnapshot(msg); } else { this.adapter.sendPatch(msg); } } this.logger.debug({ messageCount: messages.length }, 'Sent sync messages'); } /** * Handle patch message from client. * Applies patch and may send snapshot back on conflict. */ async handlePatch(storeName: string, clientSeq: number, patch: JsonPatchOp[]): Promise { this.logger.debug({ store: storeName, clientSeq, patchOps: patch.length }, 'Handling client patch'); // Get old state for triggers const oldState = this.registry.getState(storeName); // Apply patch const response = this.registry.applyClientPatch(storeName, clientSeq, patch); // Mark as dirty if persistent const storeConfig = this.stores.find((s) => s.name === storeName); if (storeConfig?.persistent) { this.dirtyStores.add(storeName); // Persist immediately so changes survive page reloads (not just graceful shutdown) await this.saveDirtyStores(); } // Send response if needed if (response && this.adapter) { this.adapter.sendSnapshot(response); } // Fire triggers const newState = this.registry.getState(storeName); await this.fireTriggers(storeName, oldState, newState, patch); } // =========================================================================== // State Access (for gateway code) // =========================================================================== /** * Get current state of a store. */ getState(storeName: K): WorkspaceStores[K] | undefined; getState(storeName: string): T | undefined; getState(storeName: string): T | undefined { return this.registry.getState(storeName); } /** * Update state of a store and notify client. */ async setState(storeName: string, state: unknown): Promise { // Get old state for triggers const oldState = this.registry.getState(storeName); // Update state (this computes and commits a patch) const patch = this.registry.updateState(storeName, () => state); if (patch) { // Mark as dirty if persistent const storeConfig = this.stores.find((s) => s.name === storeName); if (storeConfig?.persistent) { this.dirtyStores.add(storeName); } // Send patch to client if (this.adapter) { this.adapter.sendPatch({ type: 'patch', store: storeName, seq: this.registry.getSeq(storeName), patch, }); } // Fire triggers await this.fireTriggers(storeName, oldState, state, patch); } } /** * Update state with a partial merge. */ async updateState>( storeName: string, updates: Partial ): Promise { const current = this.registry.getState(storeName); if (current && typeof current === 'object') { await this.setState(storeName, { ...current, ...updates }); } } /** * Get all store names. */ getStoreNames(): string[] { return this.registry.getStoreNames(); } /** * Serialize entire workspace state as JSON. */ serializeState(): string { const state: Record = {}; for (const storeConfig of this.stores) { const storeState = this.registry.getState(storeConfig.name); if (storeState !== undefined) { state[storeConfig.name] = storeState; } } return JSON.stringify(state, null, 2); } /** * Get the highest sequence number across all stores. */ getCurrentSeq(): number { let maxSeq = 0; for (const storeName of this.registry.getStoreNames()) { const seq = this.registry.getSeq(storeName); if (seq > maxSeq) { maxSeq = seq; } } return maxSeq; } /** * Get all patches since a given sequence number across all stores. * Returns patches grouped by store name. */ getChangesSince(sinceSeq: number): Record { const changes: Record = {}; for (const storeConfig of this.stores) { const patches = this.registry.getPatchesSince(storeConfig.name, sinceSeq); if (patches && patches.length > 0) { changes[storeConfig.name] = patches; } } return changes; } // =========================================================================== // Path Triggers // =========================================================================== /** * Register a trigger for path changes. * Returns unsubscribe function. */ onPathChange(path: string, handler: PathTriggerHandler): () => void { const trigger: PathTrigger = { path, handler }; this.triggers.push(trigger); this.logger.debug({ path }, 'Registered path trigger'); return () => { const index = this.triggers.indexOf(trigger); if (index >= 0) { this.triggers.splice(index, 1); this.logger.debug({ path }, 'Unregistered path trigger'); } }; } /** * Fire triggers for paths affected by a patch. */ private async fireTriggers( storeName: string, oldState: unknown, newState: unknown, patch: JsonPatchOp[] ): Promise { if (this.triggers.length === 0) { return; } const context: PathTriggerContext = { store: storeName, path: '', seq: this.registry.getSeq(storeName), userId: this.userId, sessionId: this.sessionId, }; // Check each patch operation against triggers for (const op of patch) { const fullPath = `/${storeName}${op.path}`; for (const trigger of this.triggers) { if (this.pathMatches(fullPath, trigger.path)) { context.path = fullPath; // Extract old and new values at the path const oldValue = this.getValueAtPath(oldState, op.path); const newValue = this.getValueAtPath(newState, op.path); try { await trigger.handler(oldValue, newValue, context); } catch (error) { this.logger.error( { path: trigger.path, error }, 'Error in path trigger handler' ); } } } } } /** * Check if a path matches a trigger path pattern. * Currently supports exact match and prefix match with wildcard. */ private pathMatches(path: string, pattern: string): boolean { // Exact match if (path === pattern) { return true; } // Prefix match (e.g., /chartState/* matches /chartState/symbol) if (pattern.endsWith('/*')) { const prefix = pattern.slice(0, -2); return path.startsWith(prefix + '/'); } return false; } /** * Get value at a JSON pointer path. */ private getValueAtPath(obj: unknown, path: string): unknown { if (!path || path === '/') { return obj; } const parts = path.split('/').filter(Boolean); let current: any = obj; for (const part of parts) { if (current === null || current === undefined) { return undefined; } current = current[part]; } return current; } // =========================================================================== // Container Persistence // =========================================================================== /** * Save dirty persistent stores to container. */ async saveDirtyStores(): Promise { if (!this.containerSync?.isConnected()) { this.logger.debug('Container sync not available, skipping save'); return; } if (this.dirtyStores.size === 0) { this.logger.debug('No dirty stores to save'); return; } const toSave = new Map(); for (const storeName of this.dirtyStores) { const storeConfig = this.stores.find((s) => s.name === storeName); if (storeConfig?.persistent) { const state = this.registry.getState(storeName); if (state !== undefined) { toSave.set(storeName, state); } } } if (toSave.size > 0) { this.logger.debug({ stores: Array.from(toSave.keys()) }, 'Saving dirty stores to container'); await this.containerSync.saveAllStores(toSave); this.dirtyStores.clear(); } } /** * Force save a specific store to container. */ async saveStore(storeName: string): Promise { if (!this.containerSync?.isConnected()) { this.logger.warn({ store: storeName }, 'Container sync not available'); return; } const storeConfig = this.stores.find((s) => s.name === storeName); if (!storeConfig?.persistent) { this.logger.warn({ store: storeName }, 'Store is not persistent'); return; } const state = this.registry.getState(storeName); if (state !== undefined) { await this.containerSync.saveStore(storeName, state); this.dirtyStores.delete(storeName); } } // =========================================================================== // Accessors // =========================================================================== getUserId(): string { return this.userId; } getSessionId(): string { return this.sessionId; } isInitialized(): boolean { return this.initialized; } } // Re-export DEFAULT_STORES for convenience export { DEFAULT_STORES };