15 KiB
Workspaces
Status: proposal
A Workspace is a durable place where a Session executes: a filesystem root plus the ability to run processes there. Today every Session implicitly executes on the server host. This proposal makes hosted execution a first-class kind of Workspace — a sandbox (Modal, Vercel, ...) — without changing the Session model.
Decisions
- Workspace is the noun; sandbox is a kind.
Location.workspaceIDnames a Workspace. OmittedworkspaceIDkeeps meaning implicit local execution, unchanged. A sandbox is the hosted kind of Workspace and the only creatable kind initially; other kinds (an SSH host, a registered local directory) can arrive later as new provider strings without touching Location or Session. - A Workspace is an empty environment. No repository, branch, project, or name at creation. It is a fresh machine: files can be written and commands run immediately; cloning a repository is something a Session (or SDK caller) does later, if at all. A Workspace may contain a Project; a Workspace is not a Project.
- Creation is eager.
createresolves only when the environment is usable. No pending state, no lazy attachment, no detached Sessions — those are deferred designs, not part of this slice. - Providers are pluggable drivers behind a three-verb seam, selected by config-defaulted string, mirroring how model providers resolve.
Public API
// ordinary path: config decides (workspace.provider = "modal" in opencode.json)
const workspace = await workspaces.create()
// explicit override
const workspace = await workspaces.create({ provider: "vercel" })
const session = await sessions.create({
location: { workspaceID: workspace.id, directory: workspace.root },
})
provideris optional with a config default, exactly likemodelon Session creation. The default is reifiable:create()andcreate({ provider: config.workspace.provider })are the same call.- No configured default and no explicit provider is a typed error at the call. The system never silently picks a vendor.
createreturns{ id, root }.rootis an absolute POSIX path inside the provider filesystem; the caller threads it into the Location.
Domain Model
| Concept | What it is | Visibility |
|---|---|---|
| Workspace | Durable execution environment: id + root |
Public |
| Sandbox | The hosted kind of Workspace, backed by a provider | Vocabulary only; not a separate API noun |
| Binding | Smallest provider-owned JSON needed to reconnect to the same resource | Internal; stored opaquely, never read by core |
| WorkspaceEnvironment | Scoped live connection: files + processes at the root | Internal seam |
| Project | Logical repository identity discovered within a Location | Public, becomes optional |
Binding is the entirety of what earlier drafts called "placement." It is a column, not a concept: core persists it and hands it back to the driver.
When a provider's underlying resource is replaced (Modal restores a snapshot into a new provider sandbox), that is the same OpenCode Workspace with an updated binding. Provider instances never get a public identity.
Driver Seam
// packages/core/src/workspace/driver.ts
export interface Interface {
// allocate a new environment; resolve only when it is ready to use
readonly create: (input: {
readonly workspaceID: Workspace.ID
}) => Effect.Effect<{ binding: Binding; root: string }, CreateError>
// binding -> live capabilities; the ONLY way to obtain an environment
readonly connect: (
binding: Binding,
) => Effect.Effect<WorkspaceEnvironment.Interface, ConnectError, Scope.Scope>
// permanently release provider resources
readonly destroy: (binding: Binding) => Effect.Effect<void, DestroyError>
}
- One path to a live environment. Fresh-create and process-restart-reconnect both flow through
connect; the prior tracers found their bugs exactly where these paths diverged. connectis scoped. The environment lives as long as the scope that acquired it, which slots directly into the existing cached Location-graph lifetime inlocation-services.ts. Closing the scope drops the connection; it never stops or deletes the provider resource. There is nocloseverb to misuse.- Errors are values (
Schema.TaggedErrorClass). Aconnectfailure against a stopped provider resource is a typed, recoverable condition. - Registry keyed by provider string. Built-in drivers first, registered from Server composition; plugin-registered drivers later become "add to the registry" with no interface change.
Defining And Registering A Driver
Core owns the seam and the registry key and never imports a provider SDK:
// packages/core/src/workspace/driver.ts (continued)
export const Binding = Schema.Record(Schema.String, Schema.Json)
export type Binding = typeof Binding.Type
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
"WorkspaceDriver.ProviderNotFoundError",
{ provider: Schema.String },
) {}
export interface Registry {
readonly get: (provider: string) => Effect.Effect<Interface, ProviderNotFoundError>
}
export class RegistryService extends Context.Service<RegistryService, Registry>()("@opencode/WorkspaceDriverRegistry") {}
A driver is a plain value built by an Effect in packages/server. Its binding schema is driver-private — this is where "opaque JSON" becomes typed again, decoded at the boundary:
// packages/server/src/workspace/modal.ts
export * as ModalDriver from "./modal"
const ModalBinding = Schema.Struct({ sandboxId: Schema.String })
const ROOT = "/workspace"
export const make = Effect.gen(function* () {
const app = yield* Effect.promise(() => App.lookup("opencode-workspaces", { createIfMissing: true }))
// git, bash, rg provisioned in the image — never discovered opportunistically
const image = Image.fromRegistry("ghcr.io/anomalyco/opencode-workspace:1")
const decode = (binding: WorkspaceDriver.Binding) =>
Schema.decodeUnknownEffect(ModalBinding)(binding).pipe(
Effect.mapError((cause) => new WorkspaceDriver.ConnectError({ provider: "modal", cause })),
)
return WorkspaceDriver.make({
create: ({ workspaceID }) =>
Effect.promise(() => Sandbox.create(app, { image, name: workspaceID })).pipe(
Effect.map((sandbox) => ({ binding: { sandboxId: sandbox.sandboxId }, root: ROOT })),
),
connect: Effect.fnUntraced(function* (binding) {
const decoded = yield* decode(binding)
const sandbox = yield* Effect.promise(() => Sandbox.fromId(decoded.sandboxId))
return WorkspaceEnvironment.make({
platform: "linux",
directory: ROOT,
files: modalFiles(sandbox), // Files over the sandbox filesystem API
process: modalSpawner(sandbox), // ChildProcessSpawner over sandbox.exec
shell: WorkspaceEnvironment.linuxShell,
})
}),
destroy: (binding) =>
decode(binding).pipe(
Effect.flatMap((decoded) =>
Effect.promise(async () => {
const sandbox = await Sandbox.fromId(decoded.sandboxId)
await sandbox.terminate()
}),
),
),
})
})
Registration is ordinary Server composition — the same makeGlobalNode shape as every other server-provided service. The registry is an immutable map fixed at boot:
// packages/server/src/workspace/drivers.ts
export * as ServerWorkspaceDrivers from "./drivers"
export const layer = Layer.effect(
WorkspaceDriver.RegistryService,
Effect.gen(function* () {
const drivers = {
modal: yield* ModalDriver.make,
vercel: yield* VercelDriver.make,
}
return WorkspaceDriver.RegistryService.of({
get: (provider) =>
drivers[provider]
? Effect.succeed(drivers[provider])
: Effect.fail(new WorkspaceDriver.ProviderNotFoundError({ provider })),
})
}),
)
export const node = makeGlobalNode({ service: WorkspaceDriver.RegistryService, layer, deps: [] })
Core consumes the registry blindly:
// workspaces.create
const provider = input.provider ?? config.workspace?.provider
if (!provider) return yield* new NoWorkspaceProviderError()
const driver = yield* registry.get(provider)
const created = yield* driver.create({ workspaceID: id })
yield* store.insert({ id, provider, binding: created.binding, root: created.root })
// hosted Location graph construction (inside the existing scoped cache)
const workspace = yield* store.get(location.workspaceID)
const driver = yield* registry.get(workspace.provider)
const env = yield* driver.connect(workspace.binding)
- Dependency direction holds. Core defines the key and consumes; Server defines drivers and provides the layer;
sdk-nextcomposes. Core never sees a provider SDK. - Immutable map over
register()verbs. The prior branch's registry had runtime register/unregister with duplicate errors and scoped cleanup — machinery for a driver set that is actually fixed at boot. The registry is the map. - Drivers ship in-tree for now. Eventually a sandbox provider can live outside opencode as a plugin; that changes only how the registry map is built (read plugin contributions during layer construction).
Registry.getand every consumer are untouched.
Environment
Reuses the interface proven on origin/remote-workspaces-plan (fd92aeac66) nearly verbatim — a local implementation already exists there and the Location graph already composes over it:
// packages/core/src/workspace/environment.ts
export * as WorkspaceEnvironment from "./environment"
export interface Interface {
readonly platform: NodeJS.Platform
readonly directory: string // the Workspace root, absolute in the provider filesystem
readonly files: Files // read / resolve / list / write / writeIfUnchanged / remove ...
readonly process: ChildProcessSpawner["Service"]
readonly shell: Shell // executable + args lowering for the bash tool
}
- Naming follows the core convention: consumers reference
WorkspaceEnvironment.Service(tag) andWorkspaceEnvironment.Interface(shape).Files(the branch called itFileBackend) andShellnest in the same namespace since they exist only as environment fields.ChildProcessSpawner["Service"]is indexed access because effect's key holds its shape as a phantom member — there is no.Servicetype on it. filesearns its place next toprocess: Modal and Vercel both expose direct filesystem APIs that are dramatically faster than round-trippingcatthrough a shell, and read/write/edit are the hottest operations.- The branch's environment carried a
ripgrepfield (glob/grep shell out to an rg binary, and the host's managed rg download is meaningless inside a sandbox). That was seam pollution — a tool implementation detail leaking into the environment contract. Instead, the sandbox image contract mandatesgit,bash, andrg, and the hosted Location graph provides the existingRipgrepBinary.Servicewithfilepath: Effect.succeed("rg"). Binary resolution stays a Location-graph concern; the environment stays capabilities-only. shellremains at the seam (lowering genuinely varies by image) but core exports a Linux default so a minimal driver satisfies it in one line and is otherwisecreate/connect/destroy+ files + spawn.- Core builds tools (bash, read, edit, glob, grep) on top of the environment. Drivers never know what a tool is.
Persistence
One V2-owned table; no interaction with the V1 workspace table.
workspace
id primary key, Workspace.ID
provider driver registry key
binding opaque driver-owned JSON
root absolute POSIX root in the provider filesystem
time_created
time_updated
Metadata reads (Session lists, routing, Location validation) never contact a provider.
Required Core Changes
- Session admission.
workspaceIDpresent skips hostProject.resolveand host path expansion; directory validation usespath.posixcontainment within the Workspace root. Sessionproject_idbecomes optional — an empty Workspace has no honest Project, and inventing one was the old branch's central mistake. - Location graph.
LocationServiceMapselects local or hosted construction. The hosted branch acquires its environment viadriver.connect(binding)inside the existing scoped graph cache and supplies environment-backed filesystem/process services. - Tool catalog. A hosted Location advertises only tools that execute through the environment. Nothing advertised may fall back to host authority.
Capabilities in an empty Workspace:
- Available immediately: read/write/edit, bash, glob/grep, global config/agents/instructions, models, integrations, generic permissions.
- Unavailable until a Project exists: git status/diffs, snapshots/revert, project-root instruction discovery, project config/skills/plugins, repository-scoped saved permissions.
First Milestone
Prove an empty Workspace can host a real Session:
- Fake driver, real runner.
workspaces.create()→ Session at the root → write a file → run a foreground command → evict and rebuild the Location graph → reconnect throughconnect→ the file is still there. Local Session paths byte-identical throughout. - First real driver. Vercel provisional, Modal fallback — decided by the feasibility gates already recorded in
remote-workspace-execution.md(rooted file behavior, stable reconnect identity, confirmed process termination). Credential-gated live contract tests; a second-process restart test reconstructing the binding from SQLite.
Next slice, not this one: clone-a-repository-during-a-Session. That needs an explicit "rediscover Location context" operation (Project detection, directory-derived config rebuild, instruction-epoch refresh) and is designed after the empty-Workspace path is real.
Deferred: lazy attachment and detached Sessions; stop/resume and TTL lifecycle policy; multiple Sessions per Workspace; PTY, LSP, watchers, snapshots; provider plugin API; preview ports.
Open Questions
- Does
ChildProcessSpawner's full surface (stdin, extra file descriptors,unref, PID semantics) map honestly onto provider process APIs? The superseded plan researched this and proposed a narrower foreground-command contract; the environment seam on the branch usedChildProcessSpawnerdirectly. Resolve against the first real driver — drivers may implement an honest subset with typed unsupported errors, or the seam narrows. - Migration for
session.project_idnullability and any Project-requiring read models. - Where
workspaces.createsurfaces first: SDK/HTTP only, with TUI/web affordances later.
Prior Art
origin/remote-workspaces-plan: 09903e120f (plan + live Vercel tracer), fd92aeac66 (provider-neutral environment seam + local implementation), d1b9b6c9ce (live Modal tracer: reconnect, snapshot, restore-into-new-sandbox), 650d5a5e92 (lifecycle exploration). Both provider tracers already worked repository-free; only the outer Workspace API of that branch carried Project assumptions, and this proposal drops them.
specs/v2/remote-workspace-execution.md is superseded for domain model and API shape but retained for execution-level research: provider feasibility gates, process laws, host-authority tripwire strategy, and phase-level acceptance criteria.