import path from "path" import { EventBus } from "../events/bus" import { ConfigStore } from "../config/store" import { BinaryRegistry } from "../config/binaries" import { FileSystemBrowser } from "../filesystem/browser" import { WorkspaceDescriptor, WorkspaceFileResponse, FileSystemEntry } from "../api-types" import { WorkspaceRuntime } from "./runtime" import { Logger } from "../logger" interface WorkspaceManagerOptions { rootDir: string configStore: ConfigStore binaryRegistry: BinaryRegistry eventBus: EventBus logger: Logger } interface WorkspaceRecord extends WorkspaceDescriptor {} export class WorkspaceManager { private readonly workspaces = new Map() private readonly runtime: WorkspaceRuntime constructor(private readonly options: WorkspaceManagerOptions) { this.runtime = new WorkspaceRuntime(this.options.eventBus, this.options.logger) } list(): WorkspaceDescriptor[] { return Array.from(this.workspaces.values()) } get(id: string): WorkspaceDescriptor | undefined { return this.workspaces.get(id) } getInstancePort(id: string): number | undefined { return this.workspaces.get(id)?.port } listFiles(workspaceId: string, relativePath = "."): FileSystemEntry[] { const workspace = this.requireWorkspace(workspaceId) const browser = new FileSystemBrowser({ rootDir: workspace.path }) return browser.list(relativePath) } readFile(workspaceId: string, relativePath: string): WorkspaceFileResponse { const workspace = this.requireWorkspace(workspaceId) const browser = new FileSystemBrowser({ rootDir: workspace.path }) const contents = browser.readFile(relativePath) return { workspaceId, relativePath, contents, } } async create(folder: string, name?: string): Promise { const id = `${Date.now().toString(36)}` const binary = this.options.binaryRegistry.resolveDefault() const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder) this.options.logger.info({ workspaceId: id, folder: workspacePath, binary: binary.path }, "Creating workspace") const proxyPath = `/workspaces/${id}/instance` const descriptor: WorkspaceRecord = { id, path: workspacePath, name, status: "starting", proxyPath, binaryId: binary.id, binaryLabel: binary.label, binaryVersion: binary.version, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), } this.workspaces.set(id, descriptor) this.options.eventBus.publish({ type: "workspace.created", workspace: descriptor }) const environment = this.options.configStore.get().preferences.environmentVariables ?? {} try { const { pid, port } = await this.runtime.launch({ workspaceId: id, folder: workspacePath, binaryPath: binary.path, environment, onExit: (info) => this.handleProcessExit(info.workspaceId, info), }) descriptor.pid = pid descriptor.port = port descriptor.status = "ready" descriptor.updatedAt = new Date().toISOString() this.options.eventBus.publish({ type: "workspace.started", workspace: descriptor }) this.options.logger.info({ workspaceId: id, port }, "Workspace ready") return descriptor } catch (error) { descriptor.status = "error" descriptor.error = error instanceof Error ? error.message : String(error) descriptor.updatedAt = new Date().toISOString() this.options.eventBus.publish({ type: "workspace.error", workspace: descriptor }) this.options.logger.error({ workspaceId: id, err: error }, "Workspace failed to start") throw error } } async delete(id: string): Promise { const workspace = this.workspaces.get(id) if (!workspace) return undefined this.options.logger.info({ workspaceId: id }, "Stopping workspace") const wasRunning = Boolean(workspace.pid) if (wasRunning) { await this.runtime.stop(id).catch((error) => { this.options.logger.warn({ workspaceId: id, err: error }, "Failed to stop workspace process cleanly") }) } this.workspaces.delete(id) if (!wasRunning) { this.options.eventBus.publish({ type: "workspace.stopped", workspaceId: id }) } return workspace } async shutdown() { this.options.logger.info("Shutting down all workspaces") for (const [id, workspace] of this.workspaces) { if (workspace.pid) { this.options.logger.info({ workspaceId: id }, "Stopping workspace during shutdown") await this.runtime.stop(id).catch((error) => { this.options.logger.error({ workspaceId: id, err: error }, "Failed to stop workspace during shutdown") }) } else { this.options.logger.debug({ workspaceId: id }, "Workspace already stopped") } } this.workspaces.clear() this.options.logger.info("All workspaces cleared") } private requireWorkspace(id: string): WorkspaceRecord { const workspace = this.workspaces.get(id) if (!workspace) { throw new Error("Workspace not found") } return workspace } private handleProcessExit(workspaceId: string, info: { code: number | null; requested: boolean }) { const workspace = this.workspaces.get(workspaceId) if (!workspace) return this.options.logger.info({ workspaceId, ...info }, "Workspace process exited") workspace.pid = undefined workspace.port = undefined workspace.updatedAt = new Date().toISOString() if (info.requested || info.code === 0) { workspace.status = "stopped" workspace.error = undefined this.options.eventBus.publish({ type: "workspace.stopped", workspaceId }) } else { workspace.status = "error" workspace.error = `Process exited with code ${info.code}` this.options.eventBus.publish({ type: "workspace.error", workspace }) } } }