summaryrefslogtreecommitdiffhomepage
path: root/src/features/plugins/adapter
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/plugins/adapter')
-rw-r--r--src/features/plugins/adapter/http.test.ts177
-rw-r--r--src/features/plugins/adapter/http.ts230
2 files changed, 407 insertions, 0 deletions
diff --git a/src/features/plugins/adapter/http.test.ts b/src/features/plugins/adapter/http.test.ts
new file mode 100644
index 0000000..b1fe477
--- /dev/null
+++ b/src/features/plugins/adapter/http.test.ts
@@ -0,0 +1,177 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createPluginsHttp } from "./http";
+
+/** A stub fetch that records requests and replies from a handler. */
+function stubFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) {
+ const impl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ return await handler(url, init);
+ });
+ return impl as unknown as typeof fetch;
+}
+
+const okJson = (body: unknown): Response =>
+ ({ ok: true, status: 200, json: async () => body }) as unknown as Response;
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("createPluginsHttp — manager routes (plan 05)", () => {
+ it("GET /plugins returns the parsed list (null on failure)", async () => {
+ const fetch1 = stubFetch(() =>
+ okJson({ loaded: [], disabledWithReason: [], discovered: [], missing: [] }),
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ await expect(http.list()).resolves.toEqual({
+ loaded: [],
+ disabledWithReason: [],
+ discovered: [],
+ missing: [],
+ });
+
+ const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response);
+ await expect(createPluginsHttp("http://x", fetch2).list()).resolves.toBeNull();
+ });
+
+ it("POST /plugins/scan posts to the scan route", async () => {
+ let seenUrl = "";
+ let seenInit: RequestInit | undefined;
+ const fetch1 = stubFetch((url, init) => {
+ seenUrl = url;
+ seenInit = init;
+ return okJson({ root: "/plugins", newPlugins: [], invalid: [], incompatible: [] });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.scan();
+ expect(result.ok).toBe(true);
+ expect(seenUrl).toBe("http://x/plugins/scan");
+ expect(seenInit?.method).toBe("POST");
+ });
+
+ it("POST /plugins/:id/unload sends the force flag in the body", async () => {
+ let seenBody: string | undefined;
+ const fetch1 = stubFetch((_url, init) => {
+ seenBody = init?.body as string;
+ return okJson({ ok: true, action: "unload", id: "mcp" });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ await http.unload("mcp", true);
+ expect(JSON.parse(seenBody ?? "{}")).toEqual({ force: true });
+ });
+
+ it("maps a blocked mutation (409) to ok:false with reason + dependents", async () => {
+ const fetch1 = stubFetch(
+ () =>
+ ({
+ ok: false,
+ status: 409,
+ json: async () => ({
+ ok: false,
+ action: "unload",
+ id: "heartbeat",
+ reason: "cascade-needs-force",
+ message: "blocked",
+ dependents: ["transport-http"],
+ }),
+ }) as unknown as Response,
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.unload("heartbeat", false);
+ expect(result).toEqual({
+ ok: false,
+ error: "blocked",
+ reason: "cascade-needs-force",
+ dependents: ["transport-http"],
+ });
+ });
+
+ it("load and reload hit their routes with the encoded id", async () => {
+ const urls: string[] = [];
+ const fetch1 = stubFetch((url) => {
+ urls.push(url);
+ return okJson({ ok: true, action: "load", id: "a/b" });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ await http.load("a/b");
+ await http.reload("a/b");
+ expect(urls).toEqual(["http://x/plugins/a%2Fb/load", "http://x/plugins/a%2Fb/reload"]);
+ });
+});
+
+describe("createPluginsHttp — workspace routes (plan 06 §6.6)", () => {
+ it("GET /workspaces/:id/plugins returns the parsed response (null on failure)", async () => {
+ const fetch1 = stubFetch(() =>
+ okJson({ workspaceId: "ws", plugins: [], enabledProviderCount: 2 }),
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ await expect(http.workspacePlugins("ws")).resolves.toEqual({
+ workspaceId: "ws",
+ plugins: [],
+ enabledProviderCount: 2,
+ });
+
+ const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response);
+ await expect(createPluginsHttp("http://x", fetch2).workspacePlugins("ws")).resolves.toBeNull();
+ });
+
+ it("PUT /workspaces/:id/plugins/:pluginId sends the state body", async () => {
+ let seenUrl = "";
+ let seenBody: string | undefined;
+ const fetch1 = stubFetch((url, init) => {
+ seenUrl = url;
+ seenBody = init?.body as string;
+ return okJson({ workspaceId: "ws", pluginId: "mcp", state: "enabled" });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.setWorkspaceState("ws", "mcp", "enabled");
+ expect(result.ok).toBe(true);
+ expect(seenUrl).toBe("http://x/workspaces/ws/plugins/mcp");
+ expect(JSON.parse(seenBody ?? "{}")).toEqual({ state: "enabled" });
+ });
+
+ it("maps a 409 refusal (core-locked) to ok:false with the reason", async () => {
+ const fetch1 = stubFetch(
+ () =>
+ ({
+ ok: false,
+ status: 409,
+ json: async () => ({
+ workspaceId: "ws",
+ pluginId: "kernel",
+ reason: "core-locked",
+ message: "core is locked",
+ }),
+ }) as unknown as Response,
+ );
+ const http = createPluginsHttp("http://x", fetch1);
+ const result = await http.setWorkspaceState("ws", "kernel", "disabled");
+ expect(result).toEqual({ ok: false, error: "core is locked", reason: "core-locked" });
+ });
+
+ it("GET providers-status returns the parsed response", async () => {
+ let seenUrl = "";
+ const fetch1 = stubFetch((url) => {
+ seenUrl = url;
+ return okJson({ workspaceId: "ws", enabledProviderCount: 0 });
+ });
+ const http = createPluginsHttp("http://x", fetch1);
+ await expect(http.providersStatus("ws")).resolves.toEqual({
+ workspaceId: "ws",
+ enabledProviderCount: 0,
+ });
+ expect(seenUrl).toBe("http://x/workspaces/ws/plugins/providers-status");
+ });
+
+ it("network throws degrade reads to null and mutations to ok:false", async () => {
+ const boom = vi.fn(async () => {
+ throw new Error("down");
+ }) as unknown as typeof fetch;
+ const http = createPluginsHttp("http://x", boom);
+ await expect(http.list()).resolves.toBeNull();
+ await expect(http.providersStatus("ws")).resolves.toBeNull();
+ await expect(http.setWorkspaceState("ws", "mcp", "enabled")).resolves.toMatchObject({
+ ok: false,
+ });
+ });
+});
diff --git a/src/features/plugins/adapter/http.ts b/src/features/plugins/adapter/http.ts
new file mode 100644
index 0000000..7814f4f
--- /dev/null
+++ b/src/features/plugins/adapter/http.ts
@@ -0,0 +1,230 @@
+import type {
+ PluginListResponse,
+ PluginOpResponse,
+ PluginScanResponse,
+ SetWorkspacePluginStateBlocked,
+ SetWorkspacePluginStateRequest,
+ SetWorkspacePluginStateResponse,
+ WorkspacePluginProvidersStatusResponse,
+ WorkspacePluginsResponse,
+} from "@dispatch/transport-contract";
+
+/**
+ * Plugin HTTP effects — the injected edge that talks to the backend's plugin
+ * manager routes (plan 05) + per-workspace enablement routes (plan 06 §6.6).
+ * Mirrors the workspaces adapter's pattern: `httpBase` + an injected
+ * `fetchImpl` (testable without the network); reads return data or a safe
+ * `null` on failure (non-fatal), mutations return a typed result so a caller
+ * can surface the backend's `{ reason, message }` block.
+ *
+ * Endpoints:
+ * - `GET /plugins` → {@link PluginListResponse} (the two manager tables + missing strip)
+ * - `POST /plugins/scan` → {@link PluginScanResponse} (manual scan; never auto-loads)
+ * - `POST /plugins/:id/load` → {@link PluginOpResponse}
+ * - `POST /plugins/:id/unload` body `{ force }` → {@link PluginOpResponse}
+ * - `POST /plugins/:id/reload` → {@link PluginOpResponse}
+ * - `GET /workspaces/:id/plugins` → {@link WorkspacePluginsResponse} (READ triggers
+ * read-through materialization)
+ * - `PUT /workspaces/:id/plugins/:pluginId` body `{ state }` →
+ * {@link SetWorkspacePluginStateResponse} (409 `core-locked` / `unloaded`)
+ * - `GET /workspaces/:id/plugins/providers-status` →
+ * {@link WorkspacePluginProvidersStatusResponse} (zero-enabled-providers guardrail)
+ */
+
+/** Outcome of a plugin-manager mutation (load / unload / reload). */
+export type PluginOpResult =
+ | { readonly ok: true; readonly response: Extract<PluginOpResponse, { ok: true }> }
+ | {
+ readonly ok: false;
+ readonly error: string;
+ readonly reason?: string;
+ /** For `cascade-needs-force`: the loaded dependents a force would remove. */
+ readonly dependents?: readonly string[];
+ };
+
+/** Outcome of a per-workspace state write (`PUT .../plugins/:pluginId`). */
+export type WorkspaceStateResult =
+ | { readonly ok: true; readonly response: SetWorkspacePluginStateResponse }
+ | { readonly ok: false; readonly error: string; readonly reason?: string };
+
+/** Outcome of the manual scan (`POST /plugins/scan`). */
+export type PluginScanResult =
+ | { readonly ok: true; readonly response: PluginScanResponse }
+ | { readonly ok: false; readonly error: string };
+
+export interface PluginsHttp {
+ list(): Promise<PluginListResponse | null>;
+ scan(): Promise<PluginScanResult>;
+ load(id: string): Promise<PluginOpResult>;
+ unload(id: string, force: boolean): Promise<PluginOpResult>;
+ reload(id: string): Promise<PluginOpResult>;
+ /** Read one workspace's effective plugin states (triggers materialization). */
+ workspacePlugins(workspaceId: string): Promise<WorkspacePluginsResponse | null>;
+ setWorkspaceState(
+ workspaceId: string,
+ pluginId: string,
+ state: "enabled" | "disabled" | "unset",
+ ): Promise<WorkspaceStateResult>;
+ /** The zero-enabled-providers signal for the active workspace (§6.5). */
+ providersStatus(workspaceId: string): Promise<WorkspacePluginProvidersStatusResponse | null>;
+}
+
+async function errorBody(res: Response): Promise<{
+ error?: string;
+ reason?: string;
+ dependents?: readonly string[];
+ message?: string;
+}> {
+ try {
+ return (await res.json()) as {
+ error?: string;
+ reason?: string;
+ dependents?: readonly string[];
+ message?: string;
+ };
+ } catch {
+ return {};
+ }
+}
+
+/**
+ * Map a plugin-manager block/error response to a `PluginOpResult`. The
+ * backend's blocked shape is `{ ok: false, reason, message, dependents? }`;
+ * other failures degrade to `{ error }` only.
+ */
+async function opResult(res: Response): Promise<PluginOpResult> {
+ const body = await errorBody(res);
+ const message = body.message ?? body.error ?? `HTTP ${res.status}`;
+ return {
+ ok: false,
+ error: message,
+ ...(body.reason !== undefined ? { reason: body.reason } : {}),
+ ...(body.dependents !== undefined ? { dependents: body.dependents } : {}),
+ };
+}
+
+export function createPluginsHttp(httpBase: string, fetchImpl: typeof fetch): PluginsHttp {
+ const post = (path: string, body?: unknown): Promise<Response> =>
+ fetchImpl(`${httpBase}${path}`, {
+ method: "POST",
+ ...(body !== undefined
+ ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
+ : {}),
+ });
+
+ return {
+ async list(): Promise<PluginListResponse | null> {
+ try {
+ const res = await fetchImpl(`${httpBase}/plugins`);
+ if (!res.ok) return null;
+ return (await res.json()) as PluginListResponse;
+ } catch {
+ return null;
+ }
+ },
+
+ async scan(): Promise<PluginScanResult> {
+ try {
+ const res = await post("/plugins/scan");
+ if (!res.ok)
+ return { ok: false, error: (await errorBody(res)).error ?? `HTTP ${res.status}` };
+ return { ok: true, response: (await res.json()) as PluginScanResponse };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Scan request failed" };
+ }
+ },
+
+ async load(id): Promise<PluginOpResult> {
+ try {
+ const res = await post(`/plugins/${encodeURIComponent(id)}/load`);
+ if (!res.ok) return await opResult(res);
+ return {
+ ok: true,
+ response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>,
+ };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Load request failed" };
+ }
+ },
+
+ async unload(id, force): Promise<PluginOpResult> {
+ try {
+ const res = await post(`/plugins/${encodeURIComponent(id)}/unload`, { force });
+ if (!res.ok) return await opResult(res);
+ return {
+ ok: true,
+ response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>,
+ };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Unload request failed" };
+ }
+ },
+
+ async reload(id): Promise<PluginOpResult> {
+ try {
+ const res = await post(`/plugins/${encodeURIComponent(id)}/reload`);
+ if (!res.ok) return await opResult(res);
+ return {
+ ok: true,
+ response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>,
+ };
+ } catch (err) {
+ return { ok: false, error: err instanceof Error ? err.message : "Reload request failed" };
+ }
+ },
+
+ async workspacePlugins(workspaceId): Promise<WorkspacePluginsResponse | null> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins`,
+ );
+ if (!res.ok) return null;
+ return (await res.json()) as WorkspacePluginsResponse;
+ } catch {
+ return null;
+ }
+ },
+
+ async setWorkspaceState(workspaceId, pluginId, state): Promise<WorkspaceStateResult> {
+ const body: SetWorkspacePluginStateRequest = { state };
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/${encodeURIComponent(pluginId)}`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+ if (!res.ok) {
+ const blocked = (await errorBody(res)) as Partial<SetWorkspacePluginStateBlocked> & {
+ error?: string;
+ };
+ return {
+ ok: false,
+ error: blocked.message ?? blocked.error ?? `HTTP ${res.status}`,
+ ...(blocked.reason !== undefined ? { reason: blocked.reason } : {}),
+ };
+ }
+ return { ok: true, response: (await res.json()) as SetWorkspacePluginStateResponse };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set plugin state request failed",
+ };
+ }
+ },
+
+ async providersStatus(workspaceId): Promise<WorkspacePluginProvidersStatusResponse | null> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/providers-status`,
+ );
+ if (!res.ok) return null;
+ return (await res.json()) as WorkspacePluginProvidersStatusResponse;
+ } catch {
+ return null;
+ }
+ },
+ };
+}