Files
ai/gateway/src/workspace/workspace-manager.ts
2026-04-09 17:00:43 -04:00

510 lines
14 KiB
TypeScript

/**
* 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<string>();
// 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<void> {
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<void> {
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<string, number>): Promise<void> {
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<void> {
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<K extends keyof WorkspaceStores>(storeName: K): WorkspaceStores[K] | undefined;
getState<T = unknown>(storeName: string): T | undefined;
getState<T = unknown>(storeName: string): T | undefined {
return this.registry.getState<T>(storeName);
}
/**
* Update state of a store and notify client.
*/
async setState(storeName: string, state: unknown): Promise<void> {
// 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<T extends Record<string, unknown>>(
storeName: string,
updates: Partial<T>
): Promise<void> {
const current = this.registry.getState<T>(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<string, unknown> = {};
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<string, JsonPatchOp[]> {
const changes: Record<string, JsonPatchOp[]> = {};
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<void> {
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<void> {
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<string, unknown>();
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<void> {
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 };