10 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 so provider SDKs never load on the local-only path); plugin-registered drivers later become "add to the registry" with no interface change.
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
readonly ripgrep: Effect.Effect<string, Error> // path to rg INSIDE this environment
}
- 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.ripgrepexists because glob/grep shell out to an rg binary. LocallyRipgrepBinary.Servicedownloads a pinned rg into managed host storage; that path is meaningless inside a sandbox, so the environment answers "where is rg in here" — lazily locating or installing on first use if needed.shellandripgrepstay required at the seam but core exports Linux defaults (bash lowering; rg baked into the image and found on PATH), so a minimal driver satisfies them in one line each 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.