/** * BackendExecutionEnvironment — implements IExecutionEnvironment * by routing all filesystem and process operations through the onebox API * to Docker exec on the target container. */ import * as plugins from '../plugins.js'; import * as interfaces from '../../ts_interfaces/index.js'; // Import IExecutionEnvironment type from dees-catalog type IExecutionEnvironment = import('@design.estate/dees-catalog').IExecutionEnvironment; type IFileEntry = import('@design.estate/dees-catalog').IFileEntry; type IFileWatcher = import('@design.estate/dees-catalog').IFileWatcher; type IProcessHandle = import('@design.estate/dees-catalog').IProcessHandle; type IWorkspaceShellCommand = interfaces.requests.IWorkspaceShellCommand; const domtools = plugins.deesElement.domtools; interface IWorkspaceProcessState { outputController: ReadableStreamDefaultController; resolveExit: (exitCodeArg: number) => void; } export class BackendExecutionEnvironment implements IExecutionEnvironment { readonly type = 'backend' as const; private _ready = false; private identity: interfaces.data.IIdentity; private processRouter = new plugins.domtools.plugins.typedrequest.TypedRouter(); private processSocket: InstanceType | null = null; private processSocketPromise: Promise> | null = null; private processStates = new Map(); constructor( private serviceName: string, identity: interfaces.data.IIdentity, ) { this.identity = identity; this.registerProcessSocketHandlers(); } get ready(): boolean { return this._ready; } async init(): Promise { // Verify the container is accessible by checking if root exists const result = await this.fireRequest( 'workspaceExists', { path: '/' }, ); if (!result.exists) { throw new Error(`Cannot access container filesystem for service: ${this.serviceName}`); } this._ready = true; } async destroy(): Promise { for (const processId of Array.from(this.processStates.keys())) { await this.killProcess(processId).catch(() => {}); } await this.processSocket?.stop().catch(() => {}); this.processSocket = null; this.processSocketPromise = null; this._ready = false; } async readFile(path: string): Promise { const result = await this.fireRequest( 'workspaceReadFile', { path }, ); return result.content; } async writeFile(path: string, contents: string): Promise { await this.fireRequest( 'workspaceWriteFile', { path, content: contents }, ); } async readDir(path: string): Promise { const result = await this.fireRequest( 'workspaceReadDir', { path }, ); return result.entries; } async mkdir(path: string): Promise { await this.fireRequest( 'workspaceMkdir', { path }, ); } async rm(path: string, options?: { recursive?: boolean }): Promise { await this.fireRequest( 'workspaceRm', { path, recursive: options?.recursive }, ); } async exists(path: string): Promise { const result = await this.fireRequest( 'workspaceExists', { path }, ); return result.exists; } watch( _path: string, _callback: (event: 'rename' | 'change', filename: string | null) => void, _options?: { recursive?: boolean }, ): IFileWatcher { // Polling-based file watching — check for changes periodically // For now, return a no-op watcher. Full implementation would poll readDir. return { stop: () => {} }; } async spawn(command: string, args?: string[]): Promise { const socket = await this.ensureProcessSocket(); const processId = crypto.randomUUID(); await socket.setTag(`workspaceProcess:${processId}`, true); let resolveExit: (exitCodeArg: number) => void = () => {}; const exit = new Promise((resolve) => { resolveExit = resolve; }); const output = new ReadableStream({ start: (controller) => { this.processStates.set(processId, { outputController: controller, resolveExit, }); }, cancel: async () => { await this.killProcess(processId).catch(() => {}); }, }); try { await socket.createTypedRequest( 'workspaceStartProcess', ).fire({ identity: this.identity, serviceName: this.serviceName, processId, command, args, }); } catch (error) { const processState = this.processStates.get(processId); this.processStates.delete(processId); await socket.removeTag(`workspaceProcess:${processId}`).catch(() => {}); try { processState?.outputController.error(error); } catch { // The stream may already have been cancelled by the terminal. } throw error; } const input = new WritableStream({ write: async (chunkArg) => { await socket.createTypedRequest( 'workspaceProcessInput', ).fire({ identity: this.identity, processId, input: chunkArg, }); }, abort: async () => { await this.killProcess(processId).catch(() => {}); }, }); return { output, input, exit, kill: () => { void this.killProcess(processId); }, }; } async getShellCommand(): Promise { const result = await this.fireRequest( 'workspaceGetShellCommand', {}, ); return result.shellCommand; } private registerProcessSocketHandlers(): void { this.processRouter.addTypedHandler( new plugins.domtools.plugins.typedrequest.TypedHandler( 'pushWorkspaceProcessOutput', async (dataArg: interfaces.requests.IReq_PushWorkspaceProcessOutput['request']) => { this.processStates.get(dataArg.processId)?.outputController.enqueue(dataArg.output); return {}; }, ), ); this.processRouter.addTypedHandler( new plugins.domtools.plugins.typedrequest.TypedHandler( 'pushWorkspaceProcessExit', async (dataArg: interfaces.requests.IReq_PushWorkspaceProcessExit['request']) => { this.completeProcessState(dataArg.processId, dataArg.exitCode); await this.processSocket?.removeTag(`workspaceProcess:${dataArg.processId}`).catch(() => {}); return {}; }, ), ); } private async ensureProcessSocket(): Promise> { if (this.processSocket) return this.processSocket; if (!this.processSocketPromise) { this.processSocketPromise = plugins.typedsocket.TypedSocket.createClient( this.processRouter, plugins.typedsocket.TypedSocket.useWindowLocationOriginUrl(), { autoReconnect: true }, ); } this.processSocket = await this.processSocketPromise; return this.processSocket; } private completeProcessState(processIdArg: string, exitCodeArg: number): void { const processState = this.processStates.get(processIdArg); if (!processState) return; try { processState.outputController.close(); } catch { // The terminal may already have cancelled the stream. } processState.resolveExit(exitCodeArg); this.processStates.delete(processIdArg); } private async killProcess(processIdArg: string): Promise { const socket = this.processSocket; if (!socket) return; await socket.createTypedRequest( 'workspaceKillProcess', ).fire({ identity: this.identity, processId: processIdArg, }).catch(() => {}); this.completeProcessState(processIdArg, -1); await socket.removeTag(`workspaceProcess:${processIdArg}`).catch(() => {}); } /** * Helper to fire TypedRequests to the workspace API */ private async fireRequest( method: string, data: Omit, ): Promise { const typedRequest = new domtools.plugins.typedrequest.TypedRequest( '/typedrequest', method, ); return await typedRequest.fire({ identity: this.identity, serviceName: this.serviceName, ...data, } as T['request']); } }