import type { Environment, ProcessOutput, ProcessRequest, RemoteProcess, SubstrateTransport, WorkspaceBinding, WorkspaceOptions, } from "./types.js"; const TERMINAL_PROCESS_STATUSES: Record = { COMPLETED: true, FAILED: true, TERMINATED: true, PROCESS_STATUS_COMPLETED: true, PROCESS_STATUS_FAILED: true, PROCESS_STATUS_TERMINATED: true, }; export class Workspace { private bindingValue: WorkspaceBinding | null = null; private activeOperations = 0; private pendingStarts = 0; private startBatchUncertain = false; private transition = false; private idleTimer: Parameters[0] | undefined; private disposed = false; constructor( private readonly transport: SubstrateTransport, private readonly options: WorkspaceOptions, ) {} get binding(): WorkspaceBinding | null { return this.bindingValue === null ? null : copyBinding(this.bindingValue); } restore(binding: WorkspaceBinding | null): void { this.assertNotDisposed(); this.assertNotBusy("restore a workspace"); this.clearIdleTimer(); if (binding === null) { this.bindingValue = null; return; } if (!this.isRestoreable(binding)) { this.bindingValue = null; this.notify( "Ignored a workspace binding from a different or invalid session.", "warning", ); return; } this.bindingValue = copyBinding(binding); this.scheduleIdle(); } async attach(id: string, signal?: AbortSignal): Promise { this.assertIdentifier(id, "environment id"); await this.withTransition("attach an environment", async () => { if ( this.bindingValue !== null && this.bindingValue.environmentId !== id && (this.bindingValue.processIds.length > 0 || this.bindingValue.uncertain) ) { throw new Error( "Cannot attach a different environment while this workspace has tracked or uncertain processes; detach it explicitly first.", ); } const environment = await this.transport.get(id, { signal }); this.assertLive(); this.assertEnvironmentIdentity(environment, id); if (this.bindingValue?.environmentId === id) { this.persistBinding(); return; } this.bindingValue = this.newBinding(id); this.persistBinding(); }); } async create( id: string, template?: { name: string; atespace: string }, signal?: AbortSignal, ): Promise { this.assertIdentifier(id, "environment id"); await this.withTransition("create an environment", async () => { if (this.bindingValue !== null) { throw new Error( "Cannot create an environment while a workspace is attached; detach it explicitly first.", ); } const environment = await this.transport.create(id, template, { signal }); this.assertLive(); this.assertEnvironmentIdentity(environment, id); this.bindingValue = this.newBinding(id); this.persistBinding(); }); } async status(signal?: AbortSignal): Promise { return this.withLease("inspect environment status", signal, async (binding) => this.transport.get(binding.environmentId, { signal }), ); } async suspend(signal?: AbortSignal): Promise { await this.withTransition("suspend the environment", async () => { const binding = this.requireBinding(); await this.transport.suspend(binding.environmentId, { signal }); this.assertLive(); this.persistBinding(); }, false); } async delete(id: string, signal?: AbortSignal): Promise { this.assertIdentifier(id, "environment id"); await this.withTransition("delete the environment", async () => { const binding = this.requireBinding(); if (binding.environmentId !== id) { throw new Error( "Cannot delete an environment that is not the current workspace attachment.", ); } await this.transport.delete(id, { signal }); this.assertLive(); this.bindingValue = null; this.persistBinding(); }); } detach(): void { this.assertNotDisposed(); this.assertNotBusy("detach the workspace"); this.clearIdleTimer(); const binding = this.bindingValue; if ( binding !== null && (binding.processIds.length > 0 || binding.uncertain) ) { const processIds = binding.processIds.length > 0 ? ` tracked process IDs: ${binding.processIds.join(", ")}.` : ""; const uncertainty = binding.uncertain ? " A previous start request has uncertain remote state." : ""; this.notify( `Detached environment ${binding.environmentId} without suspending or terminating it.${processIds}${uncertainty}`, "warning", ); } this.bindingValue = null; this.persistBinding(); } async read(path: string, signal?: AbortSignal): Promise { this.assertPath(path); return this.withLease("read a workspace file", signal, async (binding) => this.transport.read(binding.environmentId, path, { signal }), ); } async write( path: string, content: Buffer, signal?: AbortSignal, ): Promise { this.assertPath(path); return this.withLease("write a workspace file", signal, async (binding) => this.transport.write(binding.environmentId, path, content, { signal }), ); } async start(request: ProcessRequest, signal?: AbortSignal): Promise { this.assertProcessRequest(request); return this.withLease("start a background process", signal, async (binding) => this.startTracked(binding, request, signal), ); } async shell( request: ProcessRequest, signal?: AbortSignal, ): Promise<{ process: RemoteProcess; output: ProcessOutput }> { this.assertProcessRequest(request); return this.withLease("run a shell process", signal, async (binding) => { const processId = await this.startTracked(binding, request, signal); let output: ProcessOutput; try { output = await this.transport.outputs( binding.environmentId, processId, { follow: true }, { signal }, ); } catch (error) { throw await this.failShellProcess( binding, processId, signal?.aborted ? "output collection was cancelled" : "output collection failed", error, ); } if (output.truncated) { throw await this.failShellProcess( binding, processId, `output exceeded the ${output.stdoutOffset + output.stderrOffset}-byte capture limit; retrieve logs with substrate_process or rerun explicitly as a background job`, ); } let process: RemoteProcess; try { process = await this.transport.process(binding.environmentId, processId, { signal, }); } catch (error) { throw await this.failShellProcess( binding, processId, signal?.aborted ? "terminal process inspection was cancelled" : "terminal process inspection failed", error, ); } if (!isTerminal(process.status)) { throw await this.failShellProcess( binding, processId, "output ended before the process reached a terminal status", ); } this.removeTrackedProcess(processId); return { process, output }; }); } async process( processId: string, action: "status" | "logs" | "kill", signal?: AbortSignal, offsets?: { stdoutOffset?: number; stderrOffset?: number }, ): Promise { this.assertIdentifier(processId, "process id"); return this.withLease("inspect a process", signal, async (binding) => { switch (action) { case "status": { const process = await this.transport.process( binding.environmentId, processId, { signal }, ); this.assertLive(); if (isTerminal(process.status)) { this.removeTrackedProcess(processId); } return process; } case "logs": return this.transport.outputs( binding.environmentId, processId, { follow: false, ...offsets }, { signal }, ); case "kill": { const exitCode = await this.transport.kill( binding.environmentId, processId, { signal }, ); this.assertLive(); this.removeTrackedProcess(processId); return exitCode; } } }); } dispose(): void { if (this.disposed) { return; } this.disposed = true; this.clearIdleTimer(); this.transport.close(); } private async startTracked( binding: WorkspaceBinding, request: ProcessRequest, signal?: AbortSignal, ): Promise { if (this.pendingStarts === 0) this.startBatchUncertain = binding.uncertain; this.pendingStarts++; binding.uncertain = true; let processId: string | undefined; try { // Persist intent before sending: shutdown can make a successful start unknowable. this.persistBinding(); processId = await this.transport.start(binding.environmentId, request, { signal }); this.assertLive(); this.assertIdentifier(processId, "remote process id"); if (!binding.processIds.includes(processId)) binding.processIds.push(processId); return processId; } catch (error) { this.markUncertain(); throw this.operationError("Starting the background process may have succeeded remotely", error); } finally { this.pendingStarts--; if (!this.disposed) { binding.uncertain = this.startBatchUncertain || this.pendingStarts > 0; try { this.persistBinding(); } catch (error) { this.markUncertain(); throw this.operationError( `Process tracking could not be persisted${processId ? ` for ${processId}` : ""}`, error, ); } } } } private async failShellProcess( binding: WorkspaceBinding, processId: string, reason: string, cause?: unknown, ): Promise { let cleanupFailure: unknown; try { // Deliberately omit the caller signal: it may be aborted, while cleanup needs // a fresh request governed by the transport's normal timeout. await this.transport.kill(binding.environmentId, processId); this.assertLive(); this.removeTrackedProcess(processId); } catch (error) { cleanupFailure = error; if (!this.disposed) { this.markUncertain(); } } const suffix = cleanupFailure ? " Cleanup failed; the process may still be running." : " The process was terminated."; return this.operationError( `Shell process ${processId} did not complete because ${reason}.${suffix}`, cause ?? cleanupFailure, ); } private markUncertain(): void { if (this.pendingStarts > 0) this.startBatchUncertain = true; if (this.disposed || this.bindingValue === null || this.bindingValue.uncertain) { return; } this.bindingValue.uncertain = true; try { this.persistBinding(); } catch (error) { this.notify( "Workspace process state is uncertain and could not be persisted.", "error", ); } } private removeTrackedProcess(processId: string): void { if (this.disposed || this.bindingValue === null) { return; } const index = this.bindingValue.processIds.indexOf(processId); if (index === -1) { return; } this.bindingValue.processIds.splice(index, 1); this.persistBinding(); } private async withLease( operation: string, signal: AbortSignal | undefined, work: (binding: WorkspaceBinding) => Promise, ): Promise { this.assertNotDisposed(); if (signal?.aborted) { throw new Error(`Cannot ${operation}: the operation was cancelled.`); } if (this.transition) { throw new Error(`Cannot ${operation}: an environment transition is in progress.`); } const binding = this.requireBinding(); this.activeOperations += 1; this.clearIdleTimer(); try { const result = await work(binding); this.assertLive(); return result; } finally { this.activeOperations -= 1; this.scheduleIdle(); } } private async withTransition( operation: string, work: () => Promise, scheduleNextIdle = true, ): Promise { this.assertNotDisposed(); this.assertNotBusy(operation); this.transition = true; this.clearIdleTimer(); try { const result = await work(); this.assertLive(); return result; } finally { this.transition = false; if (scheduleNextIdle) this.scheduleIdle(); } } private assertNotBusy(operation: string): void { if (this.transition || this.activeOperations > 0) { throw new Error(`Cannot ${operation}: the workspace is busy with an active remote operation.`); } } private requireBinding(): WorkspaceBinding { if (this.bindingValue === null) { throw new Error("No environment is attached to this workspace."); } return this.bindingValue; } private newBinding(environmentId: string): WorkspaceBinding { return { version: 1, sessionId: this.options.sessionId, endpoint: this.options.endpoint, atespace: this.options.atespace, environmentId, processIds: [], uncertain: false, }; } private isRestoreable(binding: WorkspaceBinding): boolean { return ( binding.version === 1 && binding.sessionId === this.options.sessionId && binding.endpoint === this.options.endpoint && binding.atespace === this.options.atespace && isIdentifier(binding.environmentId) && Array.isArray(binding.processIds) && binding.processIds.every(isIdentifier) && new Set(binding.processIds).size === binding.processIds.length && typeof binding.uncertain === "boolean" ); } private assertEnvironmentIdentity(environment: Environment, id: string): void { if (environment.id !== id || environment.atespace !== this.options.atespace) { throw new Error( "The remote service returned an environment outside this workspace's configured identity.", ); } } private assertProcessRequest(request: ProcessRequest): void { if (!Array.isArray(request.command) || request.command.length === 0) { throw new Error("A process command must contain at least one argument."); } if (!request.command.every((argument) => typeof argument === "string" && argument.length > 0)) { throw new Error("Every process command argument must be a non-empty string."); } } private assertIdentifier(value: string, description: string): void { if (!isIdentifier(value)) { throw new Error(`A valid ${description} is required.`); } } private assertPath(path: string): void { if (typeof path !== "string" || path.length === 0) { throw new Error("A non-empty workspace file path is required."); } } private assertNotDisposed(): void { if (this.disposed) { throw new Error("This workspace has been disposed."); } } private assertLive(): void { this.assertNotDisposed(); } private persistBinding(): void { const binding = this.binding; this.options.persist(binding); this.options.changed(binding); } private scheduleIdle(): void { this.clearIdleTimer(); if ( this.disposed || this.options.idleMs <= 0 || !Number.isFinite(this.options.idleMs) || this.bindingValue === null || this.activeOperations > 0 || this.transition || this.bindingValue.processIds.length > 0 || this.bindingValue.uncertain ) { return; } this.idleTimer = setTimeout(() => { this.idleTimer = undefined; void this.autoSuspend().catch(() => { this.notify( "Automatic workspace suspension failed; the environment remains attached.", "warning", ); }); }, this.options.idleMs); } private clearIdleTimer(): void { if (this.idleTimer !== undefined) { clearTimeout(this.idleTimer); this.idleTimer = undefined; } } private async autoSuspend(): Promise { if ( this.disposed || this.bindingValue === null || this.activeOperations > 0 || this.transition || this.bindingValue.processIds.length > 0 || this.bindingValue.uncertain ) { return; } try { await this.withTransition("automatically suspend the environment", async () => { const binding = this.requireBinding(); if (binding.processIds.length > 0 || binding.uncertain) { return; } await this.transport.suspend(binding.environmentId); this.assertLive(); this.persistBinding(); }, false); } catch (error) { if (!this.disposed) { this.notify( "Automatic workspace suspension failed; the environment remains attached.", "warning", ); } } } private notify(message: string, level: "info" | "warning" | "error"): void { try { this.options.notify(message, level); } catch { // Notifications must not surface as unhandled timer failures. } } private operationError(message: string, error: unknown): Error { if (this.disposed) { return new Error("This workspace was disposed while the remote operation was in progress."); } if (error instanceof Error && error.name === "AbortError") { return new Error(`${message}: the operation was cancelled.`); } return new Error(`${message}: the remote request failed.`); } } function copyBinding(binding: WorkspaceBinding): WorkspaceBinding { return { ...binding, processIds: [...binding.processIds] }; } function isIdentifier(value: unknown): value is string { return typeof value === "string" && value.length > 0; } function isTerminal(status: string): boolean { return TERMINAL_PROCESS_STATUSES[status] === true; }