244 lines
11 KiB
Markdown
244 lines
11 KiB
Markdown
# Workspaces
|
|
|
|
Status: proposal
|
|
|
|
A **Workspace** is a durable place a Session executes: a filesystem root plus processes. Hosted execution (Modal, Vercel, ...) becomes a kind of Workspace. The Session model does not change.
|
|
|
|
## Decisions
|
|
|
|
- **Workspace is the noun; sandbox is a kind.** `Location.workspaceID` names a Workspace; omitted still means implicit local, unchanged.
|
|
- **A Workspace is an empty environment.** No repository, project, or name at creation. Cloning happens later, inside a Session. *A Workspace may contain a Project; a Workspace is not a Project.*
|
|
- **Creation is eager.** `create` resolves when the environment is usable. No pending states, no lazy attachment, no detached Sessions.
|
|
- **Providers are pluggable drivers** behind a three-verb seam, selected by provider string.
|
|
|
|
## Public API
|
|
|
|
```typescript
|
|
const workspace = await workspaces.create({ provider: "modal" })
|
|
|
|
const session = await sessions.create({
|
|
location: { workspaceID: workspace.id, directory: workspace.root },
|
|
})
|
|
```
|
|
|
|
Consumers persist the Workspace ID as their durable handle and re-derive everything else:
|
|
|
|
```typescript
|
|
const workspace = await workspaces.get(id) // { id, provider, root } — table read, never contacts a provider
|
|
```
|
|
|
|
- `provider` is required for now. A config default (`workspace.provider`) can be added later without breaking anything; skipping it keeps config untouched.
|
|
- `root` is an absolute POSIX path in the provider filesystem.
|
|
- `get` fails with typed `WorkspaceNotFound` — also the existence check. No `getOrCreate`: the consumer owns its "which Workspace is mine" mapping, and provider-level get-or-create can silently replace resources (Vercel tracer finding).
|
|
|
|
## Domain Model
|
|
|
|
| Concept | What it is | Visibility |
|
|
| --- | --- | --- |
|
|
| Workspace | Durable execution environment: `id` + `root` | Public |
|
|
| Sandbox | The hosted kind of Workspace | Vocabulary only, not an API noun |
|
|
| Binding | Smallest provider-owned JSON to reconnect to the same resource | Internal, stored opaquely |
|
|
| WorkspaceEnvironment | Scoped live connection: files + processes at the root | Internal seam |
|
|
| Project | Repository identity discovered *within* a Location | Public, becomes optional |
|
|
|
|
Binding is all that earlier drafts called "placement" — a column, not a concept. Provider resource replacement (Modal snapshot → new sandbox) is the same Workspace with an updated binding.
|
|
|
|
## Driver Seam
|
|
|
|
```typescript title="packages/core/src/workspace/driver.ts"
|
|
export * as WorkspaceDriver from "./driver"
|
|
|
|
export const Binding = Schema.Record(Schema.String, Schema.Json)
|
|
export type Binding = typeof Binding.Type
|
|
|
|
export interface Interface {
|
|
// allocate; resolve only when 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>
|
|
}
|
|
|
|
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") {}
|
|
```
|
|
|
|
- Fresh-create and restart-reconnect both flow through `connect` — where the prior tracers found their bugs.
|
|
- `connect` is scoped: environment lifetime = the acquiring scope (the existing cached Location graph). Scope closure drops the connection, never the provider resource. No `close` verb.
|
|
- Errors are `Schema.TaggedErrorClass` values.
|
|
|
|
## Defining A Driver
|
|
|
|
A driver is a plain value built in `packages/server`. Its binding schema is driver-private — opaque JSON becomes typed again at this boundary:
|
|
|
|
```typescript title="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()
|
|
}),
|
|
),
|
|
),
|
|
})
|
|
})
|
|
```
|
|
|
|
## Registering Drivers
|
|
|
|
Ordinary Server composition. The registry is an immutable map fixed at boot:
|
|
|
|
```typescript title="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 it blindly:
|
|
|
|
```typescript
|
|
// workspaces.create
|
|
const driver = yield* registry.get(input.provider)
|
|
const created = yield* driver.create({ workspaceID: id })
|
|
yield* store.insert({ id, provider: input.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)
|
|
```
|
|
|
|
- Core defines the key and consumes; Server defines drivers and provides the layer; core never sees a provider SDK.
|
|
- Drivers ship in-tree for now. Plugin-contributed drivers later change only how the map is built; `Registry.get` and consumers are untouched.
|
|
|
|
## Environment
|
|
|
|
Reuses the seam proven on `origin/remote-workspaces-plan` (`fd92aeac66`) — a local implementation exists and the Location graph composes over it:
|
|
|
|
```typescript title="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; linuxShell default
|
|
}
|
|
```
|
|
|
|
- `files` is separate from `process`: providers expose direct filesystem APIs far faster than shelling out `cat`, and read/write/edit are the hottest ops.
|
|
- Core builds tools (bash, read, edit, glob, grep) on top. Drivers never know what a tool is.
|
|
- The branch's `ripgrep` field is dropped — tool implementation detail leaking into the seam. The image contract mandates `rg`; the hosted graph provides the existing `RipgrepBinary.Service` as `Effect.succeed("rg")`.
|
|
|
|
## Persistence
|
|
|
|
One V2-owned table; no interaction with the V1 `workspace` table. Metadata reads never contact a provider.
|
|
|
|
```typescript
|
|
const table = sqliteTable("workspace", {
|
|
id: text().primaryKey(), // Workspace.ID
|
|
provider: text().notNull(), // driver registry key
|
|
binding: text({ mode: "json" }).notNull(), // opaque driver-owned JSON
|
|
root: text().notNull(), // absolute POSIX root in provider filesystem
|
|
time_created: integer().notNull(),
|
|
time_updated: integer().notNull(),
|
|
})
|
|
```
|
|
|
|
## Core Changes
|
|
|
|
1. **Session admission** — `workspaceID` present skips host `Project.resolve`; directory validated with `path.posix` containment in the root. `session.project_id` becomes optional: an empty Workspace has no honest Project.
|
|
2. **Location graph** — `LocationServiceMap` selects local or hosted construction; hosted acquires via `driver.connect(binding)` inside the existing scoped cache.
|
|
3. **Tool catalog** — hosted Locations advertise only environment-backed tools. Nothing may fall back to host authority.
|
|
|
|
| In an empty Workspace | |
|
|
| --- | --- |
|
|
| Available | read/write/edit, bash, glob/grep, global config/agents/instructions, models, integrations |
|
|
| Needs a Project | git status/diffs, snapshots/revert, project instructions/config/skills/plugins, repo-scoped permissions |
|
|
|
|
## First Milestone
|
|
|
|
1. **Fake driver, real runner.** `create()` → Session at root → write file → run command → evict + rebuild Location graph → reconnect → file still there. Local paths byte-identical throughout.
|
|
2. **First real driver.** Vercel provisional, Modal fallback — decided by the feasibility gates in `remote-workspace-execution.md`. Credential-gated live tests; second-process restart test reconstructing the binding from SQLite.
|
|
|
|
Next slice (not this one): clone-a-repo-during-a-Session, which needs an explicit "rediscover Location context" operation (Project detection, config rebuild, instruction-epoch refresh).
|
|
|
|
Deferred: lazy attachment / detached Sessions, stop/resume + TTL policy, multi-Session Workspaces, PTY / LSP / watchers / snapshots, plugin driver API, preview ports.
|
|
|
|
## Open Questions
|
|
|
|
- Does `ChildProcessSpawner`'s full surface (stdin, extra fds, `unref`, PIDs) map honestly to provider process APIs? The superseded plan proposed a narrower foreground contract; resolve against the first real driver.
|
|
- `session.project_id` nullability migration and Project-requiring read models.
|
|
- Where `workspaces.create` surfaces first: SDK/HTTP only; TUI/web later.
|
|
|
|
## Prior Art
|
|
|
|
`origin/remote-workspaces-plan`: `09903e120f` (plan + live Vercel tracer), `fd92aeac66` (environment seam + local impl), `d1b9b6c9ce` (live Modal tracer: reconnect, snapshot, restore), `650d5a5e92` (lifecycle). Both tracers already worked repository-free; only that branch's outer Workspace API carried Project assumptions — dropped here.
|
|
|
|
`specs/v2/remote-workspace-execution.md` is superseded for domain model and API, retained for execution research: feasibility gates, process laws, host-authority tripwires, phase acceptance criteria.
|