diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/exec-backend/src/extension.test.ts | 121 | ||||
| -rw-r--r-- | packages/exec-backend/src/extension.ts | 61 | ||||
| -rw-r--r-- | packages/exec-backend/src/index.ts | 5 | ||||
| -rw-r--r-- | packages/exec-backend/src/service.ts | 19 |
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", +); |
