/** * 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 { 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 { 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 { 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> { const states = new Map(); 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): Promise { for (const [storeName, state] of stores) { await this.saveStore(storeName, state); } } /** * Check if MCP client is connected. */ isConnected(): boolean { return this.mcpClient.isConnected(); } }