test(server): run hosted location graph live on modal
This commit is contained in:
@@ -119,7 +119,8 @@ const files = (sandbox: Sandbox): WorkspaceEnvironment.Files => {
|
||||
wrap("realPath", path, async () => {
|
||||
// Both streams piped: the SDK's "ignore" path needs ReadableStream.from,
|
||||
// which Bun does not implement.
|
||||
const process = await sandbox.exec(["realpath", "--", path], { stdout: "pipe", stderr: "pipe" })
|
||||
// -e: every component must exist, matching node fs.realpath semantics.
|
||||
const process = await sandbox.exec(["realpath", "-e", "--", path], { stdout: "pipe", stderr: "pipe" })
|
||||
const [output, code] = await Promise.all([process.stdout.readText(), process.wait()])
|
||||
if (code !== 0) throw new SandboxFilesystemNotFoundError(`realpath exited ${code}`)
|
||||
return output.trim()
|
||||
@@ -148,14 +149,16 @@ const files = (sandbox: Sandbox): WorkspaceEnvironment.Files => {
|
||||
}
|
||||
}
|
||||
|
||||
const spawnError = (method: string, options?: { description?: string; cause?: unknown }) =>
|
||||
systemError({ _tag: "Unknown", module: "ModalDriver", method, ...options })
|
||||
|
||||
const spawn = (sandbox: Sandbox) => {
|
||||
let pids = 0
|
||||
return (command: Command) => {
|
||||
return Effect.fnUntraced(function* (command: Command) {
|
||||
if (command._tag === "PipedCommand")
|
||||
return Effect.fail(
|
||||
systemError({ _tag: "Unknown", module: "ModalDriver", method: "spawn", description: "piped commands unsupported" }),
|
||||
)
|
||||
return Effect.tryPromise({
|
||||
return yield* Effect.fail(spawnError("spawn", { description: "piped commands unsupported" }))
|
||||
|
||||
const process = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
sandbox.exec([command.command, ...command.args], {
|
||||
mode: "binary",
|
||||
@@ -165,41 +168,37 @@ const spawn = (sandbox: Sandbox) => {
|
||||
env: compact(command.options.env),
|
||||
timeoutMs: EXEC_TIMEOUT_MS,
|
||||
}),
|
||||
catch: (cause) =>
|
||||
systemError({ _tag: "Unknown", module: "ModalDriver", method: "spawn", cause }),
|
||||
}).pipe(
|
||||
Effect.map((process) => {
|
||||
let exited = false
|
||||
let waited: Promise<number> | undefined
|
||||
const wait = () =>
|
||||
(waited ??= process.wait().then((code) => {
|
||||
exited = true
|
||||
return code
|
||||
}))
|
||||
const onError = (cause: unknown) =>
|
||||
systemError({ _tag: "Unknown", module: "ModalDriver", method: "process", cause })
|
||||
return makeHandle({
|
||||
pid: ProcessId(++pids),
|
||||
exitCode: Effect.tryPromise({ try: wait, catch: onError }).pipe(Effect.map(ExitCode)),
|
||||
isRunning: Effect.sync(() => !exited),
|
||||
// Modal has no per-process termination; EXEC_TIMEOUT_MS reaps orphans.
|
||||
kill: () => Effect.logWarning("modal cannot kill sandbox commands; relying on exec timeout"),
|
||||
stdin: Sink.forEach((chunk: Uint8Array) =>
|
||||
Effect.tryPromise({ try: () => process.stdin.writeBytes(chunk), catch: onError }),
|
||||
),
|
||||
stdout: Stream.fromReadableStream({ evaluate: () => process.stdout, onError }),
|
||||
stderr: Stream.fromReadableStream({ evaluate: () => process.stderr, onError }),
|
||||
all: Stream.merge(
|
||||
Stream.fromReadableStream({ evaluate: () => process.stdout, onError }),
|
||||
Stream.fromReadableStream({ evaluate: () => process.stderr, onError }),
|
||||
),
|
||||
getInputFd: () => Sink.fail(systemError({ _tag: "Unknown", module: "ModalDriver", method: "getInputFd", description: "unsupported" })),
|
||||
getOutputFd: () => Stream.fail(systemError({ _tag: "Unknown", module: "ModalDriver", method: "getOutputFd", description: "unsupported" })),
|
||||
unref: Effect.succeed(Effect.void),
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
catch: (cause) => spawnError("spawn", { cause }),
|
||||
})
|
||||
|
||||
let exited = false
|
||||
let waited: Promise<number> | undefined
|
||||
const wait = () =>
|
||||
(waited ??= process.wait().then((code) => {
|
||||
exited = true
|
||||
return code
|
||||
}))
|
||||
const onError = (cause: unknown) => spawnError("process", { cause })
|
||||
return makeHandle({
|
||||
pid: ProcessId(++pids),
|
||||
exitCode: Effect.tryPromise({ try: wait, catch: onError }).pipe(Effect.map(ExitCode)),
|
||||
isRunning: Effect.sync(() => !exited),
|
||||
// Modal has no per-process termination; EXEC_TIMEOUT_MS reaps orphans.
|
||||
kill: () => Effect.logWarning("modal cannot kill sandbox commands; relying on exec timeout"),
|
||||
stdin: Sink.forEach((chunk: Uint8Array) =>
|
||||
Effect.tryPromise({ try: () => process.stdin.writeBytes(chunk), catch: onError }),
|
||||
),
|
||||
stdout: Stream.fromReadableStream({ evaluate: () => process.stdout, onError }),
|
||||
stderr: Stream.fromReadableStream({ evaluate: () => process.stderr, onError }),
|
||||
all: Stream.merge(
|
||||
Stream.fromReadableStream({ evaluate: () => process.stdout, onError }),
|
||||
Stream.fromReadableStream({ evaluate: () => process.stderr, onError }),
|
||||
),
|
||||
getInputFd: () => Sink.fail(spawnError("getInputFd", { description: "unsupported" })),
|
||||
getOutputFd: () => Stream.fail(spawnError("getOutputFd", { description: "unsupported" })),
|
||||
unref: Effect.succeed(Effect.void),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const compact = (env: Record<string, string | undefined> | undefined) => {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { existsSync, mkdtempSync } from "fs"
|
||||
import { homedir, tmpdir } from "os"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { FileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema"
|
||||
import { Shell } from "@opencode-ai/core/shell"
|
||||
import { Workspace } from "@opencode-ai/core/workspace"
|
||||
import { WorkspaceDriver } from "@opencode-ai/core/workspace/driver"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { ServerWorkspaceDrivers } from "../src/workspace/drivers"
|
||||
import { ModalDriver } from "../src/workspace/modal"
|
||||
|
||||
const hasCredentials = process.env.MODAL_TOKEN_ID !== undefined || existsSync(path.join(homedir(), ".modal.toml"))
|
||||
|
||||
const databaseFile = path.join(mkdtempSync(path.join(tmpdir(), "opencode-modal-graph-")), "graph.db")
|
||||
|
||||
const layer = AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, Workspace.node, LocationServiceMap.node]),
|
||||
[
|
||||
[Database.node, Database.configured({ path: databaseFile })],
|
||||
[WorkspaceDriver.registryNode, ServerWorkspaceDrivers.node],
|
||||
],
|
||||
)
|
||||
|
||||
// The complete hosted stack against real Modal: workspace creation, the full
|
||||
// Location graph, file mutation, real bash in the sandbox, eviction, reconnect.
|
||||
describe.skipIf(!hasCredentials)("hosted location graph on modal (live)", () => {
|
||||
test(
|
||||
"runs the location graph against a modal sandbox",
|
||||
async () => {
|
||||
await Effect.gen(function* () {
|
||||
const workspaces = yield* Workspace.Service
|
||||
const created = yield* workspaces.create({ provider: "modal" })
|
||||
const driver = yield* ModalDriver.make
|
||||
const found = yield* workspaces.binding(created.id)
|
||||
yield* Effect.addFinalizer(() => Effect.ignore(driver.destroy(found.binding)))
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const ref = Location.Ref.make({
|
||||
directory: AbsolutePath.make(created.root),
|
||||
workspaceID: created.id,
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
expect(location.project.id).toBe(Project.ID.global)
|
||||
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const mutations = yield* FileMutation.Service
|
||||
const target = yield* mutation.resolve({ path: "hello.txt" })
|
||||
yield* mutations.write({ target, content: "hello from opencode\n" })
|
||||
|
||||
const shell = yield* Shell.Service
|
||||
const command = yield* shell.create({
|
||||
command: "cat hello.txt && printf 'bash-made' > bash.txt",
|
||||
timeout: 60_000,
|
||||
})
|
||||
const finished = yield* shell.wait(command.id)
|
||||
expect(finished.status).toBe("exited")
|
||||
const output = yield* shell.output(command.id)
|
||||
expect(output.output).toContain("hello from opencode")
|
||||
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const fromBash = yield* filesystem.read({ path: RelativePath.make("bash.txt") })
|
||||
expect(new TextDecoder().decode(fromBash.content)).toBe("bash-made")
|
||||
}).pipe(Effect.provide(locations.get(ref)))
|
||||
|
||||
yield* locations.invalidate(ref)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const hello = yield* filesystem.read({ path: RelativePath.make("hello.txt") })
|
||||
expect(new TextDecoder().decode(hello.content)).toBe("hello from opencode\n")
|
||||
}).pipe(Effect.provide(locations.get(ref)))
|
||||
}).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
},
|
||||
{ timeout: 600_000 },
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user