Skip to content
Merged
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-tool-module-shape.md

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@scuffi can you take a human pass at these changelog entries, they're structured right, but can be super short.

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": patch
---

The `exec` tool now tells the model to put module code in an `export default async function (input)` shape.
5 changes: 5 additions & 0 deletions .changeset/isolate-default-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": patch
---

Isolate JavaScript modules now need a default export, and fail before running without one.
5 changes: 5 additions & 0 deletions .changeset/isolate-node-modules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": patch
---

Isolate JavaScript code can now import Node.js built-ins such as `node:path`, `node:crypto`, and `node:zlib`.
5 changes: 5 additions & 0 deletions .changeset/isolate-root-directory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": patch
---

The JavaScript backend now creates its root directory (`/workspace`) if the Workspace doesn't have one.
5 changes: 5 additions & 0 deletions .changeset/isolate-unhandled-rejections.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": patch
---

Isolate JavaScript runs now fail on I/O at module scope or an unhandled rejection, instead of completing silently, with no output.
5 changes: 5 additions & 0 deletions .changeset/storage-without-cast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": patch
---

A DO's `ctx.storage` can now be passed to a Workspace without casting.
5 changes: 5 additions & 0 deletions .changeset/workspace-error-path-once.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": patch
---

Workspace filesystem errors no longer repeat the path.
29 changes: 26 additions & 3 deletions docs/17_isolate_javascript.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ const workspace = new Workspace({
});
```

`root`, `/workspace` by default, confines every path isolate code touches. A new Workspace doesn't have that directory yet, so a read-write backend creates it the first time it runs.

Execute a module through the common runtime entry point:

```ts
Expand All @@ -49,7 +51,23 @@ const result = await handle.result();
// result.value = { value: 42, persisted: "42" }
```

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.
The source is a real ES module, with static imports and literal dynamic imports. The module needs a default export, or the run fails before it starts. A default-exported function is called with `options.input`, and any other default value is the result. To run code that's already in a file, re-export it with `export { default } from "./main.js"`.

Put the module's work in that function. Each run loads the module first, then calls its default export, and the Workers runtime doesn't allow I/O while a module loads. So `node:fs` and host module calls only work once the function is running:

```js
import fs from "node:fs/promises";

// Fails: this runs while the module loads.
const early = await fs.readFile("/workspace/a.txt", "utf8");

export default async function () {
// Works: this runs when Workspace calls the function.
return fs.readFile("/workspace/a.txt", "utf8");
}
```

A call made while the module loads fails the run with an error that names the call, even if the code catches the error, since the work it asked for never happened. The run also fails if a promise rejects and nothing has handled it by the time the function finishes. Top-level `await` is fine for anything that doesn't do I/O.

The returned value becomes the result's `value` and must be JSON-compatible plain data. As with `JSON.stringify`, an `undefined` object field is left out, so `{ kept: 1, dropped: undefined }` completes as `{ kept: 1 }`, and returning `undefined` gives `null`. A function, a class instance such as a `Date`, an `undefined` array item, or a cycle fails the run. `options.input` is checked the same way.

Expand Down Expand Up @@ -123,11 +141,12 @@ const handle = await workspace.runtime.exec(

## Modules

Caller source can import three kinds of module, and all of them are fixed when the backend is constructed:
Caller source can import four kinds of module, and all of them are fixed when the backend is constructed:

| Kind | Configured with | Runs in | Example |
| --- | --- | --- | --- |
| Built in | Always installed | The isolate, backed by the Workspace | `node:fs`, `node:fs/promises` |
| Node.js | `nodejs_compat` in `compatibilityFlags`, the default | The isolate, provided by the runtime | `node:path`, `node:crypto` |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we gonna add the node:sqlite shim to allow agents to cross communicate via DO storage?!

| Source | `modules: { name: "source" }` | The isolate | a bundled library |
| Host | `modules: { "ws:name": { fn } }`, or a factory | The Durable Object | `ws:git`, `ws:container`, your own |

Expand All @@ -150,14 +169,17 @@ new WorkerJavaScriptBackend({
});
```

An import that is not built in, configured, or a relative or absolute Workspace path fails before the Worker is created. Caller source and durable files cannot shadow a configured or built-in module.
An import that is not built in, configured, one of the allowed Node.js modules, or a relative or absolute Workspace path fails before the Worker is created. Caller source and durable files cannot shadow a configured or built-in module.

The allowed Node.js modules are the ones that work entirely inside the isolate: `node:path`, `node:url`, `node:util`, `node:events`, `node:buffer`, `node:assert`, `node:string_decoder`, `node:querystring`, `node:stream`, `node:crypto`, `node:zlib`, `node:timers`, `node:async_hooks`, and `node:diagnostics_channel`, with their subpaths such as `node:path/posix` and `node:timers/promises`. The runtime provides them, and imports of them are left as written. A bare name such as `path` works too and becomes `node:path`, unless a configured module has that name, in which case the configured module wins. The runtime has more Node.js modules, but they either duplicate what the Workspace provides, as `node:fs` does, reach outside the isolate, or are stubs that throw when called, so they stay unavailable.

