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: 3 additions & 2 deletions docs/01_vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@ facade — sandbox wiring lives behind a `WorkspaceBackend`.
Set `useThink: true` when assigning the Workspace to
`Think.workspace`. This adds Think's string-oriented filesystem
compatibility methods (`readFile`, `readFileBytes`, `writeFile`,
`readDir`, `rm`, `glob`, `mkdir`, and `stat`) directly to that
Workspace instance while leaving the primary API on `workspace.fs`.
`readDir`, `rm`, `glob`, `mkdir`, and `stat`) to that Workspace and to
clients returned by `getWorkspace()`, while leaving the primary API on
`workspace.fs`.

Illustrative layout (nothing below `/` is auto-created beyond
`ROOT_INODE` itself):
Expand Down
7 changes: 4 additions & 3 deletions packages/computer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,10 @@ const body = await ws.fs.readFile("/notes.md", "utf8");
// ws.shell throws — there's no backend wired up.
```

Pass `useThink: true` when assigning the same instance to
`Think.workspace`. That adds Think's compatibility methods directly
on the instance while keeping the primary file API on `workspace.fs`.
Pass `useThink: true` when assigning a workspace to `Think.workspace`.
That adds Think's compatibility methods directly to the local instance
and to clients returned by `getWorkspace()`, while keeping the primary
file API on `workspace.fs`.

Git, also without a backend:

Expand Down
64 changes: 62 additions & 2 deletions packages/computer/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
import { SQLiteTestStorage } from "@cloudflare/dofs/testing";
import { describe, expect, it } from "vitest";

import { getWorkspace } from "./client.js";
import { getWorkspace, type WorkspaceClient } from "./client.js";
import { WORKSPACE, type WorkspaceStubHost } from "./with-workspace.js";
import { Workspace } from "./workspace.js";
import { type ThinkWorkspaceCompatibility, Workspace } from "./workspace.js";

interface ExecCall {
command: string;
Expand Down Expand Up @@ -75,6 +75,7 @@ function fakeRemote(): {
let disposed = false;
const stub = {
fs: { marker: "fs" },
useThink: false,
git: { marker: "git" },
assets: undefined,
artifacts: { marker: "artifacts" },
Expand All @@ -91,6 +92,25 @@ function fakeRemote(): {
};
}

function fakeBrokenRemote(): {
host: WorkspaceStubHost;
disposed: () => boolean;
} {
let disposed = false;
const stub = {
get useThink(): boolean {
throw new Error("compatibility lookup failed");
},
[Symbol.dispose]() {
disposed = true;
},
};
return {
disposed: () => disposed,
host: { __getWorkspaceStub: () => Promise.resolve(stub as never) },
};
}

// Local path fake: an object carrying a real Workspace under the
// stash symbol, but with its shell swapped for a recording fake so we
// can assert on exec without a backend.
Expand All @@ -108,6 +128,7 @@ describe("getWorkspace — remote dispatch", () => {
expect(ws.fs).toEqual({ marker: "fs" });
expect(ws.git).toEqual({ marker: "git" });
expect(ws.artifacts).toEqual({ marker: "artifacts" });
expect(ws).not.toHaveProperty("readFile");
});

it("disposing the client disposes the remote stub", async () => {
Expand All @@ -116,6 +137,30 @@ describe("getWorkspace — remote dispatch", () => {
ws[Symbol.dispose]();
expect(disposed()).toBe(true);
});

it("disposes the remote stub when client initialization fails", async () => {
const { host, disposed } = fakeBrokenRemote();

await expect(getWorkspace(host)).rejects.toThrow("compatibility lookup failed");
expect(disposed()).toBe(true);
});

it("adds Think compatibility when the remote Workspace enables it", async () => {
const workspace = new Workspace({
storage: new SQLiteTestStorage(),
useThink: true,
});
await workspace.fs.writeFile("/notes.txt", "hello");
const host: WorkspaceStubHost = {
__getWorkspaceStub: () => Promise.resolve(workspace.stub()),
};

const client = (await getWorkspace(host)) as WorkspaceClient & ThinkWorkspaceCompatibility;

expect(client).toHaveProperty("readFile");
await expect(client.readFile("/notes.txt")).resolves.toBe("hello");
await expect(client.readFile("/missing.txt")).resolves.toBeNull();
});
});

describe("getWorkspace — local dispatch", () => {
Expand All @@ -124,13 +169,28 @@ describe("getWorkspace — local dispatch", () => {
const ws = await getWorkspace(host);
await ws.shell.exec`cat ${"my file.txt"}`;
expect(calls[0].command).toBe("cat 'my file.txt'");
expect(ws).not.toHaveProperty("readFile");
});

it("does not throw on dispose (the durable object owns the lifecycle)", async () => {
const { host } = fakeLocal();
const ws = await getWorkspace(host);
expect(() => ws[Symbol.dispose]()).not.toThrow();
});

it("adds Think compatibility when the local Workspace enables it", async () => {
const workspace = new Workspace({
storage: new SQLiteTestStorage(),
useThink: true,
});
const host = { [WORKSPACE]: workspace };
const { shell } = fakeShell();
Object.defineProperty(workspace, "shell", { get: () => shell });

const client = (await getWorkspace(host)) as WorkspaceClient & ThinkWorkspaceCompatibility;

expect(client).toHaveProperty("readFile");
});
});

describe("client shell.exec — tagged template form", () => {
Expand Down
44 changes: 29 additions & 15 deletions packages/computer/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,17 @@
// a `TemplateStringsArray`'s `.raw` does not survive structured clone
// over RPC.

import type { WorkspaceFilesystem } from "@cloudflare/dofs";

import { decodeExecEvents } from "./exec-wire.js";
import { type ShellValue, sh } from "./sh.js";
import type { ExecEncoding, WorkspaceExecEvent } from "./shell.js";
import { WORKSPACE, type WorkspaceStubHost } from "./with-workspace.js";
import { Workspace } from "./workspace.js";
import {
createThinkCompatibility,
type ThinkWorkspaceCompatibility,
Workspace,
} from "./workspace.js";

// The remote shell handle stub: a result / stream / kill surface
// carried across Workers RPC.
Expand Down Expand Up @@ -198,12 +204,10 @@ function makeShellClient(

// The canonical client surface. `shell.exec` is the adapted member;
// `fs`, `git`, `artifacts`, and `assets` are the underlying surface's
// members, passed through. Their concrete types differ between the
// local Workspace and the remote stub, so they're surfaced loosely
// here and narrowed by the caller when needed.
export interface WorkspaceClient {
// biome-ignore lint/suspicious/noExplicitAny: fs type differs local vs remote
readonly fs: any;
// members, passed through. The filesystem stub mirrors the local
// filesystem, so it also serves as the common client type.
export interface WorkspaceClient extends Partial<ThinkWorkspaceCompatibility> {
readonly fs: WorkspaceFilesystem;
// biome-ignore lint/suspicious/noExplicitAny: handle types differ per path
readonly shell: WorkspaceShellClient<any, any, any>;
// biome-ignore lint/suspicious/noExplicitAny: git type differs local vs remote
Expand All @@ -220,9 +224,10 @@ function makeClient(
surface: any,
rehydrate: (handle: unknown) => unknown,
dispose: () => void,
useThink: boolean,
): WorkspaceClient {
const shell = makeShellClient(surface.shell as UnderlyingShell, rehydrate);
return {
const client: WorkspaceClient = {
get fs() {
return surface.fs;
},
Expand All @@ -238,6 +243,8 @@ function makeClient(
},
[Symbol.dispose]: dispose,
};
if (useThink) Object.assign(client, createThinkCompatibility(client.fs));
return client;
}

// What `getWorkspace` accepts: a local host carrying the symbol stash
Expand All @@ -263,17 +270,24 @@ export async function getWorkspace(handle: WorkspaceHandle): Promise<WorkspaceCl
// Local handle is already a host ExecHandle — pass it through.
(h) => h,
() => {},
local.useThink,
);
}
// Remote path: fetch the stub over RPC and delegate to it. Handle
// stubs need inflating from their JSONL stream into a host-shaped
// ExecHandle.
const stub = await (handle as WorkspaceStubHost).__getWorkspaceStub();
return makeClient(
stub,
(h) => rebuildExecHandle(h as RemoteExecHandle),
() => {
(stub as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.();
},
);
try {
return makeClient(
stub,
(h) => rebuildExecHandle(h as RemoteExecHandle),
() => {
(stub as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.();
},
await stub.useThink,
);
} catch (error) {
(stub as { [Symbol.dispose]?: () => void })[Symbol.dispose]?.();
throw error;
}
}
6 changes: 6 additions & 0 deletions packages/computer/src/stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,7 @@ export class WorkspaceStub extends RpcTarget {
readonly #git: WorkspaceGitStub;
readonly #assets: WorkspaceAssetsStub | undefined;
readonly #artifacts: WorkspaceArtifactsStub;
readonly #useThink: boolean;

constructor(ws: Workspace) {
super();
Expand All @@ -686,6 +687,7 @@ export class WorkspaceStub extends RpcTarget {
this.#git = new WorkspaceGitStub(ws);
this.#assets = ws.assets === undefined ? undefined : new WorkspaceAssetsStub(ws);
this.#artifacts = new WorkspaceArtifactsStub(ws.artifacts);
this.#useThink = ws.useThink;
trackStub(this);
}

Expand All @@ -708,6 +710,10 @@ export class WorkspaceStub extends RpcTarget {
return this.#fs;
}

get useThink(): boolean {
return this.#useThink;
}

get shell(): WorkspaceShellStub {
return this.#shell;
}
Expand Down
35 changes: 24 additions & 11 deletions packages/computer/src/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,11 @@ export interface ThinkWorkspaceCompatibility {
stat(path: string): Promise<ThinkFileInfo | null>;
}

export type ThinkWorkspaceFilesystem = Pick<
WorkspaceFilesystem,
"find" | "mkdir" | "readFile" | "readdir" | "rm" | "stat" | "writeFile"
>;

export class Workspace {
readonly #db: Database;
readonly #fs: WorkspaceFilesystem;
Expand All @@ -184,6 +189,7 @@ export class Workspace {
readonly #retryMaxAttempts: number;
readonly #sessionId: string;
readonly #defaultGitIdentity: GitIdentity | undefined;
readonly #useThink: boolean;
readonly #assets: AssetsClient | undefined;
readonly #artifacts: ArtifactClient;
// Lazily-constructed git client, cached so the dynamic
Expand Down Expand Up @@ -240,6 +246,7 @@ export class Workspace {
);
this.#sessionId = options.sessionId ?? "";
this.#defaultGitIdentity = options.defaultGitIdentity;
this.#useThink = options.useThink ?? false;
this.#artifacts = options.artifacts
? createArtifact(
options.artifacts.binding,
Expand Down Expand Up @@ -278,8 +285,8 @@ export class Workspace {
mounts: this.#mounts,
});
this.#assets = typeof options.assets === "function" ? options.assets(this) : options.assets;
if (options.useThink) {
const think = createThinkCompatibility(this);
if (this.#useThink) {
const think = createThinkCompatibility(this.fs);
Object.assign(this, think);
}
}
Expand Down Expand Up @@ -326,6 +333,10 @@ export class Workspace {
return this.#fs;
}

get useThink(): boolean {
return this.#useThink;
}

// Identifier for this workspace / session, as passed to the
// constructor. Empty string when the caller did not supply one.
// Forwarded to mount factories and used by the assets module to
Expand Down Expand Up @@ -945,29 +956,31 @@ class WorkspaceShellRouter {
}
}

function createThinkCompatibility(ws: Workspace): ThinkWorkspaceCompatibility {
export function createThinkCompatibility(
fs: ThinkWorkspaceFilesystem,
): ThinkWorkspaceCompatibility {
return {
async readFile(path) {
try {
return await ws.fs.readFile(path, "utf8");
return await fs.readFile(path, "utf8");
} catch (err) {
if (isEnoent(err)) return null;
throw err;
}
},
async readFileBytes(path) {
try {
return await drainBytes(await ws.fs.readFile(path));
return await drainBytes(await fs.readFile(path));
} catch (err) {
if (isEnoent(err)) return null;
throw err;
}
},
async writeFile(path, content) {
await ws.fs.writeFile(path, content);
await fs.writeFile(path, content);
},
async readDir(dir, opts) {
const entries = await ws.fs.readdir(dir);
const entries = await fs.readdir(dir);
const offset = opts?.offset ?? 0;
const limit = opts?.limit ?? entries.length;
return entries.slice(offset, offset + limit).map((entry) =>
Expand All @@ -982,11 +995,11 @@ function createThinkCompatibility(ws: Workspace): ThinkWorkspaceCompatibility {
);
},
async rm(path, opts) {
await ws.fs.rm(path, opts);
await fs.rm(path, opts);
},
async glob(pattern) {
const { directory, relativePattern } = splitGlobPattern(pattern);
const matches = await ws.fs.find(directory, relativePattern);
const matches = await fs.find(directory, relativePattern);
return matches.map((match) =>
toThinkFileInfo({
path: match.path,
Expand All @@ -999,11 +1012,11 @@ function createThinkCompatibility(ws: Workspace): ThinkWorkspaceCompatibility {
);
},
async mkdir(path, opts) {
await ws.fs.mkdir(path, opts);
await fs.mkdir(path, opts);
},
async stat(path) {
try {
const stat = await ws.fs.stat(path);
const stat = await fs.stat(path);
return toThinkFileInfo({ ...stat, path, name: basename(path) });
} catch (err) {
if (isEnoent(err)) return null;
Expand Down
Loading