feat(server): add modal workspace driver

This commit is contained in:
Kit Langton
2026-08-07 22:46:36 -04:00
parent 334b278547
commit ec0e5373e1
4 changed files with 211 additions and 13 deletions
+3
View File
@@ -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<AuthError, AuthServices>(
],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
[WorkspaceDriver.node, modalWorkspaceRegistryNode({ app: "opencode-workspaces" })],
]
const serviceLayer = options.simulation
? Layer.unwrap(
@@ -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 = <A>(run: () => Promise<A>) =>
Effect.tryPromise({ try: run, catch: (cause) => new WorkspaceDriver.Error({ cause }) })
const useClient = <A, E>(run: (client: ModalClient) => Effect.Effect<A, E>) =>
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: [],
})
+23 -13
View File
@@ -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
@@ -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,
)