Any import that is not a path is resolved by name. The Worker Loader has no `node_modules` lookup and resolves a bare import next to the importing file, so Workspace stores each source and host module once, in a `__modules__` directory of the Worker's bundle, and rewrites every import of one into a relative path to it. Every file that imports `lodash` gets the same instance, however many directories the code spans. An absolute import is rewritten the same way. Relative paths are the only form the Worker Loader's legacy and new module registries resolve alike, so imports work whether or not `compatibilityFlags` includes `new_module_registry`. When a module fails to link, the error names it as the code wrote it.

The backend describes its modules for a model in `backend.description`, which `workspace.runtime.backends()` returns and the `exec` tool shows. It is built from the same `modules` option the backend runs with, so it always matches what is installed:

```text
`command` is ECMAScript module source, run in an isolated JavaScript runtime. Relative imports resolve from `cwd` in the workspace.
Put the work in `export default async function (input) { ... }` and call `node:fs` and the other modules below inside it, since the module's top level can't do I/O. To run a file you've already written, re-export it: `export { default } from "./main.js"`.
Code has no direct network access.

Modules code can import:
Expand All @@ -166,6 +188,7 @@ Modules code can import:
- `ws:git`: The workspace's Git repository tools: `status({ dir })`, ...
- `ws:container`: Runs shell commands in a full Linux container that shares this workspace's files. ...
- `ws:weather`: exports `forecast`.
- Node.js built-ins: `node:path`, `node:url`, ... Bare names such as `path` work too.
```

A factory adds its own text through a `description` property, as the prebuilt modules do. An object of functions is listed by its export names; say more about it in the `exec` tool's backend description if the model needs it.
Expand Down
3 changes: 1 addition & 2 deletions examples/artifacts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
import { DurableObject } from "cloudflare:workers";

