Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/exec-metadata.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

`runtime.exec()` takes `metadata`, a record of strings for host modules that the run's code never sees. Host module calls read it as `context.execution.metadata`, `runtime.pending()` lists it with each waiting call, and a replayable run keeps it when it resumes. `WorkerJavaScriptBackend` caps it at 16 KiB of keys and values.
3 changes: 2 additions & 1 deletion docs/05_runtime_interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface WorkspaceRuntimeExecOptions {
timeoutMs?: number;
env?: Record<string, string>;
stdin?: Uint8Array | string;
metadata?: Record<string, string>;
output?: { maxLines?: number; maxBytes?: number } | false;
}

Expand All @@ -45,7 +46,7 @@ interface WorkspaceRuntimeExecHandle extends ReadableStream<WorkspaceRuntimeEven
}
```

`input` is accepted by callable backends and rejected by the rest; it carries a structured value that the callable backend returns a structured value for. `env` is accepted everywhere: command backends inherit it for the spawned command, and the JavaScript module backend exposes it through `process.env`. Its values apply to that execution only and do not change later executions. `stdin` is the caller-supplied standard input, accepted by backends that model it (the JavaScript module backend reads it through `process.stdin`). `cwd` is the command working directory or the base for durable relative module imports. A handle is single-consumer: call `result()` or consume its event stream, not both. Repeated `result()` calls return the same promise. `backend` records the resolved backend needed for later reattachment.
`input` is accepted by callable backends and rejected by the rest; it carries a structured value that the callable backend returns a structured value for. `env` is accepted everywhere: command backends inherit it for the spawned command, and the JavaScript module backend exposes it through `process.env`. Its values apply to that execution only and do not change later executions. `stdin` is the caller-supplied standard input, accepted by backends that model it (the JavaScript module backend reads it through `process.stdin`). `cwd` is the command working directory or the base for durable relative module imports. `metadata` is for host modules: the JavaScript module backend hands it to every host module call as `context.execution.metadata` and keeps it from the run's code, and backends without host modules ignore it. A handle is single-consumer: call `result()` or consume its event stream, not both. Repeated `result()` calls return the same promise. `backend` records the resolved backend needed for later reattachment.

## Results

Expand Down
29 changes: 26 additions & 3 deletions docs/17_isolate_javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ modules: {
}
```

`ctx.interrupt(payload)` releases the run's isolate once the run's other calls in flight finish. The run stays running, and time spent waiting doesn't count toward its timeout. `workspace.runtime.pending()` lists the calls waiting, with their payloads, and `respond()` answers one:
`ctx.interrupt(payload)` releases the run's isolate once the run's other calls in flight finish. The run stays running, and time spent waiting doesn't count toward its timeout. `workspace.runtime.pending()` lists the calls waiting, with their payloads and their run's `metadata`, and `respond()` answers one:

```ts
for (const call of await workspace.runtime.pending()) {
Expand Down Expand Up @@ -242,7 +242,7 @@ Once none of its calls are waiting, the run replays to the call and the function

Each execution installs a small `node:process` shim so ordinary module code can read its environment and standard streams. The shim exposes only what the caller supplies for that execution; the host environment is never visible.

`process.env` is a snapshot of the `env` record passed on the exec options. Values the caller does not pass are absent, and the Durable Object's own environment is never merged in, so a module cannot read host bindings or secrets through `process.env`.
`process.env` is a snapshot of the `env` record passed on the exec options. Values the caller does not pass are absent, and the Durable Object's own environment is never merged in, so a module cannot read host bindings or secrets through `process.env`. The exec option `metadata` is for host modules and never appears here.

`process.stdin` is a non-interactive async-iterable over the caller-supplied `stdin` bytes. The caller passes `stdin` as a `Uint8Array` or string on the exec options; `for await` yields the bytes once and then ends, and there is no blocking read for further input because an evaluate-once execution has no session to wait on. `isTTY` is `false`. The supplied input is bounded by `maxStdinBytes`; exceeding it fails the run with a clear error.

Expand Down Expand Up @@ -400,10 +400,33 @@ Each function receives the arguments the isolate passed, as an array of JSON-com
| `deadline` | Epoch milliseconds after which the isolate stops waiting. |
| `access` | The backend's `"read"` or `"read-write"` access. Check it before any write. |
| `resolvePath(path, { allowMissing })` | Confines a caller path to the backend root and rejects symlinks. |
| `execution` | The run the call belongs to: `{ id }`. Every call of one run gets the same id, and a replayable run keeps it when it resumes. Ids are unique within one backend, and a module's functions are per backend connection, so the id alone tells the module's runs apart. |
| `execution` | The run the call belongs to: `{ id, metadata }`. Every call of one run gets the same values, and a replayable run keeps them when it resumes. Ids are unique within one backend, and a module's functions are per backend connection, so the id alone tells the module's runs apart. |

The arguments come from caller code, so parse them before use. A function may return a value or a promise. Arguments and results cross the isolate boundary as real values through Workers RPC, not as encoded text. The result must be JSON-compatible plain data, and the bridge checks it at runtime. As in JSON, an `undefined` result or array item becomes `null` and an `undefined` object field is left out, in arguments and results alike. Byte arrays work for `node:fs` calls but not for host modules. It fits within the same capability byte limits as every other host call. A function that ignores `signal` and never settles keeps the execution in its finalizing state.

`metadata` is whatever the app passed to `exec()`, or an empty object. It's a record of strings that tells host modules who a run is for, such as the conversation that started it:

```ts
await workspace.runtime.exec(source, {
backend: "worker-javascript",
metadata: { conversation: conversationId },
});

