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/computer-bounded-filesystem.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/computer": minor
---

Expose bounded workspace byte reads through RPC and return paginated directory listings with file metadata.
8 changes: 8 additions & 0 deletions packages/computer/src/stub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,14 @@ describe("WorkspaceStub", () => {
});
});

it("fs.readRange forwards bounded byte reads", async () => {
await withStub(async (ws) => {
const stub = ws.stub();
await stub.fs.writeFile("/bin", new Uint8Array([1, 2, 3, 4, 5]));
expect(Array.from(await stub.fs.readRange("/bin", 1, 3))).toEqual([2, 3, 4]);
});
});

it("fs.readdir forwards bounded-read options", async () => {
await withStub(async (ws) => {
const stub = ws.stub();
Expand Down
9 changes: 9 additions & 0 deletions packages/computer/src/stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ export class WorkspaceFilesystemStub extends RpcTarget {
);
}

readRange(path: string, offset: number, length: number): Promise<Uint8Array> {
return withSpan(
this.#ws.observer,
"workspace.fs.readRange",
{ "workspace.fs.path": path, "workspace.fs.offset": offset, "workspace.fs.length": length },
() => this.#ws.fs.readRange(path, offset, length),
);
}

exists(path: string): Promise<boolean> {
return withSpan(
this.#ws.observer,
Expand Down
112 changes: 91 additions & 21 deletions packages/computer/src/tools/ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,33 +179,63 @@ function memoryStore(options: {
}

describe("WorkspaceFileStore", () => {
it("slices byte ranges while reading chunks from Workspace.fs", async () => {
const workspace = makeWorkspace();
await workspace.fs.mkdir("/workspace", { recursive: true });
await workspace.fs.writeFile("/workspace/range.txt", bytes("abcdefghij"));
it("uses readRange without streaming skipped bytes from Workspace.fs", async () => {
const calls: Array<{ offset: number; length: number }> = [];
const content = bytes("abcdefghij");
const workspace = {
fs: {
async stat() {
return {
size: content.length,
mtime: 1,
mode: 0o100644,
isFile: true,
isDirectory: false,
};
},
async readRange(_path: string, offset: number, length: number) {
calls.push({ offset, length });
return content.slice(offset, offset + length);
},
async readFile(): Promise<ReadableStream<Uint8Array>> {
throw new Error("readFile must not be called for ranged reads");
},
async writeFile() {},
async mkdir() {},
async rm() {},
async readdir() {
return [];
},
},
};
const store = new WorkspaceFileStore(workspace);

await expect(
drainChunks(store.readChunks("/workspace/range.txt", 2, 5)).then(decode),
).resolves.toBe("cdefg");
expect(calls).toEqual([{ offset: 2, length: 5 }]);
});

it("cancels read streams when a byte range stops before EOF", async () => {
let cancelled = false;
it("splits unbounded reads into fixed-size ranges", async () => {
const calls: Array<{ offset: number; length: number }> = [];
const content = new Uint8Array(150_000).fill(7);
const workspace = {
fs: {
async stat() {
return { size: 10, mtime: 1, mode: 0o100644, isFile: true, isDirectory: false };
return {
size: content.length,
mtime: 1,
mode: 0o100644,
isFile: true,
isDirectory: false,
};
},
async readFile() {
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes("abcdefghij"));
},
cancel() {
cancelled = true;
},
});
async readRange(_path: string, offset: number, length: number) {
calls.push({ offset, length });
return content.slice(offset, offset + length);
},
async readFile(): Promise<ReadableStream<Uint8Array>> {
throw new Error("readFile must not be called for ranged reads");
},
async writeFile() {},
async mkdir() {},
Expand All @@ -217,10 +247,14 @@ describe("WorkspaceFileStore", () => {
};
const store = new WorkspaceFileStore(workspace);

await expect(
drainChunks(store.readChunks("/workspace/range.txt", 2, 5)).then(decode),
).resolves.toBe("cdefg");
expect(cancelled).toBe(true);
await expect(drainChunks(store.readChunks("/workspace/large.bin"))).resolves.toHaveLength(
content.length,
);
expect(calls).toEqual([
{ offset: 0, length: 65_536 },
{ offset: 65_536, length: 65_536 },
{ offset: 131_072, length: 18_928 },
]);
});
});

Expand Down Expand Up @@ -255,7 +289,17 @@ describe("createAITools filesystem tools", () => {
);
await expect(executeTool(tools.ls, { path: "/workspace/notes" })).resolves.toEqual({
path: "/workspace/notes",
entries: [{ name: "todo.txt", isFile: true, isDirectory: false }],
count: 1,
entries: [
{
name: "todo.txt",
size: 8,
mtime: 1_700_000_000_000,
isFile: true,
isDirectory: false,
isSymbolicLink: false,
},
],
});
await expect(
executeTool(tools.read, { path: "/workspace/notes/todo.txt", limit: 1 }),
Expand All @@ -279,6 +323,32 @@ describe("createAITools filesystem tools", () => {
);
});

it("paginates ls results and reports a continuation offset", async () => {
const workspace = makeWorkspace();
await workspace.fs.mkdir("/workspace", { recursive: true });
for (const name of ["a", "b", "c"]) {
await workspace.fs.writeFile(`/workspace/${name}`, name);
}
const tools = createAITools({ workspace });

await expect(
executeTool(tools.ls, { path: "/workspace", limit: 2, offset: 0 }),
).resolves.toMatchObject({
count: 2,
entries: [
{ name: "a", size: 1 },
{ name: "b", size: 1 },
],
nextOffset: 2,
});
await expect(
executeTool(tools.ls, { path: "/workspace", limit: 2, offset: 2 }),
).resolves.toMatchObject({
count: 1,
entries: [{ name: "c", size: 1 }],
});
});

it("preserves file mode when write overwrites an existing file", async () => {
const writes: Array<{ path: string; content: string; mode?: number }> = [];
const tool = createWriteTool({
Expand Down
61 changes: 51 additions & 10 deletions packages/computer/src/tools/fs/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,75 @@ import { z } from "zod";

export interface ListWorkspaceLike {
fs: {
readdir(path: string): Promise<Array<{ name: string; isFile: boolean; isDirectory: boolean }>>;
readdir(
path: string,
options?: { limit?: number; offset?: number },
): Promise<
Array<{
name: string;
size: number;
mtime: number;
isFile: boolean;
isDirectory: boolean;
isSymbolicLink: boolean;
}>
>;
};
}

export interface ListToolOptions {
workspace: ListWorkspaceLike;
}

const DEFAULT_LIMIT = 200;
const MAX_LIMIT = 1000;

const inputSchema = z.object({
path: z.string().describe("Absolute directory path to list, e.g. /workspace/src."),
limit: z
.number()
.int()
.min(1)
.max(MAX_LIMIT)
.optional()
.describe(`Maximum entries to return. Defaults to ${DEFAULT_LIMIT}.`),
offset: z.number().int().min(0).optional().describe("Number of entries to skip in name order."),
});

export function createListTool(options: ListToolOptions): Tool<z.infer<typeof inputSchema>> {
return tool({
description:
"List entries in a workspace directory. Returns each entry name and whether it is a file or directory.",
"List entries in a workspace directory with file sizes and modification times. Use limit and offset to page through large directories.",
inputSchema,
execute: async ({ path }) => {
execute: async ({ path, limit, offset }) => {
try {
const entries = await options.workspace.fs.readdir(path);
return {
const pageSize = limit ?? DEFAULT_LIMIT;
const pageOffset = offset ?? 0;
const entries = await options.workspace.fs.readdir(path, {
limit: pageSize + 1,
offset: pageOffset,
});
const truncated = entries.length > pageSize;
const page = (truncated ? entries.slice(0, pageSize) : entries).map((entry) => ({
name: entry.name,
size: entry.size,
mtime: entry.mtime,
isFile: entry.isFile,
isDirectory: entry.isDirectory,
isSymbolicLink: entry.isSymbolicLink,
}));
const result: {
path: string;
count: number;
entries: typeof page;
nextOffset?: number;
} = {
path,
entries: entries.map((entry) => ({
name: entry.name,
isFile: entry.isFile,
isDirectory: entry.isDirectory,
})),
count: page.length,
entries: page,
};
if (truncated) result.nextOffset = pageOffset + pageSize;
return result;
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
Expand Down
85 changes: 35 additions & 50 deletions packages/computer/src/tools/fs/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@
* class. This adapter is the bridge from that contract to the public
* `workspace.fs` surface.
*
* Reads go through `fs.readFile(path)` as a `ReadableStream<Uint8Array>`
* and are stitched together either chunk-by-chunk (`readChunks`) or all
* at once (`readAll`).
* Bounded reads go through `fs.readRange`; whole-file reads used by edit
* and multimodal output still drain `fs.readFile(path)`.
*/

import type { FileStat, FileStore } from "./types.js";

const RANGE_CHUNK_BYTES = 64 * 1024;

/**
* Structural subset of `@cloudflare/computer.Workspace` the tools
* depend on.
Expand All @@ -27,10 +28,23 @@ export interface WorkspaceLike {
isDirectory: boolean;
}>;
readFile(path: string): Promise<ReadableStream<Uint8Array>>;
readRange(path: string, offset: number, length: number): Promise<Uint8Array>;
writeFile(path: string, content: Uint8Array, options?: { mode?: number }): Promise<void>;
mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
readdir(path: string): Promise<Array<{ name: string; isFile: boolean; isDirectory: boolean }>>;
readdir(
path: string,
options?: { limit?: number; offset?: number },
): Promise<
Array<{
name: string;
size: number;
mtime: number;
isFile: boolean;
isDirectory: boolean;
isSymbolicLink: boolean;
}>
>;
};
}

Expand Down Expand Up @@ -64,55 +78,26 @@ export class WorkspaceFileStore implements FileStore {
}

async *readChunks(path: string, byteOffset = 0, byteLength?: number): AsyncIterable<Uint8Array> {
if (byteOffset < 0) throw new Error("readChunks: byteOffset must be non-negative");
if (byteLength !== undefined && byteLength < 0) {
throw new Error("readChunks: byteLength must be non-negative");
if (!Number.isSafeInteger(byteOffset) || byteOffset < 0) {
throw new Error("readChunks: byteOffset must be a non-negative safe integer");
}
if (byteLength !== undefined && (!Number.isSafeInteger(byteLength) || byteLength < 0)) {
throw new Error("readChunks: byteLength must be a non-negative safe integer");
}
if (byteLength === 0) return;

const stream = await this.ws.fs.readFile(path);
const reader = stream.getReader();
let skipped = 0;
let yielded = 0;
let completed = false;
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
completed = true;
break;
}
if (!value || value.byteLength === 0) continue;

let start = 0;
if (skipped < byteOffset) {
const needed = byteOffset - skipped;
if (value.byteLength <= needed) {
skipped += value.byteLength;
continue;
}
start = needed;
skipped = byteOffset;
}

let end = value.byteLength;
if (byteLength !== undefined) {
const remaining = byteLength - yielded;
if (remaining <= 0) break;
end = Math.min(end, start + remaining);
}

if (end > start) {
const chunk = value.slice(start, end);
yielded += chunk.byteLength;
yield chunk;
}

if (byteLength !== undefined && yielded >= byteLength) break;
}
} finally {
if (!completed) await reader.cancel();
reader.releaseLock();
const stat = await this.ws.fs.stat(path);
let remaining = Math.max(0, stat.size - byteOffset);
if (byteLength !== undefined) remaining = Math.min(remaining, byteLength);
let offset = byteOffset;
while (remaining > 0) {
const requested = Math.min(remaining, RANGE_CHUNK_BYTES);
const chunk = await this.ws.fs.readRange(path, offset, requested);
if (chunk.byteLength === 0) return;
yield chunk;
offset += chunk.byteLength;
remaining -= chunk.byteLength;
if (chunk.byteLength < requested) return;
}
}
}
Expand Down
Loading