diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 14:06:23 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 14:06:23 +0900 |
| commit | 1ff0eac44cd44751af979c51c746a1774c268e8a (patch) | |
| tree | bf1c4563595e5b4c23f63e1d5b0782400be7e025 /packages/tool-write-file/src/write-file.ts | |
| parent | 54db4583e66134010375a1fa94256f36034ffdff (diff) | |
| download | dispatch-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.ts | 62 |
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)}`, |