modules: {
"codemode:notes": {
add: ([text], { execution }) => {
const { conversation } = execution.metadata;
if (conversation === undefined) throw new Error("This run isn't for a conversation.");
return notes.add(conversation, String(text));
},
},
}
```

Host code reads metadata as `WorkspaceExecutionMetadata`, whose values may be `undefined`, because any key may be missing: a run may have been started without metadata or by other code, and a run saved before metadata existed resumes with an empty object.

The run's code never sees metadata: it isn't in `process.env` or the input, so code can't read or change it, and a module can trust it. It's capped at 16 KiB of keys and values. A replayable run keeps its metadata when it resumes, `pending()` lists it with each waiting call, and `workspace.stub().runtime.exec()` takes it too. Backends that run no host modules ignore it.

Specifiers and the export names of an object are checked at construction. A factory's export names are checked when the backend connects and the factory runs. An object must export at least one function. A factory may build none, as `codemode:tools` does for an agent with no tools: the backend then leaves that module out of the connection, so only code that imports it fails, with a message saying the module exports nothing, and every other run still starts. Every export name must be a JavaScript identifier name other than `default` or `then`. A reserved word such as `delete` is allowed, and caller code renames it on import: `import { delete as remove } from "codemode:files"`. Importing a name the module does not export fails when the module graph links, before any code runs.

### `codemode:git`
Expand Down
2 changes: 1 addition & 1 deletion examples/rlm/worker/capability.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ function context(signal = new AbortController().signal): WorkspaceModuleCallCont
deadline: Date.now() + 1_000,
access: "read",
callId: "test-call",
execution: { id: "test-run" },
execution: { id: "test-run", metadata: {} },
interrupt: () => Promise.reject(new Error("not used")),
resolvePath: async (path) => path,
};
Expand Down
6 changes: 6 additions & 0 deletions packages/computer/src/backends/worker-javascript/journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ export interface ResumeStart {
readonly seed: string;
/** Identifies the runtime the execution started on. */
readonly fingerprint: string;
/**
* The run's metadata for host modules. Runs saved before metadata
* existed have none and resume with an empty object, which is why
* adding it left `JOURNAL_VERSION` as it was.
*/
readonly metadata?: Record<string, string>;
}

export interface ResumeState {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest";

import type { WorkspaceRuntimeLoader } from "../../runtime/types";
import { Workspace } from "../../workspace";
import { ExecutionJournal, fingerprint, loadResume } from "./journal";
import { ExecutionJournal, fingerprint, loadResume, type ResumeStart, saveResume } from "./journal";
import { runtimeCode, WorkerJavaScriptBackend } from "./worker-javascript";

function throwingLoader(message: string) {
Expand Down Expand Up @@ -377,6 +377,39 @@ describe("WorkerJavaScriptBackend", () => {
expect(load).not.toHaveBeenCalled();
});

it("rejects metadata larger than 16 KiB", async () => {
const load = vi.fn<WorkspaceRuntimeLoader["load"]>();
const workspace = new Workspace({
storage: new SQLiteTestStorage(),
backends: [new WorkerJavaScriptBackend({ loader: { load } })],
});
await workspace.fs.mkdir("/workspace", { recursive: true });
await expect(
workspace.runtime.exec("export default 1", { metadata: { key: "x".repeat(16 * 1024) } }),
).rejects.toThrow(/metadata exceeds 16384 bytes/);
expect(load).not.toHaveBeenCalled();
});

it("rejects metadata that isn't a string-to-string record", async () => {
const load = vi.fn<WorkspaceRuntimeLoader["load"]>();
const workspace = new Workspace({
storage: new SQLiteTestStorage(),
backends: [new WorkerJavaScriptBackend({ loader: { load } })],
});
await workspace.fs.mkdir("/workspace", { recursive: true });
await expect(
workspace.runtime.exec("export default 1", {
metadata: { key: 42 as unknown as string },
}),
).rejects.toThrow(/metadata value for "key" must be a string/);
await expect(
workspace.runtime.exec("export default 1", {
metadata: ["a"] as unknown as Record<string, string>,
}),
).rejects.toThrow(/metadata must be a string-to-string record/);
expect(load).not.toHaveBeenCalled();
});

it("checks limits against the complete loader map including the runtime runner", async () => {
const load = vi.fn<WorkspaceRuntimeLoader["load"]>();
const workspace = new Workspace({
Expand Down Expand Up @@ -2040,18 +2073,65 @@ describe("WorkerJavaScriptBackend interrupted executions", () => {
});
}

async function interrupt(storage: SQLiteTestStorage, timeoutMs?: number) {
async function interrupt(
storage: SQLiteTestStorage,
timeoutMs?: number,
metadata?: Record<string, string>,
) {
const workspace = open(storage);
await workspace.fs.mkdir("/workspace", { recursive: true });
await workspace.runtime.exec("export default 1", {
backend: "worker-javascript",
id: "run",
onInterrupt: "replay",
...(timeoutMs === undefined ? {} : { timeoutMs }),
...(metadata === undefined ? {} : { metadata }),
});
return workspace;
}

// Rewrite the run's saved start as a version without metadata wrote it.
function forgetMetadata(storage: SQLiteTestStorage) {
const db = new Database(storage);
const saved = loadResume(db, "worker-javascript", "run");
if (saved === undefined) throw new Error("expected a saved run");
const { metadata: _metadata, ...start } = saved.start;
saveResume(db, "worker-javascript", "run", saved.policy, start as ResumeStart);
}

// A loader whose runs call codemode:test.who once and then hang, so the run
// stays running.
function calling() {
return {
load() {
return {
getEntrypoint() {
return {
async evaluate(
_input: unknown,
host: { call(name: string, args: unknown[]): Promise<unknown> },
) {
await host.call("host/codemode:test.who", []);
await new Promise<void>(() => undefined);
},
};
},
};
},
};
}

function recordingModule(seen: unknown[]) {
return {
"codemode:test": {
who: (_args: unknown, context: { execution: unknown }) => {
seen.push(context.execution);
return null;
},
},
};
}

async function settle(workspace: Workspace) {
const handle = await workspace.runtime.getExec("run", {
backend: "worker-javascript",
Expand Down Expand Up @@ -2182,6 +2262,53 @@ describe("WorkerJavaScriptBackend interrupted executions", () => {
for (const workspace of workspaces) await workspace.close();
});

it("keeps a run's metadata when it resumes", async () => {
const storage = new SQLiteTestStorage();
const first = await interrupt(storage, undefined, { conversation: "c1" });
const seen: unknown[] = [];
const later = open(storage, { loader: calling(), modules: recordingModule(seen) });
await later.runtime.recover();
await vi.waitFor(() => expect(seen).toHaveLength(1));
expect(seen).toEqual([{ id: "run", metadata: { conversation: "c1" } }]);
await later.close();
await first.close();
});

it("resumes a run saved without metadata, with none", async () => {
const storage = new SQLiteTestStorage();
const first = await interrupt(storage);
forgetMetadata(storage);
const seen: unknown[] = [];
const later = open(storage, { loader: calling(), modules: recordingModule(seen) });
await later.runtime.recover();
await vi.waitFor(() => expect(seen).toHaveLength(1));
expect(seen).toEqual([{ id: "run", metadata: {} }]);
await later.close();
await first.close();
});

it("lists a waiting run's metadata, and none for a run saved without it", async () => {
for (const [metadata, listed] of [
[{ conversation: "c1" }, { conversation: "c1" }],
[undefined, {}],
] as const) {
const storage = new SQLiteTestStorage();
const db = new Database(storage);
const first = await interrupt(storage, undefined, metadata);
if (metadata === undefined) forgetMetadata(storage);
const journal = new ExecutionJournal(db, "worker-javascript", "run");
const step = journal.begin("host/codemode:test.approve", []);
if (step.kind !== "live") throw new Error("expected a live call");
journal.interrupt(step.seq, { kind: "approval" });
const later = open(storage);
await expect(later.runtime.pending()).resolves.toEqual([
expect.objectContaining({ id: "run", metadata: listed }),
]);
await later.close();
await first.close();
}
});

it("asks for a REPLAY_VERSION check when the runtime's code changes", () => {
// The code the runtime loads into the isolate changed. If a run that
// started on the old code could replay differently on the new code,
Expand Down
Loading
Loading