summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-write-file/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/tool-write-file/src')
-rw-r--r--packages/tool-write-file/src/extension.ts6
-rw-r--r--packages/tool-write-file/src/write-file.test.ts32
-rw-r--r--packages/tool-write-file/src/write-file.ts62
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)}`,