diff options
Diffstat (limited to 'packages/tool-write-file/src')
| -rw-r--r-- | packages/tool-write-file/src/extension.ts | 6 | ||||
| -rw-r--r-- | packages/tool-write-file/src/write-file.test.ts | 32 | ||||
| -rw-r--r-- | packages/tool-write-file/src/write-file.ts | 62 |
3 files changed, 64 insertions, 36 deletions
diff --git a/packages/tool-write-file/src/extension.ts b/packages/tool-write-file/src/extension.ts index 2008954..0a9a10f 100644 --- a/packages/tool-write-file/src/extension.ts +++ b/packages/tool-write-file/src/extension.ts @@ -1,3 +1,4 @@ +import { execBackendHandle } from "@dispatch/exec-backend"; import type { Extension } from "@dispatch/kernel"; import { createWriteFileTool } from "./write-file.js"; @@ -11,8 +12,11 @@ export const extension: Extension = { activation: "eager", capabilities: { fs: true }, contributes: { tools: ["write_file"] }, + // Host activates exec-backend first → host.getService at activation is safe. + dependsOn: ["exec-backend"], }, activate(host) { - host.defineTool(createWriteFileTool(process.cwd())); + const resolveBackend = host.getService(execBackendHandle); + host.defineTool(createWriteFileTool({ resolveBackend, workdir: process.cwd() })); }, }; diff --git a/packages/tool-write-file/src/write-file.test.ts b/packages/tool-write-file/src/write-file.test.ts index 6b316bc..d157eb2 100644 --- a/packages/tool-write-file/src/write-file.test.ts +++ b/packages/tool-write-file/src/write-file.test.ts @@ -1,6 +1,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { localExecBackend } from "@dispatch/exec-backend"; import { createLogger, type ToolExecuteContext } from "@dispatch/kernel"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createWriteFileTool, decideOverwrite, validateArgs } from "./write-file.js"; @@ -19,6 +20,15 @@ function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext { }; } +/** + * Build a write_file tool wired to the real local ExecBackend (node:fs, + * behavior-identical to today's inline calls). No `@dispatch/*` mocking — the + * real fs edge is exercised, matching the constitution's strict-core rule. + */ +function makeTool(workdir: string) { + return createWriteFileTool({ resolveBackend: () => localExecBackend, workdir }); +} + let workdir: string; beforeEach(async () => { @@ -116,7 +126,7 @@ describe("validateArgs", () => { describe("createWriteFileTool", () => { it("creates a new file when overwrite is unset and the file is absent", async () => { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "new-file.txt", content: "hello world" }, stubCtx()); expect(result.isError).toBeUndefined(); @@ -128,7 +138,7 @@ describe("createWriteFileTool", () => { it("errors when the file exists and overwrite is unset", async () => { await writeFile(join(workdir, "existing.txt"), "old content", "utf8"); - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "existing.txt", content: "new content" }, stubCtx()); expect(result.isError).toBe(true); @@ -141,7 +151,7 @@ describe("createWriteFileTool", () => { it("overwrites an existing file when overwrite is true", async () => { await writeFile(join(workdir, "existing.txt"), "old content", "utf8"); - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute( { path: "existing.txt", content: "new content", overwrite: true }, stubCtx(), @@ -154,7 +164,7 @@ describe("createWriteFileTool", () => { }); it("errors when overwrite is true but the file is absent", async () => { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute( { path: "nonexistent.txt", content: "data", overwrite: true }, stubCtx(), @@ -165,7 +175,7 @@ describe("createWriteFileTool", () => { }); it("errors when the parent directory does not exist", async () => { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "no/such/dir/file.txt", content: "data" }, stubCtx()); expect(result.isError).toBe(true); @@ -173,12 +183,12 @@ describe("createWriteFileTool", () => { }); it("concurrencySafe is false", () => { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); expect(tool.concurrencySafe).toBe(false); }); it("has correct name and parameters shape", () => { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); expect(tool.name).toBe("write_file"); expect(tool.parameters.type).toBe("object"); expect(tool.parameters.required).toEqual(["path", "content"]); @@ -188,7 +198,7 @@ describe("createWriteFileTool", () => { }); it("never throws on bad input (always returns ToolResult)", async () => { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const inputs = [null, undefined, 42, "string", {}, { path: "" }, { path: 123 }]; for (const input of inputs) { const result = await tool.execute(input, stubCtx()); @@ -200,7 +210,7 @@ describe("createWriteFileTool", () => { it("respects ctx.cwd over baked workdir", async () => { const ctxDir = await mkdtemp(join(tmpdir(), "ctx-cwd-test-")); try { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute( { path: "ctx-file.txt", content: "from ctx" }, stubCtx({ cwd: ctxDir }), @@ -215,7 +225,7 @@ describe("createWriteFileTool", () => { }); it("writes empty content", async () => { - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "empty.txt", content: "" }, stubCtx()); expect(result.isError).toBeUndefined(); @@ -225,7 +235,7 @@ describe("createWriteFileTool", () => { it("writes content in subdirectory that exists", async () => { await mkdir(join(workdir, "sub")); - const tool = createWriteFileTool(workdir); + const tool = makeTool(workdir); const result = await tool.execute({ path: "sub/file.txt", content: "nested" }, stubCtx()); expect(result.isError).toBeUndefined(); diff --git a/packages/tool-write-file/src/write-file.ts b/packages/tool-write-file/src/write-file.ts index 1317ce8..cf761b6 100644 --- a/packages/tool-write-file/src/write-file.ts +++ b/packages/tool-write-file/src/write-file.ts @@ -1,5 +1,5 @@ -import { access, stat, writeFile } from "node:fs/promises"; import { resolve } from "node:path"; +import type { ExecBackend, ExecBackendResolver } from "@dispatch/exec-backend"; import type { ToolContract, ToolResult } from "@dispatch/kernel"; interface ValidatedArgs { @@ -51,11 +51,21 @@ export function validateArgs(args: unknown): ValidatedArgs | { readonly error: s } /** - * Factory: create a write_file ToolContract bound to a working directory. - * The working directory is injected so the tool is testable. + * Factory: create a write_file ToolContract. + * + * `resolveBackend` is the injected seam: each `execute` resolves an + * `ExecBackend` from `ctx.computerId` (undefined → local `node:fs`; a set + * id → a remote SSH backend in a later wave). The tool programs against the + * `ExecBackend` surface, never `node:fs` directly, so it is transport-agnostic. + * + * `workdir` is the fallback base directory when `ctx.cwd` is omitted. It is + * injected so the tool is testable; `execute` prefers `ctx.cwd` when present. */ -export function createWriteFileTool(workingDirectory: string): ToolContract { - const workdir = resolve(workingDirectory); +export function createWriteFileTool(deps: { + readonly resolveBackend: ExecBackendResolver; + readonly workdir?: string; +}): ToolContract { + const workdir = deps.workdir !== undefined ? resolve(deps.workdir) : undefined; return { name: "write_file", @@ -95,22 +105,21 @@ export function createWriteFileTool(workingDirectory: string): ToolContract { const { path: relPath, content, overwrite } = validated; const effectiveBase = ctx.cwd ? resolve(ctx.cwd) : workdir; + if (effectiveBase === undefined) { + return { + content: + "Error: No working directory (neither ctx.cwd nor a baked workdir was provided).", + isError: true, + }; + } const resolvedPath = resolve(effectiveBase, relPath); - // Check existence. - let fileExists = false; - try { - await access(resolvedPath); - fileExists = true; - } catch (err: unknown) { - const code = (err as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - return { - content: `Error checking file: ${err instanceof Error ? err.message : String(err)}`, - isError: true, - }; - } - } + const backend: ExecBackend = deps.resolveBackend(ctx.computerId); + + // Check existence. `backend.exists` never throws — it returns false + // when the path is missing — so the old try/catch around `access` + // collapses to a single boolean read. + const fileExists = await backend.exists(resolvedPath); // Pure decision. const decision = decideOverwrite(fileExists, overwrite); @@ -118,10 +127,13 @@ export function createWriteFileTool(workingDirectory: string): ToolContract { return { content: decision.error, isError: true }; } - // Verify it's not a directory. + // Verify it's not a directory. `backend.stat` returns a + // `{ isFile, isDirectory }` result; only reached when the file + // exists, so an ENOENT here is a lost race left to propagate + // (same as the prior uncaught `stat` call). if (fileExists) { - const pathStat = await stat(resolvedPath); - if (pathStat.isDirectory()) { + const pathStat = await backend.stat(resolvedPath); + if (pathStat.isDirectory) { return { content: `Error: "${relPath}" is a directory, not a file.`, isError: true, @@ -129,9 +141,11 @@ export function createWriteFileTool(workingDirectory: string): ToolContract { } } - // Write the file. + // Write the file. LocalExecBackend throws node:fs-style errors + // carrying a `.code` (e.g. ENOENT when the parent dir is missing); + // the catch surfaces the message verbatim. try { - await writeFile(resolvedPath, content, "utf8"); + await backend.writeFile(resolvedPath, content); } catch (err: unknown) { return { content: `Error writing file: ${err instanceof Error ? err.message : String(err)}`, |
