summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-write-file/src/write-file.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 14:06:23 +0900
committerAdam Malczewski <[email protected]>2026-06-25 14:06:23 +0900
commit1ff0eac44cd44751af979c51c746a1774c268e8a (patch)
treebf1c4563595e5b4c23f63e1d5b0782400be7e025 /packages/tool-write-file/src/write-file.ts
parent54db4583e66134010375a1fa94256f36034ffdff (diff)
downloaddispatch-1ff0eac44cd44751af979c51c746a1774c268e8a.tar.gz
dispatch-1ff0eac44cd44751af979c51c746a1774c268e8a.zip
feat(ssh): wave 2 — route filesystem/shell tools behind ExecBackend
Wave 2 of transparent SSH support (4 parallel owner-agents on disjoint tool packages). The tools now resolve an ExecBackend per-call from ctx.computerId and call backend.spawn / backend.readFile / etc. instead of node:fs and node:child_process directly — so they are transport-agnostic (local now; remote over SSH later, transparent to the agent). Still LOCAL-ONLY this wave (computerId always undefined -> LocalExecBackend, behavior-identical). - tool-shell: factory takes resolveBackend; execute calls backend.spawn. spawn.ts DELETED (realSpawn was a verbatim duplicate of exec-backend's LocalExecBackend.spawn — logic moved to the sanctioned shared package). manifest dependsOn:[exec-backend]; host.getService at activation. - tool-read-file: readFile/stat/readdir -> backend.* (pure logic untouched; ENOENT .code branches kept). - tool-write-file: exists/stat/writeFile -> backend.* (pure logic untouched). - tool-edit-file: readFile/writeFile -> backend.* + forward-compatible REMOTE diagnostics skip (ctx.computerId set -> skip LSP, return empty — plan §6.1; local path byte-identical to today). LSP lookup stays lazy. - orchestrator: pre-wired @dispatch/exec-backend dep into the 4 tool package.jsons + bun install (build/config, my lane) so isolated verify resolved cleanly; agents added the ../exec-backend tsconfig ref. Verified: tsc -b EXIT 0, biome clean, 1599 vitest pass (was 1592). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
Diffstat (limited to 'packages/tool-write-file/src/write-file.ts')
-rw-r--r--packages/tool-write-file/src/write-file.ts62
1 files changed, 38 insertions, 24 deletions
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)}`,