diff --git a/packages/core/src/control-plane/workspace.sql.ts b/packages/core/src/control-plane/workspace.sql.ts index ef5195216acf..8ecf9c4a0b55 100644 --- a/packages/core/src/control-plane/workspace.sql.ts +++ b/packages/core/src/control-plane/workspace.sql.ts @@ -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().primaryKey(), + id: text().$type().primaryKey(), type: text().notNull(), name: text().notNull().default(""), branch: text(), diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 47e3f6dd2fbc..76751b76ef9a 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -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 diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index 8228b8599ea3..699280f29132 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -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()("@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], }) +} diff --git a/packages/core/src/workspace.ts b/packages/core/src/workspace.ts index d85bbe4ba47f..5ba4def3fbf3 100644 --- a/packages/core/src/workspace.ts +++ b/packages/core/src/workspace.ts @@ -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()("Workspace.NotFoundError", { + id: ID, +}) {} + +export class InvalidError extends Schema.TaggedErrorClass()("Workspace.InvalidError", { + id: ID, + message: Schema.String, +}) {} + +export interface Interface { + readonly create: (input: CreateInput) => Effect.Effect + readonly get: (id: ID) => Effect.Effect + readonly connect: ( + id: ID, + ) => Effect.Effect + readonly borrow: ( + id: ID, + ) => Effect.Effect< + WorkspaceEnvironment.Interface, + NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError, + Scope.Scope + > + readonly suspend: ( + id: ID, + ) => Effect.Effect + readonly remove: ( + id: ID, + ) => Effect.Effect +} + +export class Service extends Context.Service()("@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 = (id: ID, effect: Effect.Effect) => + 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 = 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 = (effect: Effect.Effect) => + 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 } +} diff --git a/packages/core/src/workspace/environment.ts b/packages/core/src/workspace/environment.ts new file mode 100644 index 000000000000..c4ccfa5868c9 --- /dev/null +++ b/packages/core/src/workspace/environment.ts @@ -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()("WorkspaceEnvironment.Error", { + operation: Schema.String, + path: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect()), +}) {} + +export class StaleContentError extends Schema.TaggedErrorClass()( + "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 + readonly resolve: (path: string) => Effect.Effect + readonly read: (path: string) => Effect.Effect + readonly list: (path: string) => Effect.Effect + readonly ensureDirectory: (path: string) => Effect.Effect + readonly createExclusive: (path: string, content: Uint8Array) => Effect.Effect + readonly write: (path: string, content: Uint8Array) => Effect.Effect + readonly writeIfUnchanged: ( + path: string, + expected: Uint8Array, + content: Uint8Array, + ) => Effect.Effect + readonly remove: (path: string) => Effect.Effect +} + +export interface Shell { + readonly executable: string + readonly args: (command: string) => readonly string[] + readonly environmentOverrides: Readonly> + 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 +} + +export class Service extends Context.Service()("@opencode/WorkspaceEnvironment") {} + +export const node = LayerNode.unbound(Service, tags.values.location) + +const wrap = (operation: string, path: string | undefined, effect: Effect.Effect) => + 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() + const mutate = (path: string, effect: Effect.Effect) => + locks.withLock(path)(Effect.uninterruptible(effect)) + const notFound = (effect: Effect.Effect) => + 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 = ( + workspace: WorkspaceV2.Interface, + id: WorkspaceV2.ID, + use: (environment: Interface) => Effect.Effect, +) => + 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 = ( + target: string, + use: (files: FileBackend, resolved: ResolvedPath) => Effect.Effect, + ) => + 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], + }) +} diff --git a/packages/core/src/workspace/sandbox.ts b/packages/core/src/workspace/sandbox.ts new file mode 100644 index 000000000000..461c49cd5950 --- /dev/null +++ b/packages/core/src/workspace/sandbox.ts @@ -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()("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 + readonly decode: (binding: Binding) => Effect.Effect + readonly connect: (binding: Binding) => Effect.Effect + readonly suspend: (connection: Connection) => Effect.Effect + readonly reconcile: (binding: Binding) => Effect.Effect + readonly delete: (binding: Binding) => Effect.Effect +} + +export class DuplicateProviderError extends Schema.TaggedErrorClass()( + "Sandbox.DuplicateProviderError", + { provider: Schema.String }, +) {} + +export class ProviderNotFoundError extends Schema.TaggedErrorClass()( + "Sandbox.ProviderNotFoundError", + { provider: Schema.String }, +) {} + +export interface Registry { + readonly register: (provider: Provider) => Effect.Effect + readonly get: (key: string) => Effect.Effect +} + +export class RegistryService extends Context.Service()("@opencode/SandboxRegistry") {} + +export const registryLayer = Layer.sync(RegistryService, () => { + const providers = new Map() + 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: [] }) diff --git a/packages/core/test/location.test.ts b/packages/core/test/location.test.ts index d012a0463f96..af804900f415 100644 --- a/packages/core/test/location.test.ts +++ b/packages/core/test/location.test.ts @@ -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)) + }), + ), + ) }) diff --git a/packages/core/test/workspace.test.ts b/packages/core/test/workspace.test.ts new file mode 100644 index 000000000000..200683ed5854 --- /dev/null +++ b/packages/core/test/workspace.test.ts @@ -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() + const finishSuspend = yield* Deferred.make() + 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() + 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() + 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" }) + }), + ) +}) diff --git a/packages/schema/src/workspace.ts b/packages/schema/src/workspace.ts index f52cc3024f59..26106d5ea960 100644 --- a/packages/schema/src/workspace.ts +++ b/packages/schema/src/workspace.ts @@ -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 {} + export const Event = WorkspaceEvent diff --git a/specs/v2/remote-workspace-execution.md b/specs/v2/remote-workspace-execution.md new file mode 100644 index 000000000000..7c071906f858 --- /dev/null +++ b/specs/v2/remote-workspace-execution.md @@ -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 + readonly connect: (binding: SandboxBinding) => Effect.Effect + readonly suspend: (connection: SandboxConnection) => Effect.Effect + readonly reconcile: (binding: SandboxBinding) => Effect.Effect + readonly delete: (binding: SandboxBinding) => Effect.Effect +} + +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 +} + +interface WorkspaceShell { + readonly executable: string + readonly args: (command: string) => readonly string[] + readonly environmentOverrides: Readonly> + 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 + readonly resolve: (path: string) => Effect.Effect + readonly read: (path: string) => Effect.Effect + readonly list: (path: string) => Effect.Effect + readonly ensureDirectory: (path: string) => Effect.Effect + readonly createExclusive: (path: string, content: Uint8Array) => Effect.Effect + readonly write: (path: string, content: Uint8Array) => Effect.Effect + readonly writeIfUnchanged: ( + path: string, + expected: Uint8Array, + content: Uint8Array, + ) => Effect.Effect + readonly remove: (path: string) => Effect.Effect +} +``` + +`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` +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.