import {
type DurableObjectStorageLike,
getWorkspace,
sh,
type WorkspaceClient,
Expand Down Expand Up @@ -64,7 +63,7 @@ export class ArtifactCreator extends withWorkspace(class extends DurableObject<E
ctx,
};
return {
storage: ctx.storage as unknown as DurableObjectStorageLike,
storage: ctx.storage,
sessionId: ctx.id.toString(),
artifacts: { binding: env.ARTIFACTS },
backends: [new WorkerShellBackend(workerShellBackendOptions)],
Expand Down
7 changes: 2 additions & 5 deletions examples/assets/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
// won't produce a working link. Deploy it and hit the deployed URL.

import { DurableObject } from "cloudflare:workers";
import { type DurableObjectStorageLike, Workspace } from "@cloudflare/computer";
import { Workspace } from "@cloudflare/computer";
import { createAssets } from "@cloudflare/computer/assets";

// Black Forest Labs FLUX.2 [klein] 9B — a fast text-to-image model.
Expand All @@ -46,10 +46,7 @@ export class AssetWorkspace extends DurableObject<Env> {
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.#workspace = new Workspace({
// ctx.storage.sql.exec returns a narrower row type than
// DurableObjectStorageLike declares; the runtime shape
// matches. Cast through unknown to bypass invariance.
storage: ctx.storage as unknown as DurableObjectStorageLike,
storage: ctx.storage,
// No backend: this workspace only needs its filesystem. The
// shell half throws if touched, which we never do.
sessionId: ctx.id.toString(),
Expand Down
9 changes: 2 additions & 7 deletions examples/celld/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
import { AIChatAgent, type OnChatMessageOptions } from "@cloudflare/ai-chat";
import {
type DurableObjectStorageLike,
getWorkspace,
type WorkspaceRuntimeLoader,
withWorkspace,
} from "@cloudflare/computer";
import { getWorkspace, type WorkspaceRuntimeLoader, withWorkspace } from "@cloudflare/computer";
import { createAITools } from "@cloudflare/computer/tools/ai-sdk";
import { routeAgentRequest } from "agents";
import { convertToModelMessages, isStepCount, streamText } from "ai";
Expand Down Expand Up @@ -35,7 +30,7 @@ class CelldAgentBase extends AIChatAgent<CelldAgentEnv> {
export class CelldAgent extends withWorkspace(CelldAgentBase, (self) => {
const { ctx, env } = self as unknown as { ctx: DurableObjectState; env: CelldAgentEnv };
return {
storage: ctx.storage as unknown as DurableObjectStorageLike,
storage: ctx.storage,
backends: env.LOADER ? [new CelldJavaScriptBackend(env.LOADER)] : [],
};
}) {
Expand Down
6 changes: 1 addition & 5 deletions examples/container-legacy/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
import { DurableObject, tracing } from "cloudflare:workers";

import {
type DurableObjectStorageLike,
getWorkspace,
R2Bucket,
type WorkspaceOptions,
Expand Down Expand Up @@ -63,10 +62,7 @@ class ContainerBase extends withLegacyWorkspaceContainer(class extends DurableOb
function workspaceOptions(self: InstanceType<typeof ContainerBase>): WorkspaceOptions {
const { ctx, env } = self as unknown as { ctx: DurableObjectState; env: Env };
return {
// ctx.storage.sql.exec returns a narrower row type than
// DurableObjectStorageLike declares; the runtime shape
// matches. Cast through unknown to bypass invariance.
storage: ctx.storage as unknown as DurableObjectStorageLike,
storage: ctx.storage,
backends: [self.backend],
// Mount the Bucket binding at /workspace/r2. Seed it with
// `npm run seed:r2` (uploads ./seed/data/hello.txt) so the
Expand Down
6 changes: 1 addition & 5 deletions examples/container/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
import { DurableObject, tracing } from "cloudflare:workers";

import {
type DurableObjectStorageLike,
getWorkspace,
type WorkspaceOptions,
WorkspaceProxy,
Expand Down Expand Up @@ -79,10 +78,7 @@ class ContainerBase extends withWorkspaceContainer(class extends DurableObject<E
function workspaceOptions(self: InstanceType<typeof ContainerBase>): WorkspaceOptions {
const { ctx } = self as unknown as { ctx: DurableObjectState; env: Env };
return {
// ctx.storage.sql.exec returns a narrower row type than
// DurableObjectStorageLike declares; the runtime shape
// matches. Cast through unknown to bypass invariance.
storage: ctx.storage as unknown as DurableObjectStorageLike,
storage: ctx.storage,
backends: [self.backend],
// Route every workspace operation through the Cloudflare
// runtime's user-tracing surface. The runtime owns the span
Expand Down
3 changes: 1 addition & 2 deletions examples/egress/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { DurableObject, WorkerEntrypoint } from "cloudflare:workers";

import {
type DurableObjectStorageLike,
getWorkspace,
type WorkspaceClient,
type WorkspaceOptions,
Expand Down Expand Up @@ -62,7 +61,7 @@ function workspaceOptions(self: InstanceType<typeof EgressContainerBase>): Works
const workspace = { binding: "EgressExample", id: ctx.id.toString() };

return {
storage: ctx.storage as unknown as DurableObjectStorageLike,
storage: ctx.storage,
backends: [
self.containerBackend,
new WorkerShellBackend({
Expand Down
3 changes: 1 addition & 2 deletions examples/mcp/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { DurableObject } from "cloudflare:workers";
import {
type DurableObjectStorageLike,
getWorkspace,
type WorkspaceOptions,
WorkspaceProxy,
Expand Down Expand Up @@ -47,7 +46,7 @@ class ComputerMCPBase extends withWorkspaceContainer(ComputerMCPDurableObject) {
function workspaceOptions(self: InstanceType<typeof ComputerMCPBase>): WorkspaceOptions {
const { ctx } = self as unknown as { ctx: DurableObjectState };
return {
storage: ctx.storage as unknown as DurableObjectStorageLike,
storage: ctx.storage,
sessionId: ctx.id.toString(),
git: createGitClient(),
backends: [self.workerShell, self.containerShell],
Expand Down
9 changes: 2 additions & 7 deletions examples/pi-ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,7 @@

import { DurableObject } from "cloudflare:workers";

import {
type DurableObjectStorageLike,
Workspace,
WorkspaceServiceProxy,
type WorkspaceStub,
} from "@cloudflare/computer";
import { Workspace, WorkspaceServiceProxy, type WorkspaceStub } from "@cloudflare/computer";
import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell";
import { createPiTools } from "@cloudflare/computer/tools/pi-ai";
import { createModels, type Message } from "@earendil-works/pi-ai";
Expand All @@ -30,7 +25,7 @@ const MAX_TURNS = 10;

export class PiAgent extends DurableObject<Env> {
workspace = new Workspace({
storage: this.ctx.storage as unknown as DurableObjectStorageLike,
storage: this.ctx.storage,
backends: [
new WorkerShellBackend({
id: "shell",
Expand Down
8 changes: 2 additions & 6 deletions examples/rlm/worker/executor-agent.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import { AIChatAgent, type OnChatMessageOptions } from "@cloudflare/ai-chat";
import {
type DurableObjectStorageLike,
Workspace,
type WorkspaceRuntimeLoader,
} from "@cloudflare/computer";
import { Workspace, type WorkspaceRuntimeLoader } from "@cloudflare/computer";
import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-javascript";
import type { Connection } from "agents";
import { isStepCount, streamText, type ToolSet } from "ai";
Expand Down Expand Up @@ -58,7 +54,7 @@ export class ExecutorAgent extends AIChatAgent<ModelEnv, BenchmarkAgentState> {
maxTimeoutMs: 360_000,
});
this.#workspace = new Workspace({
storage: ctx.storage as unknown as DurableObjectStorageLike,
storage: ctx.storage,
backends: [backend],
});
this.#tools = createExecutorTool(this.#workspace, EXECUTOR_BACKEND);
Expand Down
8 changes: 2 additions & 6 deletions examples/rlm/worker/rlm-agent.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import { AIChatAgent, type OnChatMessageOptions } from "@cloudflare/ai-chat";
import {
type DurableObjectStorageLike,
Workspace,
type WorkspaceRuntimeLoader,
} from "@cloudflare/computer";
import { Workspace, type WorkspaceRuntimeLoader } from "@cloudflare/computer";
import { WorkerJavaScriptBackend } from "@cloudflare/computer/backends/worker-javascript";
import type { Connection } from "agents";
import { isStepCount, streamText, type ToolSet } from "ai";
Expand Down Expand Up @@ -121,7 +117,7 @@ export class RlmAgent extends AIChatAgent<ModelEnv, BenchmarkAgentState> {
maxTimeoutMs: 360_000,
});
this.#workspace = new Workspace({
storage: ctx.storage as unknown as DurableObjectStorageLike,
storage: ctx.storage,
backends: [backend],
});
this.#tools = createExecutorTool(this.#workspace, RLM_BACKEND);
Expand Down
9 changes: 2 additions & 7 deletions examples/tanstack-ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,7 @@

import { DurableObject } from "cloudflare:workers";

import {
type DurableObjectStorageLike,
Workspace,
WorkspaceServiceProxy,
type WorkspaceStub,
} from "@cloudflare/computer";
import { Workspace, WorkspaceServiceProxy, type WorkspaceStub } from "@cloudflare/computer";
import { WorkerShellBackend } from "@cloudflare/computer/backends/worker-shell";
import { createTanStackTools } from "@cloudflare/computer/tools/tanstack-ai";
import { chat, maxIterations, streamToText } from "@tanstack/ai";
Expand All @@ -25,7 +20,7 @@ const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast";

export class TanStackAgent extends DurableObject<Env> {
workspace = new Workspace({
storage: this.ctx.storage as unknown as DurableObjectStorageLike,
storage: this.ctx.storage,
backends: [
new WorkerShellBackend({
id: "shell",
Expand Down
3 changes: 1 addition & 2 deletions examples/think-compare-runtimes/worker/think/agents.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
type DurableObjectStorageLike,
Workspace,
WorkspaceProxy,
WorkspaceServiceProxy,
Expand Down Expand Up @@ -320,7 +319,7 @@ export class WorkspaceThinkAgent extends RuntimeThinkAgent {
containerEnv: this.env.FUSE_MOUNT ? { FUSE_MOUNT: this.env.FUSE_MOUNT } : undefined,
});
const workspace = new Workspace({
storage: this.#ctx.storage as unknown as DurableObjectStorageLike,
storage: this.#ctx.storage,
backends: [
new WorkerShellBackend({
id: "shell",
Expand Down
3 changes: 1 addition & 2 deletions examples/think/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
*/

import {
type DurableObjectStorageLike,
type ThinkWorkspaceCompatibility,
Workspace,
WorkspaceProxy,
Expand Down Expand Up @@ -90,7 +89,7 @@ export class Assistant extends withWorkspaceContainer(AssistantBase) {
* the Cloudflare Container.
*/
override workspace = new Workspace({
storage: this.ctx.storage as unknown as DurableObjectStorageLike,
storage: this.ctx.storage,
backends: [
new WorkerShellBackend({
id: "shell",
Expand Down
3 changes: 1 addition & 2 deletions examples/tutorial/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,6 @@ import {
} from "@cloudflare/computer/backends/container-legacy";
import { Think } from "@cloudflare/think";
import {
type DurableObjectStorageLike,
type ThinkWorkspaceCompatibility,
Workspace,
} from "@cloudflare/computer";
Expand All @@ -158,7 +157,7 @@ export class RecipeAgent extends withLegacyWorkspaceContainer(RecipeBase) {
});

override workspace = new Workspace({
storage: this.ctx.storage as unknown as DurableObjectStorageLike,
storage: this.ctx.storage,
backends: [this.#backend],
useThink: true,
}) as Workspace & ThinkWorkspaceCompatibility;
Expand Down
Loading
Loading