summaryrefslogtreecommitdiffhomepage
path: root/packages/exec-backend/src/local.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 12:22:41 +0900
committerAdam Malczewski <[email protected]>2026-06-25 12:22:41 +0900
commit54db4583e66134010375a1fa94256f36034ffdff (patch)
treeec0bcd395d365741ed18e160f9b5842233051ba2 /packages/exec-backend/src/local.ts
parent0b154bdad4f75a091db3ca46424abd17fbbc23ff (diff)
downloaddispatch-54db4583e66134010375a1fa94256f36034ffdff.tar.gz
dispatch-54db4583e66134010375a1fa94256f36034ffdff.zip
feat(ssh): wave 1 — ExecBackend + computer data model + runtime threading
Wave 1 of transparent SSH support (parallel owner-agents on disjoint packages, plus the orchestrator-authored kernel contract seam from wave 0): - packages/wire: + Computer/ComputerEntry (read-only view over ~/.ssh/config Host aliases) + Workspace.defaultComputerId (string|null, null=local). Types only; 3 conformance tests. - packages/exec-backend (NEW core extension): the ExecBackend abstraction (spawn + minimal fs surface) the bundled tools will program against instead of node:fs/child_process. LocalExecBackend wraps today's node calls (behavior-identical; node:fs-style .code errors). execBackendHandle + ExecBackendResolver (sync; computerId undefined -> local; set -> throws until the ssh package wires remote resolution in wave 5). 20 tests. - packages/kernel (runtime only): thread computerId through dispatch.ts + run-turn.ts exactly as cwd is threaded (opaque, forwarded to ToolExecuteContext; absent = local = byte-identical to today). +2 tests. - packages/conversation-store: computer (SSH alias) assignment + resolution mirroring cwd — WorkspaceRow.defaultComputerId + setWorkspaceDefaultComputerId + getComputerId/setComputerId/clearComputerId + getEffectiveComputer (override -> per-conv -> workspace default -> null/local). Fixes the 3 Workspace literal sites the new required wire field broke. +18 tests. - orchestrator: root tsconfig.json ref for exec-backend + bun install. Verified: tsc -b EXIT 0, biome clean, 1592 vitest pass (was 1549, +43). Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
Diffstat (limited to 'packages/exec-backend/src/local.ts')
-rw-r--r--packages/exec-backend/src/local.ts146
1 files changed, 146 insertions, 0 deletions
diff --git a/packages/exec-backend/src/local.ts b/packages/exec-backend/src/local.ts
new file mode 100644
index 0000000..ca88a11
--- /dev/null
+++ b/packages/exec-backend/src/local.ts
@@ -0,0 +1,146 @@
+import { spawn as nodeSpawn } from "node:child_process";
+import { access, readdir, readFile, stat, writeFile } from "node:fs/promises";
+import type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from "./backend.js";
+
+/**
+ * LocalExecBackend — wraps `node:fs/promises` + `node:child_process`.
+ *
+ * Behavior is IDENTICAL to today's local tools:
+ * - `spawn` mirrors `realSpawn` in `packages/tool-shell/src/spawn.ts` — same
+ * `sh -c` invocation, detached process-group kill on abort/timeout,
+ * close-based resolution, and spawn-error → `{ exitCode: 1 }`.
+ * - `readFile`/`writeFile`/`stat`/`readdir` use the same `node:fs/promises`
+ * calls (utf8, `withFileTypes`) the tools make inline today, and throw the
+ * same node errors (carrying `.code`) so the tools' existing error branches
+ * work unchanged.
+ * - `exists` swallows all errors and returns `false` (an existence check).
+ *
+ * This factors the inline node calls out behind the `ExecBackend` interface so
+ * a remote (SshExecBackend) can swap in transparently. Stateless — safe to
+ * share as a singleton.
+ */
+export function createLocalExecBackend(): ExecBackend {
+ return {
+ spawn: localSpawn,
+
+ readFile: (path) => readFile(path, "utf8"),
+
+ writeFile: (path, content) => writeFile(path, content, "utf8"),
+
+ stat: async (path): Promise<StatResult> => {
+ const s = await stat(path);
+ return { isFile: s.isFile(), isDirectory: s.isDirectory() };
+ },
+
+ readdir: async (path): Promise<readonly DirEntry[]> => {
+ const entries = await readdir(path, { encoding: "utf8", withFileTypes: true });
+ return entries.map((e): DirEntry => ({ name: e.name, isDirectory: e.isDirectory() }));
+ },
+
+ exists: async (path): Promise<boolean> => {
+ try {
+ await access(path);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ };
+}
+
+/** Default singleton — stateless, safe to share across calls. */
+export const localExecBackend: ExecBackend = createLocalExecBackend();
+
+/**
+ * Run a shell command locally via `node:child_process`.
+ *
+ * Ported verbatim from `packages/tool-shell/src/spawn.ts` (`realSpawn`) so
+ * behavior is byte-identical: `sh -c <command>`, `detached: true` (own process
+ * group), process-group `SIGKILL` on abort/timeout so a backgrounded grandchild
+ * cannot hold the stdio pipes open, and resolve-once-with-cleanup to avoid
+ * listener/timer leaks.
+ */
+function localSpawn(params: SpawnParams): Promise<ExecResult> {
+ return new Promise<ExecResult>((resolve) => {
+ // detached: true puts the child in its own process group (pgid = child.pid).
+ // This lets us kill the entire group (child + any grandchildren that inherit
+ // the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a
+ // backgrounded grandchild can't keep the stdio pipes open and stall the
+ // promise on child.on("close").
+ const child = nodeSpawn("sh", ["-c", params.command], {
+ cwd: params.cwd,
+ stdio: ["ignore", "pipe", "pipe"],
+ detached: true,
+ });
+
+ let settled = false;
+ let timedOut = false;
+ let timer: ReturnType<typeof setTimeout> | undefined;
+
+ /** Kill the entire child process group (best-effort — group may be gone). */
+ const killGroup = () => {
+ if (child.pid !== undefined) {
+ try {
+ process.kill(-child.pid, "SIGKILL");
+ } catch {
+ // Process group may already be gone — ignore.
+ }
+ }
+ };
+
+ /** Remove the abort listener and clear the timeout timer (no leaks). */
+ const cleanup = () => {
+ if (timer !== undefined) {
+ clearTimeout(timer);
+ timer = undefined;
+ }
+ params.signal.removeEventListener("abort", onAbort);
+ };
+
+ /** Resolve once, then clean up so listeners/timers never leak. */
+ const settle = (result: ExecResult) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ resolve(result);
+ };
+
+ const onAbort = () => {
+ if (settled) return;
+ killGroup();
+ // Resolve immediately — do NOT wait for child.on("close"), which may
+ // never fire if a grandchild holds the pipes open.
+ settle({ exitCode: null, timedOut: false, aborted: true });
+ };
+ params.signal.addEventListener("abort", onAbort, { once: true });
+
+ timer = setTimeout(() => {
+ if (settled) return;
+ timedOut = true;
+ killGroup();
+ // Resolve immediately — same reasoning as abort.
+ settle({ exitCode: null, timedOut: true, aborted: false });
+ }, params.timeout);
+
+ child.stdout.on("data", (chunk: Buffer) => {
+ params.onOutput(chunk.toString(), "stdout");
+ });
+
+ child.stderr.on("data", (chunk: Buffer) => {
+ params.onOutput(chunk.toString(), "stderr");
+ });
+
+ // Normal-completion path: wait for "close" so all stdout/stderr is captured.
+ // If abort/timeout already settled, this is a no-op (settled === true).
+ child.on("close", (code) => {
+ settle({ exitCode: code, timedOut, aborted: false });
+ });
+
+ // Spawn error (e.g. bad cwd, sh not found). Kill the group just in case
+ // and resolve — never leave the promise pending.
+ child.on("error", () => {
+ killGroup();
+ settle({ exitCode: 1, timedOut: false, aborted: false });
+ });
+ });
+}