From 562e278c73ffc4acfd7f2251f78ae76cec062808 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:33:44 +0000 Subject: [PATCH 01/13] computer: carry the module result on the exit event The JavaScript execution path emitted a structured return value as its own result event, separate from the exit event that reported the exit code. A consumer had to collect both and correlate them by execution to learn the outcome of a single run, even though the runner produces the value and the exit code together and only when the run exits zero. Fold the value onto the exit event as an optional result field and drop the standalone result event. The change runs the length of the module path: the runner frame, the runner-to-host frame codec, the persist-and-replay codec, the event type, the backend's ingest and finalize, and the result drain in the runtime router. The persisted form now stores the exit payload as an object so a replayed execution restores the value alongside the code. --- .../backends/worker-javascript/frames.test.ts | 15 +++-- .../src/backends/worker-javascript/frames.ts | 13 +++-- .../worker-javascript.test.ts | 3 +- .../worker-javascript/worker-javascript.ts | 55 ++++++++++--------- packages/computer/src/runtime/runtime.ts | 6 +- packages/computer/src/runtime/types.ts | 3 +- packages/computer/src/runtime/wire.test.ts | 46 ++++++++++++++++ packages/computer/src/runtime/wire.ts | 7 +-- packages/computer/src/workspace.test.ts | 3 +- 9 files changed, 103 insertions(+), 48 deletions(-) create mode 100644 packages/computer/src/runtime/wire.test.ts diff --git a/packages/computer/src/backends/worker-javascript/frames.test.ts b/packages/computer/src/backends/worker-javascript/frames.test.ts index c065f481..2bfd22f0 100644 --- a/packages/computer/src/backends/worker-javascript/frames.test.ts +++ b/packages/computer/src/backends/worker-javascript/frames.test.ts @@ -36,16 +36,21 @@ describe("parseRuntimeFrame", () => { expect(frame).toEqual({ name: "stderr", value: new TextEncoder().encode("oops") }); }); - it("decodes a result frame carrying a structured value", () => { - const frame = parseRuntimeFrame(`{"name":"result","value":{"a":[1,2,null]}}`); - expect(frame).toEqual({ name: "result", value: { a: [1, 2, null] } }); - }); - it("decodes an exit frame carrying an integer", () => { expect(parseRuntimeFrame(`{"name":"exit","value":0}`)).toEqual({ name: "exit", value: 0 }); expect(parseRuntimeFrame(`{"name":"exit","value":130}`)).toEqual({ name: "exit", value: 130 }); }); + it("decodes an exit frame carrying a structured result", () => { + const frame = parseRuntimeFrame(`{"name":"exit","value":0,"result":{"a":[1,2,null]}}`); + expect(frame).toEqual({ name: "exit", value: 0, result: { a: [1, 2, null] } }); + }); + + it("decodes an exit frame carrying a null result", () => { + const frame = parseRuntimeFrame(`{"name":"exit","value":0,"result":null}`); + expect(frame).toEqual({ name: "exit", value: 0, result: null }); + }); + it("rejects invalid JSON", () => { expect(() => parseRuntimeFrame("not json")).toThrow(); }); diff --git a/packages/computer/src/backends/worker-javascript/frames.ts b/packages/computer/src/backends/worker-javascript/frames.ts index d924a6f5..4821b875 100644 --- a/packages/computer/src/backends/worker-javascript/frames.ts +++ b/packages/computer/src/backends/worker-javascript/frames.ts @@ -3,8 +3,7 @@ import type { WorkspaceRuntimeValue } from "../../runtime/types.js"; export type RuntimeFrame = | { name: "stdout"; value: Uint8Array } | { name: "stderr"; value: Uint8Array } - | { name: "result"; value: WorkspaceRuntimeValue } - | { name: "exit"; value: number }; + | { name: "exit"; value: number; result?: WorkspaceRuntimeValue }; export function parseRuntimeFrame(line: string): RuntimeFrame { let record: Record; @@ -20,13 +19,17 @@ export function parseRuntimeFrame(line: string): RuntimeFrame { } return { name, value: decodeBase64(record.b64) }; } - if (name === "result") { - return { name, value: record.value as WorkspaceRuntimeValue }; - } if (name === "exit") { if (!Number.isSafeInteger(record.value)) { throw new Error("WorkerJavaScriptBackend received a malformed exit frame"); } + if ("result" in record) { + return { + name, + value: record.value as number, + result: record.result as WorkspaceRuntimeValue, + }; + } return { name, value: record.value as number }; } throw new Error("WorkerJavaScriptBackend received an unknown execution frame"); diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts index 686070be..3976baeb 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -34,8 +34,7 @@ async function evaluateResult( const frames: string[] = []; try { await host.assertResult(value); - frames.push(JSON.stringify({ name: "result", value })); - frames.push(JSON.stringify({ name: "exit", value: 0 })); + frames.push(JSON.stringify({ name: "exit", value: 0, result: value })); } catch (error) { const message = error instanceof Error ? error.message : String(error); frames.push(JSON.stringify({ name: "stderr", b64: btoa(`${message}\n`) })); diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index d0bdf43a..b72aeeab 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -612,11 +612,12 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { name: frame.name, value: frame.value, }); - } else if (frame.name === "result") { - record.result = frame.value; - record.hasResult = true; } else { record.exitCode = frame.value; + if ("result" in frame) { + record.result = frame.result; + record.hasResult = true; + } } } @@ -676,22 +677,17 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { return; } const exitCode = record.exitCode; - const terminal: WorkspaceRuntimeEvent[] = []; - if (exitCode === 0 && record.hasResult) { - terminal.push({ - id: record.id, - seq: record.events.length + 1, - name: "result", - value: record.result as WorkspaceRuntimeValue, - }); - } - terminal.push({ - id: record.id, - seq: record.events.length + terminal.length + 1, - name: "exit", - value: exitCode, - }); - this.#finish(record, exitCode === 0 ? "completed" : "failed", terminal); + const exit: WorkspaceRuntimeEvent = + exitCode === 0 && record.hasResult + ? { + id: record.id, + seq: record.events.length + 1, + name: "exit", + value: exitCode, + result: record.result as WorkspaceRuntimeValue, + } + : { id: record.id, seq: record.events.length + 1, name: "exit", value: exitCode }; + this.#finish(record, exitCode === 0 ? "completed" : "failed", [exit]); } #stream(record: ExecutionRecord, after?: number | "tail") { @@ -889,7 +885,9 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { function encodeEvent(event: WorkspaceRuntimeEvent): Uint8Array { if (event.name === "stdout" || event.name === "stderr") return event.value; - return new TextEncoder().encode(JSON.stringify(event.value)); + const payload = + "result" in event ? { value: event.value, result: event.result } : { value: event.value }; + return new TextEncoder().encode(JSON.stringify(payload)); } function decodeEvent( @@ -899,10 +897,16 @@ function decodeEvent( payload: Uint8Array, ): WorkspaceRuntimeEvent { if (name === "stdout" || name === "stderr") return { id, seq, name, value: payload }; - const value = JSON.parse(new TextDecoder().decode(payload)) as unknown; - if (name === "exit") return { id, seq, name, value: Number(value) }; - assertRuntimeValue(value); - return { id, seq, name: "result", value }; + const decoded = JSON.parse(new TextDecoder().decode(payload)) as unknown; + if (typeof decoded === "object" && decoded !== null && "value" in decoded) { + const record = decoded as { value: unknown; result?: unknown }; + if ("result" in record) { + assertRuntimeValue(record.result); + return { id, seq, name: "exit", value: Number(record.value), result: record.result }; + } + return { id, seq, name: "exit", value: Number(record.value) }; + } + return { id, seq, name: "exit", value: Number(decoded) }; } function startJavaScriptExecution(options: { @@ -1158,8 +1162,7 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { : module.default ?? null; const value = result ?? null; await host.assertResult(value); - enqueue({ name: "result", value }); - enqueue({ name: "exit", value: 0 }); + enqueue({ name: "exit", value: 0, result: value }); } catch (error) { const message = error instanceof Error ? error.message : String(error); enqueue({ name: "stderr", b64: toBase64(truncate(message) + "\\n") }); diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index b426a214..abcaa3e8 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -428,8 +428,10 @@ async function drainModuleResult( const event = next.value; if (event.name === "stdout") stdout.push(event.value); if (event.name === "stderr") stderr.push(event.value); - if (event.name === "result") value = event.value; - if (event.name === "exit") exitCode = event.value; + if (event.name === "exit") { + exitCode = event.value; + if ("result" in event) value = event.result; + } } } finally { reader.releaseLock(); diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index 52d505ce..90059ca7 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -87,8 +87,7 @@ type RuntimeChunk = E extends "utf8" ? string : Uint8Arr export type WorkspaceRuntimeEvent = | { id: string; seq: number; name: "stdout"; value: RuntimeChunk } | { id: string; seq: number; name: "stderr"; value: RuntimeChunk } - | { id: string; seq: number; name: "result"; value: WorkspaceRuntimeValue } - | { id: string; seq: number; name: "exit"; value: number }; + | { id: string; seq: number; name: "exit"; value: number; result?: WorkspaceRuntimeValue }; export interface WorkspaceRuntimeResult { status: WorkspaceRuntimeStatus; diff --git a/packages/computer/src/runtime/wire.test.ts b/packages/computer/src/runtime/wire.test.ts new file mode 100644 index 00000000..73ec91b6 --- /dev/null +++ b/packages/computer/src/runtime/wire.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import type { WorkspaceRuntimeEvent } from "./types.js"; +import { decodeRuntimeEvents, encodeRuntimeEvent } from "./wire.js"; + +function streamOf(...events: WorkspaceRuntimeEvent[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const event of events) controller.enqueue(encodeRuntimeEvent(event)); + controller.close(); + }, + }); +} + +async function collect( + stream: ReadableStream, +): Promise { + const events: WorkspaceRuntimeEvent[] = []; + const reader = stream.getReader(); + while (true) { + const next = await reader.read(); + if (next.done) break; + events.push(next.value as WorkspaceRuntimeEvent); + } + return events; +} + +describe("runtime wire codec", () => { + it("round-trips an exit event carrying a structured result", async () => { + const events = await collect( + decodeRuntimeEvents( + streamOf({ id: "e-1", seq: 2, name: "exit", value: 0, result: { a: [1, 2, null] } }), + ), + ); + expect(events).toEqual([ + { id: "e-1", seq: 2, name: "exit", value: 0, result: { a: [1, 2, null] } }, + ]); + }); + + it("round-trips an exit event with no result", async () => { + const events = await collect( + decodeRuntimeEvents(streamOf({ id: "e-1", seq: 1, name: "exit", value: 1 })), + ); + expect(events).toEqual([{ id: "e-1", seq: 1, name: "exit", value: 1 }]); + }); +}); diff --git a/packages/computer/src/runtime/wire.ts b/packages/computer/src/runtime/wire.ts index fac275bf..93f40a03 100644 --- a/packages/computer/src/runtime/wire.ts +++ b/packages/computer/src/runtime/wire.ts @@ -4,8 +4,7 @@ import type { WorkspaceRuntimeEvent, WorkspaceRuntimeValue } from "./types.js"; type RuntimeFrame = | { id: string; seq: number; name: "stdout" | "stderr"; enc: "utf8"; value: string } | { id: string; seq: number; name: "stdout" | "stderr"; enc: "b64"; value: string } - | { id: string; seq: number; name: "result"; value: WorkspaceRuntimeValue } - | { id: string; seq: number; name: "exit"; value: number }; + | { id: string; seq: number; name: "exit"; value: number; result?: WorkspaceRuntimeValue }; function toBase64(bytes: Uint8Array): string { let binary = ""; @@ -24,7 +23,7 @@ function fromBase64(text: string): Uint8Array { export function encodeRuntimeEvent(event: WorkspaceRuntimeEvent): Uint8Array { let frame: RuntimeFrame; - if (event.name === "exit" || event.name === "result") frame = event; + if (event.name === "exit") frame = event; else if (typeof event.value === "string") { frame = { id: event.id, seq: event.seq, name: event.name, enc: "utf8", value: event.value }; } else { @@ -40,7 +39,7 @@ export function encodeRuntimeEvent(event: WorkspaceRuntimeEvent): } function decodeFrame(frame: RuntimeFrame): WorkspaceRuntimeEvent { - if (frame.name === "exit" || frame.name === "result") return frame; + if (frame.name === "exit") return frame; return { id: frame.id, seq: frame.seq, diff --git a/packages/computer/src/workspace.test.ts b/packages/computer/src/workspace.test.ts index 467d7fee..b3236f04 100644 --- a/packages/computer/src/workspace.test.ts +++ b/packages/computer/src/workspace.test.ts @@ -417,8 +417,7 @@ describe("Workspace backend selection", () => { name: "stdout", value: new Uint8Array([0xe2]), }); - controller.enqueue({ id: "module-exec", seq: 3, name: "result", value: 42 }); - controller.enqueue({ id: "module-exec", seq: 4, name: "exit", value: 0 }); + controller.enqueue({ id: "module-exec", seq: 3, name: "exit", value: 0, result: 42 }); controller.close(); }, }); From d84f5c7ccc1ab3f8f095689242c317288e922fd1 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:21:42 +0000 Subject: [PATCH 02/13] computer: remove the waitUntil hook from the workspace DurableObjectState.waitUntil() has no effect on a durable object's lifetime. It exists for compatibility with the Workers runtime and does not extend how long the object stays resident. The JavaScript backend was the only consumer of the workspace waitUntil hook, and it used the hook to attach a detached execution's completion promise to the object lifetime, which did nothing. A JavaScript run advances while its event stream is drained and the call into the Dynamic Worker stays in flight. That pending work keeps the object resident on its own. Remove the hook and everything that fed it: the requiresWaitUntil backend flag, the connect-time guard and the dead completion registration in the backend, the waitUntil field on the host bag and on WorkspaceOptions, and the constructor guard. The worker-javascript example stops passing the hook. --- examples/worker-javascript/src/index.ts | 1 - packages/computer/src/backend.ts | 1 - .../worker-javascript.test.ts | 68 +------------------ .../worker-javascript/worker-javascript.ts | 7 -- packages/computer/src/runtime/types.ts | 1 - packages/computer/src/workspace.ts | 15 ---- 6 files changed, 2 insertions(+), 91 deletions(-) diff --git a/examples/worker-javascript/src/index.ts b/examples/worker-javascript/src/index.ts index 3aa5758f..5cd06032 100644 --- a/examples/worker-javascript/src/index.ts +++ b/examples/worker-javascript/src/index.ts @@ -13,7 +13,6 @@ export class ContainerExample extends withWorkspace(class extends DurableObject< 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), diff --git a/packages/computer/src/backend.ts b/packages/computer/src/backend.ts index ca9b517a..87482600 100644 --- a/packages/computer/src/backend.ts +++ b/packages/computer/src/backend.ts @@ -30,7 +30,6 @@ import type { WorkspaceRPC } from "@cloudflare/computer-rpc"; // their own transport); the in-process module backend uses it. export interface WorkspaceBackendHost { readonly db: import("@cloudflare/dofs").Database; - readonly waitUntil?: (promise: Promise) => void; readonly fs: import("@cloudflare/dofs").WorkspaceFilesystem; readonly git: import("./git/index.js").GitClient; readonly artifacts: import("./artifacts/index.js").ArtifactClient; diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts index 3976baeb..a2d1b7ec 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -3,15 +3,7 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { describe, expect, it, vi } from "vitest"; import { Workspace } from "../../workspace.js"; -import { WorkerJavaScriptBackend as ProductionWorkerJavaScriptBackend } from "./worker-javascript.js"; - -class WorkerJavaScriptBackend extends ProductionWorkerJavaScriptBackend { - override readonly requiresWaitUntil = false; - - override connect(host: Parameters[0]) { - return super.connect({ ...host, waitUntil: host.waitUntil ?? (() => {}) }); - } -} +import { WorkerJavaScriptBackend } from "./worker-javascript.js"; function throwingLoader(message: string) { return { @@ -51,18 +43,6 @@ async function evaluateResult( } describe("WorkerJavaScriptBackend", () => { - it("requires a host event-lifetime hook", async () => { - const backend = new ProductionWorkerJavaScriptBackend({ loader: throwingLoader("unused") }); - await expect( - backend.connect({ - db: undefined as never, - fs: undefined as never, - git: undefined as never, - artifacts: undefined as never, - }), - ).rejects.toThrow(/requires WorkspaceOptions.waitUntil/); - }); - it("validates timeout configuration", () => { expect( () => @@ -80,52 +60,11 @@ describe("WorkerJavaScriptBackend", () => { ).toThrow(/positive finite/); }); - it("cancels a started worker when waitUntil registration fails", async () => { - let entrypointDisposals = 0; - let workerDisposals = 0; - const workspace = new Workspace({ - storage: new SQLiteTestStorage(), - waitUntil() { - throw new Error("waitUntil unavailable"); - }, - backends: [ - new WorkerJavaScriptBackend({ - loader: { - load() { - return { - getEntrypoint() { - return { - evaluate: () => new Promise(() => undefined), - [Symbol.dispose]() { - entrypointDisposals += 1; - }, - }; - }, - [Symbol.dispose]() { - workerDisposals += 1; - }, - }; - }, - }, - }), - ], - }); - await workspace.fs.mkdir("/workspace", { recursive: true }); - const execution = await workspace.runtime.exec("export default 1", { encoding: "utf8" }); - await expect(execution.result()).resolves.toMatchObject({ - status: "failed", - stderr: expect.stringContaining("waitUntil unavailable"), - }); - expect(entrypointDisposals).toBe(1); - expect(workerDisposals).toBe(1); - }); - it("disposes Loader resources when evaluate throws synchronously", async () => { let entrypointDisposals = 0; let workerDisposals = 0; const workspace = new Workspace({ storage: new SQLiteTestStorage(), - waitUntil() {}, backends: [ new WorkerJavaScriptBackend({ loader: { @@ -410,15 +349,13 @@ describe("WorkerJavaScriptBackend", () => { await workspace.close(); }); - it("limits concurrent Dynamic Workers and attaches execution to waitUntil", async () => { + it("limits concurrent Dynamic Workers", async () => { let resolveEvaluation!: (value: { result: number }) => void; const evaluation = new Promise<{ result: number }>((resolve) => { resolveEvaluation = resolve; }); - const waitUntil = vi.fn<(promise: Promise) => void>(); const workspace = new Workspace({ storage: new SQLiteTestStorage(), - waitUntil, backends: [ new WorkerJavaScriptBackend({ maxConcurrentExecutions: 1, @@ -444,7 +381,6 @@ describe("WorkerJavaScriptBackend", () => { }); await workspace.fs.mkdir("/workspace", { recursive: true }); const first = await workspace.runtime.exec("export default 1", { id: "first" }); - expect(waitUntil).toHaveBeenCalledOnce(); await expect( workspace.runtime.exec("export default 2", { id: "second" }), ).rejects.toMatchObject({ code: "EEXEC_BUSY" }); diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index b72aeeab..2005fd76 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -133,7 +133,6 @@ interface ExecutionRecord { export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { readonly protocol = "module" as const; - readonly requiresWaitUntil = true; readonly type = "worker-javascript"; readonly callable = true; readonly id: string; @@ -211,11 +210,6 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { } async connect(host: WorkspaceModuleBackendHost): Promise { - if (!host.waitUntil) { - throw new Error( - "WorkerJavaScriptBackend requires WorkspaceOptions.waitUntil; pass ctx.waitUntil.bind(ctx).", - ); - } return new JavaScriptBackendHandle(this.#options, host); } } @@ -415,7 +409,6 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { onComplete: () => this.#finalize(record), onError: (message) => this.#finalize(record, message), }); - this.#host.waitUntil?.(record.control.completion); } catch (error) { record.control?.cancel(); await record.control?.completion.catch(() => undefined); diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index 90059ca7..c5e664f3 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -164,7 +164,6 @@ export type WorkspaceModuleBackendHost = import("../backend.js").WorkspaceBacken export interface WorkspaceModuleBackend { readonly protocol: "module"; 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 e75ada0f..d0f1ffd8 100644 --- a/packages/computer/src/workspace.ts +++ b/packages/computer/src/workspace.ts @@ -94,10 +94,6 @@ export interface WorkspaceOptions { // to Date.now. Override for deterministic tests. now?: () => number; - // Attach detached module execution to the Durable Object event lifetime. - // Pass `ctx.waitUntil.bind(ctx)` when using module backends in production. - waitUntil?: (promise: Promise) => void; - // Identifier for this workspace / session. Forwarded to mount // factories via MountContext.sessionId. Optional; defaults to "". sessionId?: string; @@ -219,7 +215,6 @@ export class Workspace { readonly #defaultCommandBackendId: string | undefined; readonly #observer: WorkspaceObserver; readonly #now: () => number; - readonly #waitUntil: ((promise: Promise) => void) | undefined; readonly #retryScheduler: SyncRetryScheduler | undefined; readonly #retryInitialDelayMs: number; readonly #retryMaxDelayMs: number; @@ -270,7 +265,6 @@ export class Workspace { constructor(options: WorkspaceOptions) { this.#now = options.now ?? Date.now; - this.#waitUntil = options.waitUntil; this.#retryScheduler = options.retryScheduler; this.#retryInitialDelayMs = positiveRetryOption( options.retry?.initialDelayMs, @@ -301,13 +295,6 @@ export class Workspace { initializeSchema(this.#db, this.#now); this.#fs = new WorkspaceFilesystem(this.#db, { now: this.#now }); const registered = (options.backends ?? []).slice(); - if (registered.some((backend) => isModuleBackend(backend) && backend.requiresWaitUntil)) { - if (!options.waitUntil) { - throw new Error( - "Workspace module backend requires waitUntil; pass ctx.waitUntil.bind(ctx).", - ); - } - } this.#backends = registered.filter( (backend): backend is WorkspaceBackend => !isModuleBackend(backend), ); @@ -815,7 +802,6 @@ export class Workspace { () => backend.connect({ db: this.#db, - waitUntil: this.#waitUntil, fs: this.#fs, git: this.#gitFactory ? this.git : DISABLED_GIT_CLIENT, artifacts: this.#artifacts, @@ -860,7 +846,6 @@ export class Workspace { () => backend.connect({ db: this.#db, - waitUntil: this.#waitUntil, fs: this.#fs, git: this.#gitFactory ? this.git : DISABLED_GIT_CLIENT, artifacts: this.#artifacts, From ae0a0d7a07eef2bfd7a9031b9afa64123a50b2a5 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 3 Aug 2026 18:45:29 +0000 Subject: [PATCH 03/13] rpc, computer: rename the shell exec field to source The shell exec wire took a command field while the module execution path took a source field for the same argument: the program to run. Unifying the two execution paths behind one interface needs one name for that argument. Rename the ShellRPC exec field to source and carry an optional input value and an optional result on the exit event, matching the module execution envelope. The shell server maps source onto the runner, which keeps its own command parameter since at that layer the value is always a shell command line. The worker shell adapter and the host shell facade pass source through. Command backends ignore input and never set result; the fields exist so the shell and module paths share one event and one request shape. --- .../src/backends/worker-shell/worker-shell.test.ts | 12 ++++++------ .../src/backends/worker-shell/worker-shell.ts | 2 +- packages/computer/src/shell.test.ts | 2 +- packages/computer/src/shell.ts | 2 +- packages/rpc/src/interface.ts | 8 ++++++-- packages/rpc/src/server.ts | 5 +++-- packages/rpc/tests/shell-and-composite.test.ts | 12 ++++++------ 7 files changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/computer/src/backends/worker-shell/worker-shell.test.ts b/packages/computer/src/backends/worker-shell/worker-shell.test.ts index 83941c16..755154d1 100644 --- a/packages/computer/src/backends/worker-shell/worker-shell.test.ts +++ b/packages/computer/src/backends/worker-shell/worker-shell.test.ts @@ -142,7 +142,7 @@ describe("WorkerShellBackend", () => { const backend = new WorkerShellBackend({ fetcher: () => fetcher }); const handle = await backend.connect(); - const envelope = await handle.rpc.shell.exec({ command: "echo hello" }); + const envelope = await handle.rpc.shell.exec({ source: "echo hello" }); const reader = envelope.events.getReader(); const seen: unknown[] = []; while (true) { @@ -172,7 +172,7 @@ describe("WorkerShellBackend", () => { const backend = new WorkerShellBackend({ fetcher: () => fetcher }); const handle = await backend.connect(); const envelope = await handle.rpc.shell.exec({ - command: "printenv TOKEN", + source: "printenv TOKEN", env: { TOKEN: "secret", EMPTY: "" }, }); const reader = envelope.events.getReader(); @@ -194,7 +194,7 @@ describe("WorkerShellBackend", () => { }), })); const handle = await new WorkerShellBackend({ fetcher: () => fetcher }).connect(); - const envelope = await handle.rpc.shell.exec({ command: "bad" }); + const envelope = await handle.rpc.shell.exec({ source: "bad" }); await expect(envelope.events.getReader().read()).rejects.toMatchObject({ code: "EPROTOCOL" }); }); @@ -214,7 +214,7 @@ describe("WorkerShellBackend", () => { await ws.ready(); const backend = new WorkerShellBackend({ fetcher: () => fetcher }); const handle = await backend.connect(); - await handle.rpc.shell.exec({ command: "x", cwd: "/workspace/src", id: "fixed" }); + await handle.rpc.shell.exec({ source: "x", cwd: "/workspace/src", id: "fixed" }); expect(observed?.cwd).toBe("/workspace/src"); expect(observed?.id).toBe("fixed"); }); @@ -358,8 +358,8 @@ describe("WorkerShellBackend", () => { }, }); const handle = await backend.connect(); - await handle.rpc.shell.exec({ command: "true" }); - await handle.rpc.shell.exec({ command: "true" }); + await handle.rpc.shell.exec({ source: "true" }); + await handle.rpc.shell.exec({ source: "true" }); expect(factoryCalls).toBe(1); }); }); diff --git a/packages/computer/src/backends/worker-shell/worker-shell.ts b/packages/computer/src/backends/worker-shell/worker-shell.ts index b78c9abc..a3ea33a5 100644 --- a/packages/computer/src/backends/worker-shell/worker-shell.ts +++ b/packages/computer/src/backends/worker-shell/worker-shell.ts @@ -171,7 +171,7 @@ export class WorkerShellBackend implements WorkspaceBackend { const shell: ShellRPC = { async exec(input) { const envelope = await fetcher.exec({ - command: input.command, + command: input.source, cwd: input.cwd, id: input.id, timeoutMs: input.timeoutMs, diff --git a/packages/computer/src/shell.test.ts b/packages/computer/src/shell.test.ts index 99f937b5..3158db10 100644 --- a/packages/computer/src/shell.test.ts +++ b/packages/computer/src/shell.test.ts @@ -129,7 +129,7 @@ function fakeRpc(options: FakeRpcOptions = {}): FakeRpc { const shell: ShellRPC = { async exec(input) { calls.exec.push({ - command: input.command, + command: input.source, id: input.id, cwd: input.cwd, timeoutMs: input.timeoutMs, diff --git a/packages/computer/src/shell.ts b/packages/computer/src/shell.ts index a199281c..2abd561d 100644 --- a/packages/computer/src/shell.ts +++ b/packages/computer/src/shell.ts @@ -180,7 +180,7 @@ export class WorkspaceShell { }, () => this.#shell.exec({ - command, + source: command, id: options.id, cwd: options.cwd, timeoutMs: options.timeoutMs, diff --git a/packages/rpc/src/interface.ts b/packages/rpc/src/interface.ts index 7eb5bca0..f897ca72 100644 --- a/packages/rpc/src/interface.ts +++ b/packages/rpc/src/interface.ts @@ -96,9 +96,13 @@ export interface ShellRPC { // Capnweb streams handle backpressure end-to-end; consumer-side // slowness propagates to the spawned process via the kernel pipe. exec(input: { - command: string; + source: string; cwd?: string; id?: string; + // Structured value handed to a callable backend. Command + // backends ignore it; the caller only sends it to backends that + // declare themselves callable. + input?: unknown; // Per-call timeout in milliseconds. Past this duration the // container sends SIGTERM (then SIGKILL after a short grace). // 0 disables the timeout. Omit to use the runner's default @@ -146,7 +150,7 @@ export interface WorkspaceRPC { export type ExecEvent = | { id: string; seq: number; name: "stdout"; value: Uint8Array } | { id: string; seq: number; name: "stderr"; value: Uint8Array } - | { id: string; seq: number; name: "exit"; value: number }; + | { id: string; seq: number; name: "exit"; value: number; result?: unknown }; // Error codes carried over the wire. The client adapter rethrows as // WorkspaceError preserving `code`, so application code can branch diff --git a/packages/rpc/src/server.ts b/packages/rpc/src/server.ts index b07915e3..9d0f23b8 100644 --- a/packages/rpc/src/server.ts +++ b/packages/rpc/src/server.ts @@ -237,9 +237,10 @@ class ShellRPCServer extends RpcTarget implements ShellRPC { } async exec(input: { - command: string; + source: string; cwd?: string; id?: string; + input?: unknown; timeoutMs?: number; env?: Record; stdin?: Uint8Array; @@ -247,7 +248,7 @@ class ShellRPCServer extends RpcTarget implements ShellRPC { id: string; events: ReadableStream; }> { - return this.runner.exec(input.command, { + return this.runner.exec(input.source, { id: input.id, cwd: input.cwd, timeoutMs: input.timeoutMs, diff --git a/packages/rpc/tests/shell-and-composite.test.ts b/packages/rpc/tests/shell-and-composite.test.ts index bc964df5..956d9886 100644 --- a/packages/rpc/tests/shell-and-composite.test.ts +++ b/packages/rpc/tests/shell-and-composite.test.ts @@ -158,7 +158,7 @@ describe("ShellRPC over a real WebSocket", () => { // `client.exec(...)` lands on it directly. createWorkspaceClient // proxies all property access through; capnweb routes by name. // biome-ignore lint/suspicious/noExplicitAny: client targets ShellRPC for this harness, not WorkspaceRPC - const handle = await (client as any).exec({ command: "echo hi" }); + const handle = await (client as any).exec({ source: "echo hi" }); const events = await drainExec(handle.events); expect(events).toHaveLength(2); expect(events[0]?.name).toBe("stdout"); @@ -178,7 +178,7 @@ describe("ShellRPC over a real WebSocket", () => { const client = createWorkspaceClient({ url: harness.url }); try { // biome-ignore lint/suspicious/noExplicitAny: see above - const handle = await (client as any).exec({ command: "sleep", id: "fixed" }); + const handle = await (client as any).exec({ source: "sleep", id: "fixed" }); await drainExec(handle.events); // biome-ignore lint/suspicious/noExplicitAny: see above @@ -198,7 +198,7 @@ describe("ShellRPC over a real WebSocket", () => { const client = createWorkspaceClient({ url: harness.url }); try { // biome-ignore lint/suspicious/noExplicitAny: see above - const first = await (client as any).exec({ command: "first", id: "repeat" }); + const first = await (client as any).exec({ source: "first", id: "repeat" }); await drainExec(first.events); // biome-ignore lint/suspicious/noExplicitAny: see above @@ -266,7 +266,7 @@ describe("Composite WorkspaceRPC (sync + shell on one session)", () => { expect(typeof wm.currentRev).toBe("number"); expect(wm.currentRev).toBeGreaterThanOrEqual(0); - const handle = await client.shell.exec({ command: "ls" }); + const handle = await client.shell.exec({ source: "ls" }); const events = await drainExec(handle.events); expect(events).toHaveLength(2); expect(events[1]).toMatchObject({ name: "exit", value: 0 }); @@ -287,7 +287,7 @@ describe("Composite WorkspaceRPC (sync + shell on one session)", () => { try { expect(await client.sync.readEntry("/none")).toBeNull(); - const handle = await client.shell.exec({ command: "noop" }); + const handle = await client.shell.exec({ source: "noop" }); await drainExec(handle.events); expect(harness.runner.records.size).toBe(1); @@ -304,7 +304,7 @@ describe("Composite WorkspaceRPC (sync + shell on one session)", () => { harness = await startCompositeHarness(); const client = createWorkspaceClient({ url: harness.url }); try { - const handle = await client.shell.exec({ command: "x" }); + const handle = await client.shell.exec({ source: "x" }); await drainExec(handle.events); const wm = await client.sync.watermarks(); expect(typeof wm.currentRev).toBe("number"); From 3bdfe8160a2eb7eada94141da193414bee1d8258 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:57:41 +0000 Subject: [PATCH 04/13] computer: route every backend through one execution path The runtime forked on a set of command backend ids in four places to choose between a WorkspaceShell facade that returned an ExecHandle and a module handle that returned an event envelope. The two shapes carried the same execution lifecycle and differed only in how the result was drained and whether a sync bracket ran. Give both backend kinds the same handle. The workspace now resolves a single WorkspaceModuleBackendHandle for every backend: module backends return their native handle, and command backends are presented through an adapter over their shell that produces the same envelope. The envelope carries the sync bracket stats for a backend with a remote store, so the module result drain reports the pushed and pulled counts that the command result used to carry on its own. Collapse the runtime's four forks to one path each, delete the command handle wrapper and the shell router, and drop the command-backend-id and shell accessors from the router options. Transport-failure invalidation moves onto the adapter, which classifies a failed exec dispatch and a mid-stream event error the same way the router did. --- .../computer/src/observe-integration.test.ts | 2 +- packages/computer/src/runtime/runtime.test.ts | 9 +- packages/computer/src/runtime/runtime.ts | 186 ++---------- packages/computer/src/runtime/types.ts | 9 + packages/computer/src/shell.ts | 80 ++++- packages/computer/src/workspace.ts | 275 +++++++----------- 6 files changed, 228 insertions(+), 333 deletions(-) diff --git a/packages/computer/src/observe-integration.test.ts b/packages/computer/src/observe-integration.test.ts index 5e6a5ab0..83d41281 100644 --- a/packages/computer/src/observe-integration.test.ts +++ b/packages/computer/src/observe-integration.test.ts @@ -242,7 +242,7 @@ describe("Workspace observer — runtime stub", () => { const execed: string[] = []; const shellRpc: import("@cloudflare/computer-rpc").ShellRPC = { async exec(input) { - execed.push(input.command); + execed.push(input.source); return { id: "exec-1", events: new ReadableStream({ diff --git a/packages/computer/src/runtime/runtime.test.ts b/packages/computer/src/runtime/runtime.test.ts index cc27ba26..330dc8d2 100644 --- a/packages/computer/src/runtime/runtime.test.ts +++ b/packages/computer/src/runtime/runtime.test.ts @@ -1,6 +1,5 @@ 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"; @@ -28,10 +27,8 @@ function moduleHandleStub(): WorkspaceModuleBackendHandle { 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(), + backendHandle: async () => moduleHandleStub(), resolveBackendId: () => "worker-shell", }); @@ -43,10 +40,8 @@ describe("WorkspaceRuntime callable gate", () => { 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, + backendHandle: async () => handle, resolveBackendId: () => "worker-javascript", }); diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index abcaa3e8..79d361d3 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -1,7 +1,8 @@ import type { SkippedEntry } from "@cloudflare/dofs"; -import type { ExecEncoding, ExecHandle, WorkspaceShell } from "../shell.js"; +import type { ExecEncoding } from "../shell.js"; import type { + ModuleExecutionEnvelope, WorkspaceModuleBackendHandle, WorkspaceRuntimeDisposeOptions, WorkspaceRuntimeEvent, @@ -13,10 +14,8 @@ import type { } from "./types.js"; interface WorkspaceRuntimeRouterOptions { - commandBackendIds: ReadonlySet; callableBackendIds: ReadonlySet; - shell: () => WorkspaceShell; - moduleHandle: (id: string) => Promise; + backendHandle: (id: string) => Promise; resolveBackendId: (id: string | undefined) => string; } @@ -47,31 +46,7 @@ export class WorkspaceRuntime { `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)} is not callable; it does not accept structured input.`, - ); - } - const shell = this.#options.shell(); - const handle = await ( - shell.exec as unknown as ( - command: string, - options: Record, - ) => Promise> - )(source, { - backend, - cwd: options.cwd, - encoding: options.encoding, - id: options.id, - timeoutMs: options.timeoutMs, - env: options.env, - stdin: options.stdin, - }); - return wrapCommandHandle(handle, backend); - } - - const runtime = await this.#options.moduleHandle(backend); + const runtime = await this.#options.backendHandle(backend); const envelope = await runtime.exec({ id: options.id, source, @@ -81,7 +56,15 @@ export class WorkspaceRuntime { stdin: options.stdin, timeoutMs: options.timeoutMs, }); - return wrapModuleHandle(runtime, backend, envelope.id, envelope.events, options.encoding); + return wrapModuleHandle( + runtime, + backend, + envelope.id, + envelope.events, + options.encoding, + true, + envelope.sync, + ); } getExec(id: string): Promise>; @@ -99,36 +82,7 @@ export class WorkspaceRuntime { ): Promise> { assertExecutionId(id); const backend = this.#backend(options.backend); - if (this.#options.commandBackendIds.has(backend)) { - const shell = this.#options.shell(); - const handle = await ( - shell.get as unknown as ( - id: string, - options: Record, - ) => Promise> - )(id, { - backend, - encoding: options.encoding, - resume: options.resume, - }); - const resultHandle = - options.resume === undefined || options.resume === "full" - ? undefined - : () => - ( - shell.get as unknown as ( - id: string, - options: Record, - ) => Promise> - )(id, { - backend, - encoding: options.encoding, - resume: "full", - }); - return wrapCommandHandle(handle, backend, resultHandle); - } - - const runtime = await this.#options.moduleHandle(backend); + const runtime = await this.#options.backendHandle(backend); const envelope = await runtime.getExec({ id, after: resumeToAfter(options.resume) }); return wrapModuleHandle( runtime, @@ -137,27 +91,20 @@ export class WorkspaceRuntime { envelope.events, options.encoding, options.resume === undefined || options.resume === "full", + envelope.sync, ); } async killExec(id: string, options: WorkspaceRuntimeKillOptions = {}): Promise { assertExecutionId(id); const backend = this.#backend(options.backend); - if (this.#options.commandBackendIds.has(backend)) { - await this.#options.shell().kill(id, options.signal, { backend }); - return; - } - await (await this.#options.moduleHandle(backend)).killExec({ id, signal: options.signal }); + await (await this.#options.backendHandle(backend)).killExec({ id, signal: options.signal }); } async disposeExec(id: string, options: WorkspaceRuntimeDisposeOptions = {}): Promise { assertExecutionId(id); const backend = this.#backend(options.backend); - if (this.#options.commandBackendIds.has(backend)) { - await this.#options.shell().dispose(id, { backend }); - return; - } - await (await this.#options.moduleHandle(backend)).disposeExec({ id }); + await (await this.#options.backendHandle(backend)).disposeExec({ id }); } #backend(requested: string | undefined): string { @@ -171,84 +118,6 @@ export class WorkspaceRuntime { } } -function wrapCommandHandle( - handle: ExecHandle, - backend: string, - lazyResultHandle?: () => Promise>, -): WorkspaceRuntimeExecHandle { - let claimed: "result" | "stream" | undefined; - let reader: ReadableStreamDefaultReader> | undefined; - let resultPromise: Promise> | undefined; - const stream = new ReadableStream>( - { - async pull(controller) { - if (claimed === "result") { - controller.error(new Error("runtime handle already consumed by result()")); - return; - } - claimed = "stream"; - reader ??= handle.getReader() as ReadableStreamDefaultReader>; - try { - const next = await reader.read(); - if (next.done) { - reader.releaseLock(); - reader = undefined; - controller.close(); - } else controller.enqueue(next.value); - } catch (error) { - reader?.releaseLock(); - reader = undefined; - controller.error(error); - } - }, - async cancel(reason) { - if (reader) { - try { - await reader.cancel(reason); - } finally { - reader.releaseLock(); - reader = undefined; - } - } else await handle.cancel(reason); - }, - }, - { highWaterMark: 0 }, - ) as WorkspaceRuntimeExecHandle; - Object.defineProperties(stream, { - id: { value: handle.id, enumerable: false }, - backend: { value: backend, enumerable: false }, - result: { - value: (): Promise> => { - if (claimed === "stream") { - throw new Error("runtime handle already streaming: result() and streaming are exclusive"); - } - claimed = "result"; - resultPromise ??= (async () => { - if (lazyResultHandle) await handle.cancel("result() requested a full replay"); - const result = lazyResultHandle - ? await (await lazyResultHandle()).result() - : await handle.result(); - return { - status: - result.exitCode === 0 - ? "completed" - : isCancellationExitCode(result.exitCode) - ? "cancelled" - : "failed", - ...result, - }; - })(); - return resultPromise; - }, - }, - kill: { - value: (signal?: WorkspaceRuntimeKillOptions["signal"]) => handle.kill(signal), - }, - [Symbol.dispose]: { value: () => handle[Symbol.dispose]() }, - }); - return stream; -} - function wrapModuleHandle( runtime: WorkspaceModuleBackendHandle, backend: string, @@ -256,6 +125,7 @@ function wrapModuleHandle( source: ReadableStream, encoding: E | undefined, resultMayUseSource = true, + sync?: ModuleExecutionEnvelope["sync"], ): WorkspaceRuntimeExecHandle { let claimed: "result" | "stream" | undefined; let sourceCancelled = false; @@ -314,10 +184,11 @@ function wrapModuleHandle( resultReader = active; }; if (resultMayUseSource && !sourceCancelled) { - return drainModuleResult(source, encoding, setReader); + return drainModuleResult(source, encoding, setReader, sync); } if (!sourceCancelled) await source.cancel("result() requested a full replay"); - return drainModuleResult((await runtime.getExec({ id })).events, encoding, setReader); + const replay = await runtime.getExec({ id }); + return drainModuleResult(replay.events, encoding, setReader, replay.sync); })(); return resultPromise; }, @@ -414,6 +285,7 @@ async function drainModuleResult( events: ReadableStream, encoding: E | undefined, setReader: (reader: ReadableStreamDefaultReader | undefined) => void, + sync?: ModuleExecutionEnvelope["sync"], ): Promise> { const stdout: Uint8Array[] = []; const stderr: Uint8Array[] = []; @@ -437,16 +309,20 @@ async function drainModuleResult( reader.releaseLock(); setReader(undefined); } + // A backend with a remote store reports its sync bracket stats; + // the pull outcome settles once the event stream above drains. + const pull = sync ? await sync.outcome : undefined; return { - status: exitCode === 0 ? "completed" : exitCode === 130 ? "cancelled" : "failed", + status: + exitCode === 0 ? "completed" : isCancellationExitCode(exitCode) ? "cancelled" : "failed", exitCode, stdout: join(stdout, encoding) as WorkspaceRuntimeResult["stdout"], stderr: join(stderr, encoding) as WorkspaceRuntimeResult["stderr"], ...(value === undefined ? {} : { value }), - pushed: 0, - pulled: 0, - skipped: [] as SkippedEntry[], - sync: { status: "complete", applied: 0, skipped: [] }, + pushed: sync?.pushed ?? 0, + pulled: pull?.applied ?? 0, + skipped: pull?.skipped ?? ([] as SkippedEntry[]), + sync: pull?.sync ?? { status: "complete", applied: 0, skipped: [] }, }; } diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index c5e664f3..9562106d 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -149,6 +149,15 @@ export interface ModuleExecutionInput { export interface ModuleExecutionEnvelope { id: string; events: ReadableStream; + // Sync bracket stats for a backend that pairs with a remote store. + // The pre-exec push count is known when the envelope is created; + // the post-drain pull outcome settles once `events` is consumed to + // its end. Absent for backends that reuse the host store, whose + // result reports zeroed stats. + sync?: { + pushed: number; + outcome: Promise<{ applied: number; skipped: SkippedEntry[]; sync: ExecSyncResult }>; + }; } export interface WorkspaceModuleBackendHandle { diff --git a/packages/computer/src/shell.ts b/packages/computer/src/shell.ts index 2abd561d..2f3c2a93 100644 --- a/packages/computer/src/shell.ts +++ b/packages/computer/src/shell.ts @@ -219,6 +219,78 @@ export class WorkspaceShell { return wrapHandle(this.#shell, this.#sync, id, events, options.encoding, 0); } + // Envelope form of exec / get for the unified backend handle. Returns + // raw (unencoded) events plus the sync bracket stats, matching + // ModuleExecutionEnvelope. The runtime applies encoding and drains + // the result the same way it does for module backends. `outcome` + // settles when `events` reaches its end, carrying the post-drain + // pull result. + async execution( + source: string, + options: ExecOptions = {}, + ): Promise<{ + id: string; + events: ReadableStream; + sync: { pushed: number; outcome: Promise }; + }> { + assertNotTemplate(source); + let pushed = 0; + try { + pushed = await this.#sync.push(); + } catch { + // pushed stays 0 + } + const envelope = await withSpan( + this.#observer, + "workspace.runtime.exec.spawn", + { + "workspace.runtime.cwd": options.cwd, + "workspace.runtime.timeout_ms": options.timeoutMs, + "workspace.runtime.id": options.id, + }, + () => + this.#shell.exec({ + source, + id: options.id, + cwd: options.cwd, + timeoutMs: options.timeoutMs, + env: options.env, + stdin: + typeof options.stdin === "string" + ? new TextEncoder().encode(options.stdin) + : options.stdin, + }), + (span, outcome) => { + if (outcome.ok) span.setAttribute("workspace.runtime.id", outcome.value.id); + }, + ); + const drained = disposeOnDone(envelope.events, () => maybeDispose(envelope)); + const { stream, outcome } = withPostPull(drained, this.#sync); + return { + id: envelope.id, + events: stream as ReadableStream, + sync: { pushed, outcome }, + }; + } + + // Envelope form of get / reattach. Reattach does not own the + // original push frame, so pushed = 0; the post-drain pull still + // fires scoped to whatever lands between reattach and drain. + async getExecution( + id: string, + options: GetExecOptions = {}, + ): Promise<{ + id: string; + events: ReadableStream; + sync: { pushed: number; outcome: Promise }; + }> { + const after = resumeToAfter(options.resume); + const envelope = await this.#shell.getExec({ id, after }); + const drained = disposeOnDone(envelope.events, () => maybeDispose(envelope)); + const { stream, outcome } = withPostPull(drained, this.#sync); + return { id, events: stream as ReadableStream, sync: { pushed: 0, outcome } }; + } + kill(id: string, signal?: KillSignal, _options: { backend?: string } = {}): Promise { return this.#shell.killExec({ id, signal }); } @@ -369,13 +441,13 @@ function pipeEvents( ); } -interface PostPullOutcome { +export interface PostPullOutcome { applied: number; skipped: SkippedEntry[]; sync: ExecSyncResult; } -function withPostPull( +export function withPostPull( source: ReadableStream>, sync: Sync, ): { stream: ReadableStream>; outcome: Promise } { @@ -506,7 +578,7 @@ function joinParts( // Wrap `stream` so its capnweb envelope is released exactly once on clean // completion, source failure, or consumer cancellation. -function disposeOnDone(stream: ReadableStream, onDone: () => void): ReadableStream { +export function disposeOnDone(stream: ReadableStream, onDone: () => void): ReadableStream { const reader = stream.getReader(); let finished = false; const finish = () => { @@ -552,7 +624,7 @@ function disposeOnDone(stream: ReadableStream, onDone: () => void): Readab // Best-effort dispose of a capnweb result envelope. Real envelopes // expose [Symbol.dispose]; test fakes return plain objects, so the // symbol may be absent. -function maybeDispose(value: unknown): void { +export function maybeDispose(value: unknown): void { const d = (value as { [Symbol.dispose]?: () => void } | null | undefined)?.[Symbol.dispose]; if (typeof d === "function") d.call(value); } diff --git a/packages/computer/src/workspace.ts b/packages/computer/src/workspace.ts index d0f1ffd8..23f05828 100644 --- a/packages/computer/src/workspace.ts +++ b/packages/computer/src/workspace.ts @@ -38,6 +38,7 @@ import { type WorkspaceModuleBackend, type WorkspaceModuleBackendHandle, type WorkspaceRegisteredBackend, + type WorkspaceRuntimeEvent, } from "./runtime/types.js"; import { WorkspaceShell } from "./shell.js"; import { WorkspaceStub } from "./stub.js"; @@ -212,7 +213,6 @@ export class Workspace { readonly #registeredBackendIds: Set; readonly #callableBackendIds: Set; readonly #defaultBackendId: string | undefined; - readonly #defaultCommandBackendId: string | undefined; readonly #observer: WorkspaceObserver; readonly #now: () => number; readonly #retryScheduler: SyncRetryScheduler | undefined; @@ -317,7 +317,6 @@ export class Workspace { this.#registeredBackendIds.add(backend.id); } this.#defaultBackendId = registered[0]?.id; - this.#defaultCommandBackendId = this.#backends[0]?.id; this.#observer = options.observer ?? noopObserver; this.#mounts = buildMountRegistry(options.mounts, { sessionId: options.sessionId, @@ -425,10 +424,8 @@ export class Workspace { get runtime(): WorkspaceRuntime { if (!this.#runtime) { this.#runtime = new WorkspaceRuntime({ - commandBackendIds: new Set(this.#backendsById.keys()), callableBackendIds: this.#callableBackendIds, - shell: () => this.#routedShell(), - moduleHandle: (id) => this.#moduleHandleFor(id), + backendHandle: (id) => this.#backendHandleFor(id), resolveBackendId: (id) => this.#resolveBackendId(id) ?? "", }); } @@ -743,20 +740,6 @@ export class Workspace { return target; } - #resolveCommandBackendId(id: string | undefined): string | undefined { - const target = id ?? this.#defaultCommandBackendId; - if (target === undefined) return undefined; - if (!this.#registeredBackendIds.has(target)) { - throw new Error(`Workspace: no backend with id ${JSON.stringify(target)}`); - } - if (!this.#backendsById.has(target)) { - throw new Error( - `Workspace backend ${JSON.stringify(target)} does not accept shell commands.`, - ); - } - return target; - } - async close(): Promise { // Close every cached handle in parallel. Drop caches before // awaiting so a subsequent ready() / exec sees an empty slate @@ -782,6 +765,83 @@ export class Workspace { ); } + // Unified backend handle used by the runtime. Module backends + // return their native handle; command backends are presented + // through the same interface by an adapter over their + // WorkspaceShell, so the runtime has a single execution path. + async #backendHandleFor(id: string): Promise { + if (this.#moduleBackendsById.has(id)) return this.#moduleHandleFor(id); + return this.#commandHandleFor(id); + } + + async #commandHandleFor(id: string): Promise { + const { shell, handle } = await this.#shellFor(id); + const onError = (error: unknown) => this.#onShellError(id, handle, error); + return { + exec: async (input) => { + let envelope: Awaited>; + try { + envelope = await shell.execution(input.source, { + id: input.id, + cwd: input.cwd, + timeoutMs: input.timeoutMs, + env: input.env, + stdin: input.stdin, + }); + } catch (error) { + onError(error); + throw error; + } + return { + id: envelope.id, + events: watchStreamForTransportError( + envelope.events, + onError, + ) as ReadableStream, + sync: envelope.sync, + }; + }, + getExec: async ({ id: execId, after }) => { + const resume = after === undefined ? "full" : after; + let envelope: Awaited>; + try { + envelope = await shell.getExecution(execId, { resume }); + } catch (error) { + onError(error); + throw error; + } + return { + id: envelope.id, + events: watchStreamForTransportError( + envelope.events, + onError, + ) as ReadableStream, + sync: envelope.sync, + }; + }, + killExec: async ({ id: execId, signal }) => { + try { + await shell.kill(execId, signal); + } catch (error) { + onError(error); + throw error; + } + }, + disposeExec: async ({ id: execId }) => { + try { + await shell.dispose(execId); + } catch (error) { + onError(error); + throw error; + } + }, + close: async () => { + // The backend handle owns the transport; closing happens + // through the Workspace's own close path. + }, + }; + } + #moduleHandleFor(id: string): Promise { const cached = this.#moduleHandles.get(id); if (cached) return Promise.resolve(cached); @@ -921,19 +981,6 @@ export class Workspace { return { shell, handle }; } - // Routed shell facade. Each method picks the right backend per - // call (default, or the one named through ExecOptions.backend) - // and forwards to that backend's WorkspaceShell. - #routedShell(): WorkspaceShell { - const router = new WorkspaceShellRouter( - this.#defaultCommandBackendId ?? "", - (id) => this.#shellFor(id), - (id) => this.#resolveCommandBackendId(id) ?? "", - (id, handle, error) => this.#onShellError(id, handle, error), - ); - return router as unknown as WorkspaceShell; - } - // Invalidate the cached handle for `id` when a shell-routed RPC // fails with a known transport error. Compares the caller's // captured handle against the live cache entry so a late-failing @@ -945,6 +992,36 @@ export class Workspace { } } +// Pass an execution event stream through unchanged, but classify any +// error that tears it down. A transport-classified mid-stream failure +// invalidates the cached backend handle so the next call reconnects, +// matching the invalidation the shell router used to install around a +// command handle's result(). +function watchStreamForTransportError( + events: ReadableStream, + onError: (error: unknown) => void, +): ReadableStream { + const reader = events.getReader(); + return new ReadableStream({ + async pull(controller) { + try { + const { value, done } = await reader.read(); + if (done) { + controller.close(); + return; + } + controller.enqueue(value); + } catch (error) { + onError(error); + controller.error(error); + } + }, + async cancel(reason) { + await reader.cancel(reason); + }, + }); +} + function positiveRetryOption(value: number | undefined, fallback: number, name: string): number { const resolved = value ?? fallback; if (!Number.isSafeInteger(resolved) || resolved <= 0) { @@ -974,140 +1051,6 @@ function createDisabledArtifactsClient(): ArtifactClient { } as ArtifactClient; } -// Selector wrapper that satisfies the WorkspaceShell surface but -// resolves the underlying ShellRPC per call. ExecOptions and -// GetExecOptions both gain an optional `backend` field; when -// present the router routes the call to that backend's -// WorkspaceShell, otherwise it routes to the default. -// -// Implemented as a non-extending class with the same method -// names so the routed object slots into every callsite that -// expects a WorkspaceShell without having to thread the union -// type through. -class WorkspaceShellRouter { - readonly #defaultId: string; - readonly #shellFor: (id: string) => Promise<{ shell: WorkspaceShell; handle: BackendHandle }>; - readonly #resolveId: (id: string | undefined) => string; - readonly #onError: (id: string, handle: BackendHandle, error: unknown) => void; - - constructor( - defaultId: string, - shellFor: (id: string) => Promise<{ shell: WorkspaceShell; handle: BackendHandle }>, - resolveId: (id: string | undefined) => string, - onError: (id: string, handle: BackendHandle, error: unknown) => void, - ) { - this.#defaultId = defaultId; - this.#shellFor = shellFor; - this.#resolveId = resolveId; - this.#onError = onError; - } - - async exec(command: string, options: { backend?: string } & Record = {}) { - const id = this.#resolveId(options.backend) || this.#defaultId; - // Capture the BackendHandle here, at dispatch time. A late - // failure from this command's stream must invalidate THIS - // handle, not whatever the cache holds when the rejection - // eventually fires; a concurrent reconnect may have already - // swapped in a newer handle that we must not clobber. - const { shell, handle: dispatchHandle } = await this.#shellFor(id); - const { backend: _backend, ...rest } = options; - let execHandle: unknown; - try { - execHandle = await (shell.exec as unknown as (c: string, o: typeof rest) => Promise)( - command, - rest, - ); - } catch (error) { - this.#onError(id, dispatchHandle, error); - throw error; - } - return this.#wrapHandle(id, dispatchHandle, execHandle); - } - - async get(id: string, options: { backend?: string } & Record = {}) { - const backendId = this.#resolveId(options.backend) || this.#defaultId; - const { shell, handle: dispatchHandle } = await this.#shellFor(backendId); - const { backend: _backend, ...rest } = options; - let execHandle: unknown; - try { - execHandle = await (shell.get as unknown as (e: string, o: typeof rest) => Promise)( - id, - rest, - ); - } catch (error) { - this.#onError(backendId, dispatchHandle, error); - throw error; - } - return this.#wrapHandle(backendId, dispatchHandle, execHandle); - } - - async kill( - id: string, - signal?: import("./shell.js").KillSignal, - options: { backend?: string } = {}, - ): Promise { - const backendId = this.#resolveId(options.backend) || this.#defaultId; - const { shell, handle } = await this.#shellFor(backendId); - try { - await shell.kill(id, signal); - } catch (error) { - this.#onError(backendId, handle, error); - throw error; - } - } - - async dispose(id: string, options: { backend?: string } = {}): Promise { - const backendId = this.#resolveId(options.backend) || this.#defaultId; - const { shell, handle } = await this.#shellFor(backendId); - try { - await shell.dispose(id); - } catch (error) { - this.#onError(backendId, handle, error); - throw error; - } - } - - // Wrap an ExecHandle so a transport-classified rejection from - // result() invalidates the cached backend handle. The dispatch- - // time catch above only fires when shell.exec()/get() rejects - // immediately; in practice a long-running command loses its - // transport mid-stream and the rejection surfaces through - // result() draining the event stream. - // - // The handle reference captured here is the one that was active - // when the exec was dispatched. By the time result() rejects, a - // concurrent reconnect may have replaced the cached entry for - // this id with a newer handle; invalidation in #onShellError - // checks identity against the dispatch-time handle so the newer - // entry survives. - // - // WorkspaceShell installs .result via defineProperty; we set it - // configurable: true so this slot can be redefined. The handle's - // stream identity is preserved — callers that consume the - // ReadableStream directly are unaffected; only result() routes - // through the invalidation path. - #wrapHandle(id: string, dispatchHandle: BackendHandle, execHandle: unknown): unknown { - const original = execHandle as { result?: unknown }; - if (typeof original.result !== "function") return execHandle; - const onError = this.#onError; - const originalResult = original.result.bind(execHandle) as () => Promise; - Object.defineProperty(execHandle, "result", { - value: async () => { - try { - return await originalResult(); - } catch (error) { - onError(id, dispatchHandle, error); - throw error; - } - }, - enumerable: false, - writable: false, - configurable: true, - }); - return execHandle; - } -} - export function createThinkCompatibility( fs: ThinkWorkspaceFilesystem, ): ThinkWorkspaceCompatibility { From c74cdec9093482ab5828b003221bc6aee8518eec Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:18:15 +0000 Subject: [PATCH 05/13] computer: reduce the shell facade to a command executor The host shell facade exposed an exec()/get() surface that returned a ReadableStream handle with a result() method, bolted on encoding and result accumulation, and drained the sync bracket through that result. Once every backend routed through the unified execution path, nothing in production called that surface: the runtime consumes an envelope of raw events and drains the result itself. The facade and its helpers survived only because their tests kept them alive, and those tests covered code the runtime no longer runs. Reduce the class to CommandExecutor: exec() and get() return the raw event envelope plus the sync bracket stats, and kill()/dispose() forward to the wire. Delete the handle wrapper, the per-stream utf8 transform, the result drain, and the ExecHandle and ExecResult types they produced. Retarget the executor tests onto the envelope surface and move the encoding, result-accumulation, and cancellation-code coverage onto the runtime, which is where that logic now lives. --- packages/computer/src/observe.ts | 2 +- packages/computer/src/runtime/runtime.test.ts | 149 +++- packages/computer/src/shell.test.ts | 668 ++++-------------- packages/computer/src/shell.ts | 406 ++--------- packages/computer/src/tools/ai.test.ts | 14 +- packages/computer/src/workspace.ts | 24 +- 6 files changed, 349 insertions(+), 914 deletions(-) diff --git a/packages/computer/src/observe.ts b/packages/computer/src/observe.ts index c2e8d2ba..748ab904 100644 --- a/packages/computer/src/observe.ts +++ b/packages/computer/src/observe.ts @@ -7,7 +7,7 @@ // connect() + watermark reconcile. // workspace.sync.push — one per `Workspace.push()` call. // workspace.sync.pull — one per `Workspace.pull()` call. -// workspace.runtime.exec — one per `WorkspaceShell.exec()` call, +// workspace.runtime.exec — one per command executor exec() call, // covering pre-exec push, the spawn // request, and (when `result()` is // awaited) the post-drain pull. diff --git a/packages/computer/src/runtime/runtime.test.ts b/packages/computer/src/runtime/runtime.test.ts index 330dc8d2..b7eaa234 100644 --- a/packages/computer/src/runtime/runtime.test.ts +++ b/packages/computer/src/runtime/runtime.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { WorkspaceRuntime } from "./runtime.js"; -import type { ModuleExecutionEnvelope, WorkspaceModuleBackendHandle } from "./types.js"; +import type { + ModuleExecutionEnvelope, + WorkspaceModuleBackendHandle, + WorkspaceRuntimeEvent, +} from "./types.js"; function emptyEnvelope(id: string): ModuleExecutionEnvelope { return { @@ -24,6 +28,149 @@ function moduleHandleStub(): WorkspaceModuleBackendHandle { }; } +function eventStream(events: WorkspaceRuntimeEvent[]): ReadableStream { + return new ReadableStream({ + start(controller) { + for (const event of events) controller.enqueue(event); + controller.close(); + }, + }); +} + +// A backend whose exec replays a fixed event sequence. Drives the +// runtime's encoding transform and result drain — the work the host +// shell facade used to own before every backend shared one path. +function replayBackend(events: WorkspaceRuntimeEvent[]): WorkspaceModuleBackendHandle { + return { + exec: vi.fn(async (input) => ({ id: input.id ?? "exec", events: eventStream(events) })), + getExec: vi.fn(async (input) => ({ id: input.id, events: eventStream(events) })), + killExec: vi.fn(async () => {}), + disposeExec: vi.fn(async () => {}), + close: vi.fn(async () => {}), + }; +} + +function runtimeFor(handle: WorkspaceModuleBackendHandle): WorkspaceRuntime { + return new WorkspaceRuntime({ + callableBackendIds: new Set(), + backendHandle: async () => handle, + resolveBackendId: () => "backend", + }); +} + +function stdout(seq: number, value: Uint8Array): WorkspaceRuntimeEvent { + return { id: "e", seq, name: "stdout", value }; +} +function stderr(seq: number, value: Uint8Array): WorkspaceRuntimeEvent { + return { id: "e", seq, name: "stderr", value }; +} +function bytes(text: string): Uint8Array { + return new TextEncoder().encode(text); +} + +describe("WorkspaceRuntime result accumulation", () => { + it("concatenates stdout chunks in arrival order as raw bytes", async () => { + const runtime = runtimeFor( + replayBackend([ + stdout(1, bytes("one")), + stdout(2, bytes("two")), + stdout(3, bytes("three")), + { id: "e", seq: 4, name: "exit", value: 0 }, + ]), + ); + const result = await (await runtime.exec("noop")).result(); + expect(result.stdout).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(result.stdout as Uint8Array)).toBe("onetwothree"); + }); + + it("keeps stdout and stderr separate", async () => { + const runtime = runtimeFor( + replayBackend([ + stdout(1, bytes("out")), + stderr(2, bytes("err")), + stdout(3, bytes("out2")), + { id: "e", seq: 4, name: "exit", value: 0 }, + ]), + ); + const result = await (await runtime.exec("noop")).result(); + expect(new TextDecoder().decode(result.stdout as Uint8Array)).toBe("outout2"); + expect(new TextDecoder().decode(result.stderr as Uint8Array)).toBe("err"); + }); + + it("captures the exit code from the exit event", async () => { + const runtime = runtimeFor(replayBackend([{ id: "e", seq: 1, name: "exit", value: 42 }])); + const result = await (await runtime.exec("noop")).result(); + expect(result.exitCode).toBe(42); + }); + + it("maps signal exit codes to a cancelled status", async () => { + for (const code of [129, 130, 137, 143]) { + const runtime = runtimeFor(replayBackend([{ id: "e", seq: 1, name: "exit", value: code }])); + const result = await (await runtime.exec("noop")).result(); + expect(result.status).toBe("cancelled"); + expect(result.exitCode).toBe(code); + } + }); +}); + +describe("WorkspaceRuntime utf8 encoding", () => { + it("returns stdout / stderr as strings when encoding is 'utf8'", async () => { + const runtime = runtimeFor( + replayBackend([ + stdout(1, bytes("hello ")), + stderr(2, bytes("warn")), + stdout(3, bytes("world")), + { id: "e", seq: 4, name: "exit", value: 0 }, + ]), + ); + const result = await (await runtime.exec("noop", { encoding: "utf8" })).result(); + expect(result.stdout).toBe("hello world"); + expect(result.stderr).toBe("warn"); + }); + + it("decodes multi-byte UTF-8 split across chunks correctly", async () => { + const partyHat = new Uint8Array([0xf0, 0x9f, 0x8e, 0x89]); + const runtime = runtimeFor( + replayBackend([ + stdout(1, partyHat.subarray(0, 3)), + stdout(2, partyHat.subarray(3)), + { id: "e", seq: 3, name: "exit", value: 0 }, + ]), + ); + const result = await (await runtime.exec("noop", { encoding: "utf8" })).result(); + expect(result.stdout).toBe("\u{1f389}"); + }); + + it("keeps the stdout and stderr decoders independent", async () => { + const partyHat = new Uint8Array([0xf0, 0x9f, 0x8e, 0x89]); + const heart = new Uint8Array([0xe2, 0x9d, 0xa4]); + const runtime = runtimeFor( + replayBackend([ + stdout(1, partyHat.subarray(0, 2)), + stderr(2, heart.subarray(0, 2)), + stdout(3, partyHat.subarray(2)), + stderr(4, heart.subarray(2)), + { id: "e", seq: 5, name: "exit", value: 0 }, + ]), + ); + const result = await (await runtime.exec("noop", { encoding: "utf8" })).result(); + expect(result.stdout).toBe("\u{1f389}"); + expect(result.stderr).toBe("\u2764"); + }); + + it("preserves encoding when consuming the stream directly", async () => { + const runtime = runtimeFor( + replayBackend([stdout(1, bytes("stream-mode")), { id: "e", seq: 2, name: "exit", value: 0 }]), + ); + const handle = await runtime.exec("noop", { encoding: "utf8" }); + const seen: unknown[] = []; + for await (const event of handle) { + if (event.name === "stdout") seen.push(event.value); + } + expect(seen).toEqual(["stream-mode"]); + }); +}); + describe("WorkspaceRuntime callable gate", () => { it("rejects structured input for a non-callable backend", async () => { const runtime = new WorkspaceRuntime({ diff --git a/packages/computer/src/shell.test.ts b/packages/computer/src/shell.test.ts index 3158db10..e68529ab 100644 --- a/packages/computer/src/shell.test.ts +++ b/packages/computer/src/shell.test.ts @@ -1,25 +1,20 @@ -// Unit tests for WorkspaceShell. The harness shell.test.ts under +// Unit tests for CommandExecutor. The harness shell.test.ts under // src/test-harness covers the wire end-to-end against a real computerd // container; these tests run in-process with a fake WorkspaceRPC so -// the host-side facade (RPC forwarding, handle shape, encoding, -// result accumulation, push/pull bracket math) is exercised -// without needing Docker. +// the host-side executor (RPC forwarding, envelope shape, push/pull +// bracket math, reattach) is exercised without needing Docker. +// +// Encoding and result accumulation are the runtime's job now — the +// executor returns raw events and the runtime drains them — so those +// cases live in runtime.test.ts, not here. import type { ExecEvent, ShellRPC, SyncRPC, WorkspaceRPC } from "@cloudflare/computer-rpc"; import { describe, expect, it } from "vitest"; -import type { KillSignal } from "./shell.js"; -import { type Sync, WorkspaceShell } from "./shell.js"; +import { CommandExecutor, type KillSignal, type Sync } from "./shell.js"; -// Inert sync impl. The shell unit tests aren't exercising the -// push/pull bracket — they just need a Sync that returns 0 from -// both methods so the bracket plumbing is a no-op. Workspace.test.ts -// covers the bracket against a real workspace. -// -// pull() returns the dofs ApplyResult shape ({ applied, skipped }); -// tests that only care about counts use the `applied` helper below -// to build a synthetic result. Tests that want to exercise the -// skipped path build the shape inline. +// Inert sync impl. Tests that don't exercise the bracket use a Sync +// that returns 0 from both halves. Tests that do build their own. function applied(n: number) { return { applied: n, skipped: [] }; } @@ -35,14 +30,8 @@ function makeSync(): Sync { }; } -// --------------------------------------------------------------------------- -// Test fixture: a fully synthesised WorkspaceRPC. Each helper method on the -// returned object also exposes the call log on the `calls` field so tests -// can assert on what the facade forwarded. -// --------------------------------------------------------------------------- - interface ExecCall { - command: string; + source: string; id: string | undefined; cwd: string | undefined; timeoutMs: number | undefined; @@ -68,26 +57,16 @@ interface FakeRpc { } interface FakeRpcOptions { - // Events to push onto the stream returned by shell.exec / getExec. - // The runner's id is stamped onto each event before enqueue. events?: ExecEvent[]; - // Optional: shell.exec rejects with this error. throwOnExec?: Error; - // Optional: enqueue events, then error the stream with this. streamError?: Error; - // Optional: id the runner mints when the caller doesn't supply one. - // Defaults to "runner-minted-id". mintedId?: string; } function fakeRpc(options: FakeRpcOptions = {}): FakeRpc { const events = options.events ?? [{ id: "_", seq: 1, name: "exit", value: 0 }]; const mintedId = options.mintedId ?? "runner-minted-id"; - const calls: FakeRpc["calls"] = { - exec: [], - getExec: [], - killExec: [], - }; + const calls: FakeRpc["calls"] = { exec: [], getExec: [], killExec: [] }; function makeStream(id: string): ReadableStream { return new ReadableStream({ @@ -129,7 +108,7 @@ function fakeRpc(options: FakeRpcOptions = {}): FakeRpc { const shell: ShellRPC = { async exec(input) { calls.exec.push({ - command: input.source, + source: input.source, id: input.id, cwd: input.cwd, timeoutMs: input.timeoutMs, @@ -151,79 +130,83 @@ function fakeRpc(options: FakeRpcOptions = {}): FakeRpc { return { rpc: { sync, shell }, calls }; } -// Convenience: a stream-event with the encoder bytes inlined. function stdout(seq: number, text: string): ExecEvent { return { id: "_", seq, name: "stdout", value: new TextEncoder().encode(text) }; } -function stderr(seq: number, text: string): ExecEvent { - return { id: "_", seq, name: "stderr", value: new TextEncoder().encode(text) }; -} function exit(seq: number, code: number): ExecEvent { return { id: "_", seq, name: "exit", value: code }; } -// --------------------------------------------------------------------------- -// exec() — RPC forwarding -// --------------------------------------------------------------------------- +// Drain an execution's events to completion and settle its sync +// outcome, mirroring what the runtime does when it wraps the handle. +async function drain(execution: { + events: ReadableStream; + sync: { pushed: number; outcome: Promise }; +}) { + const seen: ExecEvent[] = []; + const reader = execution.events.getReader(); + try { + while (true) { + const { value, done } = await reader.read(); + if (done) break; + seen.push(value); + } + } finally { + reader.releaseLock(); + } + const outcome = await execution.sync.outcome; + return { seen, pushed: execution.sync.pushed, outcome }; +} -describe("WorkspaceShell.exec — RPC forwarding", () => { - it("forwards the command verbatim", async () => { +describe("CommandExecutor.exec — RPC forwarding", () => { + it("forwards the source verbatim", async () => { const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); + const shell = new CommandExecutor(f.rpc.shell, makeSync()); await shell.exec("echo hi && exit 0"); expect(f.calls.exec).toHaveLength(1); - expect(f.calls.exec[0].command).toBe("echo hi && exit 0"); + expect(f.calls.exec[0].source).toBe("echo hi && exit 0"); }); it("forwards an explicit id", async () => { const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - await shell.exec("noop", { id: "stable-id" }); + await new CommandExecutor(f.rpc.shell, makeSync()).exec("noop", { id: "stable-id" }); expect(f.calls.exec[0].id).toBe("stable-id"); }); it("omits id from the RPC when the caller doesn't supply one", async () => { const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - await shell.exec("noop"); + await new CommandExecutor(f.rpc.shell, makeSync()).exec("noop"); expect(f.calls.exec[0].id).toBeUndefined(); }); it("forwards cwd", async () => { const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - await shell.exec("noop", { cwd: "/workspace/sub" }); + await new CommandExecutor(f.rpc.shell, makeSync()).exec("noop", { cwd: "/workspace/sub" }); expect(f.calls.exec[0].cwd).toBe("/workspace/sub"); }); it("forwards timeoutMs", async () => { const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - await shell.exec("noop", { timeoutMs: 1000 }); + await new CommandExecutor(f.rpc.shell, makeSync()).exec("noop", { timeoutMs: 1000 }); expect(f.calls.exec[0].timeoutMs).toBe(1000); }); it("forwards timeoutMs: 0 to disable the timeout", async () => { const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - await shell.exec("noop", { timeoutMs: 0 }); + await new CommandExecutor(f.rpc.shell, makeSync()).exec("noop", { timeoutMs: 0 }); expect(f.calls.exec[0].timeoutMs).toBe(0); }); it("leaves timeoutMs undefined when the caller omits it", async () => { const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - await shell.exec("noop"); + await new CommandExecutor(f.rpc.shell, makeSync()).exec("noop"); expect(f.calls.exec[0].timeoutMs).toBeUndefined(); }); it("uses the id the runner returned, not the caller-supplied one", async () => { - // The runner is authoritative — if it mints an id, the handle - // exposes it. The facade doesn't second-guess. const f = fakeRpc({ mintedId: "from-runner" }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop"); - expect(handle.id).toBe("from-runner"); + const execution = await new CommandExecutor(f.rpc.shell, makeSync()).exec("noop"); + expect(execution.id).toBe("from-runner"); }); it("propagates errors from shell.exec; the pre-spawn push ran, the post-drain pull did not", async () => { @@ -240,81 +223,28 @@ describe("WorkspaceShell.exec — RPC forwarding", () => { return applied(0); }, }; - const shell = new WorkspaceShell(f.rpc.shell, sync); - await expect(shell.exec("noop")).rejects.toThrow("EEXEC_BUSY"); + await expect(new CommandExecutor(f.rpc.shell, sync).exec("noop")).rejects.toThrow("EEXEC_BUSY"); expect(pushCalls).toBe(1); expect(pullCalls).toBe(0); }); }); -// --------------------------------------------------------------------------- -// exec() — handle shape -// --------------------------------------------------------------------------- - -describe("WorkspaceShell.exec — handle shape", () => { - it("returns a ReadableStream that can be consumed with getReader()", async () => { - const f = fakeRpc({ events: [stdout(1, "hi"), exit(2, 0)] }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop"); - expect(handle).toBeInstanceOf(ReadableStream); - const reader = handle.getReader(); - const first = await reader.read(); - expect(first.done).toBe(false); - reader.releaseLock(); - }); - - it("returns a stream that supports for-await iteration", async () => { +describe("CommandExecutor.exec — envelope events", () => { + it("streams the raw events through in order", async () => { const f = fakeRpc({ events: [stdout(1, "a"), stdout(2, "b"), exit(3, 0)] }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop"); - const seen: string[] = []; - for await (const event of handle) seen.push(event.name); - expect(seen).toEqual(["stdout", "stdout", "exit"]); + const execution = await new CommandExecutor(f.rpc.shell, makeSync()).exec("noop"); + const { seen } = await drain(execution); + expect(seen.map((e) => e.name)).toEqual(["stdout", "stdout", "exit"]); }); - it("runs the post-command pull after stream-only consumption", async () => { - const f = fakeRpc({ events: [exit(1, 0)] }); - let pulls = 0; - const sync: Sync = { - push: async () => 0, - pull: async () => { - pulls += 1; - return applied(0); - }, - }; - const handle = await new WorkspaceShell(f.rpc.shell, sync).exec("noop"); - for await (const _event of handle) { - // Drain the stream. - } - expect(pulls).toBe(1); - }); - - it("hides id / result / kill from Object.keys and JSON.stringify", async () => { - const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop"); - expect(Object.keys(handle)).not.toContain("id"); - expect(Object.keys(handle)).not.toContain("result"); - expect(Object.keys(handle)).not.toContain("kill"); - // JSON.stringify on a stream returns "{}" (no enumerable own - // properties), confirming the extras aren't traversed. - expect(JSON.stringify(handle)).toBe("{}"); - }); - - it("kill(signal) forwards the signal to killExec", async () => { - const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop", { id: "kid" }); - await handle.kill("SIGKILL"); - expect(f.calls.killExec).toEqual([{ id: "kid", signal: "SIGKILL" }]); - }); - - it("kill() with no signal forwards undefined (server defaults to SIGTERM)", async () => { - const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop", { id: "kid" }); - await handle.kill(); - expect(f.calls.killExec).toEqual([{ id: "kid", signal: undefined }]); + it("carries stdout bytes untouched (no host-side encoding)", async () => { + const f = fakeRpc({ events: [stdout(1, "hi"), exit(2, 0)] }); + const execution = await new CommandExecutor(f.rpc.shell, makeSync()).exec("noop"); + const { seen } = await drain(execution); + const first = seen[0]; + expect(first.name).toBe("stdout"); + expect(first.value).toBeInstanceOf(Uint8Array); + expect(new TextDecoder().decode(first.value as Uint8Array)).toBe("hi"); }); it("disposes the RPC envelope when the event stream errors", async () => { @@ -339,8 +269,8 @@ describe("WorkspaceShell.exec — handle shape", () => { async killExec() {}, async disposeExec() {}, }; - const handle = await new WorkspaceShell(shellRpc, makeSync()).exec("noop"); - await expect(handle.getReader().read()).rejects.toThrow("transport failed"); + const execution = await new CommandExecutor(shellRpc, makeSync()).exec("noop"); + await expect(execution.events.getReader().read()).rejects.toThrow("transport failed"); expect(disposed).toBe(1); }); @@ -362,229 +292,14 @@ describe("WorkspaceShell.exec — handle shape", () => { async killExec() {}, async disposeExec() {}, }; - const handle = await new WorkspaceShell(shellRpc, makeSync()).exec("noop"); - await handle.cancel(); + const execution = await new CommandExecutor(shellRpc, makeSync()).exec("noop"); + await execution.events.cancel(); expect(disposed).toBe(1); }); - - it("kill() still reaches a non-retained backend when tail replay is absent", async () => { - let kills = 0; - const shellRpc: ShellRPC = { - async exec(input) { - return { id: input.id ?? "kid", events: new ReadableStream() }; - }, - async getExec() { - const error = new Error("no retained execution") as Error & { code: string }; - error.code = "ENOENT"; - throw error; - }, - async killExec() { - kills += 1; - }, - async disposeExec() {}, - }; - const handle = await new WorkspaceShell(shellRpc, makeSync()).exec("noop", { id: "kid" }); - await handle.kill(); - expect(kills).toBe(1); - }); - - it("kill() remains a no-op when the execution already completed", async () => { - const shellRpc: ShellRPC = { - async exec(input) { - return { id: input.id ?? "kid", events: new ReadableStream() }; - }, - async getExec() { - return { - id: "kid", - events: new ReadableStream({ - start(controller) { - controller.close(); - }, - }), - }; - }, - async killExec() {}, - async disposeExec() {}, - }; - const handle = await new WorkspaceShell(shellRpc, makeSync()).exec("noop", { id: "kid" }); - await expect(handle.kill()).resolves.toBeUndefined(); - }); -}); - -// --------------------------------------------------------------------------- -// exec() — result accumulation -// --------------------------------------------------------------------------- - -describe("WorkspaceShell.exec — result accumulation", () => { - it("concatenates stdout chunks in arrival order (Uint8Array default)", async () => { - const f = fakeRpc({ - events: [stdout(1, "one"), stdout(2, "two"), stdout(3, "three"), exit(4, 0)], - }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop"); - const result = await handle.result(); - expect(result.stdout).toBeInstanceOf(Uint8Array); - expect(new TextDecoder().decode(result.stdout as Uint8Array)).toBe("onetwothree"); - }); - - it("keeps stdout and stderr separate", async () => { - const f = fakeRpc({ - events: [stdout(1, "out"), stderr(2, "err"), stdout(3, "out2"), exit(4, 0)], - }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop"); - const result = await handle.result(); - expect(new TextDecoder().decode(result.stdout as Uint8Array)).toBe("outout2"); - expect(new TextDecoder().decode(result.stderr as Uint8Array)).toBe("err"); - }); - - it("captures the exit code from the exit event", async () => { - const f = fakeRpc({ events: [exit(1, 42)] }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop"); - const result = await handle.result(); - expect(result.exitCode).toBe(42); - }); - - it("returns exitCode = -1 when the stream closes without an exit event", async () => { - // The runner shouldn't do this, but if the wire drops mid-flight - // the facade must still resolve so callers see something. -1 is - // the documented sentinel. - const f = fakeRpc({ events: [stdout(1, "partial")] }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop"); - const result = await handle.result(); - expect(result.exitCode).toBe(-1); - }); - - it("returns an empty Uint8Array when no output arrives", async () => { - const f = fakeRpc({ events: [exit(1, 0)] }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop"); - const result = await handle.result(); - expect(result.stdout).toBeInstanceOf(Uint8Array); - expect((result.stdout as Uint8Array).byteLength).toBe(0); - expect((result.stderr as Uint8Array).byteLength).toBe(0); - }); - - it("returns an empty string for utf8 encoding with no output", async () => { - const f = fakeRpc({ events: [exit(1, 0)] }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop", { encoding: "utf8" }); - const result = await handle.result(); - expect(result.stdout).toBe(""); - expect(result.stderr).toBe(""); - }); - - it("rejects result() when the wire stream errors mid-flight", async () => { - const f = fakeRpc({ - events: [stdout(1, "partial")], - streamError: new Error("ESHUTDOWN"), - }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop"); - await expect(handle.result()).rejects.toThrow("ESHUTDOWN"); - }); }); -// --------------------------------------------------------------------------- -// exec() — utf8 encoding -// --------------------------------------------------------------------------- - -describe("WorkspaceShell.exec — utf8 encoding", () => { - it("returns stdout / stderr as strings when encoding is 'utf8'", async () => { - const f = fakeRpc({ - events: [stdout(1, "hello "), stderr(2, "warn"), stdout(3, "world"), exit(4, 0)], - }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop", { encoding: "utf8" }); - const result = await handle.result(); - expect(result.stdout).toBe("hello world"); - expect(result.stderr).toBe("warn"); - }); - - it("decodes multi-byte UTF-8 split across chunks correctly", async () => { - // "🎉" is F0 9F 8E 89. Split mid-character: first three bytes, - // then the trailing byte. A naive decoder per chunk produces - // replacement characters; the streaming decoder must hold the - // partial sequence and emit the full code point. - const partyHat = new Uint8Array([0xf0, 0x9f, 0x8e, 0x89]); - const head = partyHat.subarray(0, 3); - const tail = partyHat.subarray(3); - const f = fakeRpc({ - events: [ - { id: "_", seq: 1, name: "stdout", value: head }, - { id: "_", seq: 2, name: "stdout", value: tail }, - exit(3, 0), - ], - }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop", { encoding: "utf8" }); - const result = await handle.result(); - expect(result.stdout).toBe("🎉"); - }); - - it("keeps decoder flush sequence numbers strictly monotonic", async () => { - const f = fakeRpc({ - events: [ - { id: "_", seq: 1, name: "stdout", value: new Uint8Array([0xf0, 0x9f]) }, - exit(2, 0), - ], - }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop", { encoding: "utf8" }); - const seen: Array<{ seq: number; name: string; value: unknown }> = []; - for await (const event of handle) { - seen.push({ seq: event.seq, name: event.name, value: event.value }); - } - - expect(seen).toEqual([ - { seq: 1, name: "stdout", value: "" }, - { seq: 1.5, name: "stdout", value: "�" }, - { seq: 2, name: "exit", value: 0 }, - ]); - }); - - it("keeps the stdout and stderr decoders independent", async () => { - // Interleave a partial code point on each stream. If the - // decoders share state, one stream's tail would land on the - // other's head and corrupt both. - const partyHat = new Uint8Array([0xf0, 0x9f, 0x8e, 0x89]); // 🎉 - const heart = new Uint8Array([0xe2, 0x9d, 0xa4]); // ❤ - const f = fakeRpc({ - events: [ - { id: "_", seq: 1, name: "stdout", value: partyHat.subarray(0, 2) }, - { id: "_", seq: 2, name: "stderr", value: heart.subarray(0, 2) }, - { id: "_", seq: 3, name: "stdout", value: partyHat.subarray(2) }, - { id: "_", seq: 4, name: "stderr", value: heart.subarray(2) }, - exit(5, 0), - ], - }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop", { encoding: "utf8" }); - const result = await handle.result(); - expect(result.stdout).toBe("🎉"); - expect(result.stderr).toBe("❤"); - }); - - it("preserves encoding when consuming the stream directly", async () => { - const f = fakeRpc({ events: [stdout(1, "stream-mode"), exit(2, 0)] }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.exec("noop", { encoding: "utf8" }); - const seen: unknown[] = []; - for await (const event of handle) { - if (event.name === "stdout") seen.push(event.value); - } - expect(seen).toEqual(["stream-mode"]); - }); -}); - -// --------------------------------------------------------------------------- -// exec() — push/pull bracket math -// --------------------------------------------------------------------------- - -describe("WorkspaceShell.exec — push/pull bracket", () => { - it("reports pushed and pulled from the Sync calls", async () => { +describe("CommandExecutor.exec — push/pull bracket", () => { + it("reports pushed up front and the pull outcome after drain", async () => { const f = fakeRpc({ events: [stdout(1, "hi"), exit(2, 0)] }); const sync: Sync = { async push() { @@ -594,55 +309,41 @@ describe("WorkspaceShell.exec — push/pull bracket", () => { return applied(7); }, }; - const shell = new WorkspaceShell(f.rpc.shell, sync); - const handle = await shell.exec("noop"); - const result = await handle.result(); - expect(result.exitCode).toBe(0); - expect(result.pushed).toBe(5); - expect(result.pulled).toBe(7); - expect(result.skipped).toEqual([]); - expect(result.sync).toEqual({ status: "complete", applied: 7, skipped: [] }); - }); - - it("reports a clean no-op pull as complete", async () => { - const f = fakeRpc({ events: [exit(1, 0)] }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const result = await (await shell.exec("true")).result(); - expect(result.sync).toEqual({ status: "complete", applied: 0, skipped: [] }); + const execution = await new CommandExecutor(f.rpc.shell, sync).exec("noop"); + expect(execution.sync.pushed).toBe(5); + const { outcome } = await drain(execution); + expect(outcome).toEqual({ + applied: 7, + skipped: [], + sync: { status: "complete", applied: 7, skipped: [] }, + }); }); it("surfaces skipped read-only entries from the post-drain pull", async () => { const f = fakeRpc({ events: [exit(1, 0)] }); + const skipped = [ + { + path: "/workspace/r2/touched.txt", + mountRoot: "/workspace/r2", + op: "write" as const, + reason: "read-only" as const, + }, + ]; const sync: Sync = { async push() { return 0; }, async pull() { - return { - applied: 2, - skipped: [ - { - path: "/workspace/r2/touched.txt", - mountRoot: "/workspace/r2", - op: "write", - reason: "read-only", - }, - ], - }; + return { applied: 2, skipped }; }, }; - const shell = new WorkspaceShell(f.rpc.shell, sync); - const handle = await shell.exec("noop"); - const result = await handle.result(); - expect(result.pulled).toBe(2); - expect(result.skipped).toEqual([ - { - path: "/workspace/r2/touched.txt", - mountRoot: "/workspace/r2", - op: "write", - reason: "read-only", - }, - ]); + const execution = await new CommandExecutor(f.rpc.shell, sync).exec("noop"); + const { outcome } = await drain(execution); + expect(outcome).toEqual({ + applied: 2, + skipped, + sync: { status: "complete", applied: 2, skipped }, + }); }); it("calls push() before spawn and pull() after drain, in that order", async () => { @@ -658,10 +359,9 @@ describe("WorkspaceShell.exec — push/pull bracket", () => { return applied(0); }, }; - const shell = new WorkspaceShell(f.rpc.shell, sync); - const handle = await shell.exec("noop"); + const execution = await new CommandExecutor(f.rpc.shell, sync).exec("noop"); expect(order).toEqual(["push"]); // push fired before exec returned - await handle.result(); + await drain(execution); expect(order).toEqual(["push", "pull"]); // pull fired after drain }); @@ -675,19 +375,15 @@ describe("WorkspaceShell.exec — push/pull bracket", () => { return applied(3); }, }; - const shell = new WorkspaceShell(f.rpc.shell, sync); - const handle = await shell.exec("noop"); - const result = await handle.result(); - expect(result.exitCode).toBe(0); - expect(result.pushed).toBe(0); + const execution = await new CommandExecutor(f.rpc.shell, sync).exec("noop"); + expect(execution.sync.pushed).toBe(0); // pull still fires — docs/05 says one failed half doesn't abort the other - expect(result.pulled).toBe(3); + const { outcome } = await drain(execution); + expect((outcome as { applied: number }).applied).toBe(3); }); it("reports a pending sync after a Durable Object storage reset", async () => { - const f = fakeRpc({ - events: [stdout(1, "command output"), stderr(2, "command warning"), exit(3, 23)], - }); + const f = fakeRpc({ events: [exit(1, 0)] }); const reset = "Internal error in Durable Object storage write caused object to be reset."; const sync: Sync = { async push() { @@ -697,15 +393,10 @@ describe("WorkspaceShell.exec — push/pull bracket", () => { throw new Error(reset); }, }; - const shell = new WorkspaceShell(f.rpc.shell, sync); - const handle = await shell.exec("noop", { encoding: "utf8" }); - const result = await handle.result(); - expect(result).toMatchObject({ - exitCode: 23, - stdout: "command output", - stderr: "command warning", - pushed: 2, - pulled: 0, + const execution = await new CommandExecutor(f.rpc.shell, sync).exec("noop"); + const { outcome } = await drain(execution); + expect(outcome).toEqual({ + applied: 0, skipped: [], sync: { status: "pending", applied: 0, skipped: [], error: reset }, }); @@ -722,91 +413,68 @@ describe("WorkspaceShell.exec — push/pull bracket", () => { throw new Error(`transport failed token=${secret} ${"x".repeat(700)}`); }, }; - const shell = new WorkspaceShell(f.rpc.shell, sync); - const result = await (await shell.exec("noop")).result(); - expect(result.sync.status).toBe("pending"); - if (result.sync.status !== "pending") throw new Error("expected pending sync"); - expect(result.sync.error.length).toBeLessThanOrEqual(512); - expect(result.sync.error).toContain("transport failed token=[REDACTED]"); - expect(result.sync.error).not.toContain(secret); + const execution = await new CommandExecutor(f.rpc.shell, sync).exec("noop"); + const { outcome } = await drain(execution); + const settled = outcome as { sync: { status: string; error: string } }; + expect(settled.sync.status).toBe("pending"); + expect(settled.sync.error.length).toBeLessThanOrEqual(512); + expect(settled.sync.error).toContain("transport failed token=[REDACTED]"); + expect(settled.sync.error).not.toContain(secret); }); +}); - it("reports a pending sync after an ordinary transport error", async () => { - const f = fakeRpc({ events: [exit(1, 0)] }); - const sync: Sync = { - async push() { - return 2; - }, - async pull() { - throw new Error("WebSocket closed before pull completed"); - }, - }; - const shell = new WorkspaceShell(f.rpc.shell, sync); - const handle = await shell.exec("noop"); - const result = await handle.result(); - expect(result.pushed).toBe(2); - expect(result.pulled).toBe(0); - expect(result.skipped).toEqual([]); - expect(result.sync).toEqual({ - status: "pending", - applied: 0, - skipped: [], - error: "WebSocket closed before pull completed", - }); +describe("CommandExecutor.kill / dispose", () => { + it("kill(signal) forwards the signal to killExec", async () => { + const f = fakeRpc(); + await new CommandExecutor(f.rpc.shell, makeSync()).kill("kid", "SIGKILL"); + expect(f.calls.killExec).toEqual([{ id: "kid", signal: "SIGKILL" }]); }); -}); -// --------------------------------------------------------------------------- -// get() — reattach -// --------------------------------------------------------------------------- + it("kill() with no signal forwards undefined (server defaults to SIGTERM)", async () => { + const f = fakeRpc(); + await new CommandExecutor(f.rpc.shell, makeSync()).kill("kid"); + expect(f.calls.killExec).toEqual([{ id: "kid", signal: undefined }]); + }); +}); -describe("WorkspaceShell.get — reattach", () => { +describe("CommandExecutor.get — reattach", () => { it("forwards id to getExec", async () => { const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - await shell.get("attach-id"); + await new CommandExecutor(f.rpc.shell, makeSync()).get("attach-id"); expect(f.calls.getExec[0].id).toBe("attach-id"); }); it("maps resume: 'full' to after: undefined", async () => { const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - await shell.get("id", { resume: "full" }); + await new CommandExecutor(f.rpc.shell, makeSync()).get("id", { resume: "full" }); expect(f.calls.getExec[0].after).toBeUndefined(); }); it("maps resume: 'tail' to after: 'tail'", async () => { const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - await shell.get("id", { resume: "tail" }); + await new CommandExecutor(f.rpc.shell, makeSync()).get("id", { resume: "tail" }); expect(f.calls.getExec[0].after).toBe("tail"); }); it("maps resume: to after: ", async () => { const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - await shell.get("id", { resume: 17 }); + await new CommandExecutor(f.rpc.shell, makeSync()).get("id", { resume: 17 }); expect(f.calls.getExec[0].after).toBe(17); }); it("omits after when resume is not supplied", async () => { const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - await shell.get("id"); + await new CommandExecutor(f.rpc.shell, makeSync()).get("id"); expect(f.calls.getExec[0].after).toBeUndefined(); }); - it("returns a handle whose id matches the requested id", async () => { + it("returns an envelope whose id matches the requested id", async () => { const f = fakeRpc(); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.get("replay-me"); - expect(handle.id).toBe("replay-me"); + const execution = await new CommandExecutor(f.rpc.shell, makeSync()).get("replay-me"); + expect(execution.id).toBe("replay-me"); }); it("skips the pre-exec push but still runs the post-drain pull", async () => { - // Reattach doesn't own the original push frame: pushed = 0. - // The post-drain pull still fires — anything computerd produced - // between reattach and drain lands locally. const f = fakeRpc({ events: [exit(1, 0)] }); let pushCalls = 0; let pullCalls = 0; @@ -820,83 +488,11 @@ describe("WorkspaceShell.get — reattach", () => { return applied(2); }, }; - const shell = new WorkspaceShell(f.rpc.shell, sync); - const handle = await shell.get("x", { resume: "full" }); - const result = await handle.result(); + const execution = await new CommandExecutor(f.rpc.shell, sync).get("x", { resume: "full" }); + expect(execution.sync.pushed).toBe(0); + const { outcome } = await drain(execution); expect(pushCalls).toBe(0); expect(pullCalls).toBe(1); - expect(result.pushed).toBe(0); - expect(result.pulled).toBe(2); - }); - - it("accumulates the replayed output the same way exec() does", async () => { - const f = fakeRpc({ events: [stdout(1, "replay"), exit(2, 5)] }); - const shell = new WorkspaceShell(f.rpc.shell, makeSync()); - const handle = await shell.get("id", { encoding: "utf8" }); - const result = await handle.result(); - expect(result.stdout).toBe("replay"); - expect(result.exitCode).toBe(5); - }); - - it("reattaches to a live run once the first handle is dropped", async () => { - // The runner allows one live subscriber per run, so dropping a - // handle has to give up its subscription. The handle keeps a - // second reader on the event stream to watch for the exit event - // (kill() awaits it), and that reader has to go too — otherwise - // the subscription outlives the handle and reattach is refused - // for the rest of the run. - const f = liveRpc(); - const shell = new WorkspaceShell(f.shell, makeSync()); - const started = await shell.exec("sleep 100", { id: "long-run" }); - await started.cancel(); - - const again = await shell.get("long-run", { encoding: "utf8", resume: "tail" }); - f.emit(stdout(1, "still here\n")); - f.emit(exit(2, 0)); - const result = await again.result(); - expect(result.stdout).toBe("still here\n"); + expect((outcome as { applied: number }).applied).toBe(2); }); }); - -// A ShellRPC that models the runner's live subscriber bookkeeping: one -// subscriber per run, and the slot only frees when that subscriber -// cancels. Events are pushed by the test through emit(), so the run -// stays live for as long as the test wants it to. -function liveRpc(): { - shell: ShellRPC; - emit: (event: ExecEvent) => void; -} { - let subscriber: ReadableStreamDefaultController | undefined; - const subscribe = (id: string): ReadableStream => { - if (subscriber !== undefined) { - throw new Error(`EEXEC_BUSY: exec ${id} already has a live subscriber`); - } - return new ReadableStream({ - start(c) { - subscriber = c; - }, - cancel() { - subscriber = undefined; - }, - }); - }; - return { - // The runner closes the subscriber's stream after the exit - // event; result() drains until close. - emit: (event) => { - subscriber?.enqueue(event); - if (event.name === "exit") subscriber?.close(); - }, - shell: { - async exec(input) { - const id = input.id ?? "runner-minted-id"; - return { id, events: subscribe(id) }; - }, - async getExec(input) { - return { id: input.id, events: subscribe(input.id) }; - }, - async killExec() {}, - async disposeExec() {}, - }, - }; -} diff --git a/packages/computer/src/shell.ts b/packages/computer/src/shell.ts index 2f3c2a93..d3d88bb3 100644 --- a/packages/computer/src/shell.ts +++ b/packages/computer/src/shell.ts @@ -1,30 +1,24 @@ -// Host-side WorkspaceShell facade. +// Host-side command executor. // -// Wraps the ShellRPC half of a WorkspaceRPC stub. The docs/05 -// contract: one entry point — exec() — that returns a detached -// handle. Callers either await `result()` (run-and-wait) or -// consume the ReadableStream directly (run-and-stream), or drop -// the handle entirely (fire-and-forget). +// Wraps the ShellRPC half of a WorkspaceRPC stub and presents it as +// the unified execution surface the Workspace runtime consumes: +// exec() spawns a command, get() reattaches to one, and both return +// an envelope of raw events plus the docs/05 sync bracket stats. // -// Every exec() call brackets the spawn with the docs/05 sync -// frames: -// - pushOnce(db, rpc.sync) runs *before* the spawn so any -// host-side writes since the last push are visible to the -// command. -// - pullOnce(db, rpc.sync) runs *after* the stream drains (i.e. -// after the exit event), so anything the command produced is -// visible to subsequent Workspace.fs reads. -// The pushed / pulled counts land in ExecResult. +// Every exec() call brackets the spawn with the docs/05 sync frames: +// - the pre-exec push ships any host-side writes since the last +// push so the spawned command sees them. +// - the post-drain pull runs after the event stream reaches its +// end, so anything the command produced is visible to subsequent +// Workspace.fs reads. +// The pushed count is known when the envelope is built; the pull +// outcome settles once the events stream drains. The runtime applies +// encoding and accumulates the result from the raw events. // -// Pull fires after either result() or direct stream consumption drains -// the execution events. The stream does not close until that pull settles. -// -// get() (reattach) is intentionally not bracketed. Reattaching -// to an already-running exec doesn't represent a new push frame. -// The result() of a reattached handle reports pushed = 0 and the -// pulled count from a pull that runs after its own drain — best- -// effort, can be 0 if nothing landed in computerd between reattach and -// drain. +// get() (reattach) is intentionally not push-bracketed. Reattaching +// to an already-running exec doesn't represent a new push frame, so +// it reports pushed = 0; the post-drain pull still fires, scoped to +// whatever landed between reattach and drain. import type { ExecEvent, ShellRPC } from "@cloudflare/computer-rpc"; import type { ApplyResult, SkippedEntry } from "@cloudflare/dofs"; @@ -47,46 +41,16 @@ export type ExecSyncResult = | { status: "complete"; applied: number; skipped: SkippedEntry[] } | { status: "pending"; applied: number; skipped: SkippedEntry[]; error: string }; -export interface ExecResult { - exitCode: number; - stdout: Chunk; - stderr: Chunk; - // VFS sync stats from the docs/05 bracket. - // pushed — entries shipped by the pre-exec pushOnce. - // pulled — entries the post-drain pullOnce applied locally. - // skipped — entries the post-drain pullOnce did NOT apply - // because they targeted a read-only mount root. - // Empty when no read-only mounts are registered or - // the container stayed clear of them. - // pushed is observed before the stream is returned. The remaining - // fields describe the post-command pull when result() is used. - pushed: number; - pulled: number; - skipped: SkippedEntry[]; - // Structured post-command sync outcome. The legacy pulled and - // skipped fields remain available for existing callers. - sync: ExecSyncResult; -} - export type KillSignal = "SIGTERM" | "SIGKILL" | "SIGINT" | "SIGHUP"; -// ExecHandle is a ReadableStream with three -// extras tacked on. Implemented as the wire stream + extra own -// properties (id / result / kill) rather than a subclass for two -// reasons: -// -// 1. The wire stream comes back from capnweb already built; -// subclassing means a pump-through layer that copies every -// chunk for no behavioural gain. -// 2. pipeThrough (used for the utf8 transform) returns a plain -// ReadableStream, so the subclass identity gets lost on the -// first transform anyway. -export interface ExecHandle - extends ReadableStream> { - readonly id: string; - result(): Promise>; - kill(signal?: KillSignal): Promise; - [Symbol.dispose](): void; +// The envelope both exec() and get() return: an execution id, the +// raw (unencoded) event stream, and the sync bracket stats. The +// pushed count is known up front; `outcome` settles when `events` +// reaches its end, carrying the post-drain pull result. +export interface CommandExecution { + id: string; + events: ReadableStream; + sync: { pushed: number; outcome: Promise }; } export interface ExecOptions { @@ -129,20 +93,20 @@ export interface GetExecOptions { backend?: string; } -// Push/pull bracket plumbing. WorkspaceShell doesn't know about +// Push/pull bracket plumbing. CommandExecutor doesn't know about // the local Database or the SyncRPC wire — the host wires both // behind a Sync object that exposes the entry counts. // Workspace itself satisfies this interface (push() / pull() are // public methods); tests pass a plain { push, pull } object. -// pull() returns the dofs ApplyResult so the shell can surface -// skipped read-only entries on ExecResult. +// pull() returns the dofs ApplyResult so the executor can surface +// skipped read-only entries on the sync outcome. export interface Sync { push(): Promise; pull(): Promise; onPullPending?(error: unknown): Promise; } -export class WorkspaceShell { +export class CommandExecutor { readonly #shell: ShellRPC; readonly #sync: Sync; readonly #observer: WorkspaceObserver; @@ -153,86 +117,11 @@ export class WorkspaceShell { this.#observer = observer; } - exec(command: string): Promise>; - exec(command: string, options: ExecOptions): Promise>; - exec(command: string, options: ExecOptions<"utf8">): Promise>; - async exec( - command: string, - options: ExecOptions = {}, - ): Promise> { - assertNotTemplate(command); - // Pre-exec push: ship anything the host wrote since the last - // push so the spawned command sees it. Failures non-fatal per - // docs/05 — the command still runs; pushed reports 0. - let pushed = 0; - try { - pushed = await this.#sync.push(); - } catch { - // pushed stays 0 - } - const envelope = await withSpan( - this.#observer, - "workspace.runtime.exec.spawn", - { - "workspace.runtime.cwd": options.cwd, - "workspace.runtime.timeout_ms": options.timeoutMs, - "workspace.runtime.id": options.id, - }, - () => - this.#shell.exec({ - source: command, - id: options.id, - cwd: options.cwd, - timeoutMs: options.timeoutMs, - env: options.env, - stdin: - typeof options.stdin === "string" - ? new TextEncoder().encode(options.stdin) - : options.stdin, - }), - (span, outcome) => { - if (outcome.ok) span.setAttribute("workspace.runtime.id", outcome.value.id); - }, - ); - // Dispose the result envelope when the event stream finishes - // draining. Without this, capnweb's exports table holds onto - // the envelope for the life of the session — one entry per - // exec call — because we hand the inner stream off to the - // caller and can't `using` the envelope ourselves. - const events = disposeOnDone(envelope.events, () => maybeDispose(envelope)); - return wrapHandle(this.#shell, this.#sync, envelope.id, events, options.encoding, pushed); - } - - get(id: string): Promise>; - get(id: string, options: GetExecOptions): Promise>; - get(id: string, options: GetExecOptions<"utf8">): Promise>; - async get( - id: string, - options: GetExecOptions = {}, - ): Promise> { - const after = resumeToAfter(options.resume); - const envelope = await this.#shell.getExec({ id, after }); - const events = disposeOnDone(envelope.events, () => maybeDispose(envelope)); - // Reattach doesn't own the original push frame: pushed = 0. - // The post-drain pull still fires, scoped to whatever lands - // between reattach and the next drain. - return wrapHandle(this.#shell, this.#sync, id, events, options.encoding, 0); - } - - // Envelope form of exec / get for the unified backend handle. Returns - // raw (unencoded) events plus the sync bracket stats, matching - // ModuleExecutionEnvelope. The runtime applies encoding and drains - // the result the same way it does for module backends. `outcome` - // settles when `events` reaches its end, carrying the post-drain - // pull result. - async execution( - source: string, - options: ExecOptions = {}, - ): Promise<{ - id: string; - events: ReadableStream; - sync: { pushed: number; outcome: Promise }; - }> { + // Spawn a command. Pushes host-side writes first so the command + // sees them, then returns the raw event stream and the sync + // bracket stats. The push failure is non-fatal per docs/05 — the + // command still runs and pushed reports 0. + async exec(source: string, options: ExecOptions = {}): Promise { assertNotTemplate(source); let pushed = 0; try { @@ -264,40 +153,33 @@ export class WorkspaceShell { if (outcome.ok) span.setAttribute("workspace.runtime.id", outcome.value.id); }, ); + // Dispose the RPC envelope when the event stream finishes + // draining. Without this, capnweb's exports table holds onto + // the envelope for the life of the session — one entry per exec + // call — because the inner stream is handed off to the caller + // and the envelope can't be bound with `using` here. const drained = disposeOnDone(envelope.events, () => maybeDispose(envelope)); const { stream, outcome } = withPostPull(drained, this.#sync); - return { - id: envelope.id, - events: stream as ReadableStream, - sync: { pushed, outcome }, - }; + return { id: envelope.id, events: stream, sync: { pushed, outcome } }; } - // Envelope form of get / reattach. Reattach does not own the - // original push frame, so pushed = 0; the post-drain pull still - // fires scoped to whatever lands between reattach and drain. - async getExecution( - id: string, - options: GetExecOptions = {}, - ): Promise<{ - id: string; - events: ReadableStream; - sync: { pushed: number; outcome: Promise }; - }> { + // Reattach to an in-flight or recently-completed exec. Reattach + // does not own the original push frame, so pushed = 0; the + // post-drain pull still fires, scoped to whatever landed between + // reattach and drain. + async get(id: string, options: GetExecOptions = {}): Promise { const after = resumeToAfter(options.resume); const envelope = await this.#shell.getExec({ id, after }); const drained = disposeOnDone(envelope.events, () => maybeDispose(envelope)); const { stream, outcome } = withPostPull(drained, this.#sync); - return { id, events: stream as ReadableStream, sync: { pushed: 0, outcome } }; + return { id, events: stream, sync: { pushed: 0, outcome } }; } - kill(id: string, signal?: KillSignal, _options: { backend?: string } = {}): Promise { + kill(id: string, signal?: KillSignal): Promise { return this.#shell.killExec({ id, signal }); } - dispose(id: string): Promise; - dispose(id: string, options: { backend?: string }): Promise; - dispose(id: string, _options: { backend?: string } = {}): Promise { + dispose(id: string): Promise { return this.#shell.disposeExec({ id }); } } @@ -308,139 +190,6 @@ function resumeToAfter(resume: "tail" | "full" | number | undefined): number | " return resume; } -// Stitch the runtime extras (id, result, kill) onto a fresh -// ReadableStream that pipes from the wire stream and applies any -// encoding conversion in flight. -// -// The user stream remains the only reader so backpressure reaches the backend. -// kill() requests a signal; result() or stream completion observes the exit. -function wrapHandle( - shell: ShellRPC, - sync: Sync, - id: string, - wireEvents: ReadableStream, - encoding: E | undefined, - pushed: number, -): ExecHandle { - const postPull = withPostPull(pipeEvents(wireEvents, encoding), sync); - const stream = postPull.stream; - const handle = stream as ExecHandle; - let resultPromise: Promise> | undefined; - let resultReader: ReadableStreamDefaultReader> | undefined; - // configurable: true on result/kill lets the Workspace-level - // router redefine them to add cross-cutting concerns (transport - // failure invalidation on result(); future kill hooks). The id - // slot stays non-configurable — nothing should rewrite it. - Object.defineProperties(handle, { - id: { value: id, enumerable: false, writable: false, configurable: false }, - result: { - value: () => { - resultPromise ??= drainToResult(stream, encoding, pushed, postPull.outcome, (reader) => { - resultReader = reader; - }); - return resultPromise; - }, - enumerable: false, - writable: false, - configurable: true, - }, - kill: { - value: (signal?: KillSignal) => shell.killExec({ id, signal }), - enumerable: false, - writable: false, - configurable: true, - }, - [Symbol.dispose]: { - value: () => { - if (resultReader) void resultReader.cancel().catch(() => undefined); - else void stream.cancel().catch(() => undefined); - }, - }, - }); - return handle; -} - -function pipeEvents( - source: ReadableStream, - encoding: E | undefined, -): ReadableStream> { - if (encoding !== "utf8") { - // Identity pipe — the wire shape already matches. - return source as unknown as ReadableStream>; - } - // Per-stream TextDecoders preserve multi-byte boundaries - // across chunk splits. - const stdoutDec = new TextDecoder("utf-8", { fatal: false }); - const stderrDec = new TextDecoder("utf-8", { fatal: false }); - let stdoutMeta: { id: string; seq: number } | undefined; - let stderrMeta: { id: string; seq: number } | undefined; - let lastSeq = 0; - const enqueue = ( - controller: TransformStreamDefaultController>, - event: WorkspaceExecEvent, - ) => { - lastSeq = event.seq; - controller.enqueue(event); - }; - const flushPending = ( - controller: TransformStreamDefaultController>, - beforeSeq?: number, - ) => { - const pending: Array<{ - id: string; - seq: number; - name: "stdout" | "stderr"; - value: Chunk; - }> = []; - const stdout = stdoutDec.decode(); - const stderr = stderrDec.decode(); - if (stdout && stdoutMeta) { - pending.push({ ...stdoutMeta, name: "stdout", value: stdout as Chunk }); - } - if (stderr && stderrMeta) { - pending.push({ ...stderrMeta, name: "stderr", value: stderr as Chunk }); - } - pending.sort((a, b) => a.seq - b.seq); - const span = beforeSeq !== undefined && beforeSeq > lastSeq ? beforeSeq - lastSeq : 1; - for (let index = 0; index < pending.length; index++) { - const event = pending[index]; - enqueue(controller, { - ...event, - seq: lastSeq + (span * (index + 1)) / (pending.length + 1), - }); - } - stdoutMeta = undefined; - stderrMeta = undefined; - }; - return source.pipeThrough( - new TransformStream>({ - transform(event, controller) { - if (event.name === "stdout") { - stdoutMeta = { id: event.id, seq: event.seq }; - enqueue(controller, { - id: event.id, - seq: event.seq, - name: "stdout", - value: stdoutDec.decode(event.value, { stream: true }) as Chunk, - }); - } else if (event.name === "stderr") { - stderrMeta = { id: event.id, seq: event.seq }; - enqueue(controller, { - id: event.id, - seq: event.seq, - name: "stderr", - value: stderrDec.decode(event.value, { stream: true }) as Chunk, - }); - } else { - flushPending(controller, event.seq); - enqueue(controller, event as WorkspaceExecEvent); - } - }, - flush: flushPending, - }), - ); -} - export interface PostPullOutcome { applied: number; skipped: SkippedEntry[]; @@ -519,63 +268,6 @@ async function runPostPull(sync: Sync): Promise { } } -async function drainToResult( - stream: ReadableStream>, - encoding: E | undefined, - pushed: number, - postPull: Promise, - setReader: (reader: ReadableStreamDefaultReader> | undefined) => void, -): Promise> { - const reader = stream.getReader(); - setReader(reader); - const stdoutParts: Array> = []; - const stderrParts: Array> = []; - let exitCode = -1; - try { - while (true) { - const { value, done } = await reader.read(); - if (done) break; - if (value.name === "stdout") stdoutParts.push(value.value); - else if (value.name === "stderr") stderrParts.push(value.value); - else exitCode = value.value; - } - } finally { - reader.releaseLock(); - setReader(undefined); - } - const pulled = await postPull; - return { - exitCode, - stdout: joinParts(stdoutParts, encoding), - stderr: joinParts(stderrParts, encoding), - pushed, - pulled: pulled.applied, - skipped: pulled.skipped, - sync: pulled.sync, - }; -} - -function joinParts( - parts: Array>, - encoding: E | undefined, -): Chunk { - if (parts.length === 0) { - return (encoding === "utf8" ? "" : new Uint8Array(0)) as Chunk; - } - if (typeof parts[0] === "string") { - return (parts as string[]).join("") as Chunk; - } - const arrays = parts as Uint8Array[]; - const total = arrays.reduce((acc, a) => acc + a.byteLength, 0); - const out = new Uint8Array(total); - let offset = 0; - for (const a of arrays) { - out.set(a, offset); - offset += a.byteLength; - } - return out as Chunk; -} - // Wrap `stream` so its capnweb envelope is released exactly once on clean // completion, source failure, or consumer cancellation. export function disposeOnDone(stream: ReadableStream, onDone: () => void): ReadableStream { diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 9ea35cdb..bb60d70c 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -1,6 +1,6 @@ import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; import { describe, expect, it } from "vitest"; -import type { ExecHandle, ExecResult } from "../shell.js"; +import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "../runtime/types.js"; import { Workspace } from "../workspace.js"; import { createAITools, @@ -293,7 +293,7 @@ describe("createAITools exec tool", () => { runtime: { async exec(command: string, options: { cwd?: string; encoding: "utf8"; backend?: string }) { calls.push({ command, cwd: options.cwd, backend: options.backend }); - const result: ExecResult<"utf8"> = { + const result: WorkspaceRuntimeResult<"utf8"> = { exitCode: 2, stdout: "abcdef", stderr: "uvwxyz", @@ -301,7 +301,7 @@ describe("createAITools exec tool", () => { pulled: 0, skipped: [], }; - return { result: async () => result } as ExecHandle<"utf8">; + return { result: async () => result } as unknown as WorkspaceRuntimeExecHandle<"utf8">; }, }, }; @@ -334,7 +334,7 @@ describe("createAITools exec tool", () => { const workspace = { runtime: { async exec() { - const result: ExecResult<"utf8"> = { + const result: WorkspaceRuntimeResult<"utf8"> = { exitCode: 0, stdout: "a🙂b", stderr: "🙂🙂", @@ -342,7 +342,7 @@ describe("createAITools exec tool", () => { pulled: 0, skipped: [], }; - return { result: async () => result } as ExecHandle<"utf8">; + return { result: async () => result } as unknown as WorkspaceRuntimeExecHandle<"utf8">; }, }, }; @@ -367,7 +367,7 @@ describe("createAITools exec tool", () => { runtime: { async exec(command: string, options: { encoding: "utf8"; backend?: string }) { calls.push({ command, backend: options.backend }); - const result: ExecResult<"utf8"> = { + const result: WorkspaceRuntimeResult<"utf8"> = { exitCode: 0, stdout: "ok", stderr: "", @@ -375,7 +375,7 @@ describe("createAITools exec tool", () => { pulled: 0, skipped: [], }; - return { result: async () => result } as ExecHandle<"utf8">; + return { result: async () => result } as unknown as WorkspaceRuntimeExecHandle<"utf8">; }, }, }; diff --git a/packages/computer/src/workspace.ts b/packages/computer/src/workspace.ts index 23f05828..bc283fc9 100644 --- a/packages/computer/src/workspace.ts +++ b/packages/computer/src/workspace.ts @@ -40,7 +40,7 @@ import { type WorkspaceRegisteredBackend, type WorkspaceRuntimeEvent, } from "./runtime/types.js"; -import { WorkspaceShell } from "./shell.js"; +import { CommandExecutor } from "./shell.js"; import { WorkspaceStub } from "./stub.js"; import { isWorkspaceTransportFailure } from "./transport-failure.js"; @@ -237,9 +237,9 @@ export class Workspace { // In-flight connect promises keyed by backend id, so concurrent // callers for the same backend share one connect pass. readonly #connecting = new Map>(); - // Per-backend WorkspaceShell facades. Constructed alongside each + // Per-backend CommandExecutor facades. Constructed alongside each // handle; reused for the life of the handle. - readonly #shells = new Map(); + readonly #shells = new Map(); readonly #moduleHandles = new Map(); readonly #connectingModuleHandles = new Map>(); #connectionGeneration = 0; @@ -521,7 +521,7 @@ export class Workspace { // push() ships everything the host has written since the last // push to that backend; pull() applies everything the backend // has produced since the last pull. Both are explicit — the - // package doesn't run a background loop. WorkspaceShell.exec + // package doesn't run a background loop. CommandExecutor.exec // brackets each call automatically against the backend it // selects; reach for push() / pull() directly only when an // FS-only flow needs the bracket without an exec. @@ -768,7 +768,7 @@ export class Workspace { // Unified backend handle used by the runtime. Module backends // return their native handle; command backends are presented // through the same interface by an adapter over their - // WorkspaceShell, so the runtime has a single execution path. + // its CommandExecutor, so the runtime has a single execution path. async #backendHandleFor(id: string): Promise { if (this.#moduleBackendsById.has(id)) return this.#moduleHandleFor(id); return this.#commandHandleFor(id); @@ -779,9 +779,9 @@ export class Workspace { const onError = (error: unknown) => this.#onShellError(id, handle, error); return { exec: async (input) => { - let envelope: Awaited>; + let envelope: Awaited>; try { - envelope = await shell.execution(input.source, { + envelope = await shell.exec(input.source, { id: input.id, cwd: input.cwd, timeoutMs: input.timeoutMs, @@ -803,9 +803,9 @@ export class Workspace { }, getExec: async ({ id: execId, after }) => { const resume = after === undefined ? "full" : after; - let envelope: Awaited>; + let envelope: Awaited>; try { - envelope = await shell.getExecution(execId, { resume }); + envelope = await shell.get(execId, { resume }); } catch (error) { onError(error); throw error; @@ -957,18 +957,18 @@ export class Workspace { return promise; } - // Per-backend WorkspaceShell, constructed on demand and cached + // Per-backend CommandExecutor, constructed on demand and cached // for the life of the handle. Returns both the shell and the // BackendHandle it was built against so the caller can hold the // handle reference for a later identity check; #invalidateHandle // clears both caches together, so a shell pulled from #shells is // always paired with the live handle for that id at the moment // of the lookup. - async #shellFor(id: string): Promise<{ shell: WorkspaceShell; handle: BackendHandle }> { + async #shellFor(id: string): Promise<{ shell: CommandExecutor; handle: BackendHandle }> { const handle = await this.#handleFor(id); const cached = this.#shells.get(id); if (cached !== undefined) return { shell: cached, handle }; - const shell = new WorkspaceShell( + const shell = new CommandExecutor( handle.rpc.shell, { push: () => this.push(id), From 11a55010f36b4b7e663808694bdd122d3c95fe71 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:28:40 +0000 Subject: [PATCH 06/13] docs: align the docs with the unified execution backend Update the prose and interface listings for the execution changes: the shell wire field is source rather than command and carries an optional structured input; the exit event carries an optional structured result; the workspace no longer takes a waitUntil hook and a JavaScript run stays alive while its event stream is consumed; and the host command driver is the CommandExecutor, with encoding and result accumulation living in the runtime. Touches the capnweb wire contract, the runtime lifecycle and worker backend notes, the isolate JavaScript guide, the runtime migration table, and the computer and example READMEs. --- docs/08_capnweb_interface.md | 28 +++++++++++++++++++--------- docs/11_lifecycle.md | 2 +- docs/12_worker_backend.md | 12 ++++++------ docs/17_isolate_javascript.md | 3 +-- docs/18_runtime_migration.md | 2 +- examples/worker-javascript/README.md | 7 ++++--- examples/worker-shell/README.md | 4 ++-- packages/computer/README.md | 5 ++--- 8 files changed, 36 insertions(+), 27 deletions(-) diff --git a/docs/08_capnweb_interface.md b/docs/08_capnweb_interface.md index 8b9d4c83..6ec45c70 100644 --- a/docs/08_capnweb_interface.md +++ b/docs/08_capnweb_interface.md @@ -162,15 +162,19 @@ See SUMMARY §0 'Investigation notes' (commits dc692c0, c95c74d, ```ts interface ShellRPC { - // Spawn a command. Returns a handle whose `events` stream - // yields stdout / stderr / exit frames. The stream is the - // single source of truth — there is no buffered-return - // variant. The handle's id can be passed to getExec to - // reattach after a reconnect. + // Spawn an execution. `source` is a shell command line for a + // command backend, or module source for a callable backend. + // Returns a handle whose `events` stream yields stdout / stderr / + // exit frames. The stream is the single source of truth — there + // is no buffered-return variant. The handle's id can be passed to + // getExec to reattach after a reconnect. `input` carries a + // structured value for a callable backend; command backends + // ignore it. exec(input: { - command: string; - cwd?: string; - id?: string; + source: string; + cwd?: string; + id?: string; + input?: unknown; }): Promise<{ id: string; events: ReadableStream }>; // Reattach to an in-flight or recently-completed exec by id. @@ -195,9 +199,15 @@ interface ShellRPC { type ExecEvent = | { id: string; seq: number; name: "stdout"; value: Uint8Array } | { id: string; seq: number; name: "stderr"; value: Uint8Array } - | { id: string; seq: number; name: "exit"; value: number }; + | { id: string; seq: number; name: "exit"; value: number; result?: unknown }; ``` +The `exit` frame carries the process exit code and, for a callable +backend that ran to a zero exit, the structured return value on +`result`. The value and the exit code settle together, so a single +terminal frame carries both rather than splitting them across two +frames. Command backends never set `result`. + All payloads on the wire are binary. The host-side `Workspace.runtime` converts to `string` when the caller passes `encoding: "utf8"`. Every event carries a monotonic `seq` (per exec id) so callers can resume diff --git a/docs/11_lifecycle.md b/docs/11_lifecycle.md index cfa7bf37..6bf7952b 100644 --- a/docs/11_lifecycle.md +++ b/docs/11_lifecycle.md @@ -340,7 +340,7 @@ Two things have to change for capnweb + hibernation to work: is dropped by the idempotent apply path. No attachment write is required. - **Exec streams: store `{ [id]: seq }` per in-flight exec.** - The `WorkspaceShell` driver inside the DO is the only place + The `CommandExecutor` driver inside the DO is the only place that knows where the consumer got to in the event stream. Every time it surfaces an event to the caller (or on some reasonable debounce) it has to update the attachment so the diff --git a/docs/12_worker_backend.md b/docs/12_worker_backend.md index d258349c..a207794a 100644 --- a/docs/12_worker_backend.md +++ b/docs/12_worker_backend.md @@ -138,9 +138,9 @@ the same. `BackendHandle.sync` is `"none"`. With a single authoritative store there's nothing to ship or fetch; `Workspace.push` and `Workspace.pull` short-circuit on the bit and the reconcile pass -on connect is skipped. The shell exec bracket still calls them so -the surface stays uniform — every `ExecResult.pushed`, `pulled`, -and `skipped` is empty. +on connect is skipped. The exec sync bracket still calls them so +the surface stays uniform — the pushed, pulled, and skipped counts +on the runtime result are empty. ## Event stream framing @@ -152,9 +152,9 @@ shape across the isolate hop. The backend decodes the frames into structured `ExecEvent` values and re-encodes string payloads (`stdout` / `stderr`) into -`Uint8Array` so the existing `WorkspaceShell` utf8 decoder -transforms in `packages/computer/src/shell.ts` see the shape -they already handle. +`Uint8Array` so the runtime's utf8 decoder transforms, which +accumulate the result from raw events, see the shape they already +handle. ## Fetcher factory escape hatch diff --git a/docs/17_isolate_javascript.md b/docs/17_isolate_javascript.md index de3396c1..2db84ecf 100644 --- a/docs/17_isolate_javascript.md +++ b/docs/17_isolate_javascript.md @@ -8,7 +8,6 @@ import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-ja const workspace = new Workspace({ storage: ctx.storage, - waitUntil: ctx.waitUntil.bind(ctx), backends: [ new WorkerJavaScriptBackend({ loader: env.LOADER, @@ -52,7 +51,7 @@ const result = await handle.result(); The source is a real ES module. Static imports, literal dynamic imports, and top-level await are supported. If the module default-exports a function, Workspace invokes it with `options.input`. Otherwise module evaluation completes with a `null` structured result. -`waitUntil` is required for this backend. `runtime.exec()` returns before the Dynamic Worker finishes, so the host must attach completion to the Durable Object event lifetime. Construction fails when a module backend connects without this hook. +`runtime.exec()` returns before the Dynamic Worker finishes: the run keeps advancing while its event stream is consumed and the host call into the Dynamic Worker stays in flight. That pending work keeps the Durable Object resident on its own. A run whose handle is returned but never read can be evicted once the object goes idle; drain the event stream (or `result()`) to keep the run alive, and schedule an alarm through `ctx.storage.setAlarm()` for work that must survive eviction. ## Durable relative imports diff --git a/docs/18_runtime_migration.md b/docs/18_runtime_migration.md index 541a051a..db68c8b1 100644 --- a/docs/18_runtime_migration.md +++ b/docs/18_runtime_migration.md @@ -12,7 +12,7 @@ This change is a breaking preview-API migration. Public execution now uses one r | `workspace.shell.dispose(id, options)` | `workspace.runtime.disposeExec(id, options)` | | `workspace.code` / script execution | `workspace.runtime.exec(source, { backend: "worker-javascript", input })` | -`WorkspaceShell` still exists internally to implement command backends. It is not a public Workspace property. +`CommandExecutor` exists internally to implement command backends. It is not a public Workspace property. ## Default backend IDs diff --git a/examples/worker-javascript/README.md b/examples/worker-javascript/README.md index 98221f66..473e25dd 100644 --- a/examples/worker-javascript/README.md +++ b/examples/worker-javascript/README.md @@ -50,9 +50,10 @@ client ─► Worker /c//{file,exec} 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. +The DO is a thin host; the Dynamic Worker lifecycle is the loader's +problem. A run keeps advancing while its event stream is consumed, so +the request that drains the handle holds the object resident until the +run finishes. ## Paths diff --git a/examples/worker-shell/README.md b/examples/worker-shell/README.md index ee1e0fb9..76231733 100644 --- a/examples/worker-shell/README.md +++ b/examples/worker-shell/README.md @@ -57,8 +57,8 @@ client ─► Worker /c//{file,exec} and one workspace per DO is the natural boundary. 5. `BackendHandle.sync` is `"none"`. There's a single authoritative store (the DO's SQLite); push and pull - short-circuit. `ExecResult.pushed` / `pulled` are always - zero. + short-circuit. The runtime result's `pushed` / `pulled` + counts are always zero. The DO is a thin host. There's no Dockerfile; the Dynamic Worker lifecycle is the loader's problem. diff --git a/packages/computer/README.md b/packages/computer/README.md index 1075d5f7..1f8a2c47 100644 --- a/packages/computer/README.md +++ b/packages/computer/README.md @@ -242,9 +242,8 @@ Alongside `exec`, the runtime exposes `getExec`, `killExec`, and - **Worker JavaScript** evaluates a module with structured input/results, durable relative imports, configured libraries, Workspace-backed `node:fs/promises`, and trusted `ws:git` / - `ws:artifacts` modules. It runs after `runtime.exec()` returns, so - pass `waitUntil: ctx.waitUntil.bind(ctx)` to `Workspace`; the backend - refuses to connect without it. See + `ws:artifacts` modules. It runs after `runtime.exec()` returns; the + run stays alive while its event stream is consumed. See [`docs/17_isolate_javascript.md`](../../docs/17_isolate_javascript.md) and [`examples/worker-javascript`](../../examples/worker-javascript). From 024b4e288a034acb91ee406b85c571fd2ccee5c3 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 4 Aug 2026 13:20:00 +0000 Subject: [PATCH 07/13] computer: drop dead surface left by the executor collapse Routing every backend through one envelope path left inert surface behind. ExecOptions and GetExecOptions carried encoding and backend fields the CommandExecutor never reads, and their encoding generic was only ever instantiated with undefined, so withPostPull threaded a type parameter it never varied. Drop the fields and the generic; withPostPull now operates on the raw ExecEvent stream directly. Cache the command adapter per backend so the command and module paths are symmetric, clearing it alongside the shell cache on every handle invalidation so an adapter never outlives the shell it wraps. In the module event transform, exit is the only non-stdio event, so flush the buffered partial output unconditionally before the terminal event rather than guarding on a condition that is always true. Refresh comments that still named the old shell facade in the worker shell backend and the workspace tests, and fix the stray wording in the backend-handle resolver. --- .../src/backends/worker-shell/worker-shell.ts | 8 ++--- packages/computer/src/runtime/runtime.ts | 5 ++- packages/computer/src/shell.ts | 32 ++++++------------- packages/computer/src/workspace.test.ts | 9 +++--- packages/computer/src/workspace.ts | 19 +++++++++-- 5 files changed, 40 insertions(+), 33 deletions(-) diff --git a/packages/computer/src/backends/worker-shell/worker-shell.ts b/packages/computer/src/backends/worker-shell/worker-shell.ts index a3ea33a5..a97b9197 100644 --- a/packages/computer/src/backends/worker-shell/worker-shell.ts +++ b/packages/computer/src/backends/worker-shell/worker-shell.ts @@ -191,7 +191,7 @@ export class WorkerShellBackend implements WorkspaceBackend { // The user Worker has no DB-backed log to dispose; the // event stream itself is the only resource and it ends // with the run. Treated as a no-op on this backend so - // the WorkspaceShell surface stays uniform. + // the ShellRPC surface stays uniform. }, }; @@ -262,7 +262,7 @@ export class WorkerShellBackend implements WorkspaceBackend { } // Decode a byte-framed event stream produced by ShellWorker -// into the structured ExecEvent shape WorkspaceShell expects. +// into the structured ExecEvent shape the runtime expects. // Frames are newline-delimited JSON objects. function decodeFramedEvents(source: ReadableStream): ReadableStream { const decoder = new TextDecoder(); @@ -337,8 +337,8 @@ function reshape(event: { }): ExecEvent { // ShellWorker ships stdout / stderr values as utf8 strings; // ExecEvent on the wire carries Uint8Array. Re-encode so the - // existing WorkspaceShell utf8 decoder transforms in shell.ts - // see the shape they already handle. + // runtime's utf8 decoder transforms see the shape they already + // handle. if (event.name === "stdout" || event.name === "stderr") { return { id: event.id, diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index 79d361d3..6d8f95ce 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -272,7 +272,10 @@ function transformModuleEvents( }), } as WorkspaceRuntimeEvent); } else { - if (event.name === "exit") flushPending(controller, event.seq); + // `exit` is the only non-stdio event; flush any buffered + // partial output before the terminal event so a trailing + // multi-byte remainder lands ahead of it. + flushPending(controller, event.seq); enqueue(controller, event as WorkspaceRuntimeEvent); } }, diff --git a/packages/computer/src/shell.ts b/packages/computer/src/shell.ts index d3d88bb3..eb2a267c 100644 --- a/packages/computer/src/shell.ts +++ b/packages/computer/src/shell.ts @@ -53,17 +53,13 @@ export interface CommandExecution { sync: { pushed: number; outcome: Promise }; } -export interface ExecOptions { +export interface ExecOptions { // Stable id. If omitted the runner mints a UUID. Reusing an id // while a previous run is still active throws EEXEC_BUSY. id?: string; // Absolute path inside the container. Defaults to the // workspace root. cwd?: string; - // Encoding for stdout/stderr value payloads. Default is - // Uint8Array; "utf8" decodes per-chunk through a stream-mode - // TextDecoder so multi-byte boundaries survive. - encoding?: E; // Per-call timeout in milliseconds. Past this duration the // container sends SIGTERM (then SIGKILL after a short grace). // Omit to use the runner's default (typically 320_000). Pass 0 @@ -76,21 +72,13 @@ export interface ExecOptions { // Standard input fed to the command. Bytes, or a string encoded // as UTF-8. stdin?: Uint8Array | string; - // Backend selector. Omit to use the default backend (the first - // one passed to the Workspace constructor); pass the id of - // another configured backend to route this call there. - backend?: string; } -export interface GetExecOptions { - encoding?: E; +export interface GetExecOptions { // "tail" yields only events produced after this call. A // number resumes from that seq+1. Omit to receive every // event from the start of the run (replays the whole log). resume?: "tail" | "full" | number; - // Backend selector. Same shape as ExecOptions.backend; routes - // the get / reattach to the named backend. - backend?: string; } // Push/pull bracket plumbing. CommandExecutor doesn't know about @@ -121,7 +109,7 @@ export class CommandExecutor { // sees them, then returns the raw event stream and the sync // bracket stats. The push failure is non-fatal per docs/05 — the // command still runs and pushed reports 0. - async exec(source: string, options: ExecOptions = {}): Promise { + async exec(source: string, options: ExecOptions = {}): Promise { assertNotTemplate(source); let pushed = 0; try { @@ -159,7 +147,7 @@ export class CommandExecutor { // call — because the inner stream is handed off to the caller // and the envelope can't be bound with `using` here. const drained = disposeOnDone(envelope.events, () => maybeDispose(envelope)); - const { stream, outcome } = withPostPull(drained, this.#sync); + const { stream, outcome } = withPostPull(drained, this.#sync); return { id: envelope.id, events: stream, sync: { pushed, outcome } }; } @@ -167,11 +155,11 @@ export class CommandExecutor { // does not own the original push frame, so pushed = 0; the // post-drain pull still fires, scoped to whatever landed between // reattach and drain. - async get(id: string, options: GetExecOptions = {}): Promise { + async get(id: string, options: GetExecOptions = {}): Promise { const after = resumeToAfter(options.resume); const envelope = await this.#shell.getExec({ id, after }); const drained = disposeOnDone(envelope.events, () => maybeDispose(envelope)); - const { stream, outcome } = withPostPull(drained, this.#sync); + const { stream, outcome } = withPostPull(drained, this.#sync); return { id, events: stream, sync: { pushed: 0, outcome } }; } @@ -196,16 +184,16 @@ export interface PostPullOutcome { sync: ExecSyncResult; } -export function withPostPull( - source: ReadableStream>, +export function withPostPull( + source: ReadableStream, sync: Sync, -): { stream: ReadableStream>; outcome: Promise } { +): { stream: ReadableStream; outcome: Promise } { const reader = source.getReader(); let resolveOutcome!: (outcome: PostPullOutcome) => void; const outcome = new Promise((resolve) => { resolveOutcome = resolve; }); - const stream = new ReadableStream>( + const stream = new ReadableStream( { async pull(controller) { try { diff --git a/packages/computer/src/workspace.test.ts b/packages/computer/src/workspace.test.ts index b3236f04..8fa3ddbb 100644 --- a/packages/computer/src/workspace.test.ts +++ b/packages/computer/src/workspace.test.ts @@ -147,8 +147,8 @@ function makeBackend( // Backend with a wired shell.exec that pings a counter on every // call. exec resolves immediately with an empty event stream so -// the WorkspaceShell exec bracket settles right away. Used by the -// multi-backend selection tests. +// the command executor's exec bracket settles right away. Used by +// the multi-backend selection tests. function execBackend(id: string, onExec: (command: string) => void): WorkspaceBackend { const shell: import("@cloudflare/computer-rpc").ShellRPC = { async exec(input) { @@ -182,8 +182,9 @@ function execBackend(id: string, onExec: (command: string) => void): WorkspaceBa } // Drain an ExecHandle (or its result()-aware wrapper) to settle -// the WorkspaceShell push/pull bracket. The selection tests don't -// care about the values — only that exec ran on the right backend. +// the command executor's push/pull bracket. The selection tests +// don't care about the values — only that exec ran on the right +// backend. async function drainExec(handle: { result(): Promise }): Promise { await handle.result(); } diff --git a/packages/computer/src/workspace.ts b/packages/computer/src/workspace.ts index bc283fc9..21572ae5 100644 --- a/packages/computer/src/workspace.ts +++ b/packages/computer/src/workspace.ts @@ -240,6 +240,10 @@ export class Workspace { // Per-backend CommandExecutor facades. Constructed alongside each // handle; reused for the life of the handle. readonly #shells = new Map(); + // Cached command adapters presenting a CommandExecutor as the + // unified backend handle. Cleared alongside #shells so an adapter + // never outlives the shell it wraps. + readonly #commandHandles = new Map(); readonly #moduleHandles = new Map(); readonly #connectingModuleHandles = new Map>(); #connectionGeneration = 0; @@ -670,6 +674,7 @@ export class Workspace { if (this.#handles.get(id) !== handle) return false; this.#handles.delete(id); this.#shells.delete(id); + this.#commandHandles.delete(id); return true; } @@ -749,6 +754,7 @@ export class Workspace { const moduleHandles = [...this.#moduleHandles.values()]; this.#handles.clear(); this.#shells.clear(); + this.#commandHandles.clear(); this.#connecting.clear(); this.#moduleHandles.clear(); this.#connectingModuleHandles.clear(); @@ -768,16 +774,22 @@ export class Workspace { // Unified backend handle used by the runtime. Module backends // return their native handle; command backends are presented // through the same interface by an adapter over their - // its CommandExecutor, so the runtime has a single execution path. + // CommandExecutor, so the runtime has a single execution path. async #backendHandleFor(id: string): Promise { if (this.#moduleBackendsById.has(id)) return this.#moduleHandleFor(id); return this.#commandHandleFor(id); } + // Command adapters are cached per backend so the module and + // command paths are symmetric. The cache is cleared alongside + // #shells whenever a handle is invalidated, so an adapter never + // outlives the shell it closed over. async #commandHandleFor(id: string): Promise { + const cached = this.#commandHandles.get(id); + if (cached) return cached; const { shell, handle } = await this.#shellFor(id); const onError = (error: unknown) => this.#onShellError(id, handle, error); - return { + const adapter: WorkspaceModuleBackendHandle = { exec: async (input) => { let envelope: Awaited>; try { @@ -840,6 +852,8 @@ export class Workspace { // through the Workspace's own close path. }, }; + this.#commandHandles.set(id, adapter); + return adapter; } #moduleHandleFor(id: string): Promise { @@ -944,6 +958,7 @@ export class Workspace { if (this.#handles.get(id) === handle) { this.#handles.delete(id); this.#shells.delete(id); + this.#commandHandles.delete(id); } }); } From 18eecfb48e6bf1a366e7c9ca62161d0db982f5ed Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:06:22 +0000 Subject: [PATCH 08/13] rpc, computer, computerd: name the exit event's code field The exit event carried its process exit code on a `value` field, the same key stdout and stderr use for their byte payloads, and it now sits next to the optional `result`. `value` and `result` read as near synonyms on one event. Rename the exit event's numeric payload to `code` across every shape it takes: the ShellRPC wire event, the runtime event and its wire codec, the JavaScript runner frame and its persist codec, and computerd's runner event. stdout and stderr keep `value` for their payloads; the worker shell's internal framing keeps `value` and is translated to `code` at the event boundary. --- .../backends/worker-javascript/frames.test.ts | 26 ++++++------- .../src/backends/worker-javascript/frames.ts | 8 ++-- .../worker-javascript.test.ts | 22 +++++------ .../worker-javascript/worker-javascript.ts | 38 +++++++++---------- .../worker-shell/worker-shell.test.ts | 2 +- .../src/backends/worker-shell/worker-shell.ts | 2 +- packages/computer/src/client.test.ts | 17 ++++++--- .../computer/src/observe-integration.test.ts | 2 +- packages/computer/src/retry.test.ts | 2 +- packages/computer/src/runtime/runtime.test.ts | 16 ++++---- packages/computer/src/runtime/runtime.ts | 2 +- packages/computer/src/runtime/types.ts | 2 +- packages/computer/src/runtime/wire.test.ts | 8 ++-- packages/computer/src/runtime/wire.ts | 2 +- packages/computer/src/shell.test.ts | 4 +- packages/computer/src/stub.test.ts | 32 ++++++++++------ packages/computer/src/workspace.test.ts | 29 ++++++++------ packages/computerd/src/exec/log.ts | 2 +- .../computerd/src/exec/runner.fuse.test.ts | 4 +- packages/computerd/src/exec/runner.test.ts | 20 +++++----- packages/computerd/src/exec/runner.ts | 4 +- packages/computerd/src/exec/types.ts | 2 +- packages/rpc/src/interface.ts | 2 +- .../rpc/tests/shell-and-composite.test.ts | 6 +-- 24 files changed, 137 insertions(+), 117 deletions(-) diff --git a/packages/computer/src/backends/worker-javascript/frames.test.ts b/packages/computer/src/backends/worker-javascript/frames.test.ts index 2bfd22f0..fc529825 100644 --- a/packages/computer/src/backends/worker-javascript/frames.test.ts +++ b/packages/computer/src/backends/worker-javascript/frames.test.ts @@ -37,18 +37,18 @@ describe("parseRuntimeFrame", () => { }); it("decodes an exit frame carrying an integer", () => { - expect(parseRuntimeFrame(`{"name":"exit","value":0}`)).toEqual({ name: "exit", value: 0 }); - expect(parseRuntimeFrame(`{"name":"exit","value":130}`)).toEqual({ name: "exit", value: 130 }); + expect(parseRuntimeFrame(`{"name":"exit","code":0}`)).toEqual({ name: "exit", code: 0 }); + expect(parseRuntimeFrame(`{"name":"exit","code":130}`)).toEqual({ name: "exit", code: 130 }); }); it("decodes an exit frame carrying a structured result", () => { - const frame = parseRuntimeFrame(`{"name":"exit","value":0,"result":{"a":[1,2,null]}}`); - expect(frame).toEqual({ name: "exit", value: 0, result: { a: [1, 2, null] } }); + const frame = parseRuntimeFrame(`{"name":"exit","code":0,"result":{"a":[1,2,null]}}`); + expect(frame).toEqual({ name: "exit", code: 0, result: { a: [1, 2, null] } }); }); it("decodes an exit frame carrying a null result", () => { - const frame = parseRuntimeFrame(`{"name":"exit","value":0,"result":null}`); - expect(frame).toEqual({ name: "exit", value: 0, result: null }); + const frame = parseRuntimeFrame(`{"name":"exit","code":0,"result":null}`); + expect(frame).toEqual({ name: "exit", code: 0, result: null }); }); it("rejects invalid JSON", () => { @@ -64,7 +64,7 @@ describe("parseRuntimeFrame", () => { }); it("rejects an exit frame whose value is not an integer", () => { - expect(() => parseRuntimeFrame(`{"name":"exit","value":"x"}`)).toThrow(); + expect(() => parseRuntimeFrame(`{"name":"exit","code":"x"}`)).toThrow(); }); }); @@ -72,12 +72,12 @@ describe("decodeRuntimeFrames", () => { it("decodes newline-delimited frames arriving in one chunk", async () => { const frames = await collect( decodeRuntimeFrames( - streamOf(`{"name":"stdout","b64":"${b64("hi")}"}\n{"name":"exit","value":0}\n`), + streamOf(`{"name":"stdout","b64":"${b64("hi")}"}\n{"name":"exit","code":0}\n`), ), ); expect(frames).toEqual([ { name: "stdout", value: new TextEncoder().encode("hi") }, - { name: "exit", value: 0 }, + { name: "exit", code: 0 }, ]); }); @@ -91,13 +91,13 @@ describe("decodeRuntimeFrames", () => { }); it("emits a trailing frame that arrives without a final newline", async () => { - const frames = await collect(decodeRuntimeFrames(streamOf(`{"name":"exit","value":1}`))); - expect(frames).toEqual([{ name: "exit", value: 1 }]); + const frames = await collect(decodeRuntimeFrames(streamOf(`{"name":"exit","code":1}`))); + expect(frames).toEqual([{ name: "exit", code: 1 }]); }); it("skips blank lines between frames", async () => { - const frames = await collect(decodeRuntimeFrames(streamOf(`{"name":"exit","value":0}\n\n`))); - expect(frames).toEqual([{ name: "exit", value: 0 }]); + const frames = await collect(decodeRuntimeFrames(streamOf(`{"name":"exit","code":0}\n\n`))); + expect(frames).toEqual([{ name: "exit", code: 0 }]); }); it("errors the stream on a malformed frame", async () => { diff --git a/packages/computer/src/backends/worker-javascript/frames.ts b/packages/computer/src/backends/worker-javascript/frames.ts index 4821b875..6b738238 100644 --- a/packages/computer/src/backends/worker-javascript/frames.ts +++ b/packages/computer/src/backends/worker-javascript/frames.ts @@ -3,7 +3,7 @@ import type { WorkspaceRuntimeValue } from "../../runtime/types.js"; export type RuntimeFrame = | { name: "stdout"; value: Uint8Array } | { name: "stderr"; value: Uint8Array } - | { name: "exit"; value: number; result?: WorkspaceRuntimeValue }; + | { name: "exit"; code: number; result?: WorkspaceRuntimeValue }; export function parseRuntimeFrame(line: string): RuntimeFrame { let record: Record; @@ -20,17 +20,17 @@ export function parseRuntimeFrame(line: string): RuntimeFrame { return { name, value: decodeBase64(record.b64) }; } if (name === "exit") { - if (!Number.isSafeInteger(record.value)) { + if (!Number.isSafeInteger(record.code)) { throw new Error("WorkerJavaScriptBackend received a malformed exit frame"); } if ("result" in record) { return { name, - value: record.value as number, + code: record.code as number, result: record.result as WorkspaceRuntimeValue, }; } - return { name, value: record.value as number }; + return { name, code: record.code as number }; } throw new Error("WorkerJavaScriptBackend received an unknown execution frame"); } diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts index a2d1b7ec..0439478e 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.test.ts @@ -26,11 +26,11 @@ async function evaluateResult( const frames: string[] = []; try { await host.assertResult(value); - frames.push(JSON.stringify({ name: "exit", value: 0, result: value })); + frames.push(JSON.stringify({ name: "exit", code: 0, result: value })); } catch (error) { const message = error instanceof Error ? error.message : String(error); frames.push(JSON.stringify({ name: "stderr", b64: btoa(`${message}\n`) })); - frames.push(JSON.stringify({ name: "exit", value: 1 })); + frames.push(JSON.stringify({ name: "exit", code: 1 })); } const encoder = new TextEncoder(); const readable = new ReadableStream({ @@ -448,7 +448,7 @@ describe("WorkerJavaScriptBackend", () => { releaseWrite(); const events = await terminal; expect(await fs.readFile("/workspace/output.txt", "utf8")).toBe("done"); - expect(events.at(-1)).toMatchObject({ name: "exit", value: 0 }); + expect(events.at(-1)).toMatchObject({ name: "exit", code: 0 }); }); it("streams stdout before user code returns", async () => { @@ -480,7 +480,7 @@ describe("WorkerJavaScriptBackend", () => { ); await exitReleased; controller.enqueue( - encoder.encode(`${JSON.stringify({ name: "exit", value: 0 })}\n`), + encoder.encode(`${JSON.stringify({ name: "exit", code: 0 })}\n`), ); controller.close(); }, @@ -567,7 +567,7 @@ describe("WorkerJavaScriptBackend", () => { for await (const event of execution.events) events.push(event); const exitIndex = events.findIndex((event) => event.name === "exit"); expect(exitIndex).toBeGreaterThanOrEqual(0); - expect(events[exitIndex]).toMatchObject({ name: "exit", value: 130 }); + expect(events[exitIndex]).toMatchObject({ name: "exit", code: 130 }); // The exit event is terminal: no stdout, stderr, or result follows it. expect(events.slice(exitIndex + 1)).toEqual([]); await handle.close(); @@ -619,7 +619,7 @@ describe("WorkerJavaScriptBackend", () => { const events = []; for await (const event of execution.events) events.push(event); const exit = events.find((event) => event.name === "exit"); - expect(exit).toMatchObject({ name: "exit", value: 1 }); + expect(exit).toMatchObject({ name: "exit", code: 1 }); expect(events.some((event) => event.name === "result")).toBe(false); await handle.close(); }); @@ -671,7 +671,7 @@ describe("WorkerJavaScriptBackend", () => { const events = []; for await (const event of execution.events) events.push(event); expect(aborted).toBe(true); - expect(events.at(-1)).toMatchObject({ name: "exit", value: 1 }); + expect(events.at(-1)).toMatchObject({ name: "exit", code: 1 }); }); it("waits for accepted host calls before reporting cancellation", async () => { @@ -741,7 +741,7 @@ describe("WorkerJavaScriptBackend", () => { expect(await fs.readFile("/workspace/output.txt", "utf8")).toBe("done"); const events = []; for await (const event of execution.events) events.push(event); - expect(events.at(-1)).toMatchObject({ name: "exit", value: 130 }); + expect(events.at(-1)).toMatchObject({ name: "exit", code: 130 }); }); it("settles subscribers when terminal persistence fails and repairs on reconnect", async () => { @@ -785,18 +785,18 @@ describe("WorkerJavaScriptBackend", () => { finish({ result: 1 }); const events = []; for await (const event of execution.events) events.push(event); - expect(events.at(-1)).toMatchObject({ name: "exit", value: 1 }); + expect(events.at(-1)).toMatchObject({ name: "exit", code: 1 }); const sameSessionReplay = await handle.getExec({ id: "storage-failure" }); const sameSessionEvents = []; for await (const event of sameSessionReplay.events) sameSessionEvents.push(event); - expect(sameSessionEvents.at(-1)).toMatchObject({ name: "exit", value: 1 }); + expect(sameSessionEvents.at(-1)).toMatchObject({ name: "exit", code: 1 }); db.run = originalRun as typeof db.run; const reconnected = await backend.connect(host); const replay = await reconnected.getExec({ id: "storage-failure" }); const repaired = []; for await (const event of replay.events) repaired.push(event); - expect(repaired.at(-1)).toMatchObject({ name: "exit", value: 1 }); + expect(repaired.at(-1)).toMatchObject({ name: "exit", code: 1 }); }); it("bounds durable completed-execution retention", async () => { diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 2005fd76..1b3f0935 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -278,7 +278,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { "Execution was interrupted when its Workspace runtime restarted.\n", ), }, - { id, seq: record.events.length + 2, name: "exit", value: 1 }, + { id, seq: record.events.length + 2, name: "exit", code: 1 }, ]); } } @@ -500,7 +500,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { `${error instanceof Error ? error.message : String(error)}\n`, ), }, - { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, + { id: record.id, seq: record.events.length + 2, name: "exit", code: 1 }, ]); return; } @@ -511,7 +511,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { name: "stderr", value: new TextEncoder().encode(message), }, - { id: record.id, seq: record.events.length + 2, name: "exit", value: 130 }, + { id: record.id, seq: record.events.length + 2, name: "exit", code: 130 }, ]); })(); record.finalization = finalization; @@ -555,7 +555,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { "Execution was interrupted when its Workspace runtime restarted or before its terminal state was persisted.\n", ), }, - { id, seq: record.events.length + 2, name: "exit", value: 1 }, + { id, seq: record.events.length + 2, name: "exit", code: 1 }, ]); } return record; @@ -606,7 +606,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { value: frame.value, }); } else { - record.exitCode = frame.value; + record.exitCode = frame.code; if ("result" in frame) { record.result = frame.result; record.hasResult = true; @@ -635,7 +635,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { `${error instanceof Error ? error.message : String(error)}\n`, ), }, - { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, + { id: record.id, seq: record.events.length + 2, name: "exit", code: 1 }, ]); return; } @@ -649,7 +649,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { `${truncateUtf8(errorMessage, Math.max(0, this.#options.maxStdioBytes - 1))}\n`, ), }, - { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, + { id: record.id, seq: record.events.length + 2, name: "exit", code: 1 }, ]); return; } @@ -665,7 +665,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { name: "stderr", value: new TextEncoder().encode("Execution ended without reporting a result.\n"), }, - { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, + { id: record.id, seq: record.events.length + 2, name: "exit", code: 1 }, ]); return; } @@ -676,10 +676,10 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { id: record.id, seq: record.events.length + 1, name: "exit", - value: exitCode, + code: exitCode, result: record.result as WorkspaceRuntimeValue, } - : { id: record.id, seq: record.events.length + 1, name: "exit", value: exitCode }; + : { id: record.id, seq: record.events.length + 1, name: "exit", code: exitCode }; this.#finish(record, exitCode === 0 ? "completed" : "failed", [exit]); } @@ -763,7 +763,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { "Execution failed because its terminal state could not be persisted.\n", ), }, - { id: record.id, seq: record.events.length + 2, name: "exit", value: 1 }, + { id: record.id, seq: record.events.length + 2, name: "exit", code: 1 }, ]; } record.status = settledStatus; @@ -879,7 +879,7 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { function encodeEvent(event: WorkspaceRuntimeEvent): Uint8Array { if (event.name === "stdout" || event.name === "stderr") return event.value; const payload = - "result" in event ? { value: event.value, result: event.result } : { value: event.value }; + "result" in event ? { code: event.code, result: event.result } : { code: event.code }; return new TextEncoder().encode(JSON.stringify(payload)); } @@ -891,15 +891,15 @@ function decodeEvent( ): WorkspaceRuntimeEvent { if (name === "stdout" || name === "stderr") return { id, seq, name, value: payload }; const decoded = JSON.parse(new TextDecoder().decode(payload)) as unknown; - if (typeof decoded === "object" && decoded !== null && "value" in decoded) { - const record = decoded as { value: unknown; result?: unknown }; + if (typeof decoded === "object" && decoded !== null && "code" in decoded) { + const record = decoded as { code: unknown; result?: unknown }; if ("result" in record) { assertRuntimeValue(record.result); - return { id, seq, name: "exit", value: Number(record.value), result: record.result }; + return { id, seq, name: "exit", code: Number(record.code), result: record.result }; } - return { id, seq, name: "exit", value: Number(record.value) }; + return { id, seq, name: "exit", code: Number(record.code) }; } - return { id, seq, name: "exit", value: Number(decoded) }; + return { id, seq, name: "exit", code: Number(decoded) }; } function startJavaScriptExecution(options: { @@ -1155,11 +1155,11 @@ function runtimeWorkerModule(entryName: string, maxStdioBytes: number) { : module.default ?? null; const value = result ?? null; await host.assertResult(value); - enqueue({ name: "exit", value: 0, result: value }); + enqueue({ name: "exit", code: 0, result: value }); } catch (error) { const message = error instanceof Error ? error.message : String(error); enqueue({ name: "stderr", b64: toBase64(truncate(message) + "\\n") }); - enqueue({ name: "exit", value: 1 }); + enqueue({ name: "exit", code: 1 }); } await writeChain; try { diff --git a/packages/computer/src/backends/worker-shell/worker-shell.test.ts b/packages/computer/src/backends/worker-shell/worker-shell.test.ts index 755154d1..af021566 100644 --- a/packages/computer/src/backends/worker-shell/worker-shell.test.ts +++ b/packages/computer/src/backends/worker-shell/worker-shell.test.ts @@ -155,7 +155,7 @@ describe("WorkerShellBackend", () => { expect(observedCommand).toBe("echo hello"); expect(seen).toEqual([ { id: "run-1", seq: 1, name: "stdout", value: encoder.encode("hello\n") }, - { id: "run-1", seq: 2, name: "exit", value: 0 }, + { id: "run-1", seq: 2, name: "exit", code: 0 }, ]); expect(envelope.id).toBe("run-1"); }); diff --git a/packages/computer/src/backends/worker-shell/worker-shell.ts b/packages/computer/src/backends/worker-shell/worker-shell.ts index a97b9197..e413ed42 100644 --- a/packages/computer/src/backends/worker-shell/worker-shell.ts +++ b/packages/computer/src/backends/worker-shell/worker-shell.ts @@ -347,7 +347,7 @@ function reshape(event: { value: new TextEncoder().encode(event.value as string), }; } - return { id: event.id, seq: event.seq, name: "exit", value: event.value as number }; + return { id: event.id, seq: event.seq, name: "exit", code: event.value as number }; } function disposeQuietly(value: { [Symbol.dispose]?: () => void }) { diff --git a/packages/computer/src/client.test.ts b/packages/computer/src/client.test.ts index 2e4e734d..035b0e9b 100644 --- a/packages/computer/src/client.test.ts +++ b/packages/computer/src/client.test.ts @@ -37,7 +37,7 @@ function fakeRuntime(promisedProperties = false) { const stream = new ReadableStream({ start(c) { c.enqueue( - new TextEncoder().encode(`${JSON.stringify({ id, seq: 0, name: "exit", value: 0 })}\n`), + new TextEncoder().encode(`${JSON.stringify({ id, seq: 0, name: "exit", code: 0 })}\n`), ); c.close(); }, @@ -149,7 +149,7 @@ describe("getWorkspace — remote dispatch", () => { const handle = await ws.runtime.exec("echo ok"); const events = []; for await (const event of handle) events.push(event); - expect(events).toEqual([{ id: expect.any(String), seq: 0, name: "exit", value: 0 }]); + expect(events).toEqual([{ id: expect.any(String), seq: 0, name: "exit", code: 0 }]); }); it("exposes the complete runtime lifecycle and preserves handle ids", async () => { @@ -297,11 +297,16 @@ describe("client runtime.exec — remote handle rebuild", () => { const { host } = fakeRemote(); const ws = await getWorkspace(host); const handle = await ws.runtime.exec("echo hi"); - const events: Array<{ name: string; value: unknown }> = []; - for await (const event of handle as AsyncIterable<{ name: string; value: unknown }>) { - events.push({ name: event.name, value: event.value }); + type Collected = { name: "stdout" | "stderr"; value: unknown } | { name: "exit"; code: number }; + const events: Collected[] = []; + for await (const event of handle as AsyncIterable) { + events.push( + event.name === "exit" + ? { name: "exit", code: event.code } + : { name: event.name, value: event.value }, + ); } - expect(events).toEqual([{ name: "exit", value: 0 }]); + expect(events).toEqual([{ name: "exit", code: 0 }]); }); it("throws if result() is called after the stream has started", async () => { diff --git a/packages/computer/src/observe-integration.test.ts b/packages/computer/src/observe-integration.test.ts index 83d41281..628a889f 100644 --- a/packages/computer/src/observe-integration.test.ts +++ b/packages/computer/src/observe-integration.test.ts @@ -247,7 +247,7 @@ describe("Workspace observer — runtime stub", () => { id: "exec-1", events: new ReadableStream({ start(c) { - c.enqueue({ id: "exec-1", seq: 0, name: "exit", value: 0 }); + c.enqueue({ id: "exec-1", seq: 0, name: "exit", code: 0 }); c.close(); }, }), diff --git a/packages/computer/src/retry.test.ts b/packages/computer/src/retry.test.ts index 1a7855e3..f34a9734 100644 --- a/packages/computer/src/retry.test.ts +++ b/packages/computer/src/retry.test.ts @@ -68,7 +68,7 @@ function retryBackend(options: { id: "command-1", events: new ReadableStream({ start(controller) { - controller.enqueue({ id: "command-1", seq: 1, name: "exit", value: 0 }); + controller.enqueue({ id: "command-1", seq: 1, name: "exit", code: 0 }); controller.close(); }, }), diff --git a/packages/computer/src/runtime/runtime.test.ts b/packages/computer/src/runtime/runtime.test.ts index b7eaa234..defe4a33 100644 --- a/packages/computer/src/runtime/runtime.test.ts +++ b/packages/computer/src/runtime/runtime.test.ts @@ -75,7 +75,7 @@ describe("WorkspaceRuntime result accumulation", () => { stdout(1, bytes("one")), stdout(2, bytes("two")), stdout(3, bytes("three")), - { id: "e", seq: 4, name: "exit", value: 0 }, + { id: "e", seq: 4, name: "exit", code: 0 }, ]), ); const result = await (await runtime.exec("noop")).result(); @@ -89,7 +89,7 @@ describe("WorkspaceRuntime result accumulation", () => { stdout(1, bytes("out")), stderr(2, bytes("err")), stdout(3, bytes("out2")), - { id: "e", seq: 4, name: "exit", value: 0 }, + { id: "e", seq: 4, name: "exit", code: 0 }, ]), ); const result = await (await runtime.exec("noop")).result(); @@ -98,14 +98,14 @@ describe("WorkspaceRuntime result accumulation", () => { }); it("captures the exit code from the exit event", async () => { - const runtime = runtimeFor(replayBackend([{ id: "e", seq: 1, name: "exit", value: 42 }])); + const runtime = runtimeFor(replayBackend([{ id: "e", seq: 1, name: "exit", code: 42 }])); const result = await (await runtime.exec("noop")).result(); expect(result.exitCode).toBe(42); }); it("maps signal exit codes to a cancelled status", async () => { for (const code of [129, 130, 137, 143]) { - const runtime = runtimeFor(replayBackend([{ id: "e", seq: 1, name: "exit", value: code }])); + const runtime = runtimeFor(replayBackend([{ id: "e", seq: 1, name: "exit", code: code }])); const result = await (await runtime.exec("noop")).result(); expect(result.status).toBe("cancelled"); expect(result.exitCode).toBe(code); @@ -120,7 +120,7 @@ describe("WorkspaceRuntime utf8 encoding", () => { stdout(1, bytes("hello ")), stderr(2, bytes("warn")), stdout(3, bytes("world")), - { id: "e", seq: 4, name: "exit", value: 0 }, + { id: "e", seq: 4, name: "exit", code: 0 }, ]), ); const result = await (await runtime.exec("noop", { encoding: "utf8" })).result(); @@ -134,7 +134,7 @@ describe("WorkspaceRuntime utf8 encoding", () => { replayBackend([ stdout(1, partyHat.subarray(0, 3)), stdout(2, partyHat.subarray(3)), - { id: "e", seq: 3, name: "exit", value: 0 }, + { id: "e", seq: 3, name: "exit", code: 0 }, ]), ); const result = await (await runtime.exec("noop", { encoding: "utf8" })).result(); @@ -150,7 +150,7 @@ describe("WorkspaceRuntime utf8 encoding", () => { stderr(2, heart.subarray(0, 2)), stdout(3, partyHat.subarray(2)), stderr(4, heart.subarray(2)), - { id: "e", seq: 5, name: "exit", value: 0 }, + { id: "e", seq: 5, name: "exit", code: 0 }, ]), ); const result = await (await runtime.exec("noop", { encoding: "utf8" })).result(); @@ -160,7 +160,7 @@ describe("WorkspaceRuntime utf8 encoding", () => { it("preserves encoding when consuming the stream directly", async () => { const runtime = runtimeFor( - replayBackend([stdout(1, bytes("stream-mode")), { id: "e", seq: 2, name: "exit", value: 0 }]), + replayBackend([stdout(1, bytes("stream-mode")), { id: "e", seq: 2, name: "exit", code: 0 }]), ); const handle = await runtime.exec("noop", { encoding: "utf8" }); const seen: unknown[] = []; diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index 6d8f95ce..2008e298 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -304,7 +304,7 @@ async function drainModuleResult( if (event.name === "stdout") stdout.push(event.value); if (event.name === "stderr") stderr.push(event.value); if (event.name === "exit") { - exitCode = event.value; + exitCode = event.code; if ("result" in event) value = event.result; } } diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index 9562106d..aaf7267c 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -87,7 +87,7 @@ type RuntimeChunk = E extends "utf8" ? string : Uint8Arr export type WorkspaceRuntimeEvent = | { id: string; seq: number; name: "stdout"; value: RuntimeChunk } | { id: string; seq: number; name: "stderr"; value: RuntimeChunk } - | { id: string; seq: number; name: "exit"; value: number; result?: WorkspaceRuntimeValue }; + | { id: string; seq: number; name: "exit"; code: number; result?: WorkspaceRuntimeValue }; export interface WorkspaceRuntimeResult { status: WorkspaceRuntimeStatus; diff --git a/packages/computer/src/runtime/wire.test.ts b/packages/computer/src/runtime/wire.test.ts index 73ec91b6..7363b0cd 100644 --- a/packages/computer/src/runtime/wire.test.ts +++ b/packages/computer/src/runtime/wire.test.ts @@ -29,18 +29,18 @@ describe("runtime wire codec", () => { it("round-trips an exit event carrying a structured result", async () => { const events = await collect( decodeRuntimeEvents( - streamOf({ id: "e-1", seq: 2, name: "exit", value: 0, result: { a: [1, 2, null] } }), + streamOf({ id: "e-1", seq: 2, name: "exit", code: 0, result: { a: [1, 2, null] } }), ), ); expect(events).toEqual([ - { id: "e-1", seq: 2, name: "exit", value: 0, result: { a: [1, 2, null] } }, + { id: "e-1", seq: 2, name: "exit", code: 0, result: { a: [1, 2, null] } }, ]); }); it("round-trips an exit event with no result", async () => { const events = await collect( - decodeRuntimeEvents(streamOf({ id: "e-1", seq: 1, name: "exit", value: 1 })), + decodeRuntimeEvents(streamOf({ id: "e-1", seq: 1, name: "exit", code: 1 })), ); - expect(events).toEqual([{ id: "e-1", seq: 1, name: "exit", value: 1 }]); + expect(events).toEqual([{ id: "e-1", seq: 1, name: "exit", code: 1 }]); }); }); diff --git a/packages/computer/src/runtime/wire.ts b/packages/computer/src/runtime/wire.ts index 93f40a03..f39acffe 100644 --- a/packages/computer/src/runtime/wire.ts +++ b/packages/computer/src/runtime/wire.ts @@ -4,7 +4,7 @@ import type { WorkspaceRuntimeEvent, WorkspaceRuntimeValue } from "./types.js"; type RuntimeFrame = | { id: string; seq: number; name: "stdout" | "stderr"; enc: "utf8"; value: string } | { id: string; seq: number; name: "stdout" | "stderr"; enc: "b64"; value: string } - | { id: string; seq: number; name: "exit"; value: number; result?: WorkspaceRuntimeValue }; + | { id: string; seq: number; name: "exit"; code: number; result?: WorkspaceRuntimeValue }; function toBase64(bytes: Uint8Array): string { let binary = ""; diff --git a/packages/computer/src/shell.test.ts b/packages/computer/src/shell.test.ts index e68529ab..bcbd8fdb 100644 --- a/packages/computer/src/shell.test.ts +++ b/packages/computer/src/shell.test.ts @@ -64,7 +64,7 @@ interface FakeRpcOptions { } function fakeRpc(options: FakeRpcOptions = {}): FakeRpc { - const events = options.events ?? [{ id: "_", seq: 1, name: "exit", value: 0 }]; + const events = options.events ?? [{ id: "_", seq: 1, name: "exit", code: 0 }]; const mintedId = options.mintedId ?? "runner-minted-id"; const calls: FakeRpc["calls"] = { exec: [], getExec: [], killExec: [] }; @@ -134,7 +134,7 @@ function stdout(seq: number, text: string): ExecEvent { return { id: "_", seq, name: "stdout", value: new TextEncoder().encode(text) }; } function exit(seq: number, code: number): ExecEvent { - return { id: "_", seq, name: "exit", value: code }; + return { id: "_", seq, name: "exit", code: code }; } // Drain an execution's events to completion and settle its sync diff --git a/packages/computer/src/stub.test.ts b/packages/computer/src/stub.test.ts index 507acb28..8d7dad13 100644 --- a/packages/computer/src/stub.test.ts +++ b/packages/computer/src/stub.test.ts @@ -248,7 +248,7 @@ describe("WorkspaceStub", () => { id: "e-1", events: new ReadableStream({ start(c) { - c.enqueue({ id: "e-1", seq: 1, name: "exit", value: 0 }); + c.enqueue({ id: "e-1", seq: 1, name: "exit", code: 0 }); c.close(); }, }), @@ -279,7 +279,7 @@ describe("WorkspaceStub", () => { id: "other-id", events: new ReadableStream({ start(c) { - c.enqueue({ id: "other-id", seq: 1, name: "exit", value: 0 }); + c.enqueue({ id: "other-id", seq: 1, name: "exit", code: 0 }); c.close(); }, }), @@ -317,7 +317,7 @@ describe("WorkspaceStub", () => { id: `e-${execCalls}`, events: new ReadableStream({ start(c) { - c.enqueue({ id: `e-${execCalls}`, seq: 1, name: "exit", value: 0 }); + c.enqueue({ id: `e-${execCalls}`, seq: 1, name: "exit", code: 0 }); c.close(); }, }), @@ -486,7 +486,7 @@ describe("WorkspaceStub", () => { events: new ReadableStream({ start(c) { c.enqueue({ id: `e-${execCalls}`, seq: 1, name: "stdout", value: new Uint8Array() }); - c.enqueue({ id: `e-${execCalls}`, seq: 2, name: "exit", value: 0 }); + c.enqueue({ id: `e-${execCalls}`, seq: 2, name: "exit", code: 0 }); c.close(); }, }), @@ -524,7 +524,7 @@ describe("WorkspaceStub", () => { events: new ReadableStream({ start(c) { c.enqueue({ id: "e-1", seq: 1, name: "stdout", value: payload }); - c.enqueue({ id: "e-1", seq: 2, name: "exit", value: 0 }); + c.enqueue({ id: "e-1", seq: 2, name: "exit", code: 0 }); c.close(); }, }), @@ -538,19 +538,27 @@ describe("WorkspaceStub", () => { async (ws) => { const stub = ws.stub(); const handle = await stub.runtime.exec("noop"); - const events: Array<{ name: string; value: unknown }> = []; + type Collected = + | { name: "stdout" | "stderr"; value: Uint8Array } + | { name: "exit"; code: number }; + const events: Collected[] = []; const decoded = decodeRuntimeEvents(handle.stream()); const reader = decoded.getReader(); while (true) { const { value, done } = await reader.read(); if (done) break; - events.push({ name: value.name, value: value.value }); + events.push( + value.name === "exit" + ? { name: "exit", code: value.code } + : { name: value.name, value: value.value as Uint8Array }, + ); } reader.releaseLock(); expect(events).toHaveLength(2); - expect(events[0].name).toBe("stdout"); - expect(Array.from(events[0].value as Uint8Array)).toEqual(Array.from(payload)); - expect(events[1]).toEqual({ name: "exit", value: 0 }); + const first = events[0]; + if (first.name !== "stdout") throw new Error("expected stdout first"); + expect(Array.from(first.value)).toEqual(Array.from(payload)); + expect(events[1]).toEqual({ name: "exit", code: 0 }); }, { backend: backend({ shell: shellRpc }) }, ); @@ -574,7 +582,7 @@ describe("WorkspaceStub", () => { }); return; } - c.enqueue({ id: "e-1", seq: 10, name: "exit", value: 0 }); + c.enqueue({ id: "e-1", seq: 10, name: "exit", code: 0 }); c.close(); }, }), @@ -613,7 +621,7 @@ describe("WorkspaceStub", () => { id: "e-1", events: new ReadableStream({ start(c) { - c.enqueue({ id: "e-1", seq: 1, name: "exit", value: 0 }); + c.enqueue({ id: "e-1", seq: 1, name: "exit", code: 0 }); c.close(); }, }), diff --git a/packages/computer/src/workspace.test.ts b/packages/computer/src/workspace.test.ts index 8fa3ddbb..95d3dc05 100644 --- a/packages/computer/src/workspace.test.ts +++ b/packages/computer/src/workspace.test.ts @@ -152,13 +152,13 @@ function makeBackend( function execBackend(id: string, onExec: (command: string) => void): WorkspaceBackend { const shell: import("@cloudflare/computer-rpc").ShellRPC = { async exec(input) { - onExec(input.command); + onExec(input.source); const execId = input.id ?? `${id}-${Math.random().toString(36).slice(2)}`; return { id: execId, events: new ReadableStream({ start(c) { - c.enqueue({ id: execId, seq: 1, name: "exit", value: 0 }); + c.enqueue({ id: execId, seq: 1, name: "exit", code: 0 }); c.close(); }, }), @@ -262,7 +262,7 @@ describe("Workspace backend selection", () => { id, events: new ReadableStream({ start(controller) { - controller.enqueue({ id, seq: 1, name: "exit", value: 0 }); + controller.enqueue({ id, seq: 1, name: "exit", code: 0 }); controller.close(); }, }), @@ -294,7 +294,7 @@ describe("Workspace backend selection", () => { id, events: new ReadableStream({ start(controller) { - controller.enqueue({ id, seq: 1, name: "exit", value: 0 }); + controller.enqueue({ id, seq: 1, name: "exit", code: 0 }); controller.close(); }, }), @@ -330,7 +330,7 @@ describe("Workspace backend selection", () => { events: new ReadableStream({ start(controller) { controller.enqueue({ id, seq: 1, name: "stdout", value: new Uint8Array([0xe2]) }); - controller.enqueue({ id, seq: 2, name: "exit", value: 0 }); + controller.enqueue({ id, seq: 2, name: "exit", code: 0 }); controller.close(); }, }), @@ -375,7 +375,7 @@ describe("Workspace backend selection", () => { return { id, events: events() }; }, async killExec() { - exit = { id, seq: 1, name: "exit", value: 143 }; + exit = { id, seq: 1, name: "exit", code: 143 }; for (const controller of controllers) { controller.enqueue(exit); controller.close(); @@ -418,7 +418,7 @@ describe("Workspace backend selection", () => { name: "stdout", value: new Uint8Array([0xe2]), }); - controller.enqueue({ id: "module-exec", seq: 3, name: "exit", value: 0, result: 42 }); + controller.enqueue({ id: "module-exec", seq: 3, name: "exit", code: 0, result: 42 }); controller.close(); }, }); @@ -474,7 +474,7 @@ describe("Workspace backend selection", () => { name: "stdout", value: new Uint8Array([0xf0, 0x9f]), }); - controller.enqueue({ id: "module-exec", seq: 2, name: "exit", value: 0 }); + controller.enqueue({ id: "module-exec", seq: 2, name: "exit", code: 0 }); controller.close(); }, }), @@ -491,15 +491,22 @@ describe("Workspace backend selection", () => { }; const ws = new Workspace({ storage: makeStorage(), backends: [backend] }); const execution = await ws.runtime.exec("export default 42", { encoding: "utf8" }); - const seen: Array<{ seq: number; name: string; value: unknown }> = []; + type Collected = + | { seq: number; name: "stdout" | "stderr"; value: unknown } + | { seq: number; name: "exit"; code: number }; + const seen: Collected[] = []; for await (const event of execution) { - seen.push({ seq: event.seq, name: event.name, value: event.value }); + seen.push( + event.name === "exit" + ? { seq: event.seq, name: "exit", code: event.code } + : { seq: event.seq, name: event.name, value: event.value }, + ); } expect(seen).toEqual([ { seq: 1, name: "stdout", value: "" }, { seq: 1.5, name: "stdout", value: "�" }, - { seq: 2, name: "exit", value: 0 }, + { seq: 2, name: "exit", code: 0 }, ]); }); diff --git a/packages/computerd/src/exec/log.ts b/packages/computerd/src/exec/log.ts index 7583f6a5..6a722898 100644 --- a/packages/computerd/src/exec/log.ts +++ b/packages/computerd/src/exec/log.ts @@ -212,7 +212,7 @@ function materialise(id: string, row: EventRow): ExecEvent { // node:sqlite returns BLOB as Uint8Array; we need the bytes // view to read the int32. const view = new DataView(row.value.buffer, row.value.byteOffset, row.value.byteLength); - return { id, seq: row.seq, name: "exit", value: view.getInt32(0, true) }; + return { id, seq: row.seq, name: "exit", code: view.getInt32(0, true) }; } const name = row.kind === KIND_STDOUT ? "stdout" : "stderr"; return { id, seq: row.seq, name, value: row.value }; diff --git a/packages/computerd/src/exec/runner.fuse.test.ts b/packages/computerd/src/exec/runner.fuse.test.ts index 9a7fa5b5..da5d2e8c 100644 --- a/packages/computerd/src/exec/runner.fuse.test.ts +++ b/packages/computerd/src/exec/runner.fuse.test.ts @@ -119,7 +119,7 @@ describeIfReal("Runner shell.exec under real FUSE", () => { const result = await Promise.race([ client.shell .exec({ - command: "echo hello && pwd", + source: "echo hello && pwd", cwd: "/workspace", timeoutMs: 5_000, }) @@ -149,7 +149,7 @@ describeIfReal("Runner shell.exec under real FUSE", () => { .join(""); const exit = events.find((e) => e.name === "exit"); expect(stdout).toBe("hello\n/workspace\n"); - expect(exit?.value).toBe(0); + expect(exit?.code).toBe(0); } finally { await client.close(); } diff --git a/packages/computerd/src/exec/runner.test.ts b/packages/computerd/src/exec/runner.test.ts index 97be8ec9..4258017b 100644 --- a/packages/computerd/src/exec/runner.test.ts +++ b/packages/computerd/src/exec/runner.test.ts @@ -7,7 +7,7 @@ import { Runner } from "./runner.js"; type ExecEvent = | { id: string; seq: number; name: "stdout"; value: Uint8Array } | { id: string; seq: number; name: "stderr"; value: Uint8Array } - | { id: string; seq: number; name: "exit"; value: number }; + | { id: string; seq: number; name: "exit"; code: number }; function fixture(options: Record = {}): { runner: InstanceType; @@ -66,7 +66,7 @@ test("exec captures stdout and propagates exit code", async () => { const exit = events.find((e) => e.name === "exit"); expect(stdout).toBe("hello\n"); expect(stderr).toBe("world\n"); - expect(exit?.value).toBe(3); + expect(exit?.code).toBe(3); // seq is monotonic per-id starting at 1. const seqs = events.map((e) => e.seq); for (let i = 1; i < seqs.length; i++) { @@ -114,7 +114,7 @@ test("feeds per-execution stdin to the child and closes it", async () => { .join(""); const exit = events.find((event) => event.name === "exit"); expect(stdout).toBe("piped-input"); - expect(exit?.value).toBe(0); + expect(exit?.code).toBe(0); } finally { dispose(); } @@ -180,7 +180,7 @@ test("kill() terminates a running exec", async () => { const exit = events.find((e) => e.name === "exit"); expect(exit !== undefined).toBeTruthy(); // SIGTERM → 143 per the mapping in runner.ts. - expect(exit?.value).toBe(143); + expect(exit?.code).toBe(143); } finally { dispose(); } @@ -196,7 +196,7 @@ test("exec times out at timeoutMs and exits 143", async () => { const exit = events.find((e) => e.name === "exit"); expect(exit !== undefined).toBeTruthy(); // SIGTERM → 143 per mapExitCode. - expect(exit?.value).toBe(143); + expect(exit?.code).toBe(143); } finally { dispose(); } @@ -209,7 +209,7 @@ test("exec uses defaultTimeoutMs from the runner when no per-call value", async const events = await drain(handle.events); const exit = events.find((e) => e.name === "exit"); expect(exit !== undefined).toBeTruthy(); - expect(exit?.value).toBe(143); + expect(exit?.code).toBe(143); } finally { dispose(); } @@ -235,7 +235,7 @@ test("timeoutMs: 0 disables the timeout", async () => { const handle = runner.exec("echo hi", { id: "noto", timeoutMs: 0 }); const events = await drain(handle.events); const exit = events.find((e) => e.name === "exit"); - expect(exit?.value).toBe(0); + expect(exit?.code).toBe(0); } finally { dispose(); } @@ -361,7 +361,7 @@ test("exec(cwd) does not pass cwd to spawn; threads it through the shell", async .join(""); const exit = events.find((e) => e.name === "exit"); expect(stdout.trim()).toBe("/tmp"); - expect(exit?.value).toBe(0); + expect(exit?.code).toBe(0); } finally { dispose(); } @@ -384,7 +384,7 @@ test("exec(cwd) with a missing path synthesises a spawn-failed shape", async () const exit = events.find((e) => e.name === "exit"); expect(stderr).toMatch(/^spawn failed: /); expect(stderr).toMatch(/no such path|ENOENT/i); - expect(exit?.value).toBe(-1); + expect(exit?.code).toBe(-1); } finally { dispose(); } @@ -410,7 +410,7 @@ test("exec(cwd) quotes path segments with spaces and single quotes", async () => .join(""); const exit = events.find((e) => e.name === "exit"); expect(stdout.trim()).toBe(tricky); - expect(exit?.value).toBe(0); + expect(exit?.code).toBe(0); } finally { dispose(); } diff --git a/packages/computerd/src/exec/runner.ts b/packages/computerd/src/exec/runner.ts index 76ff977e..347fd227 100644 --- a/packages/computerd/src/exec/runner.ts +++ b/packages/computerd/src/exec/runner.ts @@ -230,7 +230,7 @@ export class Runner { this.scheduleSweep(); return; } - record.subscriber?.enqueue({ id, seq, name: "exit", value: exitCode }); + record.subscriber?.enqueue({ id, seq, name: "exit", code: exitCode }); record.subscriber?.close(); record.subscriber = undefined; this.scheduleSweep(); @@ -466,7 +466,7 @@ export class Runner { const events = new ReadableStream({ start(controller) { controller.enqueue({ id, seq: stderrSeq, name: "stderr", value }); - controller.enqueue({ id, seq: exitSeq, name: "exit", value: -1 }); + controller.enqueue({ id, seq: exitSeq, name: "exit", code: -1 }); controller.close(); }, }); diff --git a/packages/computerd/src/exec/types.ts b/packages/computerd/src/exec/types.ts index 647ee0a7..68fc5cbf 100644 --- a/packages/computerd/src/exec/types.ts +++ b/packages/computerd/src/exec/types.ts @@ -20,7 +20,7 @@ export type HeartbeatValue = { export type ExecEvent = | { id: string; seq: number; name: "stdout"; value: Uint8Array } | { id: string; seq: number; name: "stderr"; value: Uint8Array } - | { id: string; seq: number; name: "exit"; value: number } + | { id: string; seq: number; name: "exit"; code: number } | { id: string; seq: number; name: "heartbeat"; value: HeartbeatValue }; export interface ExecOptions { diff --git a/packages/rpc/src/interface.ts b/packages/rpc/src/interface.ts index f897ca72..e12c3293 100644 --- a/packages/rpc/src/interface.ts +++ b/packages/rpc/src/interface.ts @@ -150,7 +150,7 @@ export interface WorkspaceRPC { export type ExecEvent = | { id: string; seq: number; name: "stdout"; value: Uint8Array } | { id: string; seq: number; name: "stderr"; value: Uint8Array } - | { id: string; seq: number; name: "exit"; value: number; result?: unknown }; + | { id: string; seq: number; name: "exit"; code: number; result?: unknown }; // Error codes carried over the wire. The client adapter rethrows as // WorkspaceError preserving `code`, so application code can branch diff --git a/packages/rpc/tests/shell-and-composite.test.ts b/packages/rpc/tests/shell-and-composite.test.ts index 956d9886..002c8e5e 100644 --- a/packages/rpc/tests/shell-and-composite.test.ts +++ b/packages/rpc/tests/shell-and-composite.test.ts @@ -75,7 +75,7 @@ function makeFakeRunner(): FakeRunner { name: "stdout", value: new TextEncoder().encode(`ran:${command}\n`), }, - { id, seq: 2, name: "exit", value: 0 }, + { id, seq: 2, name: "exit", code: 0 }, ], }; records.set(id, rec); @@ -163,7 +163,7 @@ describe("ShellRPC over a real WebSocket", () => { expect(events).toHaveLength(2); expect(events[0]?.name).toBe("stdout"); expect(new TextDecoder().decode(events[0]?.value as Uint8Array)).toBe("ran:echo hi\n"); - expect(events[1]).toMatchObject({ name: "exit", value: 0 }); + expect(events[1]).toMatchObject({ name: "exit", code: 0 }); // Server-side: the runner saw the call. const rec = harness.runner.records.get(handle.id); @@ -269,7 +269,7 @@ describe("Composite WorkspaceRPC (sync + shell on one session)", () => { const handle = await client.shell.exec({ source: "ls" }); const events = await drainExec(handle.events); expect(events).toHaveLength(2); - expect(events[1]).toMatchObject({ name: "exit", value: 0 }); + expect(events[1]).toMatchObject({ name: "exit", code: 0 }); // Sanity check: server side records both interactions. expect(harness.runner.records.get(handle.id)?.command).toBe("ls"); From 38279b5ece9554bacc6c1686f9b15f76ab631a2a Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:07:00 +0000 Subject: [PATCH 09/13] computer: drop the command adapter's dead close method The unified backend handle required a close method, but the command adapter's was never reached: the workspace closes command transports through their BackendHandle and module transports through the module handle cache, never through the adapter the runtime consumes. The adapter's close was a no-op kept only to satisfy the type. Make close optional on WorkspaceModuleBackendHandle, drop the adapter's no-op, and guard the two teardown call sites. Module backends that own a transport still implement it. --- packages/computer/src/runtime/types.ts | 5 ++++- packages/computer/src/workspace.ts | 8 ++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/computer/src/runtime/types.ts b/packages/computer/src/runtime/types.ts index aaf7267c..cf3d9931 100644 --- a/packages/computer/src/runtime/types.ts +++ b/packages/computer/src/runtime/types.ts @@ -165,7 +165,10 @@ export interface WorkspaceModuleBackendHandle { getExec(input: { id: string; after?: number | "tail" }): Promise; killExec(input: { id: string; signal?: KillSignal }): Promise; disposeExec(input: { id: string }): Promise; - close(): Promise; + // Tear down a backend-owned transport. The command adapter omits + // it: a command backend's transport is closed through its + // BackendHandle, not through the adapter the runtime consumes. + close?(): Promise; } export type WorkspaceModuleBackendHost = import("../backend.js").WorkspaceBackendHost; diff --git a/packages/computer/src/workspace.ts b/packages/computer/src/workspace.ts index 21572ae5..ac4b9e90 100644 --- a/packages/computer/src/workspace.ts +++ b/packages/computer/src/workspace.ts @@ -762,7 +762,7 @@ export class Workspace { await Promise.all( [...handles, ...moduleHandles].map(async (h) => { try { - await h.close(); + await h.close?.(); } catch { // close() is best-effort; a transport that's already // gone shouldn't take the workspace down with it. @@ -847,10 +847,6 @@ export class Workspace { throw error; } }, - close: async () => { - // The backend handle owns the transport; closing happens - // through the Workspace's own close path. - }, }; this.#commandHandles.set(id, adapter); return adapter; @@ -883,7 +879,7 @@ export class Workspace { ) .then(async (handle) => { if (generation !== this.#connectionGeneration) { - await handle.close().catch(() => undefined); + await handle.close?.().catch(() => undefined); throw new Error(`Workspace closed while backend ${JSON.stringify(id)} was connecting.`); } this.#moduleHandles.set(id, handle); From 09572ec24a6875c05c841d44cec42b9ab1f335c3 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:10:35 +0000 Subject: [PATCH 10/13] script: update the exec harnesses for the renamed wire fields The stub soak and exec-tests harnesses still sent the shell exec command on the old `command` field and read the exit code from `value`. After the field renames the command arrived undefined, so the soak harness spawned an empty command and its exec phase measured nothing, and exec-tests failed against real output. Send the command on `source` and read the exit code from `code` in both harnesses. The soak harness's exec phase again exercises a real spawn, so its stub accounting reflects exec traffic rather than a no-op. --- script/computerd-stub-soak.mjs | 4 ++-- script/exec-tests | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/script/computerd-stub-soak.mjs b/script/computerd-stub-soak.mjs index 54b640fc..2bfedc34 100755 --- a/script/computerd-stub-soak.mjs +++ b/script/computerd-stub-soak.mjs @@ -283,7 +283,7 @@ async function main() { // pattern as fetchChanges. console.error(`[soak] ${EXEC_CALLS} exec calls (no disposal)…`); for (let i = 0; i < EXEC_CALLS; i++) { - const result = await stub.shell.exec({ command: "true" }); + const result = await stub.shell.exec({ source: "true" }); const reader = result.events.getReader(); try { while (true) { @@ -324,7 +324,7 @@ async function main() { console.error(`[soak] ${EXEC_CALLS} exec calls (with disposal)…`); for (let i = 0; i < EXEC_CALLS; i++) { - const result = await stub.shell.exec({ command: "true" }); + const result = await stub.shell.exec({ source: "true" }); const reader = result.events.getReader(); try { while (true) { diff --git a/script/exec-tests b/script/exec-tests index 24533a02..05217693 100755 --- a/script/exec-tests +++ b/script/exec-tests @@ -80,7 +80,7 @@ const assert = (cond, msg) => { const client = createWorkspaceClient({ url: `ws://localhost:${port}/ws` }); try { // 1. echo + exit code. - const h = await client.shell.exec({ command: "echo hello && exit 7" }); + const h = await client.shell.exec({ source: "echo hello && exit 7" }); const dec = new TextDecoder(); const events = []; const reader = h.events.getReader(); @@ -93,17 +93,17 @@ try { .map(e => dec.decode(e.value)).join(""); const exit = events.find(e => e.name === "exit"); assert(stdout === "hello\n", `unexpected stdout: ${JSON.stringify(stdout)}`); - assert(exit && exit.value === 7, `unexpected exit: ${JSON.stringify(exit)}`); + assert(exit && exit.code === 7, `unexpected exit: ${JSON.stringify(exit)}`); console.log(" PASS exec captures stdout + exit code"); // 1b. EEXEC_BUSY surfaces with the right code over the wire. // The runner refuses a second exec on a live id; the host // catches a plain Error with err.code intact (capnweb copies // own enumerable props on Error subclasses). - const live = await client.shell.exec({ command: "sleep 30", id: "busy" }); + const live = await client.shell.exec({ source: "sleep 30", id: "busy" }); let busyErr; try { - await client.shell.exec({ command: "echo nope", id: "busy" }); + await client.shell.exec({ source: "echo nope", id: "busy" }); } catch (err) { busyErr = err; } @@ -120,14 +120,14 @@ try { console.log(" PASS EEXEC_BUSY propagates with err.code"); // 2. kill a long-running process. - const long = await client.shell.exec({ command: "sleep 30", id: "killme" }); + const long = await client.shell.exec({ source: "sleep 30", id: "killme" }); await client.shell.killExec({ id: "killme", signal: "SIGTERM" }); const longReader = long.events.getReader(); let killExit; while (true) { const { value, done } = await longReader.read(); if (done) break; - if (value.name === "exit") killExit = value.value; + if (value.name === "exit") killExit = value.code; } // SIGTERM → 143 per Runner mapping. assert(killExit === 143, `kill exit code was ${killExit}`); @@ -161,7 +161,7 @@ try { // overflowed the cap raises ELOG_TRUNCATED. EXEC_LOG_MAX_BYTES=256 // on the container makes the cap tiny so even an echo trips it. const big = await client.shell.exec({ - command: "head -c 2048 /dev/urandom | base64", + source: "head -c 2048 /dev/urandom | base64", id: "big", }); // Drain the live stream first — eviction does not gate live. @@ -214,7 +214,7 @@ try { // flow-control window stalls for long — a real test of pipe // pause needs a capnweb upgrade or a different observable. const chatty = await client.shell.exec({ - command: "head -c 8192 /dev/urandom | base64", + source: "head -c 8192 /dev/urandom | base64", id: "chatty", }); let totalBytes = 0; From 8459505e7fc2b9e0c934c736756972243e32bee0 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:12:45 +0000 Subject: [PATCH 11/13] computer: distinguish a missing exit frame from exit 1 The module result drain started the exit code at 1, so a stream that closed without an exit frame reported the same code as a command that genuinely exited 1. The command path used -1 for that case before the paths merged. Start the exit code at -1 so a truncated stream is distinguishable from a real exit 1. Both still settle as "failed". Cover the case in the runtime tests. --- packages/computer/src/runtime/runtime.test.ts | 7 +++++++ packages/computer/src/runtime/runtime.ts | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/computer/src/runtime/runtime.test.ts b/packages/computer/src/runtime/runtime.test.ts index defe4a33..e03b1ad3 100644 --- a/packages/computer/src/runtime/runtime.test.ts +++ b/packages/computer/src/runtime/runtime.test.ts @@ -111,6 +111,13 @@ describe("WorkspaceRuntime result accumulation", () => { expect(result.exitCode).toBe(code); } }); + + it("reports exit code -1 when the stream closes without an exit event", async () => { + const runtime = runtimeFor(replayBackend([stdout(1, bytes("partial"))])); + const result = await (await runtime.exec("noop")).result(); + expect(result.exitCode).toBe(-1); + expect(result.status).toBe("failed"); + }); }); describe("WorkspaceRuntime utf8 encoding", () => { diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index 2008e298..e52a400a 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -293,7 +293,9 @@ async function drainModuleResult( const stdout: Uint8Array[] = []; const stderr: Uint8Array[] = []; let value: WorkspaceRuntimeResult["value"]; - let exitCode = 1; + // -1 marks a stream that closed without an exit frame, keeping that + // case distinct from a genuine exit 1. Both settle as "failed". + let exitCode = -1; const reader = events.getReader(); setReader(reader); try { From c8428e90d6ec23f34153a909e94e321a82928698 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:13:46 +0000 Subject: [PATCH 12/13] computer: observe the JavaScript execution completion promise Removing the waitUntil hook left the execution completion promise unheld on the success path. That promise drives the finalize step, which writes the terminal execution rows and can throw, so a finalize rejection became an unhandled rejection in the Durable Object rather than a handled error. Attach a catch to the completion promise at its start site so a rejection is observed and swallowed. Cancellation and connect-time failure already awaited it. --- .../src/backends/worker-javascript/worker-javascript.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 1b3f0935..9023c2fe 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -409,6 +409,12 @@ class JavaScriptBackendHandle implements WorkspaceModuleBackendHandle { onComplete: () => this.#finalize(record), onError: (message) => this.#finalize(record, message), }); + // The completion promise drives finalize (which writes the + // terminal SQL) and is no longer handed to a host lifetime + // hook. Observe it so a finalize rejection surfaces as a + // swallowed error rather than an unhandled rejection in the + // Durable Object. + void record.control.completion.catch(() => undefined); } catch (error) { record.control?.cancel(); await record.control?.completion.catch(() => undefined); From 14774d4580f545c66a64a844c87d42ca04402877 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 4 Aug 2026 14:26:27 +0000 Subject: [PATCH 13/13] computer: export the not-callable error message from the runtime The runtime and the exec tool each built the same "backend is not callable" message from scratch, so the two user-visible copies could drift. Export the message from the runtime and build the runtime's own throw from it, giving the tool one source to reuse. --- packages/computer/src/runtime/runtime.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/computer/src/runtime/runtime.ts b/packages/computer/src/runtime/runtime.ts index e52a400a..9086b2c9 100644 --- a/packages/computer/src/runtime/runtime.ts +++ b/packages/computer/src/runtime/runtime.ts @@ -19,6 +19,14 @@ interface WorkspaceRuntimeRouterOptions { resolveBackendId: (id: string | undefined) => string; } +// The error a caller sees when it hands structured `input` to a +// backend that does not accept it. Exported so the exec tool rejects +// with the same wording the runtime raises, instead of a second copy +// that could drift. +export function notCallableMessage(backend: string): string { + return `Backend ${JSON.stringify(backend)} is not callable; it does not accept structured input.`; +} + export class WorkspaceRuntime { readonly #options: WorkspaceRuntimeRouterOptions; @@ -42,9 +50,7 @@ export class WorkspaceRuntime { 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.`, - ); + throw new Error(notCallableMessage(backend)); } const runtime = await this.#options.backendHandle(backend); const envelope = await runtime.exec({