185 lines
6.4 KiB
TypeScript
185 lines
6.4 KiB
TypeScript
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 { searchWorkspaceFiles, WorkspaceFileSearchOptions } from "../filesystem/search"
|
|
import { clearWorkspaceSearchCache } from "../filesystem/search-cache"
|
|
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<string, WorkspaceRecord>()
|
|
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)
|
|
}
|
|
|
|
searchFiles(workspaceId: string, query: string, options?: WorkspaceFileSearchOptions): FileSystemEntry[] {
|
|
const workspace = this.requireWorkspace(workspaceId)
|
|
return searchWorkspaceFiles(workspace.path, query, options)
|
|
}
|
|
|
|
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<WorkspaceDescriptor> {
|
|
|
|
const id = `${Date.now().toString(36)}`
|
|
const binary = this.options.binaryRegistry.resolveDefault()
|
|
const workspacePath = path.isAbsolute(folder) ? folder : path.resolve(this.options.rootDir, folder)
|
|
clearWorkspaceSearchCache(workspacePath)
|
|
|
|
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<WorkspaceDescriptor | undefined> {
|
|
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)
|
|
clearWorkspaceSearchCache(workspace.path)
|
|
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 })
|
|
}
|
|
}
|
|
}
|