summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 15:34:59 +0900
committerAdam Malczewski <[email protected]>2026-06-25 15:34:59 +0900
commit350b9b8e247bb1c24f49a884fdade18e44b115eb (patch)
tree502df8f103d53f1089eeee7278865d73077f9edc
parent3647acfb7f078b2f035dd325f6959980c5b46c9a (diff)
downloaddispatch-350b9b8e247bb1c24f49a884fdade18e44b115eb.tar.gz
dispatch-350b9b8e247bb1c24f49a884fdade18e44b115eb.zip
feat(ssh): wave 5a — exec-backend remote-backend factory handle
exec-backend declares remoteExecBackendFactoryHandle (a consumer-defined ServiceHandle<(computerId) => ExecBackend>) that the ssh package will provide (standard→core layering). The resolver's computerId-set branch now lazy-looks-up this factory (at tool-execute time, runtime) and calls it; if ssh isn't loaded, getService throws → a clear 'SSH remote execution is not configured' error. The computerId-undefined (local) branch is byte-identical to before. This is the seam wave 5b (the ssh package) plugs into. +tests for both branches. Verified: tsc -b EXIT 0, biome clean. No merge or push.
-rw-r--r--packages/exec-backend/src/extension.test.ts121
-rw-r--r--packages/exec-backend/src/extension.ts61
-rw-r--r--packages/exec-backend/src/index.ts5
-rw-r--r--packages/exec-backend/src/service.ts19
4 files changed, 187 insertions, 19 deletions
diff --git a/packages/exec-backend/src/extension.test.ts b/packages/exec-backend/src/extension.test.ts
new file mode 100644
index 0000000..57161a5
--- /dev/null
+++ b/packages/exec-backend/src/extension.test.ts
@@ -0,0 +1,121 @@
+import type { HostAPI, ServiceHandle } from "@dispatch/kernel";
+import { describe, expect, it } from "vitest";
+import type { ExecBackend } from "./backend.js";
+import { createExecBackendExtension } from "./extension.js";
+import { localExecBackend } from "./local.js";
+import { execBackendHandle, remoteExecBackendFactoryHandle } from "./service.js";
+
+/**
+ * Resolver tests — pure core, zero internal mocks.
+ *
+ * The resolver's ONLY external dependency is `host.getService` (the service
+ * registry — the outermost edge). We inject a minimal fake host that mirrors
+ * the real `bus.getService` contract: returns the provided impl, or throws when
+ * nothing provided the handle. No `vi.mock("@dispatch/*")` — the resolver +
+ * handles + local backend under test are all real.
+ *
+ * Three cases (matching the task spec):
+ * 1. `computerId` undefined → `localExecBackend` (byte-identical local path).
+ * 2. `computerId` set + factory provided → the factory's backend.
+ * 3. `computerId` set + factory NOT provided (ssh not loaded) → a clear
+ * "SSH remote execution is not configured" error, not a crash.
+ */
+
+/**
+ * A minimal fake host exposing only the service-registry surface the resolver
+ * touches (`getService`/`provideService`). Throws on a missing service exactly
+ * like the real `bus.getService`, so the resolver's try/catch path is exercised
+ * against behavior-equivalent input.
+ */
+function createFakeHost(services: Map<string, unknown>): HostAPI {
+ const api = {
+ provideService<T>(handle: ServiceHandle<T>, impl: T): void {
+ services.set(handle.id, impl);
+ },
+ getService<T>(handle: ServiceHandle<T>): T {
+ const impl = services.get(handle.id);
+ if (impl === undefined) {
+ throw new Error(
+ `Service "${handle.id}" has no provider. Call provideService before getService.`,
+ );
+ }
+ return impl as T;
+ },
+ };
+ // The resolver only calls getService; the rest of HostAPI is unused here.
+ return api as unknown as HostAPI;
+}
+
+/** A fake remote backend — identifiable so we can assert it's the one returned. */
+function createFakeRemoteBackend(marker: string): ExecBackend {
+ const fail = (): never => {
+ throw new Error(`fake remote backend (${marker}) should not be called in this test`);
+ };
+ return {
+ spawn: fail,
+ readFile: fail,
+ writeFile: fail,
+ stat: fail,
+ readdir: fail,
+ exists: fail,
+ };
+}
+
+describe("ExecBackend resolver", () => {
+ it("returns localExecBackend for computerId === undefined (local path unchanged)", () => {
+ const services = new Map<string, unknown>();
+ const host = createFakeHost(services);
+
+ // Activate the extension so it registers its resolver, then retrieve it.
+ createExecBackendExtension().activate(host);
+ const resolver = host.getService(execBackendHandle);
+
+ expect(resolver(undefined)).toBe(localExecBackend);
+ expect(resolver()).toBe(localExecBackend);
+ });
+
+ it("returns the factory's backend for a set computerId when the factory is provided", () => {
+ const services = new Map<string, unknown>();
+ const host = createFakeHost(services);
+
+ // The `ssh` extension (not built yet) would do this:
+ const remoteBackend = createFakeRemoteBackend("ssh-alias");
+ const factory = (computerId: string): ExecBackend => {
+ // Confirm the alias is threaded through to the factory.
+ expect(computerId).toBe("ssh-alias");
+ return remoteBackend;
+ };
+ host.provideService(remoteExecBackendFactoryHandle, factory);
+
+ createExecBackendExtension().activate(host);
+ const resolver = host.getService(execBackendHandle);
+
+ expect(resolver("ssh-alias")).toBe(remoteBackend);
+ });
+
+ it("throws a clear 'not configured' error when the factory is NOT provided (ssh not loaded)", () => {
+ const services = new Map<string, unknown>();
+ const host = createFakeHost(services);
+
+ // No remoteExecBackendFactoryHandle provided → simulates ssh not loaded.
+ createExecBackendExtension().activate(host);
+ const resolver = host.getService(execBackendHandle);
+
+ // Not a crash: a clear, actionable error mentioning computerId + ssh.
+ expect(() => resolver("some-host")).toThrow(/SSH remote execution is not configured/);
+ expect(() => resolver("some-host")).toThrow(/ssh extension is not loaded/);
+ expect(() => resolver("some-host")).toThrow(/some-host/);
+ });
+
+ it("local path is unaffected by whether the factory is provided", () => {
+ // Even with a factory present, computerId === undefined still returns local.
+ const services = new Map<string, unknown>();
+ const host = createFakeHost(services);
+ host.provideService(remoteExecBackendFactoryHandle, () => createFakeRemoteBackend("unused"));
+
+ createExecBackendExtension().activate(host);
+ const resolver = host.getService(execBackendHandle);
+
+ expect(resolver(undefined)).toBe(localExecBackend);
+ });
+});
diff --git a/packages/exec-backend/src/extension.ts b/packages/exec-backend/src/extension.ts
index c07b7a8..9d6840a 100644
--- a/packages/exec-backend/src/extension.ts
+++ b/packages/exec-backend/src/extension.ts
@@ -1,8 +1,11 @@
-import type { Extension, Manifest } from "@dispatch/kernel";
+import type { Extension, HostAPI, Manifest } from "@dispatch/kernel";
import type { ExecBackend } from "./backend.js";
import { localExecBackend } from "./local.js";
-import type { ExecBackendResolver } from "./service.js";
-import { execBackendHandle } from "./service.js";
+import {
+ type ExecBackendResolver,
+ execBackendHandle,
+ remoteExecBackendFactoryHandle,
+} from "./service.js";
export const manifest: Manifest = {
id: "exec-backend",
@@ -15,33 +18,55 @@ export const manifest: Manifest = {
};
/**
- * The resolver provided by this extension.
+ * Build the `ExecBackendResolver` for a given host.
*
- * - `computerId` undefined → `LocalExecBackend` (today's local behavior).
- * - `computerId` set → throws. Remote execution is wired by `host-bin` + the
- * `ssh` package in a later wave (`SshExecBackend` implements the same
- * `ExecBackend` interface). For now only the local path exists — failing
- * loudly here is safer than silently running locally when remote was requested.
+ * - `computerId` undefined → `localExecBackend` (byte-identical local path;
+ * no host lookup, no remote machinery — unchanged from the original behavior).
+ * - `computerId` set → remote: lazily look up the factory the `ssh` extension
+ * provides via `remoteExecBackendFactoryHandle` and call it with the alias.
+ * The lookup is deferred to resolve time (tool-EXECUTE time, after every
+ * extension has activated), so a missing provider (ssh not loaded) degrades
+ * gracefully into a clear error instead of crashing activation. This mirrors
+ * the lazy `host.getService(lspServiceHandle)` try/catch pattern `tool-edit-file`
+ * uses for its diagnostics hook.
+ *
+ * The resolver stays SYNCHRONOUS and side-effect-free with respect to
+ * connections: looking up the factory and calling it returns a backend whose
+ * methods are async, so any remote connection acquisition happens lazily
+ * inside the first backend method call, not at resolve time.
*/
-function resolveBackend(computerId?: string): ExecBackend {
- if (computerId === undefined) return localExecBackend;
- throw new Error(
- `Remote execution (computerId="${computerId}") is not yet configured. ` +
- "The SSH backend will be wired by the ssh package.",
- );
+function createResolver(host: HostAPI): ExecBackendResolver {
+ return (computerId?: string): ExecBackend => {
+ if (computerId === undefined) return localExecBackend;
+ // computerId set → remote. Look up the factory the `ssh` extension provides.
+ // `host.getService` throws when nothing provided the handle (ssh not loaded);
+ // convert that into a clear "not configured" error rather than a crash.
+ let factory: (computerId: string) => ExecBackend;
+ try {
+ factory = host.getService(remoteExecBackendFactoryHandle);
+ } catch {
+ throw new Error(
+ `SSH remote execution is not configured: the ssh extension is not loaded ` +
+ `(requested computerId="${computerId}"). Load the ssh package to enable remote execution.`,
+ );
+ }
+ return factory(computerId);
+ };
}
/**
* Factory: create the `exec-backend` core extension.
*
- * `activate` provides the local-only `ExecBackendResolver` via the typed
- * service handle. Remote resolution is added in a later wave.
+ * `activate` captures the host and provides the `ExecBackendResolver` via the
+ * typed service handle. The resolver lazily delegates the remote branch to a
+ * factory the `ssh` extension will provide (see `remoteExecBackendFactoryHandle`);
+ * until `ssh` is loaded, a remote request fails with a clear error.
*/
export function createExecBackendExtension(): Extension {
return {
manifest,
activate(host) {
- const resolver: ExecBackendResolver = resolveBackend;
+ const resolver: ExecBackendResolver = createResolver(host);
host.provideService(execBackendHandle, resolver);
},
};
diff --git a/packages/exec-backend/src/index.ts b/packages/exec-backend/src/index.ts
index 3135f79..30c12c8 100644
--- a/packages/exec-backend/src/index.ts
+++ b/packages/exec-backend/src/index.ts
@@ -2,4 +2,7 @@ export type { DirEntry, ExecBackend, ExecResult, SpawnParams, StatResult } from
export { createExecBackendExtension, manifest } from "./extension.js";
export { createLocalExecBackend, localExecBackend } from "./local.js";
export type { ExecBackendResolver } from "./service.js";
-export { execBackendHandle } from "./service.js";
+export {
+ execBackendHandle,
+ remoteExecBackendFactoryHandle,
+} from "./service.js";
diff --git a/packages/exec-backend/src/service.ts b/packages/exec-backend/src/service.ts
index 81ea5fa..6cfa9de 100644
--- a/packages/exec-backend/src/service.ts
+++ b/packages/exec-backend/src/service.ts
@@ -25,3 +25,22 @@ export type ExecBackendResolver = (computerId?: string) => ExecBackend;
* activation by `host-bin`).
*/
export const execBackendHandle = defineService<ExecBackendResolver>("exec-backend/resolver");
+
+/**
+ * A factory the `ssh` extension provides: given a computerId (SSH alias),
+ * returns the remote `ExecBackend`. Absent (ssh not loaded) → remote execution
+ * is unconfigured.
+ *
+ * This is a **consumer-defined handle**: `exec-backend` (a core extension)
+ * declares it, and the `ssh` extension (a standard extension, built in a later
+ * wave) `host.provideService`s it. That direction (standard → core, consumer
+ * defines / provider implements) is the only layering that keeps the kernel free
+ * of any concrete feature name — `exec-backend` owns the seam, `ssh` plugs in.
+ *
+ * The resolver looks this up LAZILY at resolve time (tool-execute time, after
+ * all extensions have activated), so missing-the-provider degrades gracefully
+ * rather than crashing activation.
+ */
+export const remoteExecBackendFactoryHandle = defineService<(computerId: string) => ExecBackend>(
+ "exec-backend/remote-factory",
+);