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 { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import { Workspace } from "@opencode-ai/schema/workspace"
import { ProjectTable } from "../project/sql" import { ProjectTable } from "../project/sql"
import { ProjectV2 } from "../project" import { ProjectV2 } from "../project"
import { WorkspaceV2 } from "../workspace"
export const WorkspaceTable = sqliteTable("workspace", { export const WorkspaceTable = sqliteTable("workspace", {
id: text().$type<WorkspaceV2.ID>().primaryKey(), id: text().$type<Workspace.ID>().primaryKey(),
type: text().notNull(), type: text().notNull(),
name: text().notNull().default(""), name: text().notNull().default(""),
branch: text(), branch: text(),

View file

@ -16,6 +16,7 @@ import { Image } from "./image"
import { LocationWatcher } from "./filesystem/location-watcher" import { LocationWatcher } from "./filesystem/location-watcher"
import { Integration } from "./integration" import { Integration } from "./integration"
import { Location } from "./location" import { Location } from "./location"
import { WorkspaceEnvironment } from "./workspace/environment"
import { LocationMutation } from "./location-mutation" import { LocationMutation } from "./location-mutation"
import { LocationServiceMap } from "./location-service-map" import { LocationServiceMap } from "./location-service-map"
import { MCP } from "./mcp/index" import { MCP } from "./mcp/index"
@ -50,6 +51,7 @@ export { LocationServiceMap } from "./location-service-map"
const locationServiceNodes = [ const locationServiceNodes = [
Location.node, Location.node,
WorkspaceEnvironment.node,
Config.node, Config.node,
AgentV2.node, AgentV2.node,
CommandV2.node, CommandV2.node,
@ -115,7 +117,10 @@ export function buildLocationServiceMap(
LayerMap.make( LayerMap.make(
(ref: Location.Ref) => { (ref: Location.Ref) => {
const startedAt = performance.now() 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 // Apply replacements during hoist, not afterward: replacements can
// introduce new tagged dependencies (Location.boundNode depends on // introduce new tagged dependencies (Location.boundNode depends on
// Project), and the hoist walk is the only pass that can still slice // 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 { Context, Effect, Layer } from "effect"
import { Info, Ref, response } from "@opencode-ai/schema/location" import { Info, Ref, response } from "@opencode-ai/schema/location"
import path from "path"
import { Project } from "./project" import { Project } from "./project"
import { LayerNode } from "./effect/layer-node" import { LayerNode } from "./effect/layer-node"
import { makeLocationNode, tags } from "./effect/app-node" import { makeLocationNode, tags } from "./effect/app-node"
import { WorkspaceV2 } from "./workspace"
export * as Location from "./location" 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) export const node = LayerNode.unbound(Service, tags.values.location)
const layer = (ref: Ref) => const localLayer = (ref: Ref) =>
Layer.effect( Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@ -31,9 +33,38 @@ const layer = (ref: Ref) =>
}), }),
) )
export const boundNode = (ref: Ref) => const hostedLayer = (ref: Ref & { readonly workspaceID: WorkspaceV2.ID }) =>
makeLocationNode({ 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, service: Service,
layer: layer(ref), layer: localLayer(ref),
deps: [Project.node], deps: [Project.node],
}) })
}

View file

@ -1,6 +1,106 @@
export * as WorkspaceV2 from "./workspace" export * as WorkspaceV2 from "./workspace"
import { Context, Effect, Equal, Exit, Layer, RcMap, Schema, Scope } from "effect"
import { Workspace } from "@opencode-ai/schema/workspace" 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 const ID = Workspace.ID
export type ID = typeof ID.Type 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 { 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 { 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 { Location } from "@opencode-ai/core/location"
import { Project } from "@opencode-ai/core/project" import { Project } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema" import { AbsolutePath } from "@opencode-ai/core/schema"
import { WorkspaceV2 } from "@opencode-ai/core/workspace" 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" import { testEffect } from "./lib/effect"
const workspaceID = WorkspaceV2.ID.make("wrk_test") 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( const projectLayer = Layer.succeed(
Project.Service, Project.Service,
Project.Service.of({ Project.Service.of({
@ -31,7 +37,7 @@ describe("Location", () => {
const location = yield* Location.Service const location = yield* Location.Service
expect(location.directory).toBe(AbsolutePath.make("/repo/packages/app")) 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.id).toBe(Project.ID.make("project"))
expect(location.project.directory).toBe(AbsolutePath.make("/repo")) expect(location.project.directory).toBe(AbsolutePath.make("/repo"))
expect(location.vcs).toEqual({ 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 { WorkspaceEvent } from "./workspace-event.js"
import { WorkspaceID } from "./workspace-id.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 const ID = WorkspaceID
export type 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 export const Event = WorkspaceEvent

View file

@ -149,7 +149,7 @@ existing Core policy services:
```ts ```ts
interface WorkspaceFileBackend { interface WorkspaceFileBackend {
readonly inspect: (path: string) => Effect.Effect<FileInfo, FileError> 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 read: (path: string) => Effect.Effect<Uint8Array, FileError>
readonly list: (path: string) => Effect.Effect<readonly DirectoryEntry[], FileError> readonly list: (path: string) => Effect.Effect<readonly DirectoryEntry[], FileError>
readonly ensureDirectory: (path: string) => Effect.Effect<void, 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. not emulate it with an unlocked client-side read followed by write.
`FileMutation` remains the owner of create/write/remove semantics, stale-edit `FileMutation` remains the owner of create/write/remove semantics, stale-edit
errors, parent-directory creation, result metadata, BOM handling, and errors, parent-directory creation, result metadata, BOM handling, and
OpenCode-side mutation ordering. `LocationMutation` retains symlink-safe path OpenCode-side mutation ordering. `resolve` returns a provider-canonical path
containment by composing `realPath` and `inspect`, including its nearest for an existing target or through the nearest existing ancestor for a new
existing ancestor resolution for new targets. target. `LocationMutation` uses it to retain symlink-safe containment.
Provider process transports adapt native command handles to Effect's scoped Provider process transports adapt native command handles to Effect's scoped
`ChildProcessSpawner` contract. Scope finalization interrupts an unfinished `ChildProcessSpawner` contract. Scope finalization interrupts an unfinished
@ -265,28 +265,31 @@ or when detached process reconnection and richer runtime semantics justify it.
## Implementation Sequence ## 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; - add the local environment and a test-only fake hosted provider;
- replace the experimental Workspace storage shape with public metadata plus - add browser-safe Workspace metadata while keeping provider placement private
private placement state; in the existing experimental row;
- add the Workspace connection manager and Location-scoped - add a scoped, idle-expiring Workspace connection cache and Location-scoped
`WorkspaceEnvironment.Service`; `WorkspaceEnvironment.Service`;
- make `Location.boundNode` resolve hosted project/root metadata without - make `Location.boundNode` resolve hosted project/root metadata without
`Project.resolve` or any host-path access; and `Project.resolve` or any host-path access; and
- prove lazy connection and scoped release using a hosted directory that does - prove lazy connection and scoped release using a hosted directory that does
not exist on the test host. not exist on the test host.
It does not add Vercel or migrate every tool. Its purpose is to establish the It does not add Vercel, lifecycle mutation, or migrate existing tools. Its
placement seam and prevent cloud-provider details from shaping later Core purpose is to establish the placement seam and prevent cloud-provider details
changes. 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 ### 1. Define Behavioral Contracts
- Add internal Sandbox provider, binding, file-backend, process-transport, and - Add internal Sandbox provider, binding, file-backend, process-transport, and
environment services in Core. 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. Workspace shell/environment policy in the contract.
- Keep provider registration process-global and provider implementations out - Keep provider registration process-global and provider implementations out
of tool modules. of tool modules.
@ -364,7 +367,7 @@ requires a host checkout.
Node filesystem use. Node filesystem use.
- Adapt `FileMutation` to the Workspace file backend while preserving its - Adapt `FileMutation` to the Workspace file backend while preserving its
current policy and result surface. 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 targets, missing-target ancestors, and symlink escapes retain current
containment behavior. containment behavior.
- Adapt hosted process transport once to `ChildProcessSpawner`, then reuse - Adapt hosted process transport once to `ChildProcessSpawner`, then reuse