From 446de9c8420697d9f57c20dc4cb98dbc97fb215b Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:50:14 +0000 Subject: [PATCH 1/2] examples, docs: add a worker-javascript example Mirror the worker-shell example with a Durable Object whose Workspace runs an ECMAScript module in a Dynamic Worker through the WorkerJavaScriptBackend. The DO class, HTTP routes, and R2 mount match worker-shell; the difference is the backend and what exec runs. The backend takes only the Loader binding, needs no WorkspaceServiceProxy loopback, and requires waitUntil, so the DO passes ctx.waitUntil.bind(ctx) into the Workspace options. The exec route accepts a module source plus an optional structured input and returns the run's status, streams, and the module's structured value. List the new example in the project layout doc. --- docs/10_project_layout.md | 1 + examples/worker-javascript/.gitignore | 3 + examples/worker-javascript/README.md | 142 ++++++++++++++++ examples/worker-javascript/package.json | 22 +++ .../worker-javascript/seed/data/hello.txt | 1 + examples/worker-javascript/src/index.ts | 152 ++++++++++++++++++ examples/worker-javascript/tsconfig.json | 17 ++ .../worker-configuration.d.ts | 9 ++ examples/worker-javascript/wrangler.jsonc | 51 ++++++ package-lock.json | 39 ++++- 10 files changed, 429 insertions(+), 8 deletions(-) create mode 100644 examples/worker-javascript/.gitignore create mode 100644 examples/worker-javascript/README.md create mode 100644 examples/worker-javascript/package.json create mode 100644 examples/worker-javascript/seed/data/hello.txt create mode 100644 examples/worker-javascript/src/index.ts create mode 100644 examples/worker-javascript/tsconfig.json create mode 100644 examples/worker-javascript/worker-configuration.d.ts create mode 100644 examples/worker-javascript/wrangler.jsonc diff --git a/docs/10_project_layout.md b/docs/10_project_layout.md index 0ca1546a..56679783 100644 --- a/docs/10_project_layout.md +++ b/docs/10_project_layout.md @@ -200,6 +200,7 @@ Runnable examples live at the repo root, not inside any package: examples/ ├── container/ # Reference container image for computerd ├── worker-shell/ # WorkerShellBackend example +├── worker-javascript/ # WorkerJavaScriptBackend example ├── code/ # workspace.runtime with Worker and Container shells └── think/ # @cloudflare/think integration ``` diff --git a/examples/worker-javascript/.gitignore b/examples/worker-javascript/.gitignore new file mode 100644 index 00000000..d27f2ecb --- /dev/null +++ b/examples/worker-javascript/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +.wrangler/ diff --git a/examples/worker-javascript/README.md b/examples/worker-javascript/README.md new file mode 100644 index 00000000..05cd2d1a --- /dev/null +++ b/examples/worker-javascript/README.md @@ -0,0 +1,142 @@ +# worker-javascript example + +> [!IMPORTANT] +> **PREVIEW ONLY** This package is provided as a preview for feedback only. +> APIs are unstable and the design is subject to change. + +A Cloudflare Worker + Durable Object that runs a Workspace whose +runtime evaluates **ECMAScript modules** in a **Dynamic Worker** +loaded through `env.LOADER`. It mirrors +[`examples/worker-shell`](../worker-shell) beat for beat — same DO +class, same routes, same R2 mount — but `exec` runs a JavaScript +module instead of a just-bash command. + +## Architecture + +``` +client ─► Worker /c//{file,exec} + │ (DO RPC calls) + ▼ + DO (ContainerExample) ──► Workspace ──► WorkerJavaScriptBackend + │ + │ env.LOADER.load(...) + ▼ + Dynamic Worker + (module runner) + │ + │ evaluate(input, bridge) + ▼ + host capability bridge + (fs, git, artifacts) +``` + +1. The DO constructs a `WorkerJavaScriptBackend` from + `@cloudflare/computer/backends/worker-javascript`, passing only + the Loader binding. Unlike the shell backend there is no + `WorkspaceServiceProxy` loopback: the backend is self-contained + and reaches the host through the `WorkspaceRuntimeBridge` it + builds internally. +2. Each `exec(source, { backend: "worker-javascript", input })` + builds a module graph from the source, mints a fresh Dynamic + Worker through `env.LOADER.load(...)`, and calls the runner's + `evaluate(input, bridge)`. The module's default export receives + the structured `input` and its return value becomes the + result's `value`. +3. Filesystem, Git, and artifacts operations from inside the module + round-trip through the bridge to the host DO's own store, so + storage handles stay valid and one workspace per DO is the + natural boundary. +4. `BackendHandle.sync` is `"none"`. There's a single authoritative + store (the DO's SQLite); push and pull short-circuit. + `pushed` / `pulled` are always zero. + +The backend requires `waitUntil`, so the DO passes +`ctx.waitUntil.bind(ctx)` into the Workspace options. The DO is a +thin host; the Dynamic Worker lifecycle is the loader's problem. + +## Paths + +`PUT /c//file/workspace/hello.txt` writes +`/workspace/hello.txt`, and +`GET /c//file/workspace/r2/x` reads `/workspace/r2/x` — +the URL and the on-disk path always match. Any URL outside +`/workspace` returns 400. + +`exec` runs with `cwd` defaulting to `/workspace`. The source is +an ECMAScript module; its default export may be a function that +receives the request's `input` value and returns a +JSON-compatible result. The Dynamic Worker has +`globalOutbound: null`, so the module can't reach the public +internet on its own. + +## R2 mount + +Identical to the worker-shell example. Seed once with: + +```sh +npm run seed:r2:local --workspace @example/computer-worker-javascript + +# or after deploy +npm run seed:r2 --workspace @example/computer-worker-javascript +``` + +## HTTP surface + +``` +PUT /c//file/workspace/ raw body → writeFile at /workspace/ +GET /c//file/workspace/ octet-stream of /workspace/ + (any path outside /workspace returns 400) +POST /c//exec { source, input?, cwd? } + cwd defaults to /workspace + → JSON { status, exitCode, stdout, stderr, value } +``` + +## Run it locally + +No Docker, no extra build step. The module runner ships inside +`@cloudflare/computer/backends/worker-javascript`; +`WorkerJavaScriptBackend` mints the Dynamic Worker through the +Loader binding internally so the DO constructor stays a one-line +backend invocation. + +```sh +npm run dev --workspace @example/computer-worker-javascript +``` + +Smoke test: + +```sh +curl http://127.0.0.1:8787/ + +echo 'hello' | curl -X PUT --data-binary @- \ + http://127.0.0.1:8787/c/demo/file/workspace/hello.txt + +curl http://127.0.0.1:8787/c/demo/file/workspace/hello.txt + +curl -X POST http://127.0.0.1:8787/c/demo/exec \ + -H 'content-type: application/json' \ + -d '{"source":"import { readFile } from \"node:fs/promises\";\nexport default async () => (await readFile(\"/workspace/hello.txt\", \"utf8\")).trim();"}' + +curl -X POST http://127.0.0.1:8787/c/demo/exec \ + -H 'content-type: application/json' \ + -d '{"source":"export default (input) => input.n * 2;","input":{"n":21}}' +``` + +## Layout + +``` +examples/worker-javascript/ + wrangler.jsonc Worker + DO + worker_loaders binding + src/index.ts Worker handler + DO (ContainerExample) +``` + +Nothing else. The Dynamic Worker module runner ships from +`@cloudflare/computer/backends/worker-javascript`. + +## Known limitations + +- **Exec is run-and-collect.** The handler awaits + `handle.result()` and emits one JSON response. +- **One execution at a time by default.** The backend admits a + single execution per workspace unless configured otherwise, and + bounds completed-execution retention by time and count. diff --git a/examples/worker-javascript/package.json b/examples/worker-javascript/package.json new file mode 100644 index 00000000..6188206f --- /dev/null +++ b/examples/worker-javascript/package.json @@ -0,0 +1,22 @@ +{ + "name": "@example/computer-worker-javascript", + "version": "0.0.0", + "private": true, + "type": "module", + "description": "Example Worker + Durable Object that evaluates ECMAScript modules inside a Dynamic Worker via @cloudflare/computer's WorkerJavaScriptBackend.", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "typecheck": "tsc --noEmit", + "seed:r2": "wrangler r2 object put computer-worker-javascript-hello/hello.txt --file ./seed/data/hello.txt --remote", + "seed:r2:local": "wrangler r2 object put computer-worker-javascript-hello/hello.txt --file ./seed/data/hello.txt --local" + }, + "dependencies": { + "@cloudflare/computer": "*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260616.1", + "typescript": "^6.0.3", + "wrangler": "^4.107.1" + } +} diff --git a/examples/worker-javascript/seed/data/hello.txt b/examples/worker-javascript/seed/data/hello.txt new file mode 100644 index 00000000..3b18e512 --- /dev/null +++ b/examples/worker-javascript/seed/data/hello.txt @@ -0,0 +1 @@ +hello world diff --git a/examples/worker-javascript/src/index.ts b/examples/worker-javascript/src/index.ts new file mode 100644 index 00000000..4f0fc3ea --- /dev/null +++ b/examples/worker-javascript/src/index.ts @@ -0,0 +1,152 @@ +import { DurableObject } from "cloudflare:workers"; + +import { + type DurableObjectStorageLike, + getWorkspace, + R2Bucket, + type WorkspaceRuntimeValue, + withWorkspace, +} from "@cloudflare/computer"; +import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-javascript"; + +export class ContainerExample extends withWorkspace(class extends DurableObject {}, (self) => { + const { ctx, env } = self as unknown as { ctx: DurableObjectState; env: Env }; + return { + storage: ctx.storage as unknown as DurableObjectStorageLike, + waitUntil: ctx.waitUntil.bind(ctx), + backends: [new WorkerJavaScriptBackend({ loader: env.LOADER })], + mounts: { + "/workspace/r2": R2Bucket(env.Bucket), + }, + }; +}) {} + +interface ExecRequest { + source?: string; + input?: WorkspaceRuntimeValue; + cwd?: string; +} + +const MOUNT_ROOT = "/workspace"; + +function resolveMountPath(rest: string): string | null { + const candidate = `/${rest}`; + if (candidate !== MOUNT_ROOT && !candidate.startsWith(`${MOUNT_ROOT}/`)) { + return null; + } + if (candidate.split("/").includes("..")) return null; + return candidate; +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + + const fileMatch = url.pathname.match(/^\/c\/([^/]+)\/file\/(.+)$/); + if (fileMatch) { + const resolved = resolveMountPath(fileMatch[2]); + if (resolved === null) { + return errorJSON(new Error(`path must sit under ${MOUNT_ROOT}; got /${fileMatch[2]}`), 400); + } + return handleFile(request, env, fileMatch[1], resolved); + } + + const execMatch = url.pathname.match(/^\/c\/([^/]+)\/exec\/?$/); + if (execMatch) return handleExec(request, env, execMatch[1]); + + if (url.pathname === "/" || url.pathname === "") { + return new Response( + [ + "worker-javascript example", + "", + ` PUT /c//file/workspace/ write file at ${MOUNT_ROOT}/`, + ` GET /c//file/workspace/ read file at ${MOUNT_ROOT}/`, + " POST /c//exec run an ECMAScript module (JSON result)", + "", + ].join("\n"), + { headers: { "content-type": "text/plain" } }, + ); + } + + return new Response("not found", { status: 404 }); + }, +} satisfies ExportedHandler; + +async function handleFile( + request: Request, + env: Env, + name: string, + path: string, +): Promise { + const stub = env.ContainerExample.get(env.ContainerExample.idFromName(name)); + const ws = await getWorkspace(stub as unknown as Parameters[0]); + + if (request.method === "PUT") { + const body = new Uint8Array(await request.arrayBuffer()); + try { + await ws.fs.writeFile(path, body); + return new Response(null, { status: 204 }); + } catch (error) { + return errorJSON(error, 500); + } + } + + if (request.method === "GET") { + try { + const stream = await ws.fs.readFile(path, {}); + return new Response(stream, { + status: 200, + headers: { "content-type": "application/octet-stream" }, + }); + } catch (error) { + const code = (error as { code?: string }).code; + if (code === "ENOENT") return errorJSON(error, 404); + return errorJSON(error, 500); + } + } + + return new Response("method not allowed", { status: 405, headers: { allow: "GET, PUT" } }); +} + +async function handleExec(request: Request, env: Env, name: string): Promise { + if (request.method !== "POST") { + return new Response("method not allowed", { status: 405, headers: { allow: "POST" } }); + } + let body: ExecRequest; + try { + body = (await request.json()) as ExecRequest; + } catch { + return errorJSON(new Error("invalid JSON body"), 400); + } + + if (typeof body.source !== "string" || body.source.length === 0) { + return errorJSON(new Error("must provide source"), 400); + } + + const stub = env.ContainerExample.get(env.ContainerExample.idFromName(name)); + const ws = await getWorkspace(stub as unknown as Parameters[0]); + try { + const handle = await ws.runtime.exec(body.source, { + backend: "worker-javascript", + cwd: body.cwd, + input: body.input, + encoding: "utf8", + }); + const result = await handle.result(); + return new Response(JSON.stringify(result), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } catch (error) { + return errorJSON(error, 500); + } +} + +function errorJSON(error: unknown, status: number): Response { + const message = error instanceof Error ? error.message : String(error); + const code = (error as { code?: string }).code; + return new Response(JSON.stringify({ error: message, code }), { + status, + headers: { "content-type": "application/json" }, + }); +} diff --git a/examples/worker-javascript/tsconfig.json b/examples/worker-javascript/tsconfig.json new file mode 100644 index 00000000..4a2585dc --- /dev/null +++ b/examples/worker-javascript/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "types": ["./worker-configuration.d.ts", "@cloudflare/workers-types"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["worker-configuration.d.ts", "src/**/*.ts"] +} diff --git a/examples/worker-javascript/worker-configuration.d.ts b/examples/worker-javascript/worker-configuration.d.ts new file mode 100644 index 00000000..632c8923 --- /dev/null +++ b/examples/worker-javascript/worker-configuration.d.ts @@ -0,0 +1,9 @@ +// Hand-written env shape for the platform Worker. Run +// `wrangler types` to regenerate from wrangler.jsonc when the +// bindings change. + +interface Env { + ContainerExample: DurableObjectNamespace; + LOADER: WorkerLoader; + Bucket: R2Bucket; +} diff --git a/examples/worker-javascript/wrangler.jsonc b/examples/worker-javascript/wrangler.jsonc new file mode 100644 index 00000000..83c9b550 --- /dev/null +++ b/examples/worker-javascript/wrangler.jsonc @@ -0,0 +1,51 @@ +{ + // Example: Worker + Durable Object that runs a Workspace whose + // runtime is an ECMAScript module evaluated in a Dynamic Worker + // loaded through env.LOADER. + // + // Mirrors examples/worker-shell beat for beat — same DO class + // name, same routes, same R2 mount. The only difference is the + // backend: WorkerJavaScriptBackend instead of WorkerShellBackend, + // so exec evaluates JavaScript modules rather than shell commands. + "$schema": "node_modules/wrangler/config-schema.json", + "name": "computer-worker-javascript-example", + "main": "src/index.ts", + "compatibility_date": "2026-05-26", + "compatibility_flags": ["nodejs_compat", "experimental"], + + // Worker Loader binding. The DO's WorkerJavaScriptBackend calls + // env.LOADER.load(...) to mint a Dynamic Worker that evaluates + // each module. + "worker_loaders": [ + { + "binding": "LOADER" + } + ], + + "durable_objects": { + "bindings": [ + { + "name": "ContainerExample", + "class_name": "ContainerExample" + } + ] + }, + + // R2 bucket mounted into every Workspace at /workspace/r2 via + // R2Bucket(...). Seed it once with: + // npm run seed:r2 --workspace @example/computer-worker-javascript + // which uploads ./seed/data/hello.txt to the bucket. + "r2_buckets": [ + { + "binding": "Bucket", + "bucket_name": "computer-worker-javascript-hello" + } + ], + + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["ContainerExample"] + } + ] +} diff --git a/package-lock.json b/package-lock.json index acd4bcd1..82f62631 100644 --- a/package-lock.json +++ b/package-lock.json @@ -220,8 +220,8 @@ "dev": true, "license": "MIT OR Apache-2.0" }, - "examples/worker": { - "name": "@example/computer-worker", + "examples/worker-javascript": { + "name": "@example/computer-worker-javascript", "version": "0.0.0", "dependencies": { "@cloudflare/computer": "*" @@ -232,10 +232,29 @@ "wrangler": "^4.107.1" } }, - "examples/worker/node_modules/@cloudflare/workers-types": { - "version": "4.20260702.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260702.1.tgz", - "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==", + "examples/worker-javascript/node_modules/@cloudflare/workers-types": { + "version": "4.20260626.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260626.1.tgz", + "integrity": "sha512-fBnpQyFRS3Ce1l2IUd3k+aUxgy/7VMlVXF4F672/eSrpXFeezCy3Ha6Z2uTyGgqu9sGvQPOj8nqKBv2yeI+ciw==", + "dev": true, + "license": "MIT OR Apache-2.0" + }, + "examples/worker-shell": { + "name": "@example/computer-worker-shell", + "version": "0.0.0", + "dependencies": { + "@cloudflare/computer": "*" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20260616.1", + "typescript": "^6.0.3", + "wrangler": "^4.107.1" + } + }, + "examples/worker-shell/node_modules/@cloudflare/workers-types": { + "version": "4.20260626.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20260626.1.tgz", + "integrity": "sha512-fBnpQyFRS3Ce1l2IUd3k+aUxgy/7VMlVXF4F672/eSrpXFeezCy3Ha6Z2uTyGgqu9sGvQPOj8nqKBv2yeI+ciw==", "dev": true, "license": "MIT OR Apache-2.0" }, @@ -2822,8 +2841,12 @@ "resolved": "examples/tutorial", "link": true }, - "node_modules/@example/computer-worker": { - "resolved": "examples/worker", + "node_modules/@example/computer-worker-javascript": { + "resolved": "examples/worker-javascript", + "link": true + }, + "node_modules/@example/computer-worker-shell": { + "resolved": "examples/worker-shell", "link": true }, "node_modules/@exodus/bytes": { From 042ad763f33e209493c52d22b7e126f084dab860 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:57:22 +0000 Subject: [PATCH 2/2] computer: gate structured input on a declared callable capability The runtime rejected structured input for any command-routed backend, conflating two independent questions: which downstream surface a backend speaks, and whether it accepts a structured input value and returns a structured result. A shell backend that coerced JSON to argv and parsed stdout back to a value could not opt in. Add an optional callable capability to the backend interfaces, default false. The WorkerJavaScriptBackend declares it true. The runtime router now gates input on a callableBackendIds set built from that capability, independent of command-versus-module routing, and reports a clearer "not callable" error. --- packages/computer/src/backend.ts | 7 +++ .../worker-javascript/worker-javascript.ts | 1 + packages/computer/src/runtime/runtime.test.ts | 60 +++++++++++++++++++ packages/computer/src/runtime/runtime.ts | 9 ++- packages/computer/src/runtime/types.ts | 1 + packages/computer/src/workspace.ts | 5 ++ 6 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 packages/computer/src/runtime/runtime.test.ts diff --git a/packages/computer/src/backend.ts b/packages/computer/src/backend.ts index ac3237c0..0156fd2c 100644 --- a/packages/computer/src/backend.ts +++ b/packages/computer/src/backend.ts @@ -38,6 +38,13 @@ export interface WorkspaceBackend { // Workspace selection logic. readonly type: string; + // Whether the backend accepts a structured `input` value and + // returns a structured `result` value. Independent of the + // implementation kind: a shell backend that coerces JSON to + // argv/stdin and parses stdout back to a value can be callable + // too. Defaults to false when omitted. + readonly callable?: boolean; + // Materialise a connection. Called lazily on first use, once // per backend per workspace lifetime. The Workspace caches the // resulting handle by `id`; subsequent exec / push / pull diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index ce4c5f53..daba8018 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -122,6 +122,7 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { readonly protocol = "module" as const; readonly requiresWaitUntil = true; readonly type = "worker-javascript"; + readonly callable = true; readonly id: string; readonly #options: ResolvedWorkerJavaScriptBackendOptions; diff --git a/packages/computer/src/runtime/runtime.test.ts b/packages/computer/src/runtime/runtime.test.ts new file mode 100644 index 00000000..cc27ba26 --- /dev/null +++ b/packages/computer/src/runtime/runtime.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { WorkspaceShell } from "../shell.js"; +import { WorkspaceRuntime } from "./runtime.js"; +import type { ModuleExecutionEnvelope, WorkspaceModuleBackendHandle } from "./types.js"; + +function emptyEnvelope(id: string): ModuleExecutionEnvelope { + return { + id, + events: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + }; +} + +function moduleHandleStub(): WorkspaceModuleBackendHandle { + return { + exec: vi.fn(async (input) => emptyEnvelope(input.id ?? "exec")), + getExec: vi.fn(async (input) => emptyEnvelope(input.id)), + killExec: vi.fn(async () => {}), + disposeExec: vi.fn(async () => {}), + close: vi.fn(async () => {}), + }; +} + +describe("WorkspaceRuntime callable gate", () => { + it("rejects structured input for a non-callable backend", async () => { + const runtime = new WorkspaceRuntime({ + commandBackendIds: new Set(["worker-shell"]), + callableBackendIds: new Set(), + shell: () => ({}) as unknown as WorkspaceShell, + moduleHandle: async () => moduleHandleStub(), + resolveBackendId: () => "worker-shell", + }); + + await expect( + runtime.exec("echo hi", { backend: "worker-shell", input: { n: 1 } }), + ).rejects.toThrow(/not callable/); + }); + + it("accepts structured input for a callable module backend", async () => { + const handle = moduleHandleStub(); + const runtime = new WorkspaceRuntime({ + commandBackendIds: new Set(), + callableBackendIds: new Set(["worker-javascript"]), + shell: () => ({}) as unknown as WorkspaceShell, + moduleHandle: async () => handle, + resolveBackendId: () => "worker-javascript", + }); + + await runtime.exec("export default (i) => i", { + backend: "worker-javascript", + input: { n: 1 }, + }); + + expect(handle.exec).toHaveBeenCalledWith(expect.objectContaining({ input: { n: 1 } })); + }); +}); diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index e7a98958..05cc219e 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -14,6 +14,7 @@ import type { interface WorkspaceRuntimeRouterOptions { commandBackendIds: ReadonlySet; + callableBackendIds: ReadonlySet; shell: () => WorkspaceShell; moduleHandle: (id: string) => Promise; resolveBackendId: (id: string | undefined) => string; @@ -41,10 +42,12 @@ export class WorkspaceRuntime { ): Promise> { if (options.id !== undefined) assertExecutionId(options.id); const backend = this.#backend(options.backend); + if (options.input !== undefined && !this.#options.callableBackendIds.has(backend)) { + throw new Error( + `Backend ${JSON.stringify(backend)} is not callable; it does not accept structured input.`, + ); + } if (this.#options.commandBackendIds.has(backend)) { - if (options.input !== undefined) { - throw new Error(`Backend ${JSON.stringify(backend)} does not accept structured input.`); - } const shell = this.#options.shell(); const handle = await ( shell.exec as unknown as ( diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index 7964de26..a7aaa782 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -170,6 +170,7 @@ export interface WorkspaceModuleBackend { readonly id: string; readonly requiresWaitUntil?: boolean; readonly type: string; + readonly callable?: boolean; connect(host: WorkspaceModuleBackendHost): Promise; } diff --git a/packages/computer/src/workspace.ts b/packages/computer/src/workspace.ts index 7d6b0b39..115504f7 100644 --- a/packages/computer/src/workspace.ts +++ b/packages/computer/src/workspace.ts @@ -192,6 +192,7 @@ export class Workspace { readonly #backendsById: Map; readonly #moduleBackendsById: Map; readonly #registeredBackendIds: Set; + readonly #callableBackendIds: Set; readonly #defaultBackendId: string | undefined; readonly #defaultCommandBackendId: string | undefined; readonly #observer: WorkspaceObserver; @@ -291,6 +292,9 @@ export class Workspace { registered.filter(isModuleBackend).map((backend) => [backend.id, backend]), ); this.#registeredBackendIds = new Set(); + this.#callableBackendIds = new Set( + registered.filter((backend) => backend.callable === true).map((backend) => backend.id), + ); for (const backend of registered) { if (this.#registeredBackendIds.has(backend.id)) { throw new Error( @@ -408,6 +412,7 @@ export class Workspace { if (!this.#runtime) { this.#runtime = new WorkspaceRuntime({ commandBackendIds: new Set(this.#backendsById.keys()), + callableBackendIds: this.#callableBackendIds, shell: () => this.#routedShell(), moduleHandle: (id) => this.#moduleHandleFor(id), resolveBackendId: (id) => this.#resolveBackendId(id) ?? "",