From ec0e5373e1fa2380a6ba650542d5ec68ef8f99f2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 7 Aug 2026 22:46:36 -0400 Subject: [PATCH] feat(server): add modal workspace driver --- packages/server/src/routes.ts | 3 + .../server/src/workspace/modal-workspace.ts | 134 ++++++++++++++++++ packages/server/src/workspace/modal.ts | 36 +++-- packages/server/test/workspace-modal.test.ts | 51 +++++++ 4 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 packages/server/src/workspace/modal-workspace.ts create mode 100644 packages/server/test/workspace-modal.test.ts diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 8c65f5e6e9..4b94643189 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -28,6 +28,7 @@ import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { WellKnown } from "@opencode-ai/core/wellknown" +import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver" import { Watcher } from "@opencode-ai/core/filesystem/watcher" import { HttpRouter } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -43,6 +44,7 @@ import { formLocationLayer } from "./middleware/form-location" import { sessionLocationLayer } from "./middleware/session-location" import { ServerInfo } from "./server-info" import type { ServerOptions } from "./options" +import { modalWorkspaceRegistryNode } from "./workspace/modal-workspace" const applicationServices = LayerNode.group([ Database.node, @@ -115,6 +117,7 @@ function makeRoutes( ], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], + [WorkspaceDriver.node, modalWorkspaceRegistryNode({ app: "opencode-workspaces" })], ] const serviceLayer = options.simulation ? Layer.unwrap( diff --git a/packages/server/src/workspace/modal-workspace.ts b/packages/server/src/workspace/modal-workspace.ts new file mode 100644 index 0000000000..cfd2dd0f94 --- /dev/null +++ b/packages/server/src/workspace/modal-workspace.ts @@ -0,0 +1,134 @@ +import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver" +import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" +import { Effect, Layer } from "effect" +import type { Image, ModalClient, ModalClientParams, Sandbox } from "modal" +import { createModalSandboxWithClient, makeModalDriver, type ModalImageSpec } from "./modal" + +export interface ModalWorkspaceOptions { + readonly app: string + readonly client?: ModalClientParams + readonly image?: ModalImageSpec +} + +export const modalWorkspaceDriver = (options: ModalWorkspaceOptions): WorkspaceDriver.Interface => { + const name = (workspaceID: string) => `ws-${workspaceID}` + + const openClient = Effect.tryPromise({ + try: async () => { + const { ModalClient } = await import("modal") + return new ModalClient(options.client) + }, + catch: (cause) => new WorkspaceDriver.Error({ cause }), + }) + + const attempt = (run: () => Promise) => + Effect.tryPromise({ try: run, catch: (cause) => new WorkspaceDriver.Error({ cause }) }) + + const useClient = (run: (client: ModalClient) => Effect.Effect) => + Effect.acquireUseRelease(openClient, run, (client) => Effect.sync(() => client.close())) + + const findLive = async (client: ModalClient, binding: WorkspaceDriver.Binding, workspaceID?: string) => { + const { NotFoundError } = await import("modal") + if (typeof binding.sandboxId === "string") { + const sandbox = await client.sandboxes.fromId(binding.sandboxId).catch((error) => { + if (error instanceof NotFoundError) return undefined + throw error + }) + if (sandbox && (await sandbox.poll()) === null) return sandbox + } + if (!workspaceID || typeof binding.snapshotImageId === "string") return + const sandbox = await client.sandboxes.fromName(options.app, name(workspaceID)).catch((error) => { + if (error instanceof NotFoundError) return undefined + throw error + }) + if (sandbox && (await sandbox.poll()) === null) return sandbox + } + + const createSandbox = async (client: ModalClient, workspaceID: string, image?: Image) => { + const { AlreadyExistsError } = await import("modal") + return createModalSandboxWithClient( + client, + { + app: options.app, + client: options.client, + image: options.image, + sandbox: { + name: name(workspaceID), + tags: { workspace: workspaceID }, + timeoutMs: 24 * 60 * 60 * 1000, + }, + }, + image, + ).catch((error) => { + if (error instanceof AlreadyExistsError) return client.sandboxes.fromName(options.app, name(workspaceID)) + throw error + }) + } + + const deleteImage = (client: ModalClient, imageID: unknown) => + typeof imageID === "string" ? attempt(() => client.images.delete(imageID)).pipe(Effect.ignore) : Effect.void + + const terminate = (sandbox: Sandbox | undefined) => + sandbox ? attempt(() => sandbox.terminate({ wait: true })).pipe(Effect.ignore) : Effect.void + + return WorkspaceDriver.make({ + create: ({ workspaceID }) => + useClient((client) => + attempt(async () => { + const sandbox = await createSandbox(client, workspaceID) + return { binding: { sandboxId: sandbox.sandboxId } } + }), + ), + connect: ({ workspaceID, binding, saveBinding }) => + Effect.acquireRelease(openClient, (client) => Effect.sync(() => client.close())).pipe( + Effect.flatMap((client) => + Effect.gen(function* () { + const sandbox = yield* attempt(async () => { + const existing = await findLive(client, binding, workspaceID) + const image = + existing || typeof binding.snapshotImageId !== "string" + ? undefined + : await client.images.fromId(binding.snapshotImageId) + return existing ?? createSandbox(client, workspaceID, image) + }) + if (binding.sandboxId !== sandbox.sandboxId) { + yield* saveBinding({ + sandboxId: sandbox.sandboxId, + ...(typeof binding.snapshotImageId === "string" ? { snapshotImageId: binding.snapshotImageId } : {}), + }) + } + return makeModalDriver(sandbox) + }), + ), + ), + suspendForIdle: ({ binding, saveBinding }) => + useClient((client) => + Effect.gen(function* () { + const sandbox = yield* attempt(() => findLive(client, binding)) + if (!sandbox) return + const snapshot = yield* attempt(() => sandbox.snapshotFilesystem({ ttlMs: null })) + yield* saveBinding({ snapshotImageId: snapshot.imageId }) + yield* deleteImage(client, binding.snapshotImageId) + yield* terminate(sandbox) + }), + ), + destroy: ({ binding }) => + useClient((client) => + Effect.gen(function* () { + const sandbox = yield* attempt(() => findLive(client, binding)) + yield* terminate(sandbox) + yield* deleteImage(client, binding.snapshotImageId) + }), + ), + }) +} + +export const modalWorkspaceRegistryNode = (options: ModalWorkspaceOptions) => + makeGlobalNode({ + service: WorkspaceDriver.RegistryService, + layer: Layer.succeed( + WorkspaceDriver.RegistryService, + WorkspaceDriver.RegistryService.of(WorkspaceDriver.registry({ modal: modalWorkspaceDriver(options) })), + ), + deps: [], + }) diff --git a/packages/server/src/workspace/modal.ts b/packages/server/src/workspace/modal.ts index 1f01bbd32d..d525746c9c 100644 --- a/packages/server/src/workspace/modal.ts +++ b/packages/server/src/workspace/modal.ts @@ -3,7 +3,7 @@ import { systemError } from "effect/PlatformError" import type { Command, KillOptions } from "effect/unstable/process/ChildProcess" import { ExitCode, make, makeHandle, ProcessId } from "effect/unstable/process/ChildProcessSpawner" import type { Driver } from "@opencode-ai/core/environment" -import type { ModalClientParams, Sandbox, SandboxCreateParams } from "modal" +import type { Image, ModalClient, ModalClientParams, Sandbox, SandboxCreateParams } from "modal" const INNER_WRAPPER = ` pidfile=$1 @@ -66,18 +66,7 @@ export const ubuntuImage: ModalImageSpec = { export const createModalSandbox = async (options: ModalSandboxOptions) => { const { ModalClient } = await import("modal") const client = new ModalClient(options.client) - const app = await client.apps.fromName(options.app, { createIfMissing: true }) - const imageSpec = options.image ?? ubuntuImage - const image = client.images.fromRegistry(imageSpec.registry).dockerfileCommands([...imageSpec.dockerfileCommands]) - // Always Modal's Full-VM runtime (beta, enabled per account): a real kernel - // with real device nodes, so workspaces can run Docker and other - // kernel-dependent workloads. Costs versus gVisor, measured Aug 2026: - // per-exec floor ~285-535ms versus ~90-165ms, and filesystem snapshots only - // (no memory snapshots — acceptable; fs-snapshot is the persistence design). - const sandbox = await client.sandboxes.create(app, image, { - ...options.sandbox, - experimentalOptions: { ...options.sandbox?.experimentalOptions, vm_runtime: true }, - }) + const sandbox = await createModalSandboxWithClient(client, options) return { driver: makeModalDriver(sandbox), sandbox, @@ -85,6 +74,27 @@ export const createModalSandbox = async (options: ModalSandboxOptions) => { } } +export const createModalSandboxWithClient = async ( + client: ModalClient, + options: ModalSandboxOptions, + existingImage?: Image, +) => { + const app = await client.apps.fromName(options.app, { createIfMissing: true }) + const imageSpec = options.image ?? ubuntuImage + const image = + existingImage ?? + client.images.fromRegistry(imageSpec.registry).dockerfileCommands([...imageSpec.dockerfileCommands]) + // Always Modal's Full-VM runtime (beta, enabled per account): a real kernel + // with real device nodes, so workspaces can run Docker and other + // kernel-dependent workloads. Costs versus gVisor, measured Aug 2026: + // per-exec floor ~285-535ms versus ~90-165ms, and filesystem snapshots only + // (no memory snapshots — acceptable; fs-snapshot is the persistence design). + return client.sandboxes.create(app, image, { + ...options.sandbox, + experimentalOptions: { ...options.sandbox?.experimentalOptions, vm_runtime: true }, + }) +} + /** * Adapts Modal exec to the Environment driver. Files intentionally has no native * overrides: exec latency dominates payload work (VM runtime floor measured diff --git a/packages/server/test/workspace-modal.test.ts b/packages/server/test/workspace-modal.test.ts new file mode 100644 index 0000000000..6412b85db0 --- /dev/null +++ b/packages/server/test/workspace-modal.test.ts @@ -0,0 +1,51 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { expect, test } from "bun:test" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { makeFiles } from "@opencode-ai/core/environment" +import { Workspace } from "@opencode-ai/core/workspace" +import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver" +import { Effect, Layer } from "effect" +import { TestClock } from "effect/testing" +import { modalWorkspaceRegistryNode } from "../src/workspace/modal-workspace" + +const enabled = + !!process.env.OPENCODE_TEST_MODAL && + ((!!process.env.MODAL_TOKEN_ID && !!process.env.MODAL_TOKEN_SECRET) || + fs.existsSync(path.join(os.homedir(), ".modal.toml"))) + +const testLayer = Layer.provideMerge( + AppNodeBuilder.build(Workspace.configured({ idleThreshold: "1 minute", pollInterval: "1 minute" }), [ + [WorkspaceDriver.node, modalWorkspaceRegistryNode({ app: "opencode-workspace-tests" })], + ]), + TestClock.layer(), +) +const modalTest = enabled ? test : test.skip + +modalTest( + "wakes a workspace from its filesystem snapshot", + () => + Effect.runPromise( + Effect.gen(function* () { + const workspace = yield* Workspace.Service + yield* Effect.acquireUseRelease( + workspace.create("modal"), + (created) => + Effect.gen(function* () { + const environment = yield* workspace.connect(created.id) + const files = makeFiles(environment) + const file = `/tmp/opencode-workspace-${crypto.randomUUID()}.txt` + yield* files.write(file, new TextEncoder().encode("survived snapshot")) + + yield* TestClock.adjust("2 minutes") + + const restored = yield* files.read(file) + expect(new TextDecoder().decode(restored.bytes)).toBe("survived snapshot") + }), + (created) => workspace.destroy(created.id).pipe(Effect.ignore), + ) + }).pipe(Effect.scoped, Effect.provide(testLayer)), + ), + 180_000, +)