From d65a15f2aeff7d19193bba2e7667257d2eda39a8 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:27:03 +0000 Subject: [PATCH 01/14] computer: Serialize file mutations Share a store-scoped, normalized-path lock between edit and write so read-modify-write cycles cannot clobber concurrent writes or block unrelated workspaces. --- packages/computer/src/tools/ai.test.ts | 90 +++++++++++++++++++++++++ packages/computer/src/tools/fs/edit.ts | 22 +----- packages/computer/src/tools/fs/locks.ts | 47 +++++++++++++ packages/computer/src/tools/fs/write.ts | 23 ++++--- 4 files changed, 152 insertions(+), 30 deletions(-) create mode 100644 packages/computer/src/tools/fs/locks.ts diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index e58c1054..3132d42b 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -391,6 +391,96 @@ describe("createAITools filesystem tools", () => { }); }); + it("serializes write behind an edit on the same store and path", async () => { + let releaseRead: (() => void) | undefined; + let markReadStarted: (() => void) | undefined; + const readGate = new Promise((resolve) => { + releaseRead = resolve; + }); + const readStarted = new Promise((resolve) => { + markReadStarted = resolve; + }); + const writes: string[] = []; + const store: FileStore = { + async stat() { + return { size: 3, mtime: 1 }; + }, + async readAll() { + markReadStarted?.(); + await readGate; + return bytes("old"); + }, + async *readChunks() { + yield bytes("old"); + }, + async write(_path, content) { + writes.push(decode(content)); + }, + }; + const edit = executeTool(createEditTool({ store }), { + path: "/workspace/file.txt", + edits: [{ oldText: "old", newText: "edited" }], + }); + await readStarted; + + const write = executeTool(createWriteTool({ store }), { + path: "/workspace/file.txt", + content: "written", + }); + await Promise.resolve(); + const writesBeforeEditFinished = [...writes]; + + releaseRead?.(); + await Promise.all([edit, write]); + expect(writesBeforeEditFinished).toEqual([]); + expect(writes).toEqual(["edited", "written"]); + }); + + it("does not share edit locks between stores", async () => { + let releaseRead: (() => void) | undefined; + let markFirstStarted: (() => void) | undefined; + let markSecondStarted: (() => void) | undefined; + const readGate = new Promise((resolve) => { + releaseRead = resolve; + }); + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); + const secondStarted = new Promise((resolve) => { + markSecondStarted = resolve; + }); + const first = memoryStore({ content: "old" }); + first.readAll = async () => { + markFirstStarted?.(); + await readGate; + return bytes("old"); + }; + const second = memoryStore({ content: "old" }); + second.readAll = async () => { + markSecondStarted?.(); + return bytes("old"); + }; + + const firstEdit = executeTool(createEditTool({ store: first }), { + path: "/workspace/file.txt", + edits: [{ oldText: "old", newText: "first" }], + }); + await firstStarted; + const secondEdit = executeTool(createEditTool({ store: second }), { + path: "/workspace/file.txt", + edits: [{ oldText: "old", newText: "second" }], + }); + + const secondAcquired = await Promise.race([ + secondStarted.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 0)), + ]); + + releaseRead?.(); + await Promise.all([firstEdit, secondEdit]); + expect(secondAcquired).toBe(true); + }); + it("preserves file mode when write overwrites an existing file", async () => { const writes: Array<{ path: string; content: string; mode?: number }> = []; const tool = createWriteTool({ diff --git a/packages/computer/src/tools/fs/edit.ts b/packages/computer/src/tools/fs/edit.ts index 3076c8de..ec338b9a 100644 --- a/packages/computer/src/tools/fs/edit.ts +++ b/packages/computer/src/tools/fs/edit.ts @@ -10,6 +10,7 @@ import { restoreLineEndings, stripBom, } from "./edit-diff.js"; +import { withFileLock } from "./locks.js"; import type { FileStore } from "./types.js"; export interface EditToolOptions { @@ -71,25 +72,6 @@ function prepareArguments(input: unknown): { path: string; edits: Edit[] } { return args as { path: string; edits: Edit[] }; } -// Per-path mutation queue. Edit and write should never race on the same file: -// fuzzy matching reads the entire buffer, applies a textual change, then -// writes — a concurrent edit landing between read and write would silently -// clobber the first edit. Module-scoped so all tools sharing a store also -// share the queue. -const fileLocks = new Map>(); -async function withFileLock(path: string, fn: () => Promise): Promise { - const prev = fileLocks.get(path) ?? Promise.resolve(); - const next = prev.then(fn, fn); - fileLocks.set( - path, - next.finally(() => { - // Clear only if we're still the tail of the chain. - if (fileLocks.get(path) === next) fileLocks.delete(path); - }), - ); - return next; -} - export function createEditTool(options: EditToolOptions): Tool> { const { store } = options; const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; @@ -105,7 +87,7 @@ export function createEditTool(options: EditToolOptions): Tool { + return withFileLock(store, path, async () => { try { const stat = await store.stat(path); if (!stat) return { error: `File not found: ${path}` }; diff --git a/packages/computer/src/tools/fs/locks.ts b/packages/computer/src/tools/fs/locks.ts new file mode 100644 index 00000000..f8f7b710 --- /dev/null +++ b/packages/computer/src/tools/fs/locks.ts @@ -0,0 +1,47 @@ +import type { FileStore } from "./types.js"; + +const storeLocks = new WeakMap>>(); + +export async function withFileLock( + store: FileStore, + path: string, + operation: () => Promise, +): Promise { + let paths = storeLocks.get(store); + if (paths === undefined) { + paths = new Map(); + storeLocks.set(store, paths); + } + + const key = normalizePath(path); + const previous = paths.get(key) ?? Promise.resolve(); + let release: (() => void) | undefined; + const current = new Promise((resolve) => { + release = resolve; + }); + paths.set(key, current); + + await previous; + try { + return await operation(); + } finally { + release?.(); + if (paths.get(key) === current) paths.delete(key); + if (paths.size === 0) storeLocks.delete(store); + } +} + +function normalizePath(path: string): string { + const absolute = path.startsWith("/"); + const parts: string[] = []; + for (const part of path.split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") { + parts.pop(); + } else { + parts.push(part); + } + } + const normalized = parts.join("/"); + return absolute ? `/${normalized}` : normalized; +} diff --git a/packages/computer/src/tools/fs/write.ts b/packages/computer/src/tools/fs/write.ts index d7c1132b..139b01f0 100644 --- a/packages/computer/src/tools/fs/write.ts +++ b/packages/computer/src/tools/fs/write.ts @@ -1,5 +1,6 @@ import { type Tool, tool } from "ai"; import { z } from "zod"; +import { withFileLock } from "./locks.js"; import type { FileStore } from "./types.js"; export interface WriteToolOptions { @@ -32,16 +33,18 @@ export function createWriteTool(options: WriteToolOptions): Tool { + try { + // Preserve the existing file's mode when overwriting so executable + // scripts don't silently lose its executable bits. For new files we + // let the store apply its own default. + const existing = await store.stat(path); + await store.write(path, bytes, existing ? { mode: existing.mode } : undefined); + return { path, bytesWritten: bytes.length }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + }); }, }); } From c650a0e0c3f5c7e7dff0223f3d5ebd622e79e730 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:30:46 +0000 Subject: [PATCH 02/14] computer: Add find grep and delete tools Expose the missing workspace tools, keep find and grep available in read-only mode, bound their result pages, and serialize delete with other mutations. --- packages/computer/src/tools/ai.test.ts | 92 ++++++++++++++- packages/computer/src/tools/ai.ts | 9 ++ packages/computer/src/tools/fs/delete.ts | 34 ++++++ packages/computer/src/tools/fs/find.ts | 58 +++++++++ packages/computer/src/tools/fs/grep.ts | 144 +++++++++++++++++++++++ packages/computer/src/tools/fs/store.ts | 38 +++++- packages/computer/src/tools/fs/types.ts | 5 + packages/computer/src/tools/index.ts | 5 +- 8 files changed, 377 insertions(+), 8 deletions(-) create mode 100644 packages/computer/src/tools/fs/delete.ts create mode 100644 packages/computer/src/tools/fs/find.ts create mode 100644 packages/computer/src/tools/fs/grep.ts diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 3132d42b..95646412 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -4,6 +4,7 @@ import type { WorkspaceRuntimeExecHandle, WorkspaceRuntimeResult } from "../runt import { Workspace } from "../workspace.js"; import { createAITools, + createDeleteTool, createEditTool, createReadTool, createWriteTool, @@ -301,10 +302,18 @@ describe("WorkspaceFileStore", () => { }); describe("createAITools filesystem tools", () => { - it("creates fixed read, write, edit, and ls tools by default", () => { + it("creates the complete filesystem tool set by default", () => { const tools = createAITools({ workspace: makeWorkspace() }); - expect(Object.keys(tools).sort()).toEqual(["edit", "ls", "read", "write"]); + expect(Object.keys(tools).sort()).toEqual([ + "delete", + "edit", + "find", + "grep", + "ls", + "read", + "write", + ]); }); it("returns only read-only tools when readonly is true", () => { @@ -317,7 +326,7 @@ describe("createAITools filesystem tools", () => { }, }); - expect(Object.keys(tools).sort()).toEqual(["ls", "read"]); + expect(Object.keys(tools).sort()).toEqual(["find", "grep", "ls", "read"]); }); it("reads, lists, writes, and edits workspace files", async () => { @@ -481,6 +490,83 @@ describe("createAITools filesystem tools", () => { expect(secondAcquired).toBe(true); }); + it("finds, greps, and deletes files through a real Workspace", async () => { + const workspace = makeWorkspace(); + const tools = createAITools({ workspace }); + await workspace.fs.mkdir("/workspace/src", { recursive: true }); + await workspace.fs.writeFile("/workspace/src/a.ts", "const value = 'TODO';\n"); + await workspace.fs.writeFile("/workspace/src/b.md", "todo in docs\n"); + + await expect( + executeTool(tools.find, { path: "/workspace", pattern: "**/*.ts", limit: 20 }), + ).resolves.toEqual({ + path: "/workspace", + pattern: "**/*.ts", + count: 1, + entries: [{ path: "/workspace/src/a.ts", type: "file" }], + }); + await expect( + executeTool(tools.grep, { + path: "/workspace", + query: "todo", + include: "**/*.ts", + limit: 20, + }), + ).resolves.toMatchObject({ + count: 1, + matches: [{ path: "/workspace/src/a.ts", line: 1, text: "const value = 'TODO';" }], + }); + await expect(executeTool(tools.delete, { path: "/workspace/src/a.ts" })).resolves.toEqual({ + deleted: "/workspace/src/a.ts", + }); + await expect(workspace.fs.stat("/workspace/src/a.ts")).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("serializes delete behind an edit on the same store and path", async () => { + let releaseRead: (() => void) | undefined; + let markReadStarted: (() => void) | undefined; + const readGate = new Promise((resolve) => { + releaseRead = resolve; + }); + const readStarted = new Promise((resolve) => { + markReadStarted = resolve; + }); + const events: string[] = []; + const store = memoryStore({ + content: "old", + onWrite() { + events.push("edit"); + }, + }); + store.readAll = async () => { + markReadStarted?.(); + await readGate; + return bytes("old"); + }; + const deleteStore = Object.assign(store, { + async remove() { + events.push("delete"); + }, + }); + const edit = executeTool(createEditTool({ store }), { + path: "/workspace/file.txt", + edits: [{ oldText: "old", newText: "edited" }], + }); + await readStarted; + const deletion = executeTool(createDeleteTool({ store: deleteStore }), { + path: "/workspace/file.txt", + }); + await Promise.resolve(); + const eventsBeforeEditFinished = [...events]; + + releaseRead?.(); + await Promise.all([edit, deletion]); + expect(eventsBeforeEditFinished).toEqual([]); + expect(events).toEqual(["edit", "delete"]); + }); + it("preserves file mode when write overwrites an existing file", async () => { const writes: Array<{ path: string; content: string; mode?: number }> = []; const tool = createWriteTool({ diff --git a/packages/computer/src/tools/ai.ts b/packages/computer/src/tools/ai.ts index fb3dd960..a4bc5e03 100644 --- a/packages/computer/src/tools/ai.ts +++ b/packages/computer/src/tools/ai.ts @@ -1,6 +1,9 @@ import type { ToolSet } from "ai"; import { createExecTool, type ExecToolOptions, type ExecWorkspaceLike } from "./exec.js"; +import { createDeleteTool, type DeleteToolOptions } from "./fs/delete.js"; import { createEditTool, type EditToolOptions } from "./fs/edit.js"; +import { createFindTool, type FindToolOptions } from "./fs/find.js"; +import { createGrepTool, type GrepToolOptions } from "./fs/grep.js"; import { createListTool } from "./fs/list.js"; import { createReadTool, type ReadToolOptions } from "./fs/read.js"; import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./fs/store.js"; @@ -14,6 +17,9 @@ export interface CreateAIToolsOptions { read?: Omit; write?: Omit; edit?: Omit; + find?: Omit; + grep?: Omit; + delete?: Omit; shell?: Omit; } @@ -22,12 +28,15 @@ export function createAITools(options: CreateAIToolsOptions): ToolSet { const tools: ToolSet = { read: createReadTool({ store, ...options.read }), ls: createListTool({ workspace: options.workspace }), + find: createFindTool({ workspace: options.workspace, ...options.find }), + grep: createGrepTool({ workspace: options.workspace, ...options.grep }), }; if (options.readonly === true) return tools; tools.write = createWriteTool({ store, ...options.write }); tools.edit = createEditTool({ store, ...options.edit }); + tools.delete = createDeleteTool({ store, ...options.delete }); if (options.shell !== undefined) { tools.exec = createExecTool({ diff --git a/packages/computer/src/tools/fs/delete.ts b/packages/computer/src/tools/fs/delete.ts new file mode 100644 index 00000000..6f758c29 --- /dev/null +++ b/packages/computer/src/tools/fs/delete.ts @@ -0,0 +1,34 @@ +import { type Tool, tool } from "ai"; +import { z } from "zod"; +import { withFileLock } from "./locks.js"; +import type { MutableFileStore } from "./types.js"; + +export interface DeleteToolOptions { + store: MutableFileStore; +} + +const inputSchema = z.object({ + path: z.string().describe("Absolute path to the file or directory to delete."), + recursive: z + .boolean() + .optional() + .describe("Remove a directory and all of its contents. Defaults to false."), +}); + +export function createDeleteTool(options: DeleteToolOptions): Tool> { + const { store } = options; + return tool({ + description: + "Delete a file or directory. Set recursive to true to remove a non-empty directory.", + inputSchema, + execute: async ({ path, recursive }) => + withFileLock(store, path, async () => { + try { + await store.remove(path, { recursive, force: true }); + return { deleted: path }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }), + }); +} diff --git a/packages/computer/src/tools/fs/find.ts b/packages/computer/src/tools/fs/find.ts new file mode 100644 index 00000000..5d71bdbd --- /dev/null +++ b/packages/computer/src/tools/fs/find.ts @@ -0,0 +1,58 @@ +import { type Tool, tool } from "ai"; +import { z } from "zod"; + +interface FoundEntry { + path: string; + type: "file" | "dir"; +} + +export interface FindWorkspaceLike { + fs: { + find(directory: string, pattern?: string): Promise; + }; +} + +export interface FindToolOptions { + workspace: FindWorkspaceLike; +} + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 1000; + +const inputSchema = z.object({ + path: z.string().default("/workspace").describe("Absolute directory to search."), + pattern: z + .string() + .describe('Glob pattern relative to path, for example "**/*.ts" or "src/?.js".'), + limit: z.number().int().min(1).max(MAX_LIMIT).optional(), + offset: z.number().int().min(0).optional(), +}); + +export function createFindTool(options: FindToolOptions): Tool> { + return tool({ + description: + "Find files and directories matching a glob. * stays within one path segment, ** crosses directories, and ? matches one character.", + inputSchema, + execute: async ({ path, pattern, limit, offset }) => { + try { + const pageSize = limit ?? DEFAULT_LIMIT; + const pageOffset = offset ?? 0; + const matches = await options.workspace.fs.find(path, pattern); + const page = matches.slice(pageOffset, pageOffset + pageSize + 1); + const truncated = page.length > pageSize; + const entries = truncated ? page.slice(0, pageSize) : page; + const result: { + path: string; + pattern: string; + count: number; + entries: FoundEntry[]; + nextOffset?: number; + } = { path, pattern, count: entries.length, entries }; + if (truncated) result.nextOffset = pageOffset + pageSize; + return result; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }, + }); +} diff --git a/packages/computer/src/tools/fs/grep.ts b/packages/computer/src/tools/fs/grep.ts new file mode 100644 index 00000000..2b657a19 --- /dev/null +++ b/packages/computer/src/tools/fs/grep.ts @@ -0,0 +1,144 @@ +import { type Tool, tool } from "ai"; +import { z } from "zod"; + +interface GrepContextLine { + line: number; + text: string; + isMatch: boolean; +} + +interface GrepMatch { + path: string; + line: number; + text: string; + context?: GrepContextLine[]; +} + +interface FoundEntry { + path: string; + type: "file" | "dir"; +} + +interface GrepOptions { + fixedString?: boolean; + caseSensitive?: boolean; + contextLines?: number; + limit?: number; + offset?: number; +} + +export interface GrepWorkspaceLike { + fs: { + find(directory: string, pattern?: string): Promise; + grep(pattern: string, path: string, options?: GrepOptions): Promise; + }; +} + +export interface GrepToolOptions { + workspace: GrepWorkspaceLike; +} + +const DEFAULT_LIMIT = 200; +const MAX_LIMIT = 1000; + +const inputSchema = z.object({ + path: z.string().default("/workspace").describe("Absolute file or directory to search."), + query: z.string().describe("Regular expression or fixed string to search for."), + include: z + .string() + .optional() + .describe('Glob relative to path that limits searched files, for example "**/*.ts".'), + fixedString: z.boolean().optional().describe("Treat query as plain text instead of a regex."), + caseSensitive: z.boolean().optional().describe("Match letter case. Defaults to false."), + contextLines: z.number().int().min(0).max(10).optional(), + limit: z.number().int().min(1).max(MAX_LIMIT).optional(), + offset: z.number().int().min(0).max(10_000).optional(), +}); + +export function createGrepTool(options: GrepToolOptions): Tool> { + return tool({ + description: + "Search workspace text with a regular expression or fixed string. Results include paths and line numbers and can include surrounding lines.", + inputSchema, + execute: async ({ + path, + query, + include, + fixedString, + caseSensitive, + contextLines, + limit, + offset, + }) => { + try { + const pageSize = limit ?? DEFAULT_LIMIT; + const pageOffset = offset ?? 0; + const searchOptions = { + fixedString: fixedString ?? false, + caseSensitive: caseSensitive ?? false, + contextLines: contextLines ?? 0, + }; + const matches = + include === undefined + ? await options.workspace.fs.grep(query, path, { + ...searchOptions, + limit: pageSize + 1, + offset: pageOffset, + }) + : await grepIncludedFiles( + options.workspace, + query, + path, + include, + searchOptions, + pageOffset, + pageSize + 1, + ); + const truncated = matches.length > pageSize; + const page = truncated ? matches.slice(0, pageSize) : matches; + const result: { + path: string; + query: string; + count: number; + matches: GrepMatch[]; + nextOffset?: number; + } = { path, query, count: page.length, matches: page }; + if (truncated) result.nextOffset = pageOffset + pageSize; + return result; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }, + }); +} + +async function grepIncludedFiles( + workspace: GrepWorkspaceLike, + query: string, + path: string, + include: string, + options: Pick, + offset: number, + limit: number, +): Promise { + const files = (await workspace.fs.find(path, include)) + .filter((entry) => entry.type === "file") + .map((entry) => entry.path) + .sort(); + const matches: GrepMatch[] = []; + let skipped = offset; + for (const file of files) { + const fileMatches = await workspace.fs.grep(query, file, { + ...options, + limit: skipped + (limit - matches.length), + }); + if (skipped >= fileMatches.length) { + skipped -= fileMatches.length; + continue; + } + matches.push(...fileMatches.slice(skipped, skipped + (limit - matches.length))); + skipped = 0; + if (matches.length >= limit) break; + } + return matches; +} diff --git a/packages/computer/src/tools/fs/store.ts b/packages/computer/src/tools/fs/store.ts index 442de1c4..95a00688 100644 --- a/packages/computer/src/tools/fs/store.ts +++ b/packages/computer/src/tools/fs/store.ts @@ -11,7 +11,7 @@ * multimodal output drain the same stream interface. */ -import type { FileStat, FileStore } from "./types.js"; +import type { FileStat, MutableFileStore } from "./types.js"; /** * Structural subset of `@cloudflare/computer.Workspace` the tools @@ -33,6 +33,28 @@ export interface WorkspaceLike { writeFile(path: string, content: Uint8Array, options?: { mode?: number }): Promise; mkdir(path: string, options?: { recursive?: boolean }): Promise; rm(path: string, options?: { recursive?: boolean; force?: boolean }): Promise; + find( + directory: string, + pattern?: string, + ): Promise>; + grep( + pattern: string, + path: string, + options?: { + fixedString?: boolean; + caseSensitive?: boolean; + contextLines?: number; + limit?: number; + offset?: number; + }, + ): Promise< + Array<{ + path: string; + line: number; + text: string; + context?: Array<{ line: number; text: string; isMatch: boolean }>; + }> + >; readdir( path: string, options?: { limit?: number; offset?: number }, @@ -49,8 +71,12 @@ export interface WorkspaceLike { }; } -export class WorkspaceFileStore implements FileStore { - constructor(private readonly ws: WorkspaceLike) {} +type WorkspaceFileStoreLike = { + fs: Pick; +}; + +export class WorkspaceFileStore implements MutableFileStore { + constructor(private readonly ws: WorkspaceFileStoreLike) {} async stat(path: string): Promise { try { @@ -78,6 +104,10 @@ export class WorkspaceFileStore implements FileStore { await this.ws.fs.writeFile(path, content, opts); } + async remove(path: string, opts?: { recursive?: boolean; force?: boolean }): Promise { + await this.ws.fs.rm(path, opts); + } + async *readChunks(path: string, byteOffset = 0, byteLength?: number): AsyncIterable { if (!Number.isSafeInteger(byteOffset) || byteOffset < 0) { throw new Error("readChunks: byteOffset must be a non-negative safe integer"); @@ -133,7 +163,7 @@ async function drain(stream: ReadableStream): Promise { return out; } -async function ensureParentDir(ws: WorkspaceLike, path: string): Promise { +async function ensureParentDir(ws: WorkspaceFileStoreLike, path: string): Promise { const i = path.lastIndexOf("/"); if (i <= 0) return; const parent = path.slice(0, i); diff --git a/packages/computer/src/tools/fs/types.ts b/packages/computer/src/tools/fs/types.ts index eac26d65..e5425cf7 100644 --- a/packages/computer/src/tools/fs/types.ts +++ b/packages/computer/src/tools/fs/types.ts @@ -43,3 +43,8 @@ export interface FileStore { */ write(path: string, content: Uint8Array, opts?: { mode?: number }): Promise; } + +export interface MutableFileStore extends FileStore { + /** Remove a file or directory. */ + remove(path: string, opts?: { recursive?: boolean; force?: boolean }): Promise; +} diff --git a/packages/computer/src/tools/index.ts b/packages/computer/src/tools/index.ts index b2686a55..7e064689 100644 --- a/packages/computer/src/tools/index.ts +++ b/packages/computer/src/tools/index.ts @@ -7,10 +7,13 @@ export { type ExecToolOptions, type ExecToolOutput, } from "./exec.js"; +export { createDeleteTool, type DeleteToolOptions } from "./fs/delete.js"; export { createEditTool, type EditToolOptions } from "./fs/edit.js"; +export { createFindTool, type FindToolOptions } from "./fs/find.js"; +export { createGrepTool, type GrepToolOptions } from "./fs/grep.js"; export { createListTool, type ListToolOptions } from "./fs/list.js"; export { createReadTool, type ReadToolOptions } from "./fs/read.js"; export { WorkspaceFileStore, type WorkspaceLike } from "./fs/store.js"; -export type { FileStat, FileStore } from "./fs/types.js"; +export type { FileStat, FileStore, MutableFileStore } from "./fs/types.js"; export { createWriteTool, type WriteToolOptions } from "./fs/write.js"; export { createPublishTool, type PublishToolOptions } from "./publish.js"; From 56805670be6bce29b677e4293ace19d1ea094571 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:55:20 +0000 Subject: [PATCH 03/14] computer: Accept all grep continuations Allow any non-negative grep offset so continuation values emitted after large result sets remain valid inputs to the next tool call. --- packages/computer/src/tools/ai.test.ts | 23 +++++++++++++++++++++++ packages/computer/src/tools/fs/grep.ts | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 95646412..e98923ea 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -6,6 +6,7 @@ import { createAITools, createDeleteTool, createEditTool, + createGrepTool, createReadTool, createWriteTool, type FileStore, @@ -490,6 +491,28 @@ describe("createAITools filesystem tools", () => { expect(secondAcquired).toBe(true); }); + it("accepts grep continuation offsets produced after large result sets", () => { + const tool = createGrepTool({ + workspace: { + fs: { + async find() { + return []; + }, + async grep() { + return []; + }, + }, + }, + }); + const schema = tool.inputSchema as { + safeParse(input: unknown): { success: boolean }; + }; + + expect(schema.safeParse({ path: "/workspace", query: "needle", offset: 10_200 }).success).toBe( + true, + ); + }); + it("finds, greps, and deletes files through a real Workspace", async () => { const workspace = makeWorkspace(); const tools = createAITools({ workspace }); diff --git a/packages/computer/src/tools/fs/grep.ts b/packages/computer/src/tools/fs/grep.ts index 2b657a19..71f3b589 100644 --- a/packages/computer/src/tools/fs/grep.ts +++ b/packages/computer/src/tools/fs/grep.ts @@ -52,7 +52,7 @@ const inputSchema = z.object({ caseSensitive: z.boolean().optional().describe("Match letter case. Defaults to false."), contextLines: z.number().int().min(0).max(10).optional(), limit: z.number().int().min(1).max(MAX_LIMIT).optional(), - offset: z.number().int().min(0).max(10_000).optional(), + offset: z.number().int().min(0).optional(), }); export function createGrepTool(options: GrepToolOptions): Tool> { From a90c1b69f16e792d3be5b0d2d0be59e85d27b5f4 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:24:47 +0000 Subject: [PATCH 04/14] computer: Add workspace tools changeset Record the new search and deletion tools and shared mutation locking with the Computer package that exposes them. --- .changeset/computer-workspace-file-tools.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/computer-workspace-file-tools.md diff --git a/.changeset/computer-workspace-file-tools.md b/.changeset/computer-workspace-file-tools.md new file mode 100644 index 00000000..d574c104 --- /dev/null +++ b/.changeset/computer-workspace-file-tools.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": minor +--- + +Add bounded `find` and `grep` tools, a read-only-aware `delete` tool, and shared locking for file mutations. From 5cd56f7526dc493dce414ac54a32de24039000bf Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:20:56 +0000 Subject: [PATCH 05/14] dofs, computer: Bound search pagination --- packages/computer/src/stub.test.ts | 12 ++++ packages/computer/src/stub.ts | 9 ++- packages/computer/src/tools/ai.test.ts | 49 +++++++++++++ packages/computer/src/tools/fs/find.ts | 16 +++-- packages/computer/src/tools/fs/grep.ts | 60 ++-------------- packages/computer/src/tools/fs/store.ts | 2 + packages/dofs/src/fs/filesystem.ts | 10 ++- packages/dofs/src/fs/find.test.ts | 15 ++++ packages/dofs/src/fs/find.ts | 96 +++++++++++++++++++------ packages/dofs/src/fs/grep.test.ts | 14 ++++ packages/dofs/src/fs/grep.ts | 28 +++++--- packages/dofs/src/index.ts | 2 +- 12 files changed, 218 insertions(+), 95 deletions(-) diff --git a/packages/computer/src/stub.test.ts b/packages/computer/src/stub.test.ts index 77a81c18..e3448ae2 100644 --- a/packages/computer/src/stub.test.ts +++ b/packages/computer/src/stub.test.ts @@ -228,6 +228,18 @@ describe("WorkspaceStub", () => { }); }); + it("fs.find forwards bounded search options", async () => { + await withStub(async (ws) => { + const stub = ws.stub(); + await ws.fs.writeFile("/a.ts", ""); + await ws.fs.writeFile("/b.ts", ""); + await ws.fs.writeFile("/c.ts", ""); + expect(await stub.fs.find("/", "*.ts", { limit: 1, offset: 1 })).toEqual([ + { path: "/b.ts", type: "file" }, + ]); + }); + }); + it("fs.stat propagates ENOENT for missing paths", async () => { await withStub(async (ws) => { const stub = ws.stub(); diff --git a/packages/computer/src/stub.ts b/packages/computer/src/stub.ts index 657ebc6f..0f608833 100644 --- a/packages/computer/src/stub.ts +++ b/packages/computer/src/stub.ts @@ -41,6 +41,7 @@ import { trackStub, untrackStub } from "@cloudflare/computer-rpc/debug"; import type { + FindOptions, GrepOptions, MkdirOptions, ReaddirOptions, @@ -190,12 +191,16 @@ export class WorkspaceFilesystemStub extends RpcTarget { ); } - find(directory: string, pattern?: string): Promise { + find( + directory: string, + pattern?: string, + options: FindOptions = {}, + ): Promise { return withSpan( this.#ws.observer, "workspace.fs.find", { "workspace.fs.path": directory, "workspace.fs.pattern": pattern }, - () => this.#ws.fs.find(directory, pattern), + () => this.#ws.fs.find(directory, pattern, options), (span, outcome) => { if (outcome.ok) span.setAttribute("workspace.fs.matches", outcome.value.length); }, diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index e98923ea..c01fc07a 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -6,6 +6,7 @@ import { createAITools, createDeleteTool, createEditTool, + createFindTool, createGrepTool, createReadTool, createWriteTool, @@ -491,6 +492,54 @@ describe("createAITools filesystem tools", () => { expect(secondAcquired).toBe(true); }); + it("passes find pagination to the workspace filesystem", async () => { + let received: { limit?: number; offset?: number } | undefined; + const tool = createFindTool({ + workspace: { + fs: { + async find(_path, _pattern, options) { + received = options; + return [{ path: "/workspace/a.ts", type: "file" }]; + }, + }, + }, + }); + + await executeTool(tool, { + path: "/workspace", + pattern: "**/*.ts", + limit: 2, + offset: 7, + }); + expect(received).toEqual({ limit: 3, offset: 7 }); + }); + + it("passes grep include and pagination to one filesystem search", async () => { + let received: Record | undefined; + const tool = createGrepTool({ + workspace: { + fs: { + async find() { + throw new Error("find must not be called by the grep tool"); + }, + async grep(_query, _path, options) { + received = options; + return []; + }, + }, + }, + }); + + await executeTool(tool, { + path: "/workspace", + query: "TODO", + include: "**/*.ts", + limit: 2, + offset: 7, + }); + expect(received).toMatchObject({ include: "**/*.ts", limit: 3, offset: 7 }); + }); + it("accepts grep continuation offsets produced after large result sets", () => { const tool = createGrepTool({ workspace: { diff --git a/packages/computer/src/tools/fs/find.ts b/packages/computer/src/tools/fs/find.ts index 5d71bdbd..f64822f2 100644 --- a/packages/computer/src/tools/fs/find.ts +++ b/packages/computer/src/tools/fs/find.ts @@ -8,7 +8,11 @@ interface FoundEntry { export interface FindWorkspaceLike { fs: { - find(directory: string, pattern?: string): Promise; + find( + directory: string, + pattern?: string, + options?: { limit?: number; offset?: number }, + ): Promise; }; } @@ -37,10 +41,12 @@ export function createFindTool(options: FindToolOptions): Tool pageSize; - const entries = truncated ? page.slice(0, pageSize) : page; + const matches = await options.workspace.fs.find(path, pattern, { + limit: pageSize + 1, + offset: pageOffset, + }); + const truncated = matches.length > pageSize; + const entries = truncated ? matches.slice(0, pageSize) : matches; const result: { path: string; pattern: string; diff --git a/packages/computer/src/tools/fs/grep.ts b/packages/computer/src/tools/fs/grep.ts index 71f3b589..8746feca 100644 --- a/packages/computer/src/tools/fs/grep.ts +++ b/packages/computer/src/tools/fs/grep.ts @@ -14,22 +14,17 @@ interface GrepMatch { context?: GrepContextLine[]; } -interface FoundEntry { - path: string; - type: "file" | "dir"; -} - interface GrepOptions { fixedString?: boolean; caseSensitive?: boolean; contextLines?: number; limit?: number; offset?: number; + include?: string; } export interface GrepWorkspaceLike { fs: { - find(directory: string, pattern?: string): Promise; grep(pattern: string, path: string, options?: GrepOptions): Promise; }; } @@ -78,22 +73,12 @@ export function createGrepTool(options: GrepToolOptions): Tool pageSize; const page = truncated ? matches.slice(0, pageSize) : matches; const result: { @@ -111,34 +96,3 @@ export function createGrepTool(options: GrepToolOptions): Tool, - offset: number, - limit: number, -): Promise { - const files = (await workspace.fs.find(path, include)) - .filter((entry) => entry.type === "file") - .map((entry) => entry.path) - .sort(); - const matches: GrepMatch[] = []; - let skipped = offset; - for (const file of files) { - const fileMatches = await workspace.fs.grep(query, file, { - ...options, - limit: skipped + (limit - matches.length), - }); - if (skipped >= fileMatches.length) { - skipped -= fileMatches.length; - continue; - } - matches.push(...fileMatches.slice(skipped, skipped + (limit - matches.length))); - skipped = 0; - if (matches.length >= limit) break; - } - return matches; -} diff --git a/packages/computer/src/tools/fs/store.ts b/packages/computer/src/tools/fs/store.ts index 95a00688..6017c409 100644 --- a/packages/computer/src/tools/fs/store.ts +++ b/packages/computer/src/tools/fs/store.ts @@ -36,6 +36,7 @@ export interface WorkspaceLike { find( directory: string, pattern?: string, + options?: { limit?: number; offset?: number }, ): Promise>; grep( pattern: string, @@ -46,6 +47,7 @@ export interface WorkspaceLike { contextLines?: number; limit?: number; offset?: number; + include?: string; }, ): Promise< Array<{ diff --git a/packages/dofs/src/fs/filesystem.ts b/packages/dofs/src/fs/filesystem.ts index ec4827dd..2757d4ff 100644 --- a/packages/dofs/src/fs/filesystem.ts +++ b/packages/dofs/src/fs/filesystem.ts @@ -15,7 +15,7 @@ import type { Database } from "../storage.js"; import { chmod } from "./chmod.js"; -import { find, type WorkspaceFoundEntry } from "./find.js"; +import { type FindOptions, find, type WorkspaceFoundEntry } from "./find.js"; import { type GrepOptions, grep, type WorkspaceGrepMatch } from "./grep.js"; import { ls } from "./ls.js"; import { type MkdirOptions, mkdir } from "./mkdir.js"; @@ -87,8 +87,12 @@ export class WorkspaceFilesystem { return readdir(this.db, path, options); } - async find(directory: string, pattern?: string): Promise { - return find(this.db, directory, pattern); + async find( + directory: string, + pattern?: string, + options: FindOptions = {}, + ): Promise { + return find(this.db, directory, pattern, options); } async ls(prefix: string): Promise { diff --git a/packages/dofs/src/fs/find.test.ts b/packages/dofs/src/fs/find.test.ts index bd47264c..b2b470ba 100644 --- a/packages/dofs/src/fs/find.test.ts +++ b/packages/dofs/src/fs/find.test.ts @@ -90,6 +90,21 @@ describe("find", () => { }); }); + it("applies limit and offset while walking in deterministic order", async () => { + await withDB(async (db) => { + mkdir(db, "/a/b", { recursive: true }, () => 0); + await writeFile(db, "/a/1.ts", "", {}, () => 0); + await writeFile(db, "/a/b/2.ts", "", {}, () => 0); + await writeFile(db, "/a/b/3.ts", "", {}, () => 0); + await writeFile(db, "/a/z.ts", "", {}, () => 0); + + expect(find(db, "/a", "**/*.ts", { offset: 1, limit: 2 })).toEqual([ + { path: "/a/b/2.ts", type: "file" }, + { path: "/a/b/3.ts", type: "file" }, + ]); + }); + }); + it("does not match files outside the start directory even with **", async () => { await withDB(async (db) => { mkdir(db, "/a", {}, () => 0); diff --git a/packages/dofs/src/fs/find.ts b/packages/dofs/src/fs/find.ts index 9caaf2da..f4ac2944 100644 --- a/packages/dofs/src/fs/find.ts +++ b/packages/dofs/src/fs/find.ts @@ -8,13 +8,34 @@ export interface WorkspaceFoundEntry { type: "file" | "dir"; } +export interface FindOptions { + /** Maximum matching entries to return. */ + limit?: number; + /** Matching entries to skip in traversal order. */ + offset?: number; +} + interface ChildRow { name: string; child_inode: number; type: "file" | "dir"; } -export function find(db: Database, directory: string, pattern?: string): WorkspaceFoundEntry[] { +interface WalkState { + seen: number; + offset: number; + limit: number; + out: WorkspaceFoundEntry[]; +} + +const CHILD_PAGE_SIZE = 128; + +export function find( + db: Database, + directory: string, + pattern?: string, + options: FindOptions = {}, +): WorkspaceFoundEntry[] { const { path: canonical } = canonicalizePath(directory); const node = resolveInode(db, canonical); if (node === null) { @@ -24,42 +45,71 @@ export function find(db: Database, directory: string, pattern?: string): Workspa throw createWorkspaceError("ENOTDIR", `not a directory: ${canonical}`, canonical); } - const out: WorkspaceFoundEntry[] = []; + const limit = options.limit ?? Number.MAX_SAFE_INTEGER; + if (!Number.isSafeInteger(limit) || limit < 0) { + throw new TypeError("find limit must be a non-negative safe integer"); + } + const offset = options.offset ?? 0; + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new TypeError("find offset must be a non-negative safe integer"); + } + if (limit === 0) return []; + // An empty pattern is equivalent to no pattern: walk and return // everything rather than compiling it into `^$`, which would match // only empty relative paths and yield no results. const regex = pattern ? compileGlob(pattern) : undefined; + const prefix = canonical === "/" ? "/" : `${canonical}/`; + const state: WalkState = { seen: 0, offset, limit, out: [] }; + walk(db, node.inode, canonical, prefix, regex, state); + return state.out; +} - walk(db, node.inode, canonical, out); +function walk( + db: Database, + parentInode: number, + parentPath: string, + prefix: string, + regex: RegExp | undefined, + state: WalkState, +): boolean { + let afterName = ""; + while (true) { + const children = readChildren(db, parentInode, afterName); + if (children.length === 0) return false; - if (regex === undefined) { - return out; + for (const child of children) { + const childPath = parentPath === "/" ? `/${child.name}` : `${parentPath}/${child.name}`; + const relativePath = childPath.slice(prefix.length); + if (regex === undefined || regex.test(relativePath)) { + if (state.seen >= state.offset) { + state.out.push({ path: childPath, type: child.type }); + if (state.out.length >= state.limit) return true; + } + state.seen += 1; + } + if (child.type === "dir" && walk(db, child.child_inode, childPath, prefix, regex, state)) { + return true; + } + } + + if (children.length < CHILD_PAGE_SIZE) return false; + afterName = children[children.length - 1].name; } - // Glob matches against the path relative to the start directory. - const prefix = canonical === "/" ? "/" : `${canonical}/`; - return out.filter((entry) => { - if (!entry.path.startsWith(prefix)) return false; - const rel = entry.path.slice(prefix.length); - return regex.test(rel); - }); } -function walk(db: Database, parentInode: number, parentPath: string, out: WorkspaceFoundEntry[]) { - const children = db.all( +function readChildren(db: Database, parentInode: number, afterName: string): ChildRow[] { + return db.all( `SELECT d.name AS name, d.child_inode AS child_inode, n.type AS type FROM vfs_dirents d JOIN vfs_nodes n ON n.inode = d.child_inode - WHERE d.parent_inode = ? - ORDER BY d.name`, + WHERE d.parent_inode = ? AND d.name > ? + ORDER BY d.name + LIMIT ?`, parentInode, + afterName, + CHILD_PAGE_SIZE, ); - for (const child of children) { - const childPath = parentPath === "/" ? `/${child.name}` : `${parentPath}/${child.name}`; - out.push({ path: childPath, type: child.type }); - if (child.type === "dir") { - walk(db, child.child_inode, childPath, out); - } - } } // Compile a simple glob into a regex. Supported: diff --git a/packages/dofs/src/fs/grep.test.ts b/packages/dofs/src/fs/grep.test.ts index 6fca82e0..65a67268 100644 --- a/packages/dofs/src/fs/grep.test.ts +++ b/packages/dofs/src/fs/grep.test.ts @@ -134,6 +134,20 @@ describe("grep", () => { }); }); + it("filters directory searches by an include glob before applying pagination", async () => { + await withDB(async (db) => { + await writeFile(db, "/a.md", "TODO markdown\n", {}, () => 0); + await writeFile(db, "/b.ts", "TODO one\nTODO two\n", {}, () => 0); + await writeFile(db, "/c.ts", "TODO three\n", {}, () => 0); + + expect( + (await grep(db, "TODO", "/", { include: "**/*.ts", offset: 1, limit: 2 })).map( + (match) => `${match.path}:${match.line}`, + ), + ).toEqual(["/b.ts:2", "/c.ts:1"]); + }); + }); + it("matches across a chunk boundary", async () => { await withDB(async (db) => { // Lay out a file whose line straddles the 512KiB chunk boundary. diff --git a/packages/dofs/src/fs/grep.ts b/packages/dofs/src/fs/grep.ts index 58f7c312..12b50270 100644 --- a/packages/dofs/src/fs/grep.ts +++ b/packages/dofs/src/fs/grep.ts @@ -29,6 +29,8 @@ export interface GrepOptions { limit?: number; /** Matching lines to skip before collecting results. */ offset?: number; + /** Glob relative to a searched directory that limits files. */ + include?: string; } interface ScanState { @@ -64,16 +66,9 @@ export async function grep( regex: settings.regex, ignoreCase: settings.ignoreCase, }); - const filePaths = - node.type === "file" - ? [canonical] - : find(db, canonical) - .filter((entry) => entry.type === "file") - .map((entry) => entry.path) - .sort(); - const matches: WorkspaceGrepMatch[] = []; const state: ScanState = { seen: 0, accepted: 0 }; + const filePaths = node.type === "file" ? [canonical] : filesUnder(db, canonical, options.include); for (const filePath of filePaths) { const complete = await scanFile( db, @@ -118,6 +113,23 @@ function normalizeOptions(options: GrepOptions): { }; } +function* filesUnder( + db: Database, + directory: string, + include: string | undefined, +): Iterable { + const pageSize = 128; + let offset = 0; + while (true) { + const page = find(db, directory, include, { limit: pageSize, offset }); + for (const entry of page) { + if (entry.type === "file") yield entry.path; + } + if (page.length < pageSize) return; + offset += page.length; + } +} + function compileMatcher(pattern: string, options: { regex: boolean; ignoreCase: boolean }): RegExp { const source = options.regex ? pattern : pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); try { diff --git a/packages/dofs/src/index.ts b/packages/dofs/src/index.ts index 3924fc9b..e5638a45 100644 --- a/packages/dofs/src/index.ts +++ b/packages/dofs/src/index.ts @@ -5,7 +5,7 @@ export { WorkspaceFilesystem, type WorkspaceFilesystemOptions, } from "./fs/filesystem.js"; -export type { WorkspaceFoundEntry } from "./fs/find.js"; +export type { FindOptions, WorkspaceFoundEntry } from "./fs/find.js"; export type { GrepOptions, WorkspaceGrepContextLine, From 0808f99f4592a80f7b4089bf52e86236eef3229b Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:21:42 +0000 Subject: [PATCH 06/14] computer: Share workspace mutation locks --- packages/computer/src/tools/ai.test.ts | 46 +++++++++++++++++++++++++ packages/computer/src/tools/fs/locks.ts | 9 ++--- packages/computer/src/tools/fs/store.ts | 6 +++- packages/computer/src/tools/fs/types.ts | 3 ++ 4 files changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index c01fc07a..a4161fe1 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -447,6 +447,52 @@ describe("createAITools filesystem tools", () => { expect(writes).toEqual(["edited", "written"]); }); + it("shares mutation locks across tool sets for the same workspace", async () => { + const workspace = makeWorkspace(); + await workspace.fs.mkdir("/workspace", { recursive: true }); + await workspace.fs.writeFile("/workspace/file.txt", "old"); + let releaseRead: (() => void) | undefined; + let markReadStarted: (() => void) | undefined; + const readGate = new Promise((resolve) => { + releaseRead = resolve; + }); + const readStarted = new Promise((resolve) => { + markReadStarted = resolve; + }); + const originalReadFile = workspace.fs.readFile.bind(workspace.fs); + const originalWriteFile = workspace.fs.writeFile.bind(workspace.fs); + const writes: string[] = []; + workspace.fs.readFile = async (...args: Parameters) => { + markReadStarted?.(); + await readGate; + return originalReadFile(...args); + }; + workspace.fs.writeFile = async (...args: Parameters) => { + const content = args[1]; + if (content instanceof Uint8Array) writes.push(decode(content)); + return originalWriteFile(...args); + }; + + const firstTools = createAITools({ workspace }); + const secondTools = createAITools({ workspace }); + const edit = executeTool(firstTools.edit, { + path: "/workspace/file.txt", + edits: [{ oldText: "old", newText: "edited" }], + }); + await readStarted; + const write = executeTool(secondTools.write, { + path: "/workspace/file.txt", + content: "written", + }); + await Promise.resolve(); + const writesBeforeEditFinished = [...writes]; + + releaseRead?.(); + await Promise.all([edit, write]); + expect(writesBeforeEditFinished).toEqual([]); + expect(writes).toEqual(["edited", "written"]); + }); + it("does not share edit locks between stores", async () => { let releaseRead: (() => void) | undefined; let markFirstStarted: (() => void) | undefined; diff --git a/packages/computer/src/tools/fs/locks.ts b/packages/computer/src/tools/fs/locks.ts index f8f7b710..4185e337 100644 --- a/packages/computer/src/tools/fs/locks.ts +++ b/packages/computer/src/tools/fs/locks.ts @@ -1,16 +1,17 @@ import type { FileStore } from "./types.js"; -const storeLocks = new WeakMap>>(); +const storeLocks = new WeakMap>>(); export async function withFileLock( store: FileStore, path: string, operation: () => Promise, ): Promise { - let paths = storeLocks.get(store); + const identity = store.lockIdentity ?? store; + let paths = storeLocks.get(identity); if (paths === undefined) { paths = new Map(); - storeLocks.set(store, paths); + storeLocks.set(identity, paths); } const key = normalizePath(path); @@ -27,7 +28,7 @@ export async function withFileLock( } finally { release?.(); if (paths.get(key) === current) paths.delete(key); - if (paths.size === 0) storeLocks.delete(store); + if (paths.size === 0) storeLocks.delete(identity); } } diff --git a/packages/computer/src/tools/fs/store.ts b/packages/computer/src/tools/fs/store.ts index 6017c409..c639cdd3 100644 --- a/packages/computer/src/tools/fs/store.ts +++ b/packages/computer/src/tools/fs/store.ts @@ -78,7 +78,11 @@ type WorkspaceFileStoreLike = { }; export class WorkspaceFileStore implements MutableFileStore { - constructor(private readonly ws: WorkspaceFileStoreLike) {} + readonly lockIdentity: object; + + constructor(private readonly ws: WorkspaceFileStoreLike) { + this.lockIdentity = ws.fs; + } async stat(path: string): Promise { try { diff --git a/packages/computer/src/tools/fs/types.ts b/packages/computer/src/tools/fs/types.ts index e5425cf7..2eda78a9 100644 --- a/packages/computer/src/tools/fs/types.ts +++ b/packages/computer/src/tools/fs/types.ts @@ -18,6 +18,9 @@ export interface FileStat { } export interface FileStore { + /** Shared identity used to coordinate mutations across adapters. */ + readonly lockIdentity?: object; + /** Return file metadata, or null if the path does not exist or is not a file. */ stat(path: string): Promise; From cc14132998fb2774cd00ede16d9c75836a3703d0 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Sat, 8 Aug 2026 09:22:47 +0000 Subject: [PATCH 07/14] computer: Lock recursive delete subtrees --- packages/computer/src/tools/ai.test.ts | 78 ++++++++++++++++++++++++ packages/computer/src/tools/fs/delete.ts | 21 ++++--- packages/computer/src/tools/fs/locks.ts | 65 +++++++++++++++----- 3 files changed, 142 insertions(+), 22 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index a4161fe1..58d60b07 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -685,6 +685,84 @@ describe("createAITools filesystem tools", () => { expect(events).toEqual(["edit", "delete"]); }); + it("serializes recursive delete behind a mutation in its subtree", async () => { + let releaseRead: (() => void) | undefined; + let markReadStarted: (() => void) | undefined; + const readGate = new Promise((resolve) => { + releaseRead = resolve; + }); + const readStarted = new Promise((resolve) => { + markReadStarted = resolve; + }); + const events: string[] = []; + const store = memoryStore({ + content: "old", + onWrite() { + events.push("edit"); + }, + }); + store.readAll = async () => { + markReadStarted?.(); + await readGate; + return bytes("old"); + }; + const deleteStore = Object.assign(store, { + async remove() { + events.push("delete"); + }, + }); + const edit = executeTool(createEditTool({ store }), { + path: "/workspace/tree/file.txt", + edits: [{ oldText: "old", newText: "edited" }], + }); + await readStarted; + const deletion = executeTool(createDeleteTool({ store: deleteStore }), { + path: "/workspace/tree", + recursive: true, + }); + await Promise.resolve(); + const eventsBeforeEditFinished = [...events]; + + releaseRead?.(); + await Promise.all([edit, deletion]); + expect(eventsBeforeEditFinished).toEqual([]); + expect(events).toEqual(["edit", "delete"]); + }); + + it("allows unrelated mutations while a recursive delete is pending", async () => { + let releaseRemove: (() => void) | undefined; + let markRemoveStarted: (() => void) | undefined; + const removeGate = new Promise((resolve) => { + releaseRemove = resolve; + }); + const removeStarted = new Promise((resolve) => { + markRemoveStarted = resolve; + }); + const events: string[] = []; + const store = Object.assign(memoryStore({ content: "old" }), { + async remove() { + markRemoveStarted?.(); + await removeGate; + events.push("delete"); + }, + }); + const deletion = executeTool(createDeleteTool({ store }), { + path: "/workspace/tree", + recursive: true, + }); + await removeStarted; + const write = executeTool(createWriteTool({ store }), { + path: "/workspace/other.txt", + content: "new", + }).then(() => events.push("write")); + await write; + + expect(events).toEqual(["write"]); + releaseRemove?.(); + await deletion; + expect(events).toEqual(["write", "delete"]); + }); + it("preserves file mode when write overwrites an existing file", async () => { const writes: Array<{ path: string; content: string; mode?: number }> = []; const tool = createWriteTool({ diff --git a/packages/computer/src/tools/fs/delete.ts b/packages/computer/src/tools/fs/delete.ts index 6f758c29..44c5288f 100644 --- a/packages/computer/src/tools/fs/delete.ts +++ b/packages/computer/src/tools/fs/delete.ts @@ -22,13 +22,18 @@ export function createDeleteTool(options: DeleteToolOptions): Tool - withFileLock(store, path, async () => { - try { - await store.remove(path, { recursive, force: true }); - return { deleted: path }; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } - }), + withFileLock( + store, + path, + async () => { + try { + await store.remove(path, { recursive, force: true }); + return { deleted: path }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }, + { subtree: recursive === true }, + ), }); } diff --git a/packages/computer/src/tools/fs/locks.ts b/packages/computer/src/tools/fs/locks.ts index 4185e337..56842a49 100644 --- a/packages/computer/src/tools/fs/locks.ts +++ b/packages/computer/src/tools/fs/locks.ts @@ -1,37 +1,74 @@ import type { FileStore } from "./types.js"; -const storeLocks = new WeakMap>>(); +interface LockEntry { + path: string; + subtree: boolean; + done: Promise; +} + +export interface FileLockOptions { + /** Exclude mutations at this path and every ancestor or descendant. */ + subtree?: boolean; +} + +const storeLocks = new WeakMap>(); export async function withFileLock( store: FileStore, path: string, operation: () => Promise, + options: FileLockOptions = {}, ): Promise { const identity = store.lockIdentity ?? store; - let paths = storeLocks.get(identity); - if (paths === undefined) { - paths = new Map(); - storeLocks.set(identity, paths); + let locks = storeLocks.get(identity); + if (locks === undefined) { + locks = new Set(); + storeLocks.set(identity, locks); } - const key = normalizePath(path); - const previous = paths.get(key) ?? Promise.resolve(); + const normalizedPath = normalizePath(path); + const subtree = options.subtree === true; + const previous = [...locks] + .filter((entry) => conflicts(normalizedPath, subtree, entry.path, entry.subtree)) + .map((entry) => entry.done); let release: (() => void) | undefined; - const current = new Promise((resolve) => { - release = resolve; - }); - paths.set(key, current); + const current: LockEntry = { + path: normalizedPath, + subtree, + done: new Promise((resolve) => { + release = resolve; + }), + }; + locks.add(current); - await previous; + await Promise.all(previous); try { return await operation(); } finally { release?.(); - if (paths.get(key) === current) paths.delete(key); - if (paths.size === 0) storeLocks.delete(identity); + locks.delete(current); + if (locks.size === 0) storeLocks.delete(identity); } } +function conflicts( + leftPath: string, + leftSubtree: boolean, + rightPath: string, + rightSubtree: boolean, +) { + if (leftPath === rightPath) return true; + if (!leftSubtree && !rightSubtree) return false; + return isDescendant(leftPath, rightPath) || isDescendant(rightPath, leftPath); +} + +function isDescendant(path: string, ancestor: string): boolean { + if (path === ancestor) return true; + if (ancestor === "/") return path.startsWith("/"); + if (ancestor === "") return !path.startsWith("/"); + return path.startsWith(`${ancestor}/`); +} + function normalizePath(path: string): string { const absolute = path.startsWith("/"); const parts: string[] = []; From 353c6c09292d1d8adbaa7d8d9ea105c11cc7b007 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:24:35 +0000 Subject: [PATCH 08/14] dofs: Walk grep inputs once --- packages/dofs/src/fs/find.ts | 80 +++++++++++++++++++------------ packages/dofs/src/fs/grep.test.ts | 19 +++++++- packages/dofs/src/fs/grep.ts | 13 ++--- 3 files changed, 71 insertions(+), 41 deletions(-) diff --git a/packages/dofs/src/fs/find.ts b/packages/dofs/src/fs/find.ts index f4ac2944..5580dcc2 100644 --- a/packages/dofs/src/fs/find.ts +++ b/packages/dofs/src/fs/find.ts @@ -21,11 +21,11 @@ interface ChildRow { type: "file" | "dir"; } -interface WalkState { - seen: number; - offset: number; - limit: number; - out: WorkspaceFoundEntry[]; +interface WalkStart { + inode: number; + path: string; + prefix: string; + regex: RegExp | undefined; } const CHILD_PAGE_SIZE = 128; @@ -36,15 +36,7 @@ export function find( pattern?: string, options: FindOptions = {}, ): WorkspaceFoundEntry[] { - const { path: canonical } = canonicalizePath(directory); - const node = resolveInode(db, canonical); - if (node === null) { - throw createWorkspaceError("ENOENT", `no such path: ${canonical}`, canonical); - } - if (node.type !== "dir") { - throw createWorkspaceError("ENOTDIR", `not a directory: ${canonical}`, canonical); - } - + const start = prepareWalk(db, directory, pattern); const limit = options.limit ?? Number.MAX_SAFE_INTEGER; if (!Number.isSafeInteger(limit) || limit < 0) { throw new TypeError("find limit must be a non-negative safe integer"); @@ -55,45 +47,73 @@ export function find( } if (limit === 0) return []; + const out: WorkspaceFoundEntry[] = []; + let seen = 0; + for (const entry of walk(db, start.inode, start.path, start.prefix, start.regex)) { + if (seen >= offset) { + out.push(entry); + if (out.length >= limit) break; + } + seen += 1; + } + return out; +} + +export function* iterateFoundEntries( + db: Database, + directory: string, + pattern?: string, +): IterableIterator { + const start = prepareWalk(db, directory, pattern); + yield* walk(db, start.inode, start.path, start.prefix, start.regex); +} + +function prepareWalk(db: Database, directory: string, pattern: string | undefined): WalkStart { + const { path: canonical } = canonicalizePath(directory); + const node = resolveInode(db, canonical); + if (node === null) { + throw createWorkspaceError("ENOENT", `no such path: ${canonical}`, canonical); + } + if (node.type !== "dir") { + throw createWorkspaceError("ENOTDIR", `not a directory: ${canonical}`, canonical); + } + // An empty pattern is equivalent to no pattern: walk and return // everything rather than compiling it into `^$`, which would match // only empty relative paths and yield no results. const regex = pattern ? compileGlob(pattern) : undefined; - const prefix = canonical === "/" ? "/" : `${canonical}/`; - const state: WalkState = { seen: 0, offset, limit, out: [] }; - walk(db, node.inode, canonical, prefix, regex, state); - return state.out; + return { + inode: node.inode, + path: canonical, + prefix: canonical === "/" ? "/" : `${canonical}/`, + regex, + }; } -function walk( +function* walk( db: Database, parentInode: number, parentPath: string, prefix: string, regex: RegExp | undefined, - state: WalkState, -): boolean { +): IterableIterator { let afterName = ""; while (true) { const children = readChildren(db, parentInode, afterName); - if (children.length === 0) return false; + if (children.length === 0) return; for (const child of children) { const childPath = parentPath === "/" ? `/${child.name}` : `${parentPath}/${child.name}`; const relativePath = childPath.slice(prefix.length); if (regex === undefined || regex.test(relativePath)) { - if (state.seen >= state.offset) { - state.out.push({ path: childPath, type: child.type }); - if (state.out.length >= state.limit) return true; - } - state.seen += 1; + yield { path: childPath, type: child.type }; } - if (child.type === "dir" && walk(db, child.child_inode, childPath, prefix, regex, state)) { - return true; + if (child.type === "dir") { + yield* walk(db, child.child_inode, childPath, prefix, regex); } } - if (children.length < CHILD_PAGE_SIZE) return false; + if (children.length < CHILD_PAGE_SIZE) return; afterName = children[children.length - 1].name; } } diff --git a/packages/dofs/src/fs/grep.test.ts b/packages/dofs/src/fs/grep.test.ts index 65a67268..9c6c2383 100644 --- a/packages/dofs/src/fs/grep.test.ts +++ b/packages/dofs/src/fs/grep.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { grep } from "./grep.js"; import { mkdir } from "./mkdir.js"; @@ -148,6 +148,23 @@ describe("grep", () => { }); }); + it("walks each directory page once during a search", async () => { + await withDB(async (db) => { + for (let index = 0; index < 260; index += 1) { + await writeFile(db, `/file-${String(index).padStart(3, "0")}.txt`, "plain\n", {}, () => 0); + } + const all = vi.spyOn(db, "all"); + + expect(await grep(db, "missing", "/", { include: "*.txt", limit: 1 })).toEqual([]); + + const childPageQueries = all.mock.calls.filter(([query]) => + String(query).includes("d.name > ?"), + ); + expect(childPageQueries.length).toBeGreaterThan(1); + expect(childPageQueries.filter(([, , afterName]) => afterName === "")).toHaveLength(1); + }); + }); + it("matches across a chunk boundary", async () => { await withDB(async (db) => { // Lay out a file whose line straddles the 512KiB chunk boundary. diff --git a/packages/dofs/src/fs/grep.ts b/packages/dofs/src/fs/grep.ts index 12b50270..2eae9392 100644 --- a/packages/dofs/src/fs/grep.ts +++ b/packages/dofs/src/fs/grep.ts @@ -1,7 +1,7 @@ import { createWorkspaceError } from "../errors.js"; import { canonicalizePath } from "../path.js"; import type { Database } from "../storage.js"; -import { find } from "./find.js"; +import { iterateFoundEntries } from "./find.js"; import { readFile } from "./readFile.js"; import { resolveInode } from "./resolve.js"; @@ -118,15 +118,8 @@ function* filesUnder( directory: string, include: string | undefined, ): Iterable { - const pageSize = 128; - let offset = 0; - while (true) { - const page = find(db, directory, include, { limit: pageSize, offset }); - for (const entry of page) { - if (entry.type === "file") yield entry.path; - } - if (page.length < pageSize) return; - offset += page.length; + for (const entry of iterateFoundEntries(db, directory, include)) { + if (entry.type === "file") yield entry.path; } } From 64ae333c65b8a1a5d1571fdd1b8462a1149a8b53 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:49:17 +0000 Subject: [PATCH 09/14] computer: Simplify grep tool options --- packages/computer/src/tools/ai.test.ts | 29 ++++++++++++++++++++-- packages/computer/src/tools/fs/grep.ts | 33 +++++++++---------------- packages/computer/src/tools/fs/store.ts | 6 ++--- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 58d60b07..faecee17 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -578,12 +578,36 @@ describe("createAITools filesystem tools", () => { await executeTool(tool, { path: "/workspace", - query: "TODO", + query: "TODO.+", include: "**/*.ts", + regex: true, + ignoreCase: true, + context: 2, limit: 2, offset: 7, }); - expect(received).toMatchObject({ include: "**/*.ts", limit: 3, offset: 7 }); + expect(received).toEqual({ + include: "**/*.ts", + regex: true, + ignoreCase: true, + context: 2, + limit: 3, + offset: 7, + }); + }); + + it("defaults grep to literal case-sensitive matching", async () => { + const workspace = makeWorkspace(); + const tool = createGrepTool({ workspace }); + await workspace.fs.mkdir("/workspace"); + await workspace.fs.writeFile("/workspace/search.txt", "TODO\ntodo\nT.DO\n"); + + await expect( + executeTool(tool, { path: "/workspace/search.txt", query: "T.DO" }), + ).resolves.toMatchObject({ + count: 1, + matches: [{ path: "/workspace/search.txt", line: 3, text: "T.DO" }], + }); }); it("accepts grep continuation offsets produced after large result sets", () => { @@ -628,6 +652,7 @@ describe("createAITools filesystem tools", () => { path: "/workspace", query: "todo", include: "**/*.ts", + ignoreCase: true, limit: 20, }), ).resolves.toMatchObject({ diff --git a/packages/computer/src/tools/fs/grep.ts b/packages/computer/src/tools/fs/grep.ts index 8746feca..43f35cf8 100644 --- a/packages/computer/src/tools/fs/grep.ts +++ b/packages/computer/src/tools/fs/grep.ts @@ -15,9 +15,9 @@ interface GrepMatch { } interface GrepOptions { - fixedString?: boolean; - caseSensitive?: boolean; - contextLines?: number; + regex?: boolean; + ignoreCase?: boolean; + context?: number; limit?: number; offset?: number; include?: string; @@ -38,14 +38,14 @@ const MAX_LIMIT = 1000; const inputSchema = z.object({ path: z.string().default("/workspace").describe("Absolute file or directory to search."), - query: z.string().describe("Regular expression or fixed string to search for."), + query: z.string().describe("Literal string or regular expression to search for."), include: z .string() .optional() .describe('Glob relative to path that limits searched files, for example "**/*.ts".'), - fixedString: z.boolean().optional().describe("Treat query as plain text instead of a regex."), - caseSensitive: z.boolean().optional().describe("Match letter case. Defaults to false."), - contextLines: z.number().int().min(0).max(10).optional(), + regex: z.boolean().optional().describe("Interpret query as a regular expression."), + ignoreCase: z.boolean().optional().describe("Ignore letter case."), + context: z.number().int().min(0).max(10).optional(), limit: z.number().int().min(1).max(MAX_LIMIT).optional(), offset: z.number().int().min(0).optional(), }); @@ -53,25 +53,16 @@ const inputSchema = z.object({ export function createGrepTool(options: GrepToolOptions): Tool> { return tool({ description: - "Search workspace text with a regular expression or fixed string. Results include paths and line numbers and can include surrounding lines.", + "Search workspace text with a literal string or regular expression. Results include paths and line numbers and can include surrounding lines.", inputSchema, - execute: async ({ - path, - query, - include, - fixedString, - caseSensitive, - contextLines, - limit, - offset, - }) => { + execute: async ({ path, query, include, regex, ignoreCase, context, limit, offset }) => { try { const pageSize = limit ?? DEFAULT_LIMIT; const pageOffset = offset ?? 0; const searchOptions = { - fixedString: fixedString ?? false, - caseSensitive: caseSensitive ?? false, - contextLines: contextLines ?? 0, + regex: regex ?? false, + ignoreCase: ignoreCase ?? false, + context: context ?? 0, }; const matches = await options.workspace.fs.grep(query, path, { ...searchOptions, diff --git a/packages/computer/src/tools/fs/store.ts b/packages/computer/src/tools/fs/store.ts index c639cdd3..9f818c14 100644 --- a/packages/computer/src/tools/fs/store.ts +++ b/packages/computer/src/tools/fs/store.ts @@ -42,9 +42,9 @@ export interface WorkspaceLike { pattern: string, path: string, options?: { - fixedString?: boolean; - caseSensitive?: boolean; - contextLines?: number; + regex?: boolean; + ignoreCase?: boolean; + context?: number; limit?: number; offset?: number; include?: string; From 6e998e9bf39239162d889d1991f0c2266623b35b Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:54:26 +0000 Subject: [PATCH 10/14] computer: State the default ls page size --- packages/computer/src/tools/ai.test.ts | 6 ++++++ packages/computer/src/tools/fs/list.ts | 3 +-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index faecee17..6b4b9d1c 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -318,6 +318,12 @@ describe("createAITools filesystem tools", () => { ]); }); + it("states the default ls page size in the tool description", () => { + const tools = createAITools({ workspace: makeWorkspace() }); + + expect(toolDescription(tools.ls)).toContain("defaults to 200 entries"); + }); + it("returns only read-only tools when readonly is true", () => { const tools = createAITools({ workspace: makeWorkspace(), diff --git a/packages/computer/src/tools/fs/list.ts b/packages/computer/src/tools/fs/list.ts index eafe7097..744dd757 100644 --- a/packages/computer/src/tools/fs/list.ts +++ b/packages/computer/src/tools/fs/list.ts @@ -40,8 +40,7 @@ const inputSchema = z.object({ export function createListTool(options: ListToolOptions): Tool> { return tool({ - description: - "List entries in a workspace directory with file sizes and modification times. Use limit and offset to page through large directories.", + description: `List entries in a workspace directory with file sizes and modification times. The result defaults to ${DEFAULT_LIMIT} entries; use limit and offset to page through large directories.`, inputSchema, execute: async ({ path, limit, offset }) => { try { From 2467bb4f2e3436be7ad9a4cb576c39b05101b673 Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:03:12 +0000 Subject: [PATCH 11/14] computer: Drop the readRange store dependency --- packages/computer/src/tools/fs/store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/computer/src/tools/fs/store.ts b/packages/computer/src/tools/fs/store.ts index 9f818c14..468ff56e 100644 --- a/packages/computer/src/tools/fs/store.ts +++ b/packages/computer/src/tools/fs/store.ts @@ -74,7 +74,7 @@ export interface WorkspaceLike { } type WorkspaceFileStoreLike = { - fs: Pick; + fs: Pick; }; export class WorkspaceFileStore implements MutableFileStore { From 0cb9a17bdfcb1eb4e4c5eb84ef85eeccdc6b95ee Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:37:51 +0000 Subject: [PATCH 12/14] computer: Remove empty tool option bags --- packages/computer/src/tools/ai.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/computer/src/tools/ai.ts b/packages/computer/src/tools/ai.ts index a4bc5e03..78cc8358 100644 --- a/packages/computer/src/tools/ai.ts +++ b/packages/computer/src/tools/ai.ts @@ -1,9 +1,9 @@ import type { ToolSet } from "ai"; import { createExecTool, type ExecToolOptions, type ExecWorkspaceLike } from "./exec.js"; -import { createDeleteTool, type DeleteToolOptions } from "./fs/delete.js"; +import { createDeleteTool } from "./fs/delete.js"; import { createEditTool, type EditToolOptions } from "./fs/edit.js"; -import { createFindTool, type FindToolOptions } from "./fs/find.js"; -import { createGrepTool, type GrepToolOptions } from "./fs/grep.js"; +import { createFindTool } from "./fs/find.js"; +import { createGrepTool } from "./fs/grep.js"; import { createListTool } from "./fs/list.js"; import { createReadTool, type ReadToolOptions } from "./fs/read.js"; import { type WorkspaceLike as FileWorkspaceLike, WorkspaceFileStore } from "./fs/store.js"; @@ -17,9 +17,6 @@ export interface CreateAIToolsOptions { read?: Omit; write?: Omit; edit?: Omit; - find?: Omit; - grep?: Omit; - delete?: Omit; shell?: Omit; } @@ -28,15 +25,15 @@ export function createAITools(options: CreateAIToolsOptions): ToolSet { const tools: ToolSet = { read: createReadTool({ store, ...options.read }), ls: createListTool({ workspace: options.workspace }), - find: createFindTool({ workspace: options.workspace, ...options.find }), - grep: createGrepTool({ workspace: options.workspace, ...options.grep }), + find: createFindTool({ workspace: options.workspace }), + grep: createGrepTool({ workspace: options.workspace }), }; if (options.readonly === true) return tools; tools.write = createWriteTool({ store, ...options.write }); tools.edit = createEditTool({ store, ...options.edit }); - tools.delete = createDeleteTool({ store, ...options.delete }); + tools.delete = createDeleteTool({ store }); if (options.shell !== undefined) { tools.exec = createExecTool({ From 5391ec567014395ab1925dea791e95032bb4dcba Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:07:06 +0000 Subject: [PATCH 13/14] computer: Remove obsolete readRange test doubles --- packages/computer/src/tools/ai.test.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 6b4b9d1c..29d22e54 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -182,7 +182,7 @@ function memoryStore(options: { } describe("WorkspaceFileStore", () => { - it("opens one ranged stream instead of issuing repeated range calls", async () => { + it("opens one ranged stream for a bounded read", async () => { const calls: Array<{ byteOffset?: number; byteLength?: number }> = []; const content = bytes("abcdefghij"); const workspace = { @@ -190,9 +190,6 @@ describe("WorkspaceFileStore", () => { async stat() { throw new Error("stat must not be called by readChunks"); }, - async readRange() { - throw new Error("readRange must not be called by readChunks"); - }, async readFile( _path: string, options: { byteOffset?: number; byteLength?: number } = {}, @@ -277,9 +274,6 @@ describe("WorkspaceFileStore", () => { async stat() { throw new Error("stat must not be called by readChunks"); }, - async readRange() { - throw new Error("readRange must not be called by readChunks"); - }, async readFile(): Promise> { return new ReadableStream({ start(controller) { From 260632a14c6a17528516718aeb56330e9e3b205b Mon Sep 17 00:00:00 2001 From: aron <263346377+aron-cf@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:39:39 +0000 Subject: [PATCH 14/14] computer: Test filesystem mutation internals --- packages/computer/src/tools/ai.test.ts | 36 ------ packages/computer/src/tools/fs/delete.test.ts | 41 ++++++ packages/computer/src/tools/fs/delete.ts | 40 +++--- packages/computer/src/tools/fs/locks.test.ts | 119 ++++++++++++++++++ packages/computer/src/tools/fs/write.test.ts | 60 +++++++++ packages/computer/src/tools/fs/write.ts | 52 ++++---- 6 files changed, 275 insertions(+), 73 deletions(-) create mode 100644 packages/computer/src/tools/fs/delete.test.ts create mode 100644 packages/computer/src/tools/fs/locks.test.ts create mode 100644 packages/computer/src/tools/fs/write.test.ts diff --git a/packages/computer/src/tools/ai.test.ts b/packages/computer/src/tools/ai.test.ts index 29d22e54..28a9196d 100644 --- a/packages/computer/src/tools/ai.test.ts +++ b/packages/computer/src/tools/ai.test.ts @@ -788,42 +788,6 @@ describe("createAITools filesystem tools", () => { expect(events).toEqual(["write", "delete"]); }); - it("preserves file mode when write overwrites an existing file", async () => { - const writes: Array<{ path: string; content: string; mode?: number }> = []; - const tool = createWriteTool({ - store: memoryStore({ - content: "old", - mode: 0o100755, - onWrite(path, content, opts) { - writes.push({ path, content: decode(content), mode: opts?.mode }); - }, - }), - }); - - await expect( - executeTool(tool, { path: "/workspace/script.sh", content: "new" }), - ).resolves.toEqual({ path: "/workspace/script.sh", bytesWritten: 3 }); - expect(writes).toEqual([{ path: "/workspace/script.sh", content: "new", mode: 0o100755 }]); - }); - - it("returns structured write errors for filesystem failures", async () => { - const tool = createWriteTool({ - store: memoryStore({ content: "old", writeError: new Error("disk full") }), - }); - - await expect( - executeTool(tool, { path: "/workspace/out.txt", content: "new" }), - ).resolves.toEqual({ error: "disk full" }); - }); - - it("rejects writes over the byte cap", async () => { - const tool = createWriteTool({ store: memoryStore({}), maxBytes: 3 }); - - await expect( - executeTool(tool, { path: "/workspace/out.txt", content: "abcd" }), - ).resolves.toMatchObject({ error: expect.stringContaining("exceeds the 3-byte write cap") }); - }); - it("returns structured edit errors for non-unique replacements", async () => { const tool = createEditTool({ store: memoryStore({ content: "same\nsame\n" }) }); diff --git a/packages/computer/src/tools/fs/delete.test.ts b/packages/computer/src/tools/fs/delete.test.ts new file mode 100644 index 00000000..7efb8064 --- /dev/null +++ b/packages/computer/src/tools/fs/delete.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { deleteFromStore } from "./delete.js"; +import type { MutableFileStore } from "./types.js"; + +function store(remove: MutableFileStore["remove"]): MutableFileStore { + return { + async stat() { + return null; + }, + async *readChunks() {}, + async readAll() { + return null; + }, + async write() {}, + remove, + }; +} + +describe("deleteFromStore", () => { + it("uses forced idempotent removal and forwards recursive", async () => { + const calls: Array<{ path: string; recursive?: boolean; force?: boolean }> = []; + const target = store(async (path, options) => { + calls.push({ path, ...options }); + }); + + await expect( + deleteFromStore({ store: target }, { path: "/workspace/build", recursive: true }), + ).resolves.toEqual({ deleted: "/workspace/build" }); + expect(calls).toEqual([{ path: "/workspace/build", recursive: true, force: true }]); + }); + + it("returns structured filesystem errors", async () => { + const target = store(async () => { + throw new Error("directory not empty"); + }); + + await expect(deleteFromStore({ store: target }, { path: "/workspace/build" })).resolves.toEqual( + { error: "directory not empty" }, + ); + }); +}); diff --git a/packages/computer/src/tools/fs/delete.ts b/packages/computer/src/tools/fs/delete.ts index 44c5288f..41cf6315 100644 --- a/packages/computer/src/tools/fs/delete.ts +++ b/packages/computer/src/tools/fs/delete.ts @@ -15,25 +15,35 @@ const inputSchema = z.object({ .describe("Remove a directory and all of its contents. Defaults to false."), }); +export interface DeleteInput { + path: string; + recursive?: boolean; +} + +export function deleteFromStore( + options: DeleteToolOptions, + { path, recursive }: DeleteInput, +): Promise<{ deleted: string } | { error: string }> { + return withFileLock( + options.store, + path, + async () => { + try { + await options.store.remove(path, { recursive, force: true }); + return { deleted: path }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } + }, + { subtree: recursive === true }, + ); +} + export function createDeleteTool(options: DeleteToolOptions): Tool> { - const { store } = options; return tool({ description: "Delete a file or directory. Set recursive to true to remove a non-empty directory.", inputSchema, - execute: async ({ path, recursive }) => - withFileLock( - store, - path, - async () => { - try { - await store.remove(path, { recursive, force: true }); - return { deleted: path }; - } catch (error) { - return { error: error instanceof Error ? error.message : String(error) }; - } - }, - { subtree: recursive === true }, - ), + execute: (input) => deleteFromStore(options, input), }); } diff --git a/packages/computer/src/tools/fs/locks.test.ts b/packages/computer/src/tools/fs/locks.test.ts new file mode 100644 index 00000000..8049eada --- /dev/null +++ b/packages/computer/src/tools/fs/locks.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { withFileLock } from "./locks.js"; +import type { FileStore } from "./types.js"; + +function store(lockIdentity?: object): FileStore { + return { + lockIdentity, + async stat() { + return null; + }, + async *readChunks() {}, + async readAll() { + return null; + }, + async write() {}, + }; +} + +describe("withFileLock", () => { + it("serializes normalized aliases of the same path", async () => { + const target = store(); + const events: string[] = []; + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + + const first = withFileLock(target, "/workspace/a/../file", async () => { + events.push("first:start"); + await gate; + events.push("first:end"); + }); + await Promise.resolve(); + const second = withFileLock(target, "/workspace/file", async () => { + events.push("second"); + }); + await Promise.resolve(); + + expect(events).toEqual(["first:start"]); + release?.(); + await Promise.all([first, second]); + expect(events).toEqual(["first:start", "first:end", "second"]); + }); + + it("shares locks through lockIdentity but isolates other stores", async () => { + const identity = {}; + const firstStore = store(identity); + const secondStore = store(identity); + const independent = store(); + const events: string[] = []; + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + + const first = withFileLock(firstStore, "/same", async () => { + events.push("first:start"); + await gate; + events.push("first:end"); + }); + await Promise.resolve(); + const shared = withFileLock(secondStore, "/same", async () => { + events.push("shared"); + }); + const separate = withFileLock(independent, "/same", async () => { + events.push("separate"); + }); + await separate; + + expect(events).toEqual(["first:start", "separate"]); + release?.(); + await Promise.all([first, shared]); + expect(events).toEqual(["first:start", "separate", "first:end", "shared"]); + }); + + it("blocks ancestor and descendant mutations for subtree locks only", async () => { + const target = store(); + const events: string[] = []; + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + }); + + const subtree = withFileLock( + target, + "/workspace/tree", + async () => { + events.push("tree:start"); + await gate; + events.push("tree:end"); + }, + { subtree: true }, + ); + await Promise.resolve(); + const descendant = withFileLock(target, "/workspace/tree/file", async () => { + events.push("descendant"); + }); + const unrelated = withFileLock(target, "/workspace/other", async () => { + events.push("unrelated"); + }); + await unrelated; + + expect(events).toEqual(["tree:start", "unrelated"]); + release?.(); + await Promise.all([subtree, descendant]); + expect(events).toEqual(["tree:start", "unrelated", "tree:end", "descendant"]); + }); + + it("releases a lock when the operation rejects", async () => { + const target = store(); + await expect( + withFileLock(target, "/workspace/file", async () => { + throw new Error("failed"); + }), + ).rejects.toThrow("failed"); + + await expect(withFileLock(target, "/workspace/file", async () => "next")).resolves.toBe("next"); + }); +}); diff --git a/packages/computer/src/tools/fs/write.test.ts b/packages/computer/src/tools/fs/write.test.ts new file mode 100644 index 00000000..fd70b619 --- /dev/null +++ b/packages/computer/src/tools/fs/write.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import type { FileStore } from "./types.js"; +import { writeToStore } from "./write.js"; + +function store(overrides: Partial = {}): FileStore { + return { + async stat() { + return null; + }, + async *readChunks() {}, + async readAll() { + return null; + }, + async write() {}, + ...overrides, + }; +} + +describe("writeToStore", () => { + it("preserves the mode when overwriting a file", async () => { + const writes: Array<{ content: string; mode?: number }> = []; + const target = store({ + async stat() { + return { size: 3, mtime: 1, mode: 0o100755 }; + }, + async write(_path, content, options) { + writes.push({ content: new TextDecoder().decode(content), mode: options?.mode }); + }, + }); + + await expect( + writeToStore({ store: target }, { path: "/workspace/script.sh", content: "new" }), + ).resolves.toEqual({ path: "/workspace/script.sh", bytesWritten: 3 }); + expect(writes).toEqual([{ content: "new", mode: 0o100755 }]); + }); + + it("returns structured filesystem errors", async () => { + const target = store({ + async write() { + throw new Error("disk full"); + }, + }); + + await expect( + writeToStore({ store: target }, { path: "/workspace/out.txt", content: "new" }), + ).resolves.toEqual({ error: "disk full" }); + }); + + it("rejects content over the UTF-8 byte cap", async () => { + const target = store(); + + await expect( + writeToStore({ store: target, maxBytes: 3 }, { path: "/workspace/out.txt", content: "éé" }), + ).resolves.toEqual({ + error: + "Content too large: 4 bytes exceeds the 3-byte write cap. Use the edit tool for incremental changes to existing files, or split the write into smaller pieces.", + }); + }); +}); diff --git a/packages/computer/src/tools/fs/write.ts b/packages/computer/src/tools/fs/write.ts index 139b01f0..89d3d479 100644 --- a/packages/computer/src/tools/fs/write.ts +++ b/packages/computer/src/tools/fs/write.ts @@ -19,32 +19,40 @@ const inputSchema = z.object({ content: z.string().describe("File content"), }); -export function createWriteTool(options: WriteToolOptions): Tool> { - const { store } = options; +export interface WriteInput { + path: string; + content: string; +} + +export async function writeToStore( + options: WriteToolOptions, + { path, content }: WriteInput, +): Promise<{ path: string; bytesWritten: number } | { error: string }> { const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + const bytes = new TextEncoder().encode(content); + if (bytes.length > maxBytes) { + return { + error: `Content too large: ${bytes.length} bytes exceeds the ${maxBytes}-byte write cap. Use the edit tool for incremental changes to existing files, or split the write into smaller pieces.`, + }; + } + return withFileLock(options.store, path, async () => { + try { + // Preserve the existing file's mode when overwriting so executable + // scripts don't silently lose its executable bits. For new files we + // let the store apply its own default. + const existing = await options.store.stat(path); + await options.store.write(path, bytes, existing ? { mode: existing.mode } : undefined); + return { path, bytesWritten: bytes.length }; + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } + }); +} +export function createWriteTool(options: WriteToolOptions): Tool> { return tool({ description: "Write content to a file. Overwrites any existing file at the path.", inputSchema, - execute: async ({ path, content }) => { - const bytes = new TextEncoder().encode(content); - if (bytes.length > maxBytes) { - return { - error: `Content too large: ${bytes.length} bytes exceeds the ${maxBytes}-byte write cap. Use the edit tool for incremental changes to existing files, or split the write into smaller pieces.`, - }; - } - return withFileLock(store, path, async () => { - try { - // Preserve the existing file's mode when overwriting so executable - // scripts don't silently lose its executable bits. For new files we - // let the store apply its own default. - const existing = await store.stat(path); - await store.write(path, bytes, existing ? { mode: existing.mode } : undefined); - return { path, bytesWritten: bytes.length }; - } catch (err) { - return { error: err instanceof Error ? err.message : String(err) }; - } - }); - }, + execute: (input) => writeToStore(options, input), }); }