From 4487be9a2eb319148fc18079315c9465abdd8477 Mon Sep 17 00:00:00 2001 From: Matt Carey Date: Fri, 16 Oct 2026 09:33:03 +0200 Subject: [PATCH] computer: Let several Workspaces share one durable object's storage A durable object's storage held exactly one Workspace, because every table it creates has a fixed name. An agent that wanted a scratch area next to a durable project, or one Workspace per sub-task, needed a durable object for each. WorkspaceOptions now takes a `namespace`. A namespaced Workspace gets its own copy of every table and index, named with a `ws__` prefix, so `vfs_nodes` becomes `ws_scratch_vfs_nodes`. Without a namespace a Workspace keeps the existing names, so stored data stays where it is. The table names are spread across the filesystem, sync, mount, and runtime code, so rather than thread a prefix through all of it, the Workspace wraps its storage handle and renames its own identifiers in the SQL on the way to SQLite. Values always travel as bindings, so file names and contents are never touched. Namespaces are lowercase letters and digits only, which keeps two namespaces from ever mapping to the same table names. A test checks that a namespaced Workspace creates no table or index outside its prefix, so a new table that the rename misses fails the build. A durable object still has one container, so only one Workspace in it should have a container backend. The README and the schema doc say so. Fixes #219. --- .changeset/workspace-namespaces.md | 5 + docs/03_filesystem_schema.md | 7 ++ packages/computer/README.md | 16 +++ packages/computer/src/namespace.test.ts | 125 ++++++++++++++++++++++++ packages/computer/src/namespace.ts | 70 +++++++++++++ packages/computer/src/workspace.ts | 18 +++- 6 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 .changeset/workspace-namespaces.md create mode 100644 packages/computer/src/namespace.test.ts create mode 100644 packages/computer/src/namespace.ts diff --git a/.changeset/workspace-namespaces.md b/.changeset/workspace-namespaces.md new file mode 100644 index 00000000..18772a98 --- /dev/null +++ b/.changeset/workspace-namespaces.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +`new Workspace({ storage, namespace })` keeps a Workspace's files in their own tables, so one durable object can hold more than one Workspace. Workspaces without a namespace keep using the existing tables. diff --git a/docs/03_filesystem_schema.md b/docs/03_filesystem_schema.md index 2e258179..7b0e6e65 100644 --- a/docs/03_filesystem_schema.md +++ b/docs/03_filesystem_schema.md @@ -13,6 +13,13 @@ ultimately hits one of these tables. All tables are prefixed with `vfs_` (or `_vfs_` for internal bookkeeping) so they don't collide with application-owned tables in the same DO storage. +A Workspace created with a `namespace` uses its own copy of every +table and index below, named with a `ws__` prefix: +`vfs_nodes` becomes `ws_scratch_vfs_nodes` for the namespace +`scratch`. The Workspace runtime's tables (`workspace_runtime_*`, +`computer_execution_runtime`) get the same prefix. A Workspace +without a namespace uses the names as written here. + Paths are resolved through an inode-style indirection (`vfs_dirents` → `vfs_nodes`), so the local namespace move in a rename is O(1) and hardlinks fall out for free. Directory rename sync still walks the diff --git a/packages/computer/README.md b/packages/computer/README.md index 81799049..13bd3175 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -185,6 +185,22 @@ const hits = await ws.fs.grep("TODO", "/", { ignoreCase: true }); See [`docs/04_filesystem_interface.md`](../../docs/04_filesystem_interface.md) for the full surface. +### Several workspaces in one durable object + +Pass a `namespace` to keep a Workspace's files apart from other +Workspaces on the same storage. Each namespace gets its own tables, +so a durable object can hold, say, a scratch area and a project: + +```ts +const project = new Workspace({ storage: ctx.storage }); +const scratch = new Workspace({ storage: ctx.storage, namespace: "scratch" }); +``` + +A namespace is 1 to 32 lowercase letters and digits. Without one, a +Workspace uses the default tables, where existing data already is. A +durable object still has one container, so give a container backend +to only one of the Workspaces. + ### Read-only mounts Pre-fill part of the tree from an R2 bucket. Files under the mount point diff --git a/packages/computer/src/namespace.test.ts b/packages/computer/src/namespace.test.ts new file mode 100644 index 00000000..47d45a0d --- /dev/null +++ b/packages/computer/src/namespace.test.ts @@ -0,0 +1,125 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it } from "vitest"; + +import { createGitClient } from "./git/index.js"; +import { namespacePrefix } from "./namespace.js"; +import { Workspace } from "./workspace.js"; + +function exists(ws: Workspace, path: string): Promise { + return ws.fs.stat(path).then( + () => true, + () => false, + ); +} + +function tables(storage: SQLiteTestStorage): string[] { + return storage.sql + .exec("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .toArray() + .map((row) => String((row as { name: unknown }).name)); +} + +describe("Workspace namespaces", () => { + it("keeps files apart between namespaces on one storage", async () => { + const storage = new SQLiteTestStorage(); + const main = new Workspace({ storage }); + const scratch = new Workspace({ storage, namespace: "scratch" }); + const project = new Workspace({ storage, namespace: "project" }); + + await main.fs.writeFile("/notes.md", "main"); + await scratch.fs.writeFile("/notes.md", "scratch"); + await project.fs.writeFile("/notes.md", "project"); + await scratch.fs.writeFile("/only-scratch.txt", "x"); + + expect(await main.fs.readFile("/notes.md", "utf8")).toBe("main"); + expect(await scratch.fs.readFile("/notes.md", "utf8")).toBe("scratch"); + expect(await project.fs.readFile("/notes.md", "utf8")).toBe("project"); + expect(await exists(main, "/only-scratch.txt")).toBe(false); + expect(await exists(project, "/only-scratch.txt")).toBe(false); + + await scratch.fs.rm("/notes.md"); + expect(await main.fs.readFile("/notes.md", "utf8")).toBe("main"); + expect(await project.fs.readFile("/notes.md", "utf8")).toBe("project"); + }); + + it("finds the same files again when reopened with the same namespace", async () => { + const storage = new SQLiteTestStorage(); + const first = new Workspace({ storage, namespace: "scratch" }); + await first.fs.mkdir("/a"); + await first.fs.writeFile("/a/b.txt", "kept"); + + const reopened = new Workspace({ storage, namespace: "scratch" }); + + expect(await reopened.fs.readFile("/a/b.txt", "utf8")).toBe("kept"); + expect(await exists(new Workspace({ storage }), "/a/b.txt")).toBe(false); + }); + + it("leaves the default tables where existing data already is", async () => { + const storage = new SQLiteTestStorage(); + await new Workspace({ storage }).fs.writeFile("/old.txt", "before namespaces"); + new Workspace({ storage, namespace: "scratch" }); + + const names = tables(storage); + + expect(names).toContain("vfs_nodes"); + expect(names).toContain("_vfs_mounts"); + expect(names).toContain("ws_scratch_vfs_nodes"); + expect(names).toContain("ws_scratch__vfs_mounts"); + expect(names).toContain("ws_scratch_computer_execution_runtime"); + expect(await new Workspace({ storage }).fs.readFile("/old.txt", "utf8")).toBe( + "before namespaces", + ); + }); + + it("creates every table and index under the namespace", async () => { + const storage = new SQLiteTestStorage(); + const scratch = new Workspace({ storage, namespace: "scratch" }); + await scratch.fs.writeFile("/a.txt", "a"); + + const names = storage.sql + .exec( + "SELECT name FROM sqlite_master WHERE type IN ('table', 'index') AND sql IS NOT NULL AND name NOT LIKE 'sqlite_%'", + ) + .toArray() + .map((row) => String((row as { name: unknown }).name)); + + expect(names.length).toBeGreaterThan(0); + expect(names.filter((name) => !name.startsWith("ws_scratch_"))).toEqual([]); + }); + + it("never rewrites file names or contents", async () => { + const storage = new SQLiteTestStorage(); + const scratch = new Workspace({ storage, namespace: "scratch" }); + const text = "SELECT * FROM vfs_nodes; DROP TABLE _vfs_mounts;"; + + await scratch.fs.writeFile("/vfs_nodes.sql", text); + + expect(await scratch.fs.readFile("/vfs_nodes.sql", "utf8")).toBe(text); + expect((await scratch.fs.readdir("/")).map((entry) => entry.name)).toContain("vfs_nodes.sql"); + }); + + it("gives each namespace its own git repository", async () => { + const storage = new SQLiteTestStorage(); + const identity = { name: "T", email: "t@example.test" }; + const scratch = new Workspace({ storage, namespace: "scratch", git: createGitClient() }); + const project = new Workspace({ storage, namespace: "project", git: createGitClient() }); + + await scratch.git.init({ dir: "/repo" }); + await scratch.fs.writeFile("/repo/a.txt", "a\n"); + await scratch.git.add({ dir: "/repo", paths: ["a.txt"] }); + await scratch.git.commit({ dir: "/repo", message: "a", author: identity }); + + expect(await scratch.git.log({ dir: "/repo" })).toHaveLength(1); + expect((await project.git.cli({ argv: ["status"], cwd: "/repo" })).exitCode).not.toBe(0); + }); + + it("rejects namespaces that could clash", () => { + const storage = new SQLiteTestStorage(); + for (const namespace of ["", "Scratch", "a_b", "a-b", "x".repeat(33)]) { + expect(() => new Workspace({ storage, namespace })).toThrow( + "namespace must be 1 to 32 lowercase letters and digits", + ); + } + expect(namespacePrefix("scratch2")).toBe("ws_scratch2_"); + }); +}); diff --git a/packages/computer/src/namespace.ts b/packages/computer/src/namespace.ts new file mode 100644 index 00000000..74b0abe7 --- /dev/null +++ b/packages/computer/src/namespace.ts @@ -0,0 +1,70 @@ +// Workspace namespaces: more than one Workspace in one Durable +// Object's storage. +// +// Every table a Workspace owns has a fixed name (`vfs_nodes`, +// `_vfs_mounts`, `workspace_runtime_executions`, ...), and the SQL +// that reads and writes them is spread across the filesystem, sync, +// mount, and runtime code. Rather than thread a table prefix through +// all of it, a namespaced Workspace gets a storage handle that renames +// those tables on the way to SQLite. `vfs_nodes` becomes +// `ws_scratch_vfs_nodes` for the namespace `scratch`. +// +// The rename matches only identifiers this package creates. Values +// always travel as bindings, never inside the SQL text, so file data +// is never rewritten. + +import type { DurableObjectStorageLike } from "@cloudflare/dofs"; + +const NAMESPACE = /^[a-z0-9]{1,32}$/; + +// Tables, indexes, and migration scratch tables the Workspace +// creates. `\b` stops a match inside a longer identifier. +const WORKSPACE_IDENTIFIER = + /\b(?:_?vfs_[a-z0-9_]+|workspace_runtime_[a-z0-9_]+|computer_execution_runtime)\b/g; + +/** + * Check a namespace and return the table prefix for it. + * + * Namespaces are 1 to 32 lowercase letters and digits. They can't + * contain `_`, so no two namespaces map to the same table names, and + * none maps to the default ones. + */ +export function namespacePrefix(namespace: string): string { + if (!NAMESPACE.test(namespace)) { + throw new TypeError( + `Workspace: namespace must be 1 to 32 lowercase letters and digits, got ${JSON.stringify(namespace)}.`, + ); + } + return `ws_${namespace}_`; +} + +/** + * Wrap `storage` so the SQL a Workspace runs reads and writes its own + * copy of the Workspace tables. + * + * @param storage - The Durable Object storage to share. + * @param namespace - 1 to 32 lowercase letters and digits. + * @returns Storage that renames the Workspace tables for `namespace`. + */ +export function namespacedStorage( + storage: DurableObjectStorageLike, + namespace: string, +): DurableObjectStorageLike { + const prefix = namespacePrefix(namespace); + const rewrite = (query: string) => + query.replace(WORKSPACE_IDENTIFIER, (identifier) => prefix + identifier); + const namespaced: DurableObjectStorageLike = { + sql: { + exec: (query, ...bindings) => storage.sql.exec(rewrite(query), ...bindings), + }, + }; + if (storage.transactionSync !== undefined) { + const transactionSync = storage.transactionSync.bind(storage); + namespaced.transactionSync = (closure) => transactionSync(closure); + } + if (storage.transaction !== undefined) { + const transaction = storage.transaction.bind(storage); + namespaced.transaction = (closure) => transaction(closure); + } + return namespaced; +} diff --git a/packages/computer/src/workspace.ts b/packages/computer/src/workspace.ts index ba053da3..fd3afc96 100644 --- a/packages/computer/src/workspace.ts +++ b/packages/computer/src/workspace.ts @@ -42,6 +42,7 @@ import type { GitClient, GitClientFactory, GitIdentity } from "./git/index.js"; import { MountIndex } from "./mounts/index.js"; import { buildMountRegistry, type MountValue } from "./mounts/registry.js"; import type { Mount } from "./mounts/types.js"; +import { namespacedStorage } from "./namespace.js"; import { noopObserver, safeErrorMessage, type WorkspaceObserver, withSpan } from "./observe.js"; import { createSyncLogger, type SyncLogger } from "./observe/sync-telemetry.js"; import { @@ -82,6 +83,17 @@ export interface WorkspaceOptions { // Database against it and runs initializeSchema (idempotent). storage: DurableObjectStorageLike; + // Keeps this Workspace's files apart from other Workspaces on the + // same storage. Each namespace gets its own copy of the Workspace + // tables, so one Durable Object can hold, say, a scratch area and a + // project side by side. 1 to 32 lowercase letters and digits. Omit it + // for the default tables, which is where existing data already is. + // + // Only the files and the runtime's records are separate. A Durable + // Object still has one container, so give a container backend to + // only one of the Workspaces. + namespace?: string; + // Registered execution backends. The first is the default; // callers can select another by stable id. // Omit to construct a filesystem-only Workspace whose runtime @@ -305,7 +317,11 @@ export class Workspace { this.#artifacts = options.artifacts ? createArtifact(options.artifacts.binding, artifactsSessionId(options)) : createDisabledArtifactsClient(); - this.#db = new Database(options.storage); + this.#db = new Database( + options.namespace === undefined + ? options.storage + : namespacedStorage(options.storage, options.namespace), + ); initializeSchema(this.#db, this.#now); this.#executionRuntimes = new ExecutionRuntimeTracker( 1_024,