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