feat(core): add remote workspace environment seam

This commit is contained in:
Kit Langton 2026-07-17 00:04:13 -04:00
commit fd92aeac66
10 changed files with 805 additions and 24 deletions

View file

@ -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(),

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

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],
})
}

View file

@ -1,6 +1,106 @@
export * as WorkspaceV2 from "./workspace"
import { Context, Effect, Equal, Exit, Layer, RcMap, Schema, Scope } 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 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 get: (id: ID) => Effect.Effect<Info, NotFoundError | InvalidError>
readonly borrow: (
id: ID,
) => Effect.Effect<
WorkspaceEnvironment.Interface,
NotFoundError | InvalidError | Sandbox.Error | Sandbox.ProviderNotFoundError,
Scope.Scope
>
}
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 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 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 connections = yield* RcMap.make({
idleTimeToLive: "1 minute",
lookup: Effect.fn("Workspace.connect")(function* (id: ID) {
const placement = yield* row(id)
if (!Schema.is(Sandbox.Placement)(placement.extra)) {
return yield* new InvalidError({ id, message: "Workspace has no sandbox binding" })
}
const provider = yield* registry.get(placement.type)
const binding = yield* provider.decode(placement.extra.binding)
const connection = yield* provider.connect(binding)
yield* persistBinding(id, binding, connection.binding)
yield* persistBinding(id, connection.binding, yield* provider.reconcile(connection.binding))
return connection
}),
})
return Service.of({
get,
borrow: (id) =>
RcMap.get(connections, id).pipe(
Effect.onExit((exit) => (Exit.isFailure(exit) ? RcMap.invalidate(connections, id) : Effect.void)),
Effect.map((connection) => connection.environment),
),
})
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [Database.node, Sandbox.registryNode],
})

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],
})
}

View file

@ -0,0 +1,70 @@
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 Provider {
readonly key: string
readonly decode: (binding: Binding) => Effect.Effect<Binding, Error>
readonly connect: (binding: Binding) => Effect.Effect<Connection, Error, Scope.Scope>
readonly reconcile: (binding: Binding) => Effect.Effect<Binding, 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: [] })

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,117 @@ 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({
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
}),
}),
)
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))
}),
),
)
})

View file

@ -0,0 +1,171 @@
import { describe, expect } from "bun:test"
import { Effect, Exit } 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 = { connected: 0, reconciled: 0, released: 0 }
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",
decode: Effect.succeed,
connect: () =>
Effect.acquireRelease(
Effect.sync(() => {
lifecycle.connected++
return { binding: { sandbox: "live", retired: "one" }, environment }
}),
() => Effect.sync(() => lifecycle.released++),
),
reconcile: () =>
Effect.sync(() => {
lifecycle.reconciled++
return { sandbox: "live" }
}),
})
expect(yield* workspace.get(id)).toEqual({
id,
name: "Hosted",
directory,
project: { id: projectID, directory },
})
expect(lifecycle.connected).toBe(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).toBe(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).toBe(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",
decode: Effect.succeed,
connect: (binding) =>
Effect.sync(() => ++retry.attempts).pipe(
Effect.flatMap((attempt) =>
attempt === 1 ? Effect.die("Transient provider defect") : Effect.succeed({ binding, environment }),
),
),
reconcile: Effect.succeed,
})
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)
}),
)
})

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

View file

@ -149,7 +149,7 @@ existing Core policy services:
```ts
interface WorkspaceFileBackend {
readonly inspect: (path: string) => Effect.Effect<FileInfo, FileError>
readonly realPath: (path: string) => Effect.Effect<string, 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>
@ -168,9 +168,9 @@ interface WorkspaceFileBackend {
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. `LocationMutation` retains symlink-safe path
containment by composing `realPath` and `inspect`, including its nearest
existing ancestor resolution for new targets.
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
@ -265,28 +265,31 @@ or when detached process reconnection and richer runtime semantics justify it.
## Implementation Sequence
The first integration PR is a narrow vertical slice across stages 1 through 4:
The first integration PR establishes the narrow seam needed by stages 1
through 4:
- add the provider-neutral file, process, environment, and lifecycle contracts;
- add provider-neutral file, process, environment, connection, and binding
reconciliation contracts;
- add the local environment and a test-only fake hosted provider;
- replace the experimental Workspace storage shape with public metadata plus
private placement state;
- add the Workspace connection manager and Location-scoped
- 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 or migrate every tool. Its purpose is to establish the
placement seam and prevent cloud-provider details from shaping later Core
changes.
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 real-path and directory primitives plus explicit
- 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.
@ -364,7 +367,7 @@ requires a host checkout.
Node filesystem use.
- Adapt `FileMutation` to the Workspace file backend while preserving its
current policy and result surface.
- Adapt `LocationMutation` to provider-side `realPath` and `inspect` so existing
- 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