chart data loading

This commit is contained in:
2026-03-24 21:37:49 -04:00
parent f6bd22a8ef
commit c76887ab92
65 changed files with 6350 additions and 713 deletions

View File

@@ -0,0 +1,190 @@
/**
* Container Sync
*
* Handles synchronization of persistent workspace stores with the user container
* via MCP tools. Persistent stores (chartStore, userPreferences, etc.) are
* stored in the container and loaded/saved via MCP tool calls.
*
* Container-side storage: /data/workspace/{store_name}.json
*
* MCP Tools used:
* - workspace_read(store_name) -> dict
* - workspace_write(store_name, data) -> None
* - workspace_patch(store_name, patch) -> dict (new state)
*/
import type { FastifyBaseLogger } from 'fastify';
import type { Operation as JsonPatchOp } from 'fast-json-patch';
import type { MCPClientConnector } from '../harness/mcp-client.js';
/**
* Result of loading a store from the container.
*/
export interface LoadResult {
exists: boolean;
state?: unknown;
error?: string;
}
/**
* Result of saving a store to the container.
*/
export interface SaveResult {
success: boolean;
error?: string;
}
/**
* Result of patching a store in the container.
*/
export interface PatchResult {
success: boolean;
newState?: unknown;
error?: string;
}
/**
* Handles synchronization with the user's container via MCP.
*/
export class ContainerSync {
private mcpClient: MCPClientConnector;
private logger: FastifyBaseLogger;
constructor(mcpClient: MCPClientConnector, logger: FastifyBaseLogger) {
this.mcpClient = mcpClient;
this.logger = logger.child({ component: 'ContainerSync' });
}
/**
* Load a workspace store from the container.
* Returns the stored state or indicates the store doesn't exist.
*/
async loadStore(storeName: string): Promise<LoadResult> {
if (!this.mcpClient.isConnected()) {
this.logger.warn({ store: storeName }, 'MCP client not connected, cannot load store');
return { exists: false, error: 'MCP client not connected' };
}
try {
this.logger.debug({ store: storeName }, 'Loading store from container');
const result = (await this.mcpClient.callTool('workspace_read', {
store_name: storeName,
})) as { exists: boolean; data?: unknown; error?: string };
if (result.error) {
this.logger.warn({ store: storeName, error: result.error }, 'Container returned error');
return { exists: false, error: result.error };
}
if (!result.exists) {
this.logger.debug({ store: storeName }, 'Store does not exist in container');
return { exists: false };
}
this.logger.debug({ store: storeName }, 'Loaded store from container');
return { exists: true, state: result.data };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error({ store: storeName, error: message }, 'Failed to load store from container');
return { exists: false, error: message };
}
}
/**
* Save a workspace store to the container.
* Overwrites any existing state.
*/
async saveStore(storeName: string, state: unknown): Promise<SaveResult> {
if (!this.mcpClient.isConnected()) {
this.logger.warn({ store: storeName }, 'MCP client not connected, cannot save store');
return { success: false, error: 'MCP client not connected' };
}
try {
this.logger.debug({ store: storeName }, 'Saving store to container');
const result = (await this.mcpClient.callTool('workspace_write', {
store_name: storeName,
data: state,
})) as { success: boolean; error?: string };
if (result.error || !result.success) {
this.logger.warn({ store: storeName, error: result.error }, 'Failed to save store');
return { success: false, error: result.error || 'Unknown error' };
}
this.logger.debug({ store: storeName }, 'Saved store to container');
return { success: true };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error({ store: storeName, error: message }, 'Failed to save store to container');
return { success: false, error: message };
}
}
/**
* Apply a JSON patch to a store in the container.
* Returns the new state after applying the patch.
*/
async patchStore(storeName: string, patch: JsonPatchOp[]): Promise<PatchResult> {
if (!this.mcpClient.isConnected()) {
this.logger.warn({ store: storeName }, 'MCP client not connected, cannot patch store');
return { success: false, error: 'MCP client not connected' };
}
try {
this.logger.debug({ store: storeName, patchOps: patch.length }, 'Patching store in container');
const result = (await this.mcpClient.callTool('workspace_patch', {
store_name: storeName,
patch,
})) as { success: boolean; data?: unknown; error?: string };
if (result.error || !result.success) {
this.logger.warn({ store: storeName, error: result.error }, 'Failed to patch store');
return { success: false, error: result.error || 'Unknown error' };
}
this.logger.debug({ store: storeName }, 'Patched store in container');
return { success: true, newState: result.data };
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
this.logger.error({ store: storeName, error: message }, 'Failed to patch store in container');
return { success: false, error: message };
}
}
/**
* Load all persistent stores from the container.
* Returns a map of store name -> state.
*/
async loadAllStores(storeNames: string[]): Promise<Map<string, unknown>> {
const states = new Map<string, unknown>();
for (const storeName of storeNames) {
const result = await this.loadStore(storeName);
if (result.exists && result.state !== undefined) {
states.set(storeName, result.state);
}
}
return states;
}
/**
* Save all persistent stores to the container.
*/
async saveAllStores(stores: Map<string, unknown>): Promise<void> {
for (const [storeName, state] of stores) {
await this.saveStore(storeName, state);
}
}
/**
* Check if MCP client is connected.
*/
isConnected(): boolean {
return this.mcpClient.isConnected();
}
}

View File

@@ -0,0 +1,86 @@
/**
* Workspace Module
*
* Provides two-way state synchronization between web clients, gateway, and user containers.
*
* Key components:
* - WorkspaceManager: Per-session state manager with channel-agnostic interface
* - SyncRegistry: Handles JSON patch sync protocol
* - ContainerSync: Persists state to user containers via MCP
*
* Usage:
* ```typescript
* import { WorkspaceManager, ContainerSync, DEFAULT_STORES } from './workspace/index.js';
*
* // Create container sync (optional, for persistent stores)
* const containerSync = new ContainerSync(mcpClient, logger);
*
* // Create workspace manager for session
* const workspace = new WorkspaceManager({
* userId: 'user-123',
* sessionId: 'session-456',
* stores: DEFAULT_STORES,
* containerSync,
* logger,
* });
*
* // Initialize (loads persistent stores from container)
* await workspace.initialize();
*
* // Attach channel adapter
* workspace.setAdapter({
* sendSnapshot: (msg) => socket.send(JSON.stringify(msg)),
* sendPatch: (msg) => socket.send(JSON.stringify(msg)),
* getCapabilities: () => ({ supportsSync: true, ... }),
* });
*
* // Handle sync messages from client
* workspace.handleHello(clientSeqs);
* workspace.handlePatch(storeName, seq, patch);
*
* // Access state
* const chartState = workspace.getState('chartState');
* await workspace.setState('chartState', newState);
*
* // Register triggers (future use)
* const unsub = workspace.onPathChange('/chartState/symbol', (old, new, ctx) => {
* console.log('Symbol changed:', old, '->', new);
* });
*
* // Cleanup
* await workspace.shutdown();
* ```
*/
// Types
export type {
SnapshotMessage,
PatchMessage,
HelloMessage,
InboundSyncMessage,
OutboundSyncMessage,
StoreConfig,
ChannelAdapter,
ChannelCapabilities,
PathTrigger,
PathTriggerHandler,
PathTriggerContext,
ChartState,
ChartStore,
ChannelState,
ChannelInfo,
WorkspaceStores,
} from './types.js';
export { DEFAULT_STORES } from './types.js';
// Sync registry
export { SyncRegistry } from './sync-registry.js';
// Container sync
export { ContainerSync } from './container-sync.js';
export type { LoadResult, SaveResult, PatchResult } from './container-sync.js';
// Workspace manager
export { WorkspaceManager } from './workspace-manager.js';
export type { WorkspaceManagerConfig } from './workspace-manager.js';

View File

@@ -0,0 +1,407 @@
/**
* Sync Registry
*
* Manages synchronized state stores with JSON patch-based updates.
* Ported from backend.old/src/sync/registry.py.
*
* Key features:
* - Sequence-numbered patches for reliable sync
* - History buffer for catchup patches
* - Conflict resolution (frontend wins)
* - Optimistic updates with rollback on conflict
*/
import type { Operation as JsonPatchOp } from 'fast-json-patch';
import fastJsonPatch from 'fast-json-patch';
const { applyPatch, compare: computePatch, deepClone } = fastJsonPatch;
import type { FastifyBaseLogger } from 'fastify';
import type { SnapshotMessage, PatchMessage, StoreConfig } from './types.js';
/**
* History entry: sequence number and the patch that produced it.
*/
interface HistoryEntry {
seq: number;
patch: JsonPatchOp[];
}
/**
* Entry for a single synchronized store.
*/
class SyncEntry {
readonly storeName: string;
private state: unknown;
private seq: number = 0;
private lastSnapshot: unknown;
private history: HistoryEntry[] = [];
private readonly historySize: number;
constructor(storeName: string, initialState: unknown, historySize: number = 50) {
this.storeName = storeName;
this.state = deepClone(initialState);
this.lastSnapshot = deepClone(initialState);
this.historySize = historySize;
}
/**
* Get current state (deep clone to prevent mutation).
*/
getState(): unknown {
return deepClone(this.state);
}
/**
* Get current sequence number.
*/
getSeq(): number {
return this.seq;
}
/**
* Set state directly (used for loading from container).
* Resets sequence to 0.
*/
setState(newState: unknown): void {
this.state = deepClone(newState);
this.lastSnapshot = deepClone(newState);
this.seq = 0;
this.history = [];
}
/**
* Compute patch from last snapshot to current state.
* Returns null if no changes.
*/
computePatch(): JsonPatchOp[] | null {
const currentState = deepClone(this.state);
const patch = computePatch(this.lastSnapshot as any, currentState as any);
return patch.length > 0 ? patch : null;
}
/**
* Commit a patch to history and update snapshot.
*/
commitPatch(patch: JsonPatchOp[]): void {
this.seq += 1;
this.history.push({ seq: this.seq, patch });
// Trim history if needed
while (this.history.length > this.historySize) {
this.history.shift();
}
this.lastSnapshot = deepClone(this.state);
}
/**
* Get catchup patches since a given sequence.
* Returns null if catchup not possible (need full snapshot).
*/
getCatchupPatches(sinceSeq: number): HistoryEntry[] | null {
if (sinceSeq === this.seq) {
return [];
}
// Check if we have all patches needed
if (this.history.length === 0 || this.history[0].seq > sinceSeq + 1) {
return null; // Need full snapshot
}
return this.history.filter((entry) => entry.seq > sinceSeq);
}
/**
* Apply a patch to state (used when applying local changes).
*/
applyPatch(patch: JsonPatchOp[]): void {
const result = applyPatch(deepClone(this.state), patch, false, false);
this.state = result.newDocument;
}
/**
* Apply client patch with conflict resolution.
* Returns the resolved state and any patches to send back.
*/
applyClientPatch(
clientBaseSeq: number,
patch: JsonPatchOp[],
logger?: FastifyBaseLogger
): { needsSnapshot: boolean; resolvedState?: unknown } {
try {
if (clientBaseSeq === this.seq) {
// No conflict - apply directly
const currentState = deepClone(this.state);
const result = applyPatch(currentState, patch, false, false);
this.state = result.newDocument;
this.commitPatch(patch);
logger?.debug(
{ store: this.storeName, seq: this.seq },
'Applied client patch without conflict'
);
return { needsSnapshot: false };
}
if (clientBaseSeq < this.seq) {
// Conflict! Frontend wins.
logger?.debug(
{ store: this.storeName, clientSeq: clientBaseSeq, serverSeq: this.seq },
'Conflict detected, frontend wins'
);
// Get backend patches since client's base
const backendPatches: JsonPatchOp[][] = [];
for (const entry of this.history) {
if (entry.seq > clientBaseSeq) {
backendPatches.push(entry.patch);
}
}
// Get paths modified by frontend
const frontendPaths = new Set(patch.map((op) => op.path));
// Apply frontend patch first
const currentState = deepClone(this.state);
let newState: unknown;
try {
const result = applyPatch(currentState, patch, false, false);
newState = result.newDocument;
} catch (e) {
logger?.warn(
{ store: this.storeName, error: e },
'Failed to apply client patch during conflict resolution'
);
return { needsSnapshot: true, resolvedState: this.state };
}
// Re-apply backend patches that don't overlap with frontend
for (const bPatch of backendPatches) {
const filteredPatch = bPatch.filter((op) => !frontendPaths.has(op.path));
if (filteredPatch.length > 0) {
try {
const result = applyPatch(deepClone(newState), filteredPatch, false, false);
newState = result.newDocument;
} catch (e) {
logger?.debug(
{ store: this.storeName, error: e },
'Skipping backend patch during conflict resolution'
);
}
}
}
this.state = newState;
// Compute final patch from last snapshot
const finalPatch = computePatch(this.lastSnapshot as any, newState as any);
if (finalPatch.length > 0) {
this.commitPatch(finalPatch);
}
// Send snapshot to converge
return { needsSnapshot: true, resolvedState: this.state };
}
// clientBaseSeq > this.seq - client is ahead, shouldn't happen
logger?.warn(
{ store: this.storeName, clientSeq: clientBaseSeq, serverSeq: this.seq },
'Client ahead of server, sending snapshot'
);
return { needsSnapshot: true, resolvedState: this.state };
} catch (e) {
logger?.error(
{ store: this.storeName, error: e },
'Unexpected error applying client patch'
);
return { needsSnapshot: true, resolvedState: this.state };
}
}
}
/**
* Registry managing multiple synchronized stores.
*/
export class SyncRegistry {
private entries = new Map<string, SyncEntry>();
private logger?: FastifyBaseLogger;
constructor(logger?: FastifyBaseLogger) {
this.logger = logger?.child({ component: 'SyncRegistry' });
}
/**
* Register a store with initial state.
*/
register(config: StoreConfig): void {
const entry = new SyncEntry(config.name, config.initialState());
this.entries.set(config.name, entry);
this.logger?.debug({ store: config.name }, 'Registered store');
}
/**
* Check if a store is registered.
*/
has(storeName: string): boolean {
return this.entries.has(storeName);
}
/**
* Get current state of a store.
*/
getState<T = unknown>(storeName: string): T | undefined {
const entry = this.entries.get(storeName);
return entry?.getState() as T | undefined;
}
/**
* Get current sequence number for a store.
*/
getSeq(storeName: string): number {
const entry = this.entries.get(storeName);
return entry?.getSeq() ?? 0;
}
/**
* Set state directly (used for loading from container).
*/
setState(storeName: string, state: unknown): void {
const entry = this.entries.get(storeName);
if (entry) {
entry.setState(state);
this.logger?.debug({ store: storeName }, 'Set store state');
}
}
/**
* Update state locally and compute patch.
* Returns the patch if state changed, null otherwise.
*/
updateState(storeName: string, updater: (state: unknown) => unknown): JsonPatchOp[] | null {
const entry = this.entries.get(storeName);
if (!entry) {
return null;
}
const currentState = entry.getState();
const newState = updater(currentState);
// Compute patch
const patch = computePatch(currentState as any, newState as any);
if (patch.length === 0) {
return null;
}
// Apply and commit
entry.applyPatch(patch);
entry.commitPatch(patch);
this.logger?.debug(
{ store: storeName, seq: entry.getSeq(), patchOps: patch.length },
'Updated store state'
);
return patch;
}
/**
* Sync client based on their reported sequences.
* Returns messages to send (snapshots or patches).
*/
syncClient(clientSeqs: Record<string, number>): (SnapshotMessage | PatchMessage)[] {
const messages: (SnapshotMessage | PatchMessage)[] = [];
for (const [storeName, entry] of this.entries) {
const clientSeq = clientSeqs[storeName] ?? -1;
const catchupPatches = entry.getCatchupPatches(clientSeq);
if (catchupPatches === null) {
// Need full snapshot
messages.push({
type: 'snapshot',
store: storeName,
seq: entry.getSeq(),
state: entry.getState(),
});
this.logger?.debug(
{ store: storeName, clientSeq, serverSeq: entry.getSeq() },
'Sending snapshot'
);
} else {
// Send catchup patches
for (const { seq, patch } of catchupPatches) {
messages.push({
type: 'patch',
store: storeName,
seq,
patch,
});
}
if (catchupPatches.length > 0) {
this.logger?.debug(
{ store: storeName, patchCount: catchupPatches.length },
'Sending catchup patches'
);
}
}
}
return messages;
}
/**
* Apply a patch from the client.
* Returns message to send back (snapshot if conflict, null otherwise).
*/
applyClientPatch(
storeName: string,
clientBaseSeq: number,
patch: JsonPatchOp[]
): SnapshotMessage | null {
const entry = this.entries.get(storeName);
if (!entry) {
this.logger?.warn({ store: storeName }, 'Store not found');
return null;
}
const result = entry.applyClientPatch(clientBaseSeq, patch, this.logger);
if (result.needsSnapshot) {
return {
type: 'snapshot',
store: storeName,
seq: entry.getSeq(),
state: result.resolvedState ?? entry.getState(),
};
}
return null;
}
/**
* Get all registered store names.
*/
getStoreNames(): string[] {
return Array.from(this.entries.keys());
}
/**
* Get all current sequences (for persistence).
*/
getAllSeqs(): Record<string, number> {
const seqs: Record<string, number> = {};
for (const [name, entry] of this.entries) {
seqs[name] = entry.getSeq();
}
return seqs;
}
/**
* Get all current states (for persistence).
*/
getAllStates(): Record<string, unknown> {
const states: Record<string, unknown> = {};
for (const [name, entry] of this.entries) {
states[name] = entry.getState();
}
return states;
}
}

View File

@@ -0,0 +1,239 @@
/**
* Workspace Sync Types
*
* Defines the protocol messages and abstractions for two-way state sync
* between web clients, gateway, and user containers.
*
* The workspace is a unified namespace that:
* - Syncs transient state (chartState) between client and gateway
* - Syncs persistent state (chartStore) between client, gateway, and container
* - Provides triggers for path changes (future use)
* - Is channel-agnostic (works with WebSocket, Telegram, Slack, etc.)
*/
import type { Operation as JsonPatchOp } from 'fast-json-patch';
// =============================================================================
// Protocol Messages
// =============================================================================
/**
* Full state snapshot for a store.
* Sent when client connects or when catchup patches are unavailable.
*/
export interface SnapshotMessage {
type: 'snapshot';
store: string;
seq: number;
state: unknown;
}
/**
* Incremental patch for a store.
* Uses JSON Patch (RFC 6902) format.
*/
export interface PatchMessage {
type: 'patch';
store: string;
seq: number;
patch: JsonPatchOp[];
}
/**
* Client hello message with current sequence numbers.
* Sent on connect to request catchup patches or snapshots.
*/
export interface HelloMessage {
type: 'hello';
seqs: Record<string, number>;
}
/** Messages from client to gateway */
export type InboundSyncMessage = HelloMessage | PatchMessage;
/** Messages from gateway to client */
export type OutboundSyncMessage = SnapshotMessage | PatchMessage;
// =============================================================================
// Store Configuration
// =============================================================================
/**
* Configuration for a workspace store.
*/
export interface StoreConfig {
/** Unique store name (e.g., 'chartState', 'chartStore') */
name: string;
/** If true, store is persisted to user container via MCP */
persistent: boolean;
/** Factory function returning initial state for new sessions */
initialState: () => unknown;
}
/**
* Default store configurations.
* Additional stores can be registered at runtime.
*/
export const DEFAULT_STORES: StoreConfig[] = [
{
name: 'chartState',
persistent: false,
initialState: () => ({
symbol: 'BINANCE:BTC/USDT',
start_time: null,
end_time: null,
interval: '15',
selected_shapes: [],
}),
},
{
name: 'chartStore',
persistent: true,
initialState: () => ({
drawings: {},
templates: {},
}),
},
{
name: 'channelState',
persistent: false,
initialState: () => ({
connected: {},
}),
},
];
// =============================================================================
// Channel Adapter Interface
// =============================================================================
/**
* Capabilities that a channel may support.
*/
export interface ChannelCapabilities {
/** Channel supports sync protocol (snapshot/patch messages) */
supportsSync: boolean;
/** Channel supports sending images */
supportsImages: boolean;
/** Channel supports markdown formatting */
supportsMarkdown: boolean;
/** Channel supports streaming responses */
supportsStreaming: boolean;
/** Channel supports TradingView chart embeds */
supportsTradingViewEmbed: boolean;
}
/**
* Adapter interface for communication channels.
* Implemented by WebSocket handler, Telegram handler, etc.
*/
export interface ChannelAdapter {
/** Send a full state snapshot to the client */
sendSnapshot(msg: SnapshotMessage): void;
/** Send an incremental patch to the client */
sendPatch(msg: PatchMessage): void;
/** Get channel capabilities */
getCapabilities(): ChannelCapabilities;
}
// =============================================================================
// Path Triggers (Future Use)
// =============================================================================
/**
* Trigger handler function type.
* Called when a watched path changes.
*/
export type PathTriggerHandler = (
oldValue: unknown,
newValue: unknown,
context: PathTriggerContext
) => void | Promise<void>;
/**
* Context passed to trigger handlers.
*/
export interface PathTriggerContext {
/** Store name where change occurred */
store: string;
/** Full path that changed (JSON pointer) */
path: string;
/** Current sequence number after change */
seq: number;
/** User ID for this workspace */
userId: string;
/** Session ID for this workspace */
sessionId: string;
}
/**
* Registered path trigger.
*/
export interface PathTrigger {
/** JSON pointer path to watch (e.g., '/chartState/symbol') */
path: string;
/** Handler called when path changes */
handler: PathTriggerHandler;
}
// =============================================================================
// Store State Types (for type-safe access)
// =============================================================================
/**
* Chart state - transient, tracks current view.
*/
export interface ChartState {
symbol: string;
start_time: number | null;
end_time: number | null;
interval: string;
selected_shapes: string[];
}
/**
* Chart store - persistent, stores drawings and templates.
*/
export interface ChartStore {
drawings: Record<string, unknown>;
templates: Record<string, unknown>;
}
/**
* Channel state - transient, tracks connected channels.
*/
export interface ChannelState {
connected: Record<string, ChannelInfo>;
}
/**
* Information about a connected channel.
*/
export interface ChannelInfo {
type: string;
connectedAt: number;
capabilities: ChannelCapabilities;
}
/**
* Map of store names to their state types.
*/
export interface WorkspaceStores {
chartState: ChartState;
chartStore: ChartStore;
channelState: ChannelState;
[key: string]: unknown;
}

View File

@@ -0,0 +1,460 @@
/**
* 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);
}
// 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();
}
// ===========================================================================
// 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 };