diff options
| author | Adam Malczewski <[email protected]> | 2026-09-03 18:05:29 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-03 18:05:29 +0900 |
| commit | 3a4922a05930b0dc22740bf5621910803e8f275e (patch) | |
| tree | d5f831c3d3838cfff346e378f5370a9c58bc2ed3 /src/features/plugins/adapter | |
| parent | 33c7245f18346553f660434bc3ac589cff3e80c2 (diff) | |
| download | dispatch-web-3a4922a05930b0dc22740bf5621910803e8f275e.tar.gz dispatch-web-3a4922a05930b0dc22740bf5621910803e8f275e.zip | |
feat(plugin-system): plan 07 frontend plugin manager ui
Diffstat (limited to 'src/features/plugins/adapter')
| -rw-r--r-- | src/features/plugins/adapter/http.test.ts | 177 | ||||
| -rw-r--r-- | src/features/plugins/adapter/http.ts | 230 |
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; + } + }, + }; +} |
