Compare commits

...
10 changed files with 1576 additions and 10 deletions
@@ -1,10 +1,10 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { Workspace } from "@opencode-ai/schema/workspace"
import { ProjectTable } from "../project/sql"
import { ProjectV2 } from "../project"
import { WorkspaceV2 } from "../workspace"
export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<WorkspaceV2.ID>().primaryKey(),
id: text().$type<Workspace.ID>().primaryKey(),
type: text().notNull(),
name: text().notNull().default(""),
branch: text(),
+6 -1
View File
@@ -16,6 +16,7 @@ import { Image } from "./image"
import { LocationWatcher } from "./filesystem/location-watcher"
import { Integration } from "./integration"
import { Location } from "./location"
import { WorkspaceEnvironment } from "./workspace/environment"
import { LocationMutation } from "./location-mutation"
import { LocationServiceMap } from "./location-service-map"
import { MCP } from "./mcp/index"
@@ -50,6 +51,7 @@ export { LocationServiceMap } from "./location-service-map"
const locationServiceNodes = [
Location.node,
WorkspaceEnvironment.node,
Config.node,
AgentV2.node,
CommandV2.node,
@@ -115,7 +117,10 @@ export function buildLocationServiceMap(
LayerMap.make(
(ref: Location.Ref) => {
const startedAt = performance.now()
const allReplacements = replacements.concat([[Location.node, Location.boundNode(ref)]])
const allReplacements = replacements.concat([
[Location.node, Location.boundNode(ref)],
[WorkspaceEnvironment.node, WorkspaceEnvironment.boundNode(ref)],
])
// Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice
+35 -4
View File
@@ -1,8 +1,10 @@
import { Context, Effect, Layer } from "effect"
import { Info, Ref, response } from "@opencode-ai/schema/location"
import path from "path"
import { Project } from "./project"
import { LayerNode } from "./effect/layer-node"
import { makeLocationNode, tags } from "./effect/app-node"
import { WorkspaceV2 } from "./workspace"
export * as Location from "./location"
@@ -16,7 +18,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
export const node = LayerNode.unbound(Service, tags.values.location)
const layer = (ref: Ref) =>
const localLayer = (ref: Ref) =>
Layer.effect(
Service,
Effect.gen(function* () {
@@ -31,9 +33,38 @@ const layer = (ref: Ref) =>
}),
)
export const boundNode = (ref: Ref) =>
makeLocationNode({
const hostedLayer = (ref: Ref & { readonly workspaceID: WorkspaceV2.ID }) =>
Layer.effect(
Service,
Effect.gen(function* () {
const workspace = yield* WorkspaceV2.Service
const info = yield* workspace.get(ref.workspaceID)
const relative = path.posix.relative(info.directory, ref.directory)
if (relative === ".." || relative.startsWith("../") || path.posix.isAbsolute(relative)) {
return yield* new WorkspaceV2.InvalidError({
id: ref.workspaceID,
message: `Location directory is outside Workspace root: ${ref.directory}`,
})
}
return Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: info.project,
})
}),
).pipe(Layer.orDie)
export const boundNode = (ref: Ref) => {
if (ref.workspaceID) {
return makeLocationNode({
service: Service,
layer: hostedLayer({ ...ref, workspaceID: ref.workspaceID }),
deps: [WorkspaceV2.node],
})
}
return makeLocationNode({
service: Service,
layer: layer(ref),
layer: localLayer(ref),
deps: [Project.node],
})
}
+269
View File
@@ -1,6 +1,275 @@
export * as WorkspaceV2 from "./workspace"
import { Context, Effect, Equal, Exit, Latch, Layer, RcMap, Schema, Scope, Semaphore } from "effect"
import { Workspace } from "@opencode-ai/schema/workspace"
import { eq } from "drizzle-orm"
import { Database } from "./database/database"
import { makeGlobalNode } from "./effect/app-node"
import { AbsolutePath } from "./schema"
import { WorkspaceTable } from "./control-plane/workspace.sql"
import { Sandbox } from "./workspace/sandbox"
import type { WorkspaceEnvironment } from "./workspace/environment"
export const ID = Workspace.ID
export type ID = typeof ID.Type
export const Info = Workspace.Info
export type Info = Workspace.Info
export interface CreateInput {
readonly provider: string
readonly name: string
readonly directory: AbsolutePath
readonly projectID: Info["project"]["id"]
}
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Workspace.NotFoundError", {
id: ID,
}) {}
export class InvalidError extends Schema.TaggedErrorClass<InvalidError>()("Workspace.InvalidError", {
id: ID,
message: Schema.String,
}) {}
export interface Interface {
readonly create: (input: CreateInput) => Effect.Effect<Info, Sandbox.Error | Sandbox.ProviderNotFoundError>
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError | InvalidError>
readonly connect: (
id: ID,
) => Effect.Effect<void, NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError>
readonly borrow: (
id: ID,
) => Effect.Effect<
WorkspaceEnvironment.Interface,
NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError,
Scope.Scope
>
readonly suspend: (
id: ID,
) => Effect.Effect<void, NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError>
readonly remove: (
id: ID,
) => Effect.Effect<void, NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Workspace") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const registry = yield* Sandbox.RegistryService
const lifecycle = yield* RcMap.make({
idleTimeToLive: 0,
lookup: () => Effect.succeed(makeLifecycleGate()),
})
const row = Effect.fn("Workspace.row")(function* (id: ID) {
const value = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get().pipe(Effect.orDie)
if (!value) return yield* new NotFoundError({ id })
if (!value.directory) return yield* new InvalidError({ id, message: "Workspace has no directory" })
return { ...value, directory: value.directory }
})
const get = Effect.fn("Workspace.get")(function* (id: ID) {
const value = yield* row(id)
const directory = AbsolutePath.make(value.directory)
return Info.make({
id,
name: value.name,
directory,
project: { id: value.project_id, directory },
})
})
const create = Effect.fn("Workspace.create")(function* (input: CreateInput) {
const id = ID.create()
const provider = yield* registry.get(input.provider)
return yield* Effect.acquireUseRelease(
provider.create({ identity: id }),
(binding) =>
db
.insert(WorkspaceTable)
.values({
id,
type: provider.key,
name: input.name,
directory: input.directory,
extra: Sandbox.Placement.make({ kind: "sandbox", version: 1, binding }),
project_id: input.projectID,
})
.run()
.pipe(
Effect.orDie,
Effect.as(
Info.make({
id,
name: input.name,
directory: input.directory,
project: { id: input.projectID, directory: input.directory },
}),
),
),
(binding, exit) => (Exit.isFailure(exit) ? Effect.uninterruptible(provider.delete(binding)) : Effect.void),
)
})
const persistBinding = Effect.fnUntraced(function* (id: ID, previous: Sandbox.Binding, next: Sandbox.Binding) {
if (Equal.equals(previous, next)) return
yield* db
.update(WorkspaceTable)
.set({ extra: Sandbox.Placement.make({ kind: "sandbox", version: 1, binding: next }) })
.where(eq(WorkspaceTable.id, id))
.run()
.pipe(Effect.orDie)
})
const placement = Effect.fnUntraced(function* (id: ID) {
const value = yield* row(id)
if (!Schema.is(Sandbox.Placement)(value.extra)) {
return yield* new InvalidError({ id, message: "Workspace has no sandbox binding" })
}
const provider = yield* registry.get(value.type)
return { provider, binding: yield* provider.decode(value.extra.binding) }
})
const reconcile = Effect.fnUntraced(function* (id: ID, provider: Sandbox.Provider, binding: Sandbox.Binding) {
const next = yield* provider.reconcile(binding)
yield* persistBinding(id, binding, next)
return next
})
const connections = yield* RcMap.make({
idleTimeToLive: "1 minute",
lookup: Effect.fn("Workspace.connect")(function* (id: ID) {
const current = yield* placement(id)
const provider = current.provider
const binding = current.binding
const connection = yield* provider.connect(binding)
yield* persistBinding(id, binding, connection.binding)
yield* reconcile(id, provider, connection.binding)
return { provider, connection }
}),
})
const lease = (id: ID) => RcMap.get(lifecycle, id).pipe(Effect.flatMap((gate) => gate.read))
const borrow = (id: ID) =>
lease(id).pipe(
Effect.andThen(RcMap.get(connections, id)),
Effect.onExit((exit) => (Exit.isFailure(exit) ? RcMap.invalidate(connections, id) : Effect.void)),
Effect.map((value) => value.connection.environment),
)
const transition = <A, E, R>(id: ID, effect: Effect.Effect<A, E, R>) =>
Effect.scoped(RcMap.get(lifecycle, id).pipe(Effect.flatMap((gate) => gate.write(effect))))
const suspend = Effect.fn("Workspace.suspend")((id: ID) =>
transition(
id,
Effect.scoped(
RcMap.get(connections, id).pipe(
Effect.flatMap((value) =>
Effect.gen(function* () {
const binding = yield* Effect.uninterruptible(
value.provider
.suspend(value.connection)
.pipe(Effect.tap((binding) => persistBinding(id, value.connection.binding, binding))),
)
yield* reconcile(id, value.provider, binding)
}),
),
),
).pipe(Effect.ensuring(RcMap.invalidate(connections, id))),
),
)
const remove = Effect.fn("Workspace.remove")((id: ID) =>
transition(
id,
Effect.gen(function* () {
yield* RcMap.invalidate(connections, id)
const current = yield* placement(id)
const binding = yield* reconcile(id, current.provider, current.binding)
yield* Effect.uninterruptible(
current.provider
.delete(binding)
.pipe(
Effect.andThen(db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run().pipe(Effect.orDie)),
),
)
}),
),
)
return Service.of({
create,
get,
connect: (id) => Effect.scoped(borrow(id)).pipe(Effect.asVoid),
borrow,
suspend,
remove,
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [Database.node, Sandbox.registryNode],
})
function makeLifecycleGate() {
const admission = Semaphore.makeUnsafe(1)
const transition = Semaphore.makeUnsafe(1)
const open = Latch.makeUnsafe(true)
const drained = Latch.makeUnsafe(true)
let active = 0
let blocked = false
const read: Effect.Effect<void, never, Scope.Scope> = Effect.suspend(() =>
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
yield* restore(open.await)
yield* restore(admission.take(1))
if (blocked) {
yield* admission.release(1)
return yield* restore(read)
}
active++
if (active === 1) drained.closeUnsafe()
const scope = yield* Scope.Scope
yield* Scope.addFinalizer(
scope,
Effect.sync(() => {
active--
if (active === 0) drained.openUnsafe()
}),
)
yield* admission.release(1)
}),
),
)
const write = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
transition.withPermit(
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
yield* restore(admission.take(1))
blocked = true
open.closeUnsafe()
yield* admission.release(1)
return yield* restore(drained.await.pipe(Effect.andThen(effect))).pipe(
Effect.ensuring(
Effect.sync(() => {
blocked = false
}).pipe(Effect.andThen(open.open)),
),
)
}),
),
)
return { read, write }
}
+271
View File
@@ -0,0 +1,271 @@
export * as WorkspaceEnvironment from "./environment"
import { Context, Effect, FileSystem, Layer, Schema } from "effect"
import { PlatformError, systemError } from "effect/PlatformError"
import { ChildProcessSpawner, make } from "effect/unstable/process/ChildProcessSpawner"
import { AppProcess } from "../process"
import { makeLocationNode, tags } from "../effect/app-node"
import { LayerNode } from "../effect/layer-node"
import { FSUtil } from "../fs-util"
import { KeyedMutex } from "../effect/keyed-mutex"
import { Location } from "../location"
import { RipgrepBinary } from "../ripgrep/binary"
import { ShellSelect } from "../shell/select"
import { WorkspaceV2 } from "../workspace"
import path from "path"
export class Error extends Schema.TaggedErrorClass<Error>()("WorkspaceEnvironment.Error", {
operation: Schema.String,
path: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect()),
}) {}
export class StaleContentError extends Schema.TaggedErrorClass<StaleContentError>()(
"WorkspaceEnvironment.StaleContentError",
{ path: Schema.String },
) {}
export interface FileInfo {
readonly type: FileSystem.File.Type
}
export interface ResolvedPath extends FileInfo {
readonly canonical: string
readonly directory: string
}
export interface DirectoryEntry {
readonly name: string
readonly type: "file" | "directory" | "symlink" | "other"
}
export interface FileBackend {
readonly inspect: (path: string) => Effect.Effect<FileInfo, Error>
readonly resolve: (path: string) => Effect.Effect<ResolvedPath, Error>
readonly read: (path: string) => Effect.Effect<Uint8Array, Error>
readonly list: (path: string) => Effect.Effect<readonly DirectoryEntry[], Error>
readonly ensureDirectory: (path: string) => Effect.Effect<void, Error>
readonly createExclusive: (path: string, content: Uint8Array) => Effect.Effect<void, Error>
readonly write: (path: string, content: Uint8Array) => Effect.Effect<void, Error>
readonly writeIfUnchanged: (
path: string,
expected: Uint8Array,
content: Uint8Array,
) => Effect.Effect<void, Error | StaleContentError>
readonly remove: (path: string) => Effect.Effect<void, Error>
}
export interface Shell {
readonly executable: string
readonly args: (command: string) => readonly string[]
readonly environmentOverrides: Readonly<Record<string, string>>
readonly detached: boolean
}
export interface Interface {
readonly platform: NodeJS.Platform
readonly directory: string
readonly files: FileBackend
readonly process: ChildProcessSpawner["Service"]
readonly shell: Shell
readonly ripgrep: Effect.Effect<string, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/WorkspaceEnvironment") {}
export const node = LayerNode.unbound(Service, tags.values.location)
const wrap = <A>(operation: string, path: string | undefined, effect: Effect.Effect<A, unknown>) =>
effect.pipe(Effect.mapError((cause) => new Error({ operation, path, cause })))
const sameBytes = (left: Uint8Array, right: Uint8Array) =>
left.length === right.length && left.every((byte, index) => byte === right[index])
const local = Effect.fnUntraced(function* (directory: string) {
const fs = yield* FSUtil.Service
const proc = yield* AppProcess.Service
const ripgrep = yield* RipgrepBinary.Service
const locks = KeyedMutex.makeUnsafe<string>()
const mutate = (path: string, effect: Effect.Effect<void, unknown>) =>
locks.withLock(path)(Effect.uninterruptible(effect))
const notFound = <A>(effect: Effect.Effect<A, FSUtil.Error>) =>
effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
const resolve = Effect.fn("WorkspaceEnvironment.resolve")(function* (absolute: string) {
const existing = yield* notFound(fs.realPath(absolute))
if (existing) {
const info = yield* fs.stat(existing)
return {
canonical: existing,
directory: info.type === "Directory" ? existing : path.dirname(existing),
type: info.type,
}
}
let anchor = path.dirname(absolute)
while (true) {
const canonical = yield* notFound(fs.realPath(anchor))
if (canonical) {
const info = yield* fs.stat(canonical)
if (info.type !== "Directory") {
return yield* new Error({ operation: "resolve", path: absolute, cause: "Non-directory ancestor" })
}
return {
canonical: path.resolve(canonical, path.relative(anchor, absolute)),
directory: canonical,
type: "Unknown" as const,
}
}
const parent = path.dirname(anchor)
if (parent === anchor) return yield* new Error({ operation: "resolve", path: absolute, cause: "No ancestor" })
anchor = parent
}
})
const files: FileBackend = {
inspect: (path) => wrap("inspect", path, fs.stat(path)),
resolve: (path) =>
resolve(path).pipe(
Effect.mapError((cause) => (cause instanceof Error ? cause : new Error({ operation: "resolve", path, cause }))),
),
read: (path) => wrap("read", path, fs.readFile(path)),
list: (path) => wrap("list", path, fs.readDirectoryEntries(path)),
ensureDirectory: (path) => wrap("ensureDirectory", path, fs.ensureDir(path)),
createExclusive: (path, content) =>
wrap("createExclusive", path, mutate(path, fs.writeFile(path, content, { flag: "wx" }))),
write: (path, content) => wrap("write", path, mutate(path, fs.writeFile(path, content))),
writeIfUnchanged: (path, expected, content) =>
mutate(
path,
Effect.gen(function* () {
const current = yield* fs.readFile(path)
if (!sameBytes(current, expected)) return yield* new StaleContentError({ path })
yield* fs.writeFile(path, content)
}),
).pipe(
Effect.mapError((cause) =>
cause instanceof StaleContentError ? cause : new Error({ operation: "writeIfUnchanged", path, cause }),
),
),
remove: (path) => wrap("remove", path, mutate(path, fs.remove(path))),
}
const executable = ShellSelect.preferred() ?? "/bin/sh"
return Service.of({
platform: process.platform,
directory,
files,
process: proc,
shell: {
executable,
args: (command) => ShellSelect.args(executable, command),
environmentOverrides: {
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
detached: process.platform !== "win32",
},
ripgrep: ripgrep.filepath.pipe(Effect.mapError((cause) => new Error({ operation: "ripgrep", cause }))),
})
})
const borrow = <A, E>(
workspace: WorkspaceV2.Interface,
id: WorkspaceV2.ID,
use: (environment: Interface) => Effect.Effect<A, E>,
) =>
Effect.scoped(
workspace.borrow(id).pipe(
Effect.mapError((cause) => new Error({ operation: "connect", cause })),
Effect.flatMap(use),
),
)
const contains = (root: string, target: string) => {
const relative = path.posix.relative(root, target)
return relative !== ".." && !relative.startsWith("../") && !path.posix.isAbsolute(relative)
}
const localLayer = (ref: Location.Ref) => Layer.effect(Service, local(ref.directory))
const hostedLayer = (ref: Location.Ref & { readonly workspaceID: WorkspaceV2.ID }) =>
Layer.effect(
Service,
Effect.gen(function* () {
const location = yield* Location.Service
const workspace = yield* WorkspaceV2.Service
const id = ref.workspaceID
const useFile = <A, E>(
target: string,
use: (files: FileBackend, resolved: ResolvedPath) => Effect.Effect<A, E>,
) =>
borrow(workspace, id, (environment) =>
Effect.gen(function* () {
const absolute = path.posix.resolve(location.directory, target)
const [root, resolved] = yield* Effect.all([
environment.files.resolve(location.project.directory),
environment.files.resolve(absolute),
])
if (!contains(root.canonical, resolved.canonical)) {
return yield* new Error({ operation: "containment", path: target })
}
return yield* use(environment.files, resolved)
}),
)
const files: FileBackend = {
inspect: (path) => useFile(path, (files, resolved) => files.inspect(resolved.canonical)),
resolve: (path) => useFile(path, (_files, resolved) => Effect.succeed(resolved)),
read: (path) => useFile(path, (files, resolved) => files.read(resolved.canonical)),
list: (path) => useFile(path, (files, resolved) => files.list(resolved.canonical)),
ensureDirectory: (path) => useFile(path, (files, resolved) => files.ensureDirectory(resolved.canonical)),
createExclusive: (path, content) =>
useFile(path, (files, resolved) => files.createExclusive(resolved.canonical, content)),
write: (path, content) => useFile(path, (files, resolved) => files.write(resolved.canonical, content)),
writeIfUnchanged: (path, expected, content) =>
useFile(path, (files, resolved) => files.writeIfUnchanged(resolved.canonical, expected, content)),
remove: (path) => useFile(path, (files, resolved) => files.remove(resolved.canonical)),
}
return Service.of({
platform: "linux",
directory: location.directory,
files,
process: make((command) =>
workspace.borrow(id).pipe(
Effect.flatMap((environment) => environment.process.spawn(command)),
Effect.mapError((cause) =>
cause instanceof PlatformError
? cause
: systemError({
_tag: "Unknown",
module: "WorkspaceEnvironment",
method: "spawn",
cause,
}),
),
),
),
shell: {
executable: "/bin/sh",
args: (command) => ["-c", command],
environmentOverrides: {
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
},
detached: false,
},
ripgrep: borrow(workspace, id, (environment) => environment.ripgrep),
})
}),
)
export const boundNode = (ref: Location.Ref) => {
if (ref.workspaceID) {
return makeLocationNode({
service: Service,
layer: hostedLayer({ ...ref, workspaceID: ref.workspaceID }),
deps: [Location.node, WorkspaceV2.node],
})
}
return makeLocationNode({
service: Service,
layer: localLayer(ref),
deps: [FSUtil.node, AppProcess.node, RipgrepBinary.node],
})
}
+77
View File
@@ -0,0 +1,77 @@
export * as Sandbox from "./sandbox"
import { Context, Effect, Layer, Schema, Scope } from "effect"
import { makeGlobalNode } from "../effect/app-node"
import type { WorkspaceEnvironment } from "./environment"
export const Binding = Schema.Record(Schema.String, Schema.Json).annotate({ identifier: "Sandbox.Binding" })
export type Binding = typeof Binding.Type
export const Placement = Schema.Struct({
kind: Schema.Literal("sandbox"),
version: Schema.Literal(1),
binding: Binding,
}).annotate({ identifier: "Sandbox.Placement" })
export type Placement = typeof Placement.Type
export class Error extends Schema.TaggedErrorClass<Error>()("Sandbox.Error", {
provider: Schema.String,
operation: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
export interface Connection {
readonly binding: Binding
readonly environment: WorkspaceEnvironment.Interface
}
export interface CreateInput {
readonly identity: string
}
export interface Provider {
readonly key: string
readonly create: (input: CreateInput) => Effect.Effect<Binding, Error>
readonly decode: (binding: Binding) => Effect.Effect<Binding, Error>
readonly connect: (binding: Binding) => Effect.Effect<Connection, Error, Scope.Scope>
readonly suspend: (connection: Connection) => Effect.Effect<Binding, Error>
readonly reconcile: (binding: Binding) => Effect.Effect<Binding, Error>
readonly delete: (binding: Binding) => Effect.Effect<void, Error>
}
export class DuplicateProviderError extends Schema.TaggedErrorClass<DuplicateProviderError>()(
"Sandbox.DuplicateProviderError",
{ provider: Schema.String },
) {}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"Sandbox.ProviderNotFoundError",
{ provider: Schema.String },
) {}
export interface Registry {
readonly register: (provider: Provider) => Effect.Effect<void, DuplicateProviderError, Scope.Scope>
readonly get: (key: string) => Effect.Effect<Provider, ProviderNotFoundError>
}
export class RegistryService extends Context.Service<RegistryService, Registry>()("@opencode/SandboxRegistry") {}
export const registryLayer = Layer.sync(RegistryService, () => {
const providers = new Map<string, Provider>()
return RegistryService.of({
register: (provider) =>
Effect.acquireRelease(
Effect.sync(() => {
if (providers.has(provider.key)) return new DuplicateProviderError({ provider: provider.key })
providers.set(provider.key, provider)
}).pipe(Effect.flatMap((error) => (error ? Effect.fail(error) : Effect.void))),
() => Effect.sync(() => providers.delete(provider.key)),
),
get: (key) => {
const provider = providers.get(key)
return provider ? Effect.succeed(provider) : Effect.fail(new ProviderNotFoundError({ provider: key }))
},
})
})
export const registryNode = makeGlobalNode({ service: RegistryService, layer: registryLayer, deps: [] })
+126 -3
View File
@@ -1,14 +1,20 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import fs from "fs/promises"
import path from "path"
import { Effect, Exit, Layer } from "effect"
import { make } from "effect/unstable/process/ChildProcessSpawner"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const workspaceID = WorkspaceV2.ID.make("wrk_test")
const ref = { directory: AbsolutePath.make("/repo/packages/app"), workspaceID }
const ref = { directory: AbsolutePath.make("/repo/packages/app") }
const projectLayer = Layer.succeed(
Project.Service,
Project.Service.of({
@@ -31,7 +37,7 @@ describe("Location", () => {
const location = yield* Location.Service
expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app"))
expect(location.workspaceID).toBe(workspaceID)
expect(location.workspaceID).toBeUndefined()
expect(location.project.id).toBe(Project.ID.make("project"))
expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
expect(location.vcs).toEqual({
@@ -40,4 +46,121 @@ describe("Location", () => {
})
}),
)
it.live("resolves hosted metadata without reading the host path", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const directory = AbsolutePath.make(path.join(tmp.path, "hosted-checkout"))
const connections = { count: 0 }
const reads = { count: 0 }
const unsupported = () => Effect.die("Unsupported fake environment operation")
const providerEnvironment = WorkspaceEnvironment.Service.of({
platform: "linux",
directory,
process: make(() => unsupported()),
shell: {
executable: "/bin/sh",
args: (command) => ["-c", command],
environmentOverrides: {},
detached: false,
},
ripgrep: Effect.succeed("/usr/bin/rg"),
files: {
resolve: (target) =>
Effect.succeed({
canonical: target.includes("symlink") ? "/outside/secret" : target,
directory: path.posix.dirname(target),
type: "File",
}),
inspect: unsupported,
read: () =>
Effect.sync(() => {
reads.count++
return new Uint8Array([1])
}),
list: unsupported,
ensureDirectory: unsupported,
createExclusive: unsupported,
write: unsupported,
writeIfUnchanged: unsupported,
remove: unsupported,
},
})
const workspaceLayer = Layer.succeed(
WorkspaceV2.Service,
WorkspaceV2.Service.of({
create: () => Effect.die("Unsupported fake Workspace operation"),
get: () =>
Effect.succeed(
WorkspaceV2.Info.make({
id: workspaceID,
name: "Hosted",
directory,
project: {
id: Project.ID.make("hosted-project"),
directory,
},
}),
),
borrow: () =>
Effect.sync(() => {
connections.count++
return providerEnvironment
}),
connect: () => Effect.die("Unsupported fake Workspace operation"),
suspend: () => Effect.die("Unsupported fake Workspace operation"),
remove: () => Effect.die("Unsupported fake Workspace operation"),
}),
)
const hostedRef = { directory, workspaceID }
const layer = AppNodeBuilder.build(LayerNode.group([Location.node, WorkspaceEnvironment.node]), [
[Location.node, Location.boundNode(hostedRef)],
[WorkspaceEnvironment.node, WorkspaceEnvironment.boundNode(hostedRef)],
[WorkspaceV2.node, workspaceLayer],
])
const invalidLayer = AppNodeBuilder.build(
Location.boundNode({ directory: AbsolutePath.make(path.join(tmp.path, "outside")), workspaceID }),
[[WorkspaceV2.node, workspaceLayer]],
)
return Effect.gen(function* () {
expect(
yield* Effect.promise(() =>
fs.stat(directory).then(
() => true,
() => false,
),
),
).toBe(false)
const location = yield* Location.Service
const environment = yield* WorkspaceEnvironment.Service
expect(location.directory).toBe(directory)
expect(location.workspaceID).toBe(workspaceID)
expect(location.project).toEqual({
id: Project.ID.make("hosted-project"),
directory,
})
expect(environment.directory).toBe(directory)
expect(environment.platform).toBe("linux")
expect(connections.count).toBe(0)
expect(yield* environment.files.read(path.posix.join(directory, "file.txt"))).toEqual(new Uint8Array([1]))
expect(connections.count).toBe(1)
expect(reads.count).toBe(1)
const outsideFile = yield* environment.files.read("/outside/secret").pipe(Effect.flip)
expect(outsideFile.operation).toBe("containment")
const symlink = yield* environment.files.read(path.posix.join(directory, "symlink")).pipe(Effect.flip)
expect(symlink.operation).toBe("containment")
expect(reads.count).toBe(1)
const invalid = yield* Location.Service.pipe(Effect.provide(invalidLayer), Effect.exit)
expect(Exit.isFailure(invalid)).toBe(true)
}).pipe(Effect.provide(layer))
}),
),
)
})
+275
View File
@@ -0,0 +1,275 @@
import { describe, expect } from "bun:test"
import { Deferred, Effect, Exit, Fiber, Scope } from "effect"
import { adjust } from "effect/testing/TestClock"
import { eq } from "drizzle-orm"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Database } from "@opencode-ai/core/database/database"
import { AppProcess } from "@opencode-ai/core/process"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
import { Sandbox } from "@opencode-ai/core/workspace/sandbox"
import { WorkspaceEnvironment } from "@opencode-ai/core/workspace/environment"
import { testEffect } from "./lib/effect"
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Database.node, Sandbox.registryNode, WorkspaceV2.node, AppProcess.node])),
)
describe("WorkspaceV2", () => {
it.effect("loads metadata without connecting and shares a scoped connection", () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const process = yield* AppProcess.Service
const registry = yield* Sandbox.RegistryService
const workspace = yield* WorkspaceV2.Service
const id = WorkspaceV2.ID.make("wrk_hosted")
const projectID = Project.ID.make("hosted-project")
const directory = AbsolutePath.make("/workspace/repo")
const lifecycle = {
created: [] as string[],
connected: [] as Sandbox.Binding[],
reconciled: 0,
released: 0,
suspended: 0,
deleted: [] as Sandbox.Binding[],
failDelete: false,
}
const suspendStarted = yield* Deferred.make<void>()
const finishSuspend = yield* Deferred.make<void>()
const unsupported = (operation: string) => Effect.fail(new WorkspaceEnvironment.Error({ operation }))
const environment = WorkspaceEnvironment.Service.of({
platform: "linux",
directory,
process,
shell: {
executable: "/bin/sh",
args: (command) => ["-c", command],
environmentOverrides: {},
detached: false,
},
ripgrep: Effect.succeed("/usr/bin/rg"),
files: {
inspect: () => unsupported("inspect"),
resolve: () => unsupported("resolve"),
read: () => unsupported("read"),
list: () => unsupported("list"),
ensureDirectory: () => unsupported("ensureDirectory"),
createExclusive: () => unsupported("createExclusive"),
write: () => unsupported("write"),
writeIfUnchanged: () => unsupported("writeIfUnchanged"),
remove: () => unsupported("remove"),
},
})
yield* db
.insert(ProjectTable)
.values({
id: projectID,
worktree: directory,
sandboxes: [],
time_created: 1,
time_updated: 1,
})
.run()
yield* db
.insert(WorkspaceTable)
.values({
id,
type: "fake",
name: "Hosted",
directory,
extra: { kind: "sandbox", version: 1, binding: { sandbox: "one" } },
project_id: projectID,
time_used: 1,
})
.run()
yield* registry.register({
key: "fake",
create: (input) =>
Effect.sync(() => {
lifecycle.created.push(input.identity)
return { sandbox: input.identity }
}),
decode: Effect.succeed,
connect: (binding) =>
Effect.acquireRelease(
Effect.sync(() => {
lifecycle.connected.push(binding)
return { binding: { sandbox: "live", retired: "one" }, environment }
}),
() => Effect.sync(() => lifecycle.released++),
),
suspend: () =>
Deferred.succeed(suspendStarted, undefined).pipe(
Effect.andThen(Deferred.await(finishSuspend)),
Effect.tap(() => Effect.sync(() => lifecycle.suspended++)),
Effect.as({ snapshot: "snap-one", retired: "live" }),
),
reconcile: (binding) =>
Effect.sync(() => {
lifecycle.reconciled++
const next: Sandbox.Binding = "snapshot" in binding ? { snapshot: binding.snapshot } : { sandbox: "live" }
return next
}),
delete: (binding) => {
if (lifecycle.failDelete) {
return Effect.fail(new Sandbox.Error({ provider: "fake", operation: "delete" }))
}
return Effect.sync(() => lifecycle.deleted.push(binding))
},
})
expect(yield* workspace.get(id)).toEqual({
id,
name: "Hosted",
directory,
project: { id: projectID, directory },
})
expect(lifecycle.connected).toHaveLength(0)
const borrowed = yield* Effect.all([workspace.borrow(id), workspace.borrow(id)]).pipe(Effect.scoped)
expect(borrowed[0]).toBe(environment)
expect(borrowed[1]).toBe(environment)
expect(lifecycle.connected).toHaveLength(1)
expect(lifecycle.reconciled).toBe(1)
expect(lifecycle.released).toBe(0)
const placement = yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get()
expect(placement?.extra).toEqual({
kind: "sandbox",
version: 1,
binding: { sandbox: "live" },
})
yield* adjust("1 minute")
yield* Effect.yieldNow
expect(lifecycle.released).toBe(1)
const invalidID = WorkspaceV2.ID.make("wrk_invalid")
yield* db
.insert(WorkspaceTable)
.values({
id: invalidID,
type: "fake",
name: "Legacy",
directory,
extra: { sandbox: "legacy-adapter-state" },
project_id: projectID,
time_used: 1,
})
.run()
const invalid = yield* workspace.borrow(invalidID).pipe(Effect.scoped, Effect.flip)
expect(invalid._tag).toBe("Workspace.InvalidError")
expect(lifecycle.connected).toHaveLength(1)
const retryID = WorkspaceV2.ID.make("wrk_retry")
const retry = { attempts: 0 }
yield* db
.insert(WorkspaceTable)
.values({
id: retryID,
type: "flaky",
name: "Retry",
directory,
extra: { kind: "sandbox", version: 1, binding: { sandbox: "retry" } },
project_id: projectID,
time_used: 1,
})
.run()
yield* registry.register({
key: "flaky",
create: () => Effect.die("Unexpected create"),
decode: Effect.succeed,
connect: (binding) =>
Effect.sync(() => ++retry.attempts).pipe(
Effect.flatMap((attempt) =>
attempt === 1 ? Effect.die("Transient provider defect") : Effect.succeed({ binding, environment }),
),
),
suspend: () => Effect.die("Unexpected suspend"),
reconcile: Effect.succeed,
delete: () => Effect.die("Unexpected delete"),
})
expect(Exit.isFailure(yield* workspace.borrow(retryID).pipe(Effect.scoped, Effect.exit))).toBe(true)
expect(yield* workspace.borrow(retryID).pipe(Effect.scoped)).toBe(environment)
expect(retry.attempts).toBe(2)
const created = yield* workspace.create({
provider: "fake",
name: "Created",
directory,
projectID,
})
expect(created).toEqual({
id: created.id,
name: "Created",
directory,
project: { id: projectID, directory },
})
expect(lifecycle.created.at(-1)).toBe(created.id)
const failedCreate = yield* workspace
.create({
provider: "fake",
name: "Invalid project",
directory,
projectID: Project.ID.make("missing-project"),
})
.pipe(Effect.exit)
expect(Exit.isFailure(failedCreate)).toBe(true)
const failedIdentity = lifecycle.created.at(-1)
if (!failedIdentity) return yield* Effect.die("Missing failed create identity")
expect(lifecycle.deleted).toContainEqual({ sandbox: failedIdentity })
const active = yield* Scope.make()
yield* workspace.borrow(id).pipe(Scope.provide(active))
const suspendInvoked = yield* Deferred.make<void>()
const suspendFiber = yield* Deferred.succeed(suspendInvoked, undefined).pipe(
Effect.andThen(workspace.suspend(id)),
Effect.forkChild,
)
yield* Deferred.await(suspendInvoked)
yield* Effect.yieldNow
expect(yield* Deferred.isDone(suspendStarted)).toBe(false)
const admitted = yield* Deferred.make<void>()
const borrowFiber = yield* workspace.borrow(id).pipe(
Effect.tap(() => Deferred.succeed(admitted, undefined)),
Effect.scoped,
Effect.forkChild,
)
yield* Effect.yieldNow
expect(yield* Deferred.isDone(admitted)).toBe(false)
yield* Scope.close(active, Exit.void)
yield* Deferred.await(suspendStarted)
expect(yield* Deferred.isDone(admitted)).toBe(false)
yield* Deferred.succeed(finishSuspend, undefined)
yield* Fiber.join(suspendFiber)
yield* Fiber.join(borrowFiber)
expect(lifecycle.suspended).toBe(1)
expect(lifecycle.connected).toContainEqual({ snapshot: "snap-one" })
expect((yield* db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())?.extra).toEqual({
kind: "sandbox",
version: 1,
binding: { sandbox: "live" },
})
lifecycle.failDelete = true
const deleteError = yield* workspace.remove(created.id).pipe(Effect.flip)
expect(deleteError._tag).toBe("Sandbox.Error")
if (deleteError._tag !== "Sandbox.Error") return yield* Effect.die("Unexpected delete error")
expect(deleteError.operation).toBe("delete")
expect((yield* workspace.get(created.id)).id).toBe(created.id)
lifecycle.failDelete = false
yield* workspace.remove(created.id)
expect((yield* workspace.get(created.id).pipe(Effect.flip))._tag).toBe("Workspace.NotFoundError")
expect(lifecycle.deleted).toContainEqual({ sandbox: "live" })
}),
)
})
+11
View File
@@ -2,8 +2,19 @@ export * as Workspace from "./workspace.js"
import { WorkspaceEvent } from "./workspace-event.js"
import { WorkspaceID } from "./workspace-id.js"
import { Project } from "./project.js"
import { AbsolutePath } from "./schema.js"
import { Schema } from "effect"
export const ID = WorkspaceID
export type ID = WorkspaceID
export const Info = Schema.Struct({
id: ID,
name: Schema.String,
directory: AbsolutePath,
project: Project.Current,
}).annotate({ identifier: "Workspace.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Event = WorkspaceEvent
+504
View File
@@ -0,0 +1,504 @@
# Remote Workspace Execution
Status: implementation plan
Tracking: Organizer item `2ff92129`
## Goal
Prove that one OpenCode V2 Session can operate on a real hosted development
environment whose checkout does not exist on the OpenCode server host.
The first slice must support:
- one stable Workspace identity referenced by a Session Location;
- one provider-backed sandbox for that Workspace;
- reconnecting to the existing provider sandbox;
- reading, writing, editing, globbing, and grepping Workspace files;
- running and interrupting foreground shell commands;
- retaining Workspace files across provider suspension and reconnection;
- local Locations continuing to behave exactly as they do today.
The design must permit Modal, Daytona, and E2B implementations without placing
provider concepts in Session, Location, or tool APIs.
## Non-Goals
The first slice does not include:
- Slack integration or other product surfaces;
- background or detached commands;
- PTY sessions;
- filesystem watches;
- user-visible snapshots, Session revert, or Workspace forks;
- concurrent mutating Sessions in one Workspace;
- provider migration;
- arbitrary plugin code transparently using the remote filesystem;
- post-crash retry of ambiguous provider or tool work;
- running the Session runner inside the sandbox;
- clustering Session execution.
## Domain Model
### Session
A Session owns conversation and agent execution history. The Session runner,
LLM calls, tool registry, permissions, durable events, and tool settlement stay
in the central OpenCode server.
### Workspace
A Workspace is the stable project environment referenced by
`Location.workspaceID`. It may be the primary local checkout, a local git
worktree, or a provider-backed hosted environment.
Workspace identity is orthogonal to Session identity. The first product slice
may create one primary Session per Workspace, but this is policy rather than an
identity invariant.
The Workspace record stores placement metadata. It does not mirror or
synchronize files, dependencies, or Git state. The environment filesystem is
authoritative.
### Provider Sandbox
A provider sandbox is implementation machinery backing a hosted Workspace. Its
provider identifier and lifecycle state belong to the Workspace placement, not
to Session or Location.
The Workspace ID remains stable when the provider reconnects, resumes, or
recreates its compute from retained state.
## API Shape
Keep provider lifecycle separate from environment capabilities.
```ts
interface SandboxProvider {
readonly create: (input: CreateInput) => Effect.Effect<SandboxBinding, CreateError>
readonly connect: (binding: SandboxBinding) => Effect.Effect<SandboxConnection, ConnectError, Scope.Scope>
readonly suspend: (connection: SandboxConnection) => Effect.Effect<SandboxBinding, SuspendError>
readonly reconcile: (binding: SandboxBinding) => Effect.Effect<SandboxBinding, ReconcileError>
readonly delete: (binding: SandboxBinding) => Effect.Effect<void, DeleteError>
}
interface SandboxConnection {
readonly binding: SandboxBinding
readonly environment: SandboxEnvironment
}
```
`SandboxBinding` is opaque provider state persisted with the Workspace. Core
does not inspect it beyond selecting the provider that owns it. Persisted
binding data uses provider-specific Effect Schemas whose encoded form is
`Schema.Json`, not `unknown`. Both `connect` and `suspend` may replace the
binding. This is necessary for providers such as Modal where restoring a
filesystem snapshot creates a new provider Sandbox ID.
A replacement binding must remain reconnectable and include enough state to
identify any retired provider resources. Workspace persists it before exposing
a new connection or destroying the previous durable resource, then calls the
provider's idempotent `reconcile` and persists the simplified binding it
returns. A crash at either boundary therefore leaves a valid binding whose
cleanup can resume on the next lifecycle operation rather than an
undiscoverable sandbox or snapshot. Creation compensates by deleting provider
state if the initial Workspace write fails. `CreateInput` carries a
caller-generated stable provider identity derived from the Workspace ID, and
provider creation is idempotent or discoverable by that identity. Workspace
uses interruption-masked acquire/use/release around allocation and initial
persistence, so interruption after the binding becomes known either commits
the Workspace or deletes the allocation. Automatic retry of ambiguous command
work remains out of scope.
```ts
interface SandboxEnvironment {
readonly platform: "linux"
readonly directory: string
readonly files: WorkspaceFileBackend
readonly process: ChildProcessSpawner["Service"]
readonly shell: WorkspaceShell
readonly ripgrep: Effect.Effect<string, UnsupportedCapabilityError>
}
interface WorkspaceShell {
readonly executable: string
readonly args: (command: string) => readonly string[]
readonly environmentOverrides: Readonly<Record<string, string>>
readonly detached: boolean
}
```
Core exposes these capabilities through a Location-scoped
`WorkspaceEnvironment.Service`. The process-global Workspace service owns one
connection manager per Workspace ID. Its Effect `RcRef` acquires a scoped
`SandboxConnection` lazily, so constructing or inspecting a Location does not
wake provider compute. Each Workspace operation borrows the connection in a
scope; concurrent operations share it, and an idle TTL releases the provider
client after the final borrower without suspending compute or discarding the
durable binding.
The manager also owns a lifecycle gate and active-lease count. Suspension
blocks new leases, waits for current leases to close, performs and persists the
provider transition, invalidates the `RcRef`, then reopens the gate. Thus a
suspend cannot run under an active command or file operation, and an old
connection cannot be handed to a new operation after a binding transition.
The provider filesystem boundary contains only primitives needed beneath the
existing Core policy services:
```ts
interface WorkspaceFileBackend {
readonly inspect: (path: string) => Effect.Effect<FileInfo, FileError>
readonly resolve: (path: string) => Effect.Effect<ResolvedPath, FileError>
readonly read: (path: string) => Effect.Effect<Uint8Array, FileError>
readonly list: (path: string) => Effect.Effect<readonly DirectoryEntry[], FileError>
readonly ensureDirectory: (path: string) => Effect.Effect<void, FileError>
readonly createExclusive: (path: string, content: Uint8Array) => Effect.Effect<void, FileError>
readonly write: (path: string, content: Uint8Array) => Effect.Effect<void, FileError>
readonly writeIfUnchanged: (
path: string,
expected: Uint8Array,
content: Uint8Array,
) => Effect.Effect<void, FileError | StaleContentError>
readonly remove: (path: string) => Effect.Effect<void, FileError>
}
```
`writeIfUnchanged` is one provider-side operation. A remote implementation must
not emulate it with an unlocked client-side read followed by write.
`FileMutation` remains the owner of create/write/remove semantics, stale-edit
errors, parent-directory creation, result metadata, BOM handling, and
OpenCode-side mutation ordering. `resolve` returns a provider-canonical path
for an existing target or through the nearest existing ancestor for a new
target. `LocationMutation` uses it to retain symlink-safe containment.
Provider process transports adapt native command handles to Effect's scoped
`ChildProcessSpawner` contract. Scope finalization interrupts an unfinished
provider process. Existing `AppProcess` and `Shell` continue to own timeout,
abort, bounded output, streaming, command IDs, and settlement, but `Shell` must
take executable, arguments, environment, and detached policy from
`WorkspaceShell` instead of `process.env` or `process.platform`. Hosted policy
preserves the provider's baseline environment and adds only explicit safe
OpenCode overrides; it never copies the server environment.
Foreground `Shell` acquisition is scoped. It uses `Effect.acquireRelease`
around process creation so the interrupt finalizer is installed atomically
before acquisition completes, closing the current create-to-`onInterrupt`
window. The first hosted slice rejects background requests. Existing `Ripgrep`
continues to own command construction, bounded JSON parsing, result schemas,
and invalid-pattern behavior; the environment supplies process transport and
the remote ripgrep executable path.
OpenCode services expose three distinct lifecycle operations:
```ts
Workspace.get(id) // Read metadata without waking compute.
Workspace.connect(id) // Ensure usable compute through a short-lived lease.
Workspace.remove(id) // Delete provider state and the Workspace record.
```
Core also has an internal
`Workspace.borrow(id): Effect<SandboxEnvironment, WorkspaceError, Scope.Scope>`
operation used by the Location environment. File operations scope the borrow
to one operation. A spawned process holds its borrow in the caller's process
scope until the handle exits or is finalized. Raw provider connections and
bindings are not public API.
## Placement
`Location.workspaceID` remains the only Session-to-Workspace reference.
```text
Location without workspaceID
-> current implicit-local Location graph
Location with workspaceID
-> load Workspace
-> lexically normalize its directory against Workspace root metadata
-> build a lazy Location graph
-> borrow the Workspace environment only when an operation runs
```
The provider sandbox ID never appears in a Session row or durable Session
event.
The existing experimental Workspace adapter API is not preserved. It mixes
configuration, provisioning, discovery, lifecycle, and request routing. The
replacement should be derived from the lifecycle and environment interfaces
above.
## Host And Workspace Authority
Remote execution forces a split that is currently implicit in `FSUtil`:
- host authority owns OpenCode global configuration, caches, credentials,
durable events, and managed tool output;
- Workspace authority owns repository files, project instructions, project
configuration, project skills, Git state, search, and commands.
Do not implement the permanent remote boundary by emulating Effect's complete
low-level `FileSystem.FileSystem` interface. Migrate Location-owned consumers
to the Workspace file backend, or compose existing policy services over that
backend and the environment's `ChildProcessSpawner`. Host-global consumers
continue using the host filesystem.
## Provider Strategy
Build one provider-neutral contract and one real reference provider.
Vercel is the recommended first adapter because the tracer has verified the
complete lifecycle against an available account. Its stable persistent name,
automatic snapshot-on-stop, direct filesystem API, and reconnectable command
handles map closely to the proposed contract.
Daytona is the next comparison because its public API directly supports
filesystem operations, command sessions, interruption, and durable stop/pause
semantics. Modal follows with an intentionally different binding transition:
suspension snapshots the filesystem and reconnection creates a new Sandbox.
Its process handles do not expose per-process termination, so an adapter needs
an explicit provider-side mechanism that terminates a command and its children;
client fiber interruption alone is insufficient.
E2B can provide a fourth validation through pause/resume if the first three
leave meaningful ambiguity.
No in-sandbox OpenCode worker is required for the first Daytona/E2B/Modal
slice. Their SDKs can implement the small environment contract directly. Add a
provider-neutral worker later only when supporting providers such as exe.dev,
or when detached process reconnection and richer runtime semantics justify it.
## Implementation Sequence
The first integration PR establishes the narrow seam needed by stages 1
through 4:
- add provider-neutral file, process, environment, connection, and binding
reconciliation contracts;
- add the local environment and a test-only fake hosted provider;
- add browser-safe Workspace metadata while keeping provider placement private
in the existing experimental row;
- add a scoped, idle-expiring Workspace connection cache and Location-scoped
`WorkspaceEnvironment.Service`;
- make `Location.boundNode` resolve hosted project/root metadata without
`Project.resolve` or any host-path access; and
- prove lazy connection and scoped release using a hosted directory that does
not exist on the test host.
It does not add Vercel, lifecycle mutation, or migrate existing tools. Its
purpose is to establish the placement seam and prevent cloud-provider details
from shaping later Core changes. The lifecycle gate and create, suspend, and
remove orchestration land with stage 3 rather than as unused first-PR methods.
### 1. Define Behavioral Contracts
- Add internal Sandbox provider, binding, file-backend, process-transport, and
environment services in Core.
- Include provider-side canonical-path and directory primitives plus explicit
Workspace shell/environment policy in the contract.
- Keep provider registration process-global and provider implementations out
of tool modules.
- Define typed errors for unavailable Workspace, connection failure, file
operations, stale content, command failure, and unsupported capability.
- Add a contract test suite that can run against any environment
implementation.
- Cover create, reconnect, file retention, conditional write, command output,
timeout, caller interruption, and cleanup when a caller fails before wait.
- Observe a long command running before interruption, then assert promptly that
its provider process no longer exists.
- Inject failures around replacement-binding persistence and verify that the
durable binding can reconnect and reconcile retired resources.
- Interrupt creation around allocation and initial persistence, then verify
that the stable provider identity resolves to either one committed Workspace
or no retained provider resource.
Result: contracts and reusable tests are ready for the local and fake
implementations in the same first integration PR.
### 2. Establish Local Parity
- Implement the file backend against the current local filesystem and use the
current `ChildProcessSpawner` plus ripgrep resolver.
- Compose the existing `FileMutation`, `AppProcess`, `Shell`, and `Ripgrep`
services over those local primitives.
- Run the contract suite against the local implementation.
- Do not route Sessions through it yet.
Result: the contract describes existing semantics rather than only one cloud
provider's API.
### 3. Replace The Experimental Workspace Adapter Core
- Define a safe Workspace public metadata model in Schema instead of exposing
only its ID and events.
- Replace arbitrary adapter `type` plus `extra: unknown` with an explicit local
or sandbox placement in Core-owned storage. Sandbox placement carries a
provider key and provider-schema-validated JSON binding; it is not included
in browser-facing Workspace metadata.
- Add a Core Workspace service for create, get, connect, suspend, and remove.
- Keep provider lookup behind a process-global provider registry.
- Key one scoped connection manager by Workspace ID. Fence suspend/remove
against new leases and wait for active leases before changing placement.
- Persist replacement bindings before destructive cleanup and reconcile
interrupted transitions on the next lifecycle operation.
- Preserve `Workspace.ID` and existing Session `workspace_id` references.
- Migrate or reset only experimental Workspace rows; do not add compatibility
machinery without a concrete persisted-data requirement.
Result: Workspace owns stable identity and placement without owning files.
### 4. Introduce The Location Environment Seam
- Add a Location-scoped `WorkspaceEnvironment.Service` whose local
implementation wraps the current filesystem and process services.
- Give hosted Workspaces an implementation whose operations borrow the
process-global Workspace connection manager.
- Change `Location.boundNode` so a Location with `workspaceID` gets project and
root metadata from the Workspace record instead of calling host-local
`Project.resolve(directory)`.
- Treat Workspace metadata as the canonical root. Interpret Location directory
as a working directory within that root and lexically reject paths outside
it before looking up the Location graph. Resolve symlinks only when an
operation borrows the environment.
- Prove this with a fake hosted Workspace whose directory does not exist on the
test host. Do not add a cloud SDK in this change.
Result: Core has one placement seam and remote Location identity no longer
requires a host checkout.
### 5. Separate Host Files From Workspace Files
- Inventory every Location-scoped `FSUtil`, `Ripgrep`, `AppProcess`, and direct
Node filesystem use.
- Adapt `FileMutation` to the Workspace file backend while preserving its
current policy and result surface.
- Adapt `LocationMutation` to provider-side `resolve` and `inspect` so existing
targets, missing-target ancestors, and symlink escapes retain current
containment behavior.
- Adapt hosted process transport once to `ChildProcessSpawner`, then reuse
`AppProcess`, `Shell`, and `Ripgrep` rather than adding provider command or
search state machines.
- Change `Shell` to consume Workspace shell/environment policy and add scoped
foreground acquisition with an atomic interrupt finalizer. Reject background
shell requests for hosted Workspaces in this slice.
- Move read, edit, patch, path resolution, project instruction discovery, and
project configuration onto those environment-aware services.
- Keep global config, global skills, caches, credentials, and managed tool
output on host services.
- Report the environment platform in built-in instructions instead of
`process.platform`.
- Make unsupported remote snapshot, watcher, VCS, and PTY services explicit for
the first slice rather than accidentally acting on the server host.
Result: no supported Workspace operation can silently touch the wrong host.
### 6. Make The Full Location Graph Environment-Aware
- Migrate each supported Location service to `WorkspaceEnvironment.Service`
rather than selecting services through dynamic `LayerNode` replacements.
- Preserve the current implicit-local path when `workspaceID` is absent.
- Cache the connection manager by Workspace ID. Cache Location graphs by the
canonical `{ workspaceID, directoryWithinWorkspace }` identity.
- Keep Location graphs valid across binding replacement: their environment
service borrows the current Workspace connection for each operation rather
than retaining provider state.
- Add an end-to-end fake-provider test that materializes the full Location graph
without reading, searching, spawning, or watching the host path.
Result: a remote Location can boot when its directory does not exist locally.
### 7. Implement One Real Provider
- Add the Vercel provider behind a narrow optional package or lazy import so
its SDK does not affect local startup.
- Provision a named persistent Sandbox from provider configuration.
- Persist only the binding required to reconnect.
- Use the Vercel SDK directly for lifecycle and files. Adapt detached SDK
command handles to scoped `ChildProcessSpawner` handles; do not shell through
the Vercel CLI or expose detached commands to tools.
- Implement atomic conditional write inside the sandbox, plus suspend,
reconnect, and delete semantics.
- Never inject OpenCode model-provider credentials into the sandbox.
- Run the generic environment contract suite against a real provider when
credentials are available.
Result: a provider-backed Workspace can be created and reconnected directly.
### 8. Prove The Hosted Coding Slice
- Create a provider-backed Workspace containing a fixture repository.
- Create a V2 Session whose Location references that Workspace.
- Verify that the central server has no copy of the checkout.
- Prompt the Session to read a file, edit it, search it, and run `git status`.
- Interrupt a long-running foreground command.
- Suspend the Workspace, reconnect it, and verify that edited files remain.
- Confirm that a normal local Session still passes its existing test suite.
Result: the milestone goal is complete.
### 9. Add Additional Providers
- Run Daytona and Modal implementations through the same contract suite, then
add E2B if it reveals another required lifecycle shape.
- Document where each lifecycle maps differently while preserving OpenCode's
observable contract.
- Add capabilities only when a required operation cannot be represented by the
common contract.
Result: provider genericity is demonstrated rather than assumed.
## Acceptance Tests
The first milestone is accepted when all of the following hold:
- A Session with no `workspaceID` behaves exactly as before.
- A Session with a hosted Workspace boots without its directory on the server.
- `read`, `write`, `edit`, `patch`, `glob`, `grep`, and foreground `shell`
operate only in the hosted environment.
- Permission checks and tool settlement remain central.
- Shell interruption reaches the provider process.
- Interrupting a waiting tool fiber, timing it out, or failing before wait does
not leave its provider process running.
- A stale conditional edit fails rather than overwriting changed content.
- Workspace metadata can be read without resuming compute.
- Reconnection targets the existing provider resource when available.
- A crash after persisting a replacement binding leaves placement reconnectable
and its retired resource eligible for reconciliation.
- Suspension followed by reconnection retains files.
- Suspension waits for active Workspace leases and admits no new operations
until its binding transition is durable.
- Deletion removes retained provider state.
- No model-provider credential is present in sandbox environment variables.
- Hosted commands do not inherit the OpenCode server's environment, shell, or
platform decisions.
- Existing and newly created paths cannot escape the hosted Workspace through
symlinks or missing parent directories.
- The same environment contract tests pass against local and hosted
implementations.
## Deferred Design Pressure
The following concerns are real but do not expand the first milestone:
- Mutating remote requests need idempotency before automatic network retries.
- Background jobs need durable ownership and reconnection semantics.
- Workspace sharing needs a mutation lease or another concurrency policy.
- Provider migration needs an explicit file-transfer or snapshot contract.
- Portable plugins need explicit Workspace capabilities; ambient `fs` and
process imports remain local-only.
- A resident OpenCode worker becomes useful for SSH-only providers, richer Git
operations, PTY, LSP, watches, and detached commands.
- Clustered Session execution and stale-runner fencing remain separate from
sandbox placement.
## Research
Disposable Daytona, Modal, and Vercel tracer experiments informed this plan and
were removed after review. Vercel was exercised live through create, reconnect,
file I/O, foreground command interruption, stop, resume with retained files,
and deletion. A later Modal live tracer exercised create, reconnect by Sandbox
ID, the direct filesystem API, foreground commands, command timeout cleanup,
filesystem snapshot, restoration into a new Sandbox, files that survived
restoration and remained writable, and Sandbox deletion. In the timeout check,
the command was no longer running and the Python SDK resolved with `-1` rather
than raising. Neither the current Python nor JavaScript process handle exposes
direct per-process termination. Daytona was type-checked but not run because
credentials were unavailable.