feat(server): add modal workspace driver
This commit is contained in:
@@ -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: [],
|
||||
})
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user