diff --git a/.changeset/export-workspace-file-store.md b/.changeset/export-workspace-file-store.md new file mode 100644 index 000000000..ebf7d9f73 --- /dev/null +++ b/.changeset/export-workspace-file-store.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/computer": patch +--- + +`WorkspaceFileStore` and `WORKSPACE_ROOT` are exported from `@cloudflare/computer`, so code outside the tools can read and write workspace files without its own helper. diff --git a/docs/09_tool_interface.md b/docs/09_tool_interface.md index eb176901b..c8f46c0a7 100644 --- a/docs/09_tool_interface.md +++ b/docs/09_tool_interface.md @@ -422,6 +422,8 @@ interface MutableFileStore extends FileStore { `WorkspaceFileStore` adapts the corresponding `workspace.fs` methods. Its chunk iterator opens one ranged `readFile` stream, so seeking to a byte continuation neither transfers the preceding content nor issues one RPC invocation per chunk. +Code outside the tools can import `WorkspaceFileStore` from `@cloudflare/computer` without loading a tool library: `readAll` returns `null` for a missing file, and `write` creates parent directories. `WORKSPACE_ROOT` is the `/workspace` directory the backends and tools default to. + ## Conventions for agents - Tools take absolute paths. Pass `root` to resolve relative paths against a directory and refuse paths outside it, or resolve user input yourself before calling them. See [01. VFS](./01_vfs.md). diff --git a/packages/computer/src/backends/container-legacy/cloudflare-container.ts b/packages/computer/src/backends/container-legacy/cloudflare-container.ts index 29d918370..1fb17c880 100644 --- a/packages/computer/src/backends/container-legacy/cloudflare-container.ts +++ b/packages/computer/src/backends/container-legacy/cloudflare-container.ts @@ -55,6 +55,7 @@ import { type WorkspaceEgressPolicy, } from "../../runtime/egress.js"; import { WorkspaceTransportError } from "../../transport-failure.js"; +import { WORKSPACE_ROOT } from "../../workspace-root.js"; import type { ILegacyWorkspaceContainerAPI, WorkspaceRef } from "./container-host.js"; import { probeComputerdHealth } from "./health-probe.js"; @@ -249,7 +250,7 @@ export class LegacyContainerBackend implements WorkspaceBackend { const env = { PORT: String(this.#options.containerPort), - MOUNT_POINT: "/workspace", + MOUNT_POINT: WORKSPACE_ROOT, ...this.#options.containerEnv, }; let runtimeId: string; diff --git a/packages/computer/src/backends/container/container-backend.ts b/packages/computer/src/backends/container/container-backend.ts index ad5a546bb..426256622 100644 --- a/packages/computer/src/backends/container/container-backend.ts +++ b/packages/computer/src/backends/container/container-backend.ts @@ -55,6 +55,7 @@ import { type WorkspaceEgressPolicy, } from "../../runtime/egress.js"; import { WorkspaceTransportError } from "../../transport-failure.js"; +import { WORKSPACE_ROOT } from "../../workspace-root.js"; import type { IWorkspaceContainerAPI, WorkspaceRef } from "./container-host.js"; import type { ContainerInstanceSize, ContainerLaunchSpec } from "./container-launch-record.js"; import { probeComputerdHealth } from "./health-probe.js"; @@ -329,7 +330,7 @@ export class ContainerBackend implements WorkspaceBackend { const env = { PORT: String(this.#options.containerPort), - MOUNT_POINT: "/workspace", + MOUNT_POINT: WORKSPACE_ROOT, ...(this.#options.ignore !== undefined ? { MOUNT_IGNORE: this.#options.ignore.join(",") } : {}), diff --git a/packages/computer/src/backends/container/ignore-assertion.ts b/packages/computer/src/backends/container/ignore-assertion.ts index 57bd53ce7..a61afd308 100644 --- a/packages/computer/src/backends/container/ignore-assertion.ts +++ b/packages/computer/src/backends/container/ignore-assertion.ts @@ -11,6 +11,8 @@ // Object -- the #179 symptom. A mismatch is a deployment error, and a // loud one is cheaper than a slow one. +import { WORKSPACE_ROOT } from "../../workspace-root.js"; + /** The `ignore` block computerd reports on /__computerd/info. */ export interface ComputerdIgnoreReport { readonly supported?: boolean; @@ -88,7 +90,7 @@ export function readIgnoreReport(info: unknown): ResolvedIgnore { return { patterns, root: typeof typed.root === "string" ? typed.root : undefined, - mountPoint: typeof mountPoint === "string" && mountPoint !== "" ? mountPoint : "/workspace", + mountPoint: typeof mountPoint === "string" && mountPoint !== "" ? mountPoint : WORKSPACE_ROOT, supported: true, }; } diff --git a/packages/computer/src/backends/worker-javascript/worker-javascript.ts b/packages/computer/src/backends/worker-javascript/worker-javascript.ts index 3a8ee6e30..e6ba51956 100644 --- a/packages/computer/src/backends/worker-javascript/worker-javascript.ts +++ b/packages/computer/src/backends/worker-javascript/worker-javascript.ts @@ -15,6 +15,7 @@ import type { WorkspaceRuntimeLoader, WorkspaceRuntimeValue, } from "../../runtime/types.js"; +import { WORKSPACE_ROOT } from "../../workspace-root.js"; import { decodeRuntimeFrames, type RuntimeFrame } from "./frames.js"; import { assertHostModuleExports, @@ -277,7 +278,7 @@ export class WorkerJavaScriptBackend implements WorkspaceModuleBackend { ...backendOptions, egress: resolvedEgress, modules: parseModules(options.modules ?? {}), - root: options.root ?? "/workspace", + root: options.root ?? WORKSPACE_ROOT, access: options.access ?? "read-write", defaultTimeoutMs, maxTimeoutMs, diff --git a/packages/computer/src/backends/worker-shell/entrypoint.ts b/packages/computer/src/backends/worker-shell/entrypoint.ts index 492d2c996..fdc9f3152 100644 --- a/packages/computer/src/backends/worker-shell/entrypoint.ts +++ b/packages/computer/src/backends/worker-shell/entrypoint.ts @@ -20,6 +20,7 @@ import { WorkerEntrypoint } from "cloudflare:workers"; import { Bash, type CustomCommand, type SecureFetch } from "just-bash"; +import { WORKSPACE_ROOT } from "../../workspace-root.js"; import { WorkspaceFsAdapter } from "./adapter.js"; import { type ArtifactsCommandHost, defineArtifactsCommand } from "./artifacts-command.js"; import { type AssetsCommandHost, defineAssetsCommand } from "./assets-command.js"; @@ -110,7 +111,7 @@ export interface HostWorkspaceStub extends GitCommandHost, AssetsCommandHost { [Symbol.dispose]?: () => void; } -const DEFAULT_CWD = "/workspace"; +const DEFAULT_CWD = WORKSPACE_ROOT; const MAX_OUTPUT_BYTES = 1024 * 1024; // Wire event shape. Matches @cloudflare/computer-rpc's ExecEvent diff --git a/packages/computer/src/index.test.ts b/packages/computer/src/index.test.ts new file mode 100644 index 000000000..3404cbdfe --- /dev/null +++ b/packages/computer/src/index.test.ts @@ -0,0 +1,31 @@ +import { SQLiteTestStorage } from "@cloudflare/dofs/testing"; +import { describe, expect, it } from "vitest"; + +import { getWorkspace, Workspace, WORKSPACE_ROOT, WorkspaceFileStore } from "./index.js"; + +describe("the file helper from @cloudflare/computer", () => { + it("reads missing files as null and writes into new directories", async () => { + const workspace = new Workspace({ storage: new SQLiteTestStorage() }); + const files = new WorkspaceFileStore(workspace); + const path = `${WORKSPACE_ROOT}/notes/today.md`; + + await expect(files.readAll(path)).resolves.toBeNull(); + await files.write(path, new TextEncoder().encode("ship it")); + const written = await files.readAll(path); + expect(written && new TextDecoder().decode(written)).toBe("ship it"); + await workspace.close(); + }); + + it("works on a client that has not loaded yet", async () => { + const workspace = new Workspace({ storage: new SQLiteTestStorage() }); + const client = getWorkspace({ __getWorkspaceStub: () => Promise.resolve(workspace.stub()) }); + const files = new WorkspaceFileStore(client); + const path = `${WORKSPACE_ROOT}/a/b.txt`; + + await expect(files.readAll(path)).resolves.toBeNull(); + await files.write(path, new TextEncoder().encode("hello")); + await expect(files.stat(path)).resolves.toMatchObject({ size: 5 }); + client[Symbol.dispose](); + await workspace.close(); + }); +}); diff --git a/packages/computer/src/index.ts b/packages/computer/src/index.ts index 02a7cf79a..89993768f 100644 --- a/packages/computer/src/index.ts +++ b/packages/computer/src/index.ts @@ -131,4 +131,7 @@ export { type WorkspaceStubHost, withWorkspace, } from "./with-workspace.js"; +export { WorkspaceFileStore } from "./tools/common/fs/store.js"; +export type { FileStat, FileStore, MutableFileStore } from "./tools/common/fs/types.js"; export { Workspace, type WorkspaceGitFactory, type WorkspaceOptions } from "./workspace.js"; +export { WORKSPACE_ROOT } from "./workspace-root.js"; diff --git a/packages/computer/src/tools/common/fs/find.ts b/packages/computer/src/tools/common/fs/find.ts index 4a9f3ee29..1ab0b3de5 100644 --- a/packages/computer/src/tools/common/fs/find.ts +++ b/packages/computer/src/tools/common/fs/find.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { WORKSPACE_ROOT } from "../../../workspace-root.js"; import { globList } from "./globs.js"; interface FoundEntry { @@ -24,7 +25,7 @@ const DEFAULT_LIMIT = 200; const MAX_LIMIT = 1000; export const findInputSchema = z.object({ - path: z.string().default("/workspace").describe("Absolute directory to search."), + path: z.string().default(WORKSPACE_ROOT).describe("Absolute directory to search."), pattern: z .string() .describe('Glob pattern relative to path, for example "**/*.ts" or "src/?.js".'), @@ -70,7 +71,7 @@ export async function findInWorkspace( workspace: FindWorkspaceLike, { path, pattern, exclude, limit, offset }: FindInput, ): Promise { - const directory = path ?? "/workspace"; + const directory = path ?? WORKSPACE_ROOT; try { const pageSize = limit ?? DEFAULT_LIMIT; const pageOffset = offset ?? 0; diff --git a/packages/computer/src/tools/common/fs/grep.ts b/packages/computer/src/tools/common/fs/grep.ts index aa33fe7b8..bd6cfe15e 100644 --- a/packages/computer/src/tools/common/fs/grep.ts +++ b/packages/computer/src/tools/common/fs/grep.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { WORKSPACE_ROOT } from "../../../workspace-root.js"; import { globList, globOne } from "./globs.js"; interface GrepContextLine { @@ -38,7 +39,7 @@ const DEFAULT_LIMIT = 200; const MAX_LIMIT = 1000; export const grepInputSchema = z.object({ - path: z.string().default("/workspace").describe("Absolute file or directory to search."), + path: z.string().default(WORKSPACE_ROOT).describe("Absolute file or directory to search."), query: z.string().describe("Literal string or regular expression to search for."), include: z .union([z.string(), z.array(z.string())]) @@ -95,7 +96,7 @@ export async function grepInWorkspace( workspace: GrepWorkspaceLike, { path, query, include, exclude, regex, ignoreCase, context, limit, offset }: GrepInput, ): Promise { - const target = path ?? "/workspace"; + const target = path ?? WORKSPACE_ROOT; try { const pageSize = limit ?? DEFAULT_LIMIT; const pageOffset = offset ?? 0; diff --git a/packages/computer/src/workspace-root.ts b/packages/computer/src/workspace-root.ts new file mode 100644 index 000000000..ea5345e3f --- /dev/null +++ b/packages/computer/src/workspace-root.ts @@ -0,0 +1,2 @@ +/** The directory Computer's backends and tools treat as the working root. */ +export const WORKSPACE_ROOT = "/workspace";