Files
anomalyco_opencode/specs/v2/workspaces.md
T

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

  1. Workspace is the noun; sandbox is a kind. Location.workspaceID names a Workspace. Omitted workspaceID keeps 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.
  2. 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.
  3. Creation is eager. create resolves only when the environment is usable. No pending state, no lazy attachment, no detached Sessions — those are deferred designs, not part of this slice.
  4. 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 },
})
  • provider is optional with a config default, exactly like model on Session creation. The default is reifiable: create() and create({ 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.
  • create returns { id, root }. root is 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.
  • connect is scoped. The environment lives as long as the scope that acquired it, which slots directly into the existing cached Location-graph lifetime in location-services.ts. Closing the scope drops the connection; it never stops or deletes the provider resource. There is no close verb to misuse.
  • Errors are values (Schema.TaggedErrorClass). A connect failure 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) and WorkspaceEnvironment.Interface (shape). Files (the branch called it FileBackend) and Shell nest 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 .Service type on it.
  • files earns its place next to process: Modal and Vercel both expose direct filesystem APIs that are dramatically faster than round-tripping cat through a shell, and read/write/edit are the hottest operations.
  • ripgrep exists because glob/grep shell out to an rg binary. Locally RipgrepBinary.Service downloads 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.
  • shell and ripgrep stay 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 otherwise create/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

  1. Session admission. workspaceID present skips host Project.resolve and host path expansion; directory validation uses path.posix containment within the Workspace root. Session project_id becomes optional — an empty Workspace has no honest Project, and inventing one was the old branch's central mistake.
  2. Location graph. LocationServiceMap selects local or hosted construction. The hosted branch acquires its environment via driver.connect(binding) inside the existing scoped graph cache and supplies environment-backed filesystem/process services.
  3. 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:

  1. Fake driver, real runner. workspaces.create() → Session at the root → write a file → run a foreground command → evict and rebuild the Location graph → reconnect through connect → the file is still there. Local Session paths byte-identical throughout.
  2. 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 used ChildProcessSpawner directly. Resolve against the first real driver — drivers may implement an honest subset with typed unsupported errors, or the seam narrows.
  • Migration for session.project_id nullability and any Project-requiring read models.
  • Where workspaces.create surfaces 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.