diff options
| author | Adam Malczewski <[email protected]> | 2026-09-05 04:42:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-05 04:42:20 +0900 |
| commit | 5e5f02c67d0c165dd93f8277a3815d51a540d996 (patch) | |
| tree | 2b98579d25f3f16204feaf25ddac2746e4bbb934 /src | |
| parent | 2f282f1a5c784dff97af7c936ccb43ca2b63f03f (diff) | |
| download | dispatch-web-5e5f02c67d0c165dd93f8277a3815d51a540d996.tar.gz dispatch-web-5e5f02c67d0c165dd93f8277a3815d51a540d996.zip | |
feat(layout): plan 03 — frontend plugin ABI + handshake + load lifecycle
Diffstat (limited to 'src')
22 files changed, 1409 insertions, 18 deletions
diff --git a/src/app/plugin-host.svelte.ts b/src/app/plugin-host.svelte.ts new file mode 100644 index 0000000..9e53bc1 --- /dev/null +++ b/src/app/plugin-host.svelte.ts @@ -0,0 +1,134 @@ +/** + * The frontend-plugin HOST wiring (plan 03 §3.2) — the composition-root glue + * between the ABI driver (`core/plugin-abi`), the view registry, the + * surfaces ports, and the plugins HTTP edge. + * + * The sync triggers (boot, `extensions-changed`, `workspace.set` → the app + * store calls `sync()` on exactly those events) each fetch the plugin list + + * the ACTIVE workspace's enablement rows, derive the desired candidate set + * (pure), and run one driver pass. The driver's effects are injected HERE at + * the edge: + * - `importModule` — the dynamic `import()` (the only module-system touch); + * - `buildHostApi` — the per-plugin `FrontendHostAPI` with FORCED pluginId + * attribution into the layout registry, app-store-backed surfaces ports, + * and a plugin-prefixed logger; + * - `unregisterViewsByPlugin` — the registry's unload sweep. + * + * After every pass the per-plugin FE states are reported to the plugins + * store (the manager UI renders "incompatible frontend plugin" and + * activation failures). + */ +import { + createFrontendDriver, + FRONTEND_ABI_VERSION, + type FrontendDriver, + type FrontendHostAPI, + type FrontendPluginState, + frontendCandidates, + usablePluginIds, +} from "../core/plugin-abi"; +import { + registerPluginView, + unregisterViewsByPlugin, + type ViewComponent, +} from "../features/layout"; +import type { PluginsHttp } from "../features/plugins"; + +/** The surfaces ports the ABI hands plugin views — backed by the app store. */ +export interface FrontendSurfacesPorts { + readonly subscribe: (surfaceId: string, conversationId?: string) => void; + readonly unsubscribe: (surfaceId: string, conversationId?: string) => void; + readonly invoke: ( + surfaceId: string, + actionId: string, + payload?: unknown, + conversationId?: string, + ) => void; + readonly getSpec: (surfaceId: string) => unknown; +} + +/** The shape of the plugins store the FE states are reported to. */ +export interface FrontendStateReporter { + setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void; +} + +export interface PluginHostOptions { + readonly http: PluginsHttp; + /** The backend HTTP base (import-URL origin). */ + readonly httpBase: string; + readonly reporter: FrontendStateReporter; + /** The ACTIVE workspace provider (read per sync — workspace.set trigger). */ + readonly activeWorkspaceId: () => string; + readonly surfaces: FrontendSurfacesPorts; +} + +export interface PluginHost { + /** + * Run one sync pass (all three triggers share it — the active workspace + * is read per call, which is what makes `workspace.set` a distinct + * trigger). Never throws: a failed list fetch keeps the current set. + */ + sync(): Promise<void>; +} + +export function createPluginHost(opts: PluginHostOptions): PluginHost { + const driver: FrontendDriver = createFrontendDriver({ + importModule: (url) => import(/* @vite-ignore */ url), + buildHostApi: (pluginId) => buildHostApi(pluginId), + unregisterViewsByPlugin, + logger: console, + }); + + function buildHostApi(pluginId: string): FrontendHostAPI { + return { + abiVersion: FRONTEND_ABI_VERSION, + registerView: (reg) => + registerPluginView( + { + id: reg.id, + label: reg.label, + // ONE controlled cast at the ABI seam — the registry is a + // heterogeneous component bag (register-views.ts precedent); + // the plugin's own build already vetted its component. + component: reg.component as ViewComponent, + ...(reg.panelEligible !== undefined ? { panelEligible: reg.panelEligible } : {}), + }, + pluginId, + ), + surfaces: { + subscribe: (surfaceId, conversationId) => + opts.surfaces.subscribe(surfaceId, conversationId), + unsubscribe: (surfaceId, conversationId) => + opts.surfaces.unsubscribe(surfaceId, conversationId), + invoke: (surfaceId, actionId, payload, conversationId) => + opts.surfaces.invoke(surfaceId, actionId, payload, conversationId), + getSpec: (surfaceId) => opts.surfaces.getSpec(surfaceId) as never, + }, + logger: { + info: (msg) => console.info(`[frontend-plugin:${pluginId}] ${msg}`), + warn: (msg) => console.warn(`[frontend-plugin:${pluginId}] ${msg}`), + }, + }; + } + + return { + async sync(): Promise<void> { + // Both reads are fail-soft (null on network failure) — a down backend + // keeps the CURRENT imported set untouched (no unload on a flaky + // fetch, and the manager UI's last-known states persist). + const workspaceId = opts.activeWorkspaceId(); + const [list, workspace] = await Promise.all([ + opts.http.list(), + opts.http.workspacePlugins(workspaceId), + ]); + if (list === null) return; + const candidates = frontendCandidates(list, { + workspaceId, + usableIds: usablePluginIds(workspace?.plugins ?? []), + httpBase: opts.httpBase, + }); + await driver.sync(candidates); + opts.reporter.setFrontendStates(driver.states()); + }, + }; +} diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index bfec235..8a80160 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -103,6 +103,7 @@ import { type VisionSettings, type VisionSettingsPatch, } from "../features/vision"; +import { createPluginHost } from "./plugin-host.svelte"; import { resolveHttpUrl } from "./resolve-http-url"; import { resolveWsUrl } from "./resolve-ws-url"; import { randomId } from "./uuid"; @@ -250,6 +251,15 @@ export interface AppStore { renameTab(conversationId: string, title: string): void; invoke(surfaceId: string, actionId: string, payload?: unknown): void; /** + * Explicitly (re-)subscribe one surface, scoped to `conversationId` (or the + * focused conversation when absent) — the frontend-plugin ABI's + * `surfaces.subscribe` path (plan 03). The app auto-subscribes every + * catalog entry; this is the re-assertion port (same wire semantics). + */ + subscribeSurface(surfaceId: string, conversationId?: string): void; + /** Drop one surface's subscription (the ABI's `surfaces.unsubscribe`). */ + unsubscribeSurface(surfaceId: string, conversationId?: string): void; + /** * Manually warm the focused conversation's prompt cache (`POST /chat/warm`). * Returns null when no conversation is focused (a draft has nothing to warm). */ @@ -703,7 +713,8 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // The app store wires the server's `extensions-changed` broadcast into it // (§11 multi-client coherence) and refreshes the workspace-scoped model // picker + guardrail on the same event. - const pluginsStore = createPluginsStore(createPluginsHttp(httpBase, fetchImpl)); + const pluginsHttp = createPluginsHttp(httpBase, fetchImpl); + const pluginsStore = createPluginsStore(pluginsHttp); // ── Layout (plan 02) ───────────────────────────────────────────────────────── // The ACTIVE workspace's tile tree, owned by the backend's layout provider @@ -1180,6 +1191,57 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { let socket: ReturnType<typeof createSurfaceSocket> | null = null; /** + * The surface OP implementations — closure functions shared by the app + * store's public methods AND the frontend-plugin ABI's surfaces ports (the + * plugin host binds exactly these; there is no second protocol state). + */ + function invokeSurfaceOp( + surfaceId: string, + actionId: string, + payload?: unknown, + conversationId?: string, + ): void { + const result = protocolInvoke( + protocol, + surfaceId, + actionId, + payload, + conversationId ?? focusedConversationId(), + ); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + } + + function subscribeSurfaceOp(surfaceId: string, conversationId?: string): void { + const result = protocolSubscribe( + protocol, + surfaceId, + conversationId ?? focusedConversationId(), + ); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + } + + function unsubscribeSurfaceOp(surfaceId: string, conversationId?: string): void { + // protocolUnsubscribe keys on the surface alone (it drops the pair it + // was subscribed under); the optional id documents the intent. + void conversationId; + const result = protocolUnsubscribe(protocol, surfaceId); + protocol = result.state; + for (const msg of result.outgoing) { + socket?.send(msg); + } + } + + function surfaceSpecOp(surfaceId: string): SurfaceSpec | null { + return getSurfaceSpec(protocol, surfaceId); + } + + /** * Open a conversation tab — used by the `conversation.open` WS broadcast * (CLI `--open` flag) and by `conversation.statusChanged` when a new active * conversation is discovered. If the conversation is already open, this is a @@ -1335,6 +1397,28 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { } } + // ── Frontend plugins (plan 03) ───────────────────────────────────────────── + // The ABI driver's composition-root wiring: one host instance per app + // store. `sync()` runs on boot, on `extensions-changed`, and on every + // workspace switch (the three load-lifecycle triggers — the active + // workspace is read per call, which makes workspace.set meaningful). + // The surfaces ports delegate to the SAME protocol/socket machinery the + // app store's own surface methods use (closure functions below, shared + // with the returned object — no second protocol state). + const pluginHost = createPluginHost({ + http: pluginsHttp, + httpBase, + reporter: pluginsStore, + activeWorkspaceId: () => activeWorkspaceId, + surfaces: { + subscribe: (surfaceId, conversationId) => subscribeSurfaceOp(surfaceId, conversationId), + unsubscribe: (surfaceId, conversationId) => unsubscribeSurfaceOp(surfaceId, conversationId), + invoke: (surfaceId, actionId, payload, conversationId) => + invokeSurfaceOp(surfaceId, actionId, payload, conversationId), + getSpec: (surfaceId) => surfaceSpecOp(surfaceId), + }, + }); + const socketOpts: SurfaceSocketOptions = { url: wsUrl, onMessage: handleServerMessage, @@ -1428,6 +1512,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // surface catalog is NOT refetched here — the server re-pushes it // (workspace-filtered) on the same broadcast. pluginsStore.onExtensionsChanged(); + // Frontend plugins (plan 03): the load lifecycle's extensions-changed + // trigger — newly-loaded/enabled frontend plugins import + activate; + // unloaded/disabled ones are swept (their tiles fall back). + void pluginHost.sync(); // A layout plugin enabled/disabled mid-session starts/stops serving — // the probe picks the new provider state up without a reload (§2.6). void layoutStore.probe(activeWorkspaceId); @@ -1464,6 +1552,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // served by the backend's layout plugin when one is active. `boot: true` // arms the one-way §2.7 localStorage migration on the first success. void layoutStore.probe(activeWorkspaceId, { boot: true }); + // Frontend plugins (plan 03): the load lifecycle's boot trigger — every + // loaded ∧ enabled ∧ frontend-declaring ∧ handshake-passing plugin is + // imported + activated (after the first list fetch lands). + void pluginHost.sync(); // One-time legacy model-name migration on boot (independent of the fetch; // preserved from the pre-refreshModels boot chain). if (migrateLegacyModelName(activeModel) !== activeModel) { @@ -1587,6 +1679,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { // default for the new workspace and probe its provider (§2.6 — every // workspace switch is a probe, which is also the missed-push catch-up). layoutStore.onWorkspaceChanged(workspaceId); + // Frontend plugins (plan 03): the load lifecycle's workspace.set + // trigger — the enablement set is WORKSPACE-SCOPED, so a switch may + // load and unload frontend plugins. + void pluginHost.sync(); }, get activeChat(): ChatStore { return activeChat; @@ -1793,17 +1889,15 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { }, invoke(surfaceId: string, actionId: string, payload?: unknown): void { - const result = protocolInvoke( - protocol, - surfaceId, - actionId, - payload, - focusedConversationId(), - ); - protocol = result.state; - for (const msg of result.outgoing) { - socket?.send(msg); - } + invokeSurfaceOp(surfaceId, actionId, payload); + }, + + subscribeSurface(surfaceId: string, conversationId?: string): void { + subscribeSurfaceOp(surfaceId, conversationId); + }, + + unsubscribeSurface(surfaceId: string, conversationId?: string): void { + unsubscribeSurfaceOp(surfaceId, conversationId); }, async warmNow(): Promise<WarmResult | null> { diff --git a/src/core/plugin-abi/driver.test.ts b/src/core/plugin-abi/driver.test.ts new file mode 100644 index 0000000..e8c8e7e --- /dev/null +++ b/src/core/plugin-abi/driver.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from "vitest"; +import { createFrontendDriver, type FrontendDriverDeps } from "./driver.js"; +import type { FrontendCandidate } from "./load-diff.js"; + +/** + * Asymmetric testing: the ONLY fake is the OUTERMOST edge (the dynamic + * import + the host-API builder — injected effects). The driver's internals, + * the load diff, and the registry-facing sweep contract are real. + */ + +const c = (id: string, bust = "1"): FrontendCandidate => ({ + id, + url: `http://x/plugins/${id}/frontend?workspace=w&v=${bust}`, + apiVersion: "^1", +}); + +interface Harness { + readonly deps: FrontendDriverDeps & { + // The race test swaps the import edge before the driver exists — the + // driver reads the dep object per call, so the mutation is the swap. + importModule: (url: string) => Promise<unknown>; + }; + readonly imports: string[]; + readonly sweeps: string[]; + /** The modules the fake import() returns, keyed by the next id. */ + readonly modules: Map<string, unknown>; + readonly logs: string[]; +} + +function harness(): Harness { + const imports: string[] = []; + const sweeps: string[] = []; + const logs: string[] = []; + const modules = new Map<string, unknown>(); + const deps: FrontendDriverDeps = { + importModule: async (url) => { + imports.push(url); + const id = new URL(url).pathname.split("/")[2] ?? ""; + const mod = modules.get(id); + if (mod === undefined) throw new Error(`404: ${url}`); + return mod; + }, + buildHostApi: (pluginId) => ({ + abiVersion: "1", + registerView: () => () => {}, + surfaces: { + subscribe: () => {}, + unsubscribe: () => {}, + invoke: () => {}, + getSpec: () => null, + }, + logger: { info: () => {}, warn: () => {} }, + ...(pluginId === undefined ? {} : {}), + }), + unregisterViewsByPlugin: (pluginId) => sweeps.push(pluginId), + logger: { + info: (msg) => logs.push(msg), + warn: (msg) => logs.push(msg), + }, + }; + return { deps, imports, sweeps, modules, logs }; +} + +describe("createFrontendDriver — the load lifecycle", () => { + it("boot: imports newcomers and calls activate with the plugin's own host API", async () => { + const h = harness(); + const activate = vi.fn(); + h.modules.set("a", { default: { activate } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a")]); + expect(h.imports).toEqual([c("a").url]); + expect(activate).toHaveBeenCalledOnce(); + expect(driver.states().get("a")).toEqual({ kind: "loaded" }); + }); + + it("extensions-changed: a vanished plugin runs its deactivate + the registry sweep", async () => { + const h = harness(); + const deactivate = vi.fn(); + h.modules.set("a", { default: { activate: () => deactivate } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a")]); + await driver.sync([]); // the plugin is gone from the desired set + expect(deactivate).toHaveBeenCalledOnce(); + expect(h.sweeps).toEqual(["a"]); + expect(driver.states().has("a")).toBe(false); + }); + + it("unload disposes EXACTLY that plugin (the sweep is per-pluginId)", async () => { + const h = harness(); + h.modules.set("a", { default: { activate: () => () => {} } }); + h.modules.set("b", { default: { activate: () => () => {} } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a"), c("b")]); + await driver.sync([c("b")]); // only `a` vanishes + expect(h.sweeps).toEqual(["a"]); + }); + + it("workspace.set / re-enable: the SAME url re-activates the cached module", async () => { + const h = harness(); + const activate = vi.fn(); + h.modules.set("a", { default: { activate } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a")]); + await driver.sync([]); // disable → unload (disposer + sweep) + await driver.sync([c("a")]); // re-enable → activate re-runs + expect(h.imports).toEqual([c("a").url, c("a").url]); // re-fetched URL + expect(activate).toHaveBeenCalledTimes(2); + expect(h.sweeps).toEqual(["a"]); + }); + + it("a changed URL (backend reload) unloads then re-imports fresh code", async () => { + const h = harness(); + const activate = vi.fn(() => () => {}); + h.modules.set("a", { default: { activate } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a", "old")]); + await driver.sync([c("a", "new")]); + expect(h.sweeps).toEqual(["a"]); + expect(h.imports).toEqual([c("a", "old").url, c("a", "new").url]); + expect(activate).toHaveBeenCalledTimes(2); + }); + + it("a throwing activate is LOUD: failed state, no crash, others still load", async () => { + const h = harness(); + h.modules.set("bad", { + default: { + activate: () => { + throw new Error("boom"); + }, + }, + }); + h.modules.set("good", { default: { activate: () => () => {} } }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("bad"), c("good")]); + expect(driver.states().get("bad")).toEqual({ kind: "failed", error: "boom" }); + expect(driver.states().get("good")).toEqual({ kind: "loaded" }); + // The failed plugin owns nothing — the sweep never ran for it. + expect(h.sweeps).toEqual([]); + }); + + it("a broken module (404 / missing activate) is a failed state, not a crash", async () => { + const h = harness(); + h.modules.set("no-activate", { default: {} }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("ghost"), c("no-activate")]); + expect(driver.states().get("ghost")?.kind).toBe("failed"); + expect(driver.states().get("no-activate")?.kind).toBe("failed"); + expect(h.logs.some((l) => l.includes("activation failed"))).toBe(true); + }); + + it("a throwing deactivate still gets the registry sweep (views never linger)", async () => { + const h = harness(); + h.modules.set("a", { + default: { + activate: () => () => { + throw new Error("disposer boom"); + }, + }, + }); + const driver = createFrontendDriver(h.deps); + await driver.sync([c("a")]); + await driver.sync([]); + expect(h.sweeps).toEqual(["a"]); + }); + + it("handshake-incompatible candidates are never imported (reported for the UI)", async () => { + const h = harness(); + const driver = createFrontendDriver(h.deps); + await driver.sync([{ ...c("bad"), apiVersion: "^2" }]); + expect(h.imports).toEqual([]); + expect(driver.states().get("bad")).toEqual({ kind: "incompatible", apiVersion: "^2" }); + }); + + it("overlapping syncs serialize (boot + extensions-changed race)", async () => { + const h = harness(); + let releaseImport: (() => void) | undefined; + h.modules.set("a", { + default: { + activate: () => { + return () => {}; + }, + }, + }); + const blockingImport = async (url: string) => { + h.imports.push(url); + await new Promise<void>((resolve) => { + releaseImport = resolve; + }); + return h.modules.get("a"); + }; + // Swap the import edge BEFORE the driver exists (the driver reads the + // dep object per call — the swap is the injection point for the race). + h.deps.importModule = blockingImport; + const driver = createFrontendDriver(h.deps); + const first = driver.sync([c("a")]); + const second = driver.sync([]); // queued behind the first + // While the first sync is blocked on import, the second must not have + // started (no unload of `a` mid-import). + await new Promise((r) => setTimeout(r, 10)); + releaseImport?.(); + await Promise.all([first, second]); + // biome-ignore lint/suspicious/noConsole: temporary debug + // The second pass sees `a` already imported (the queued sync ran after) + // and unloads it — exactly one sweep, no interleaving. + expect(h.sweeps).toEqual(["a"]); + }); +}); diff --git a/src/core/plugin-abi/driver.ts b/src/core/plugin-abi/driver.ts new file mode 100644 index 0000000..7ba326f --- /dev/null +++ b/src/core/plugin-abi/driver.ts @@ -0,0 +1,140 @@ +/** + * The frontend-module DRIVER (plan 03 §3.2) — the effectful lifecycle host. + * Owns the imported-module set and runs ONE sync pass per trigger (boot, + * `extensions-changed`, `workspace.set`): + * + * 1. diff (pure, `decideFrontendLoads`) the desired set against the + * imported set; + * 2. UNLOAD stragglers: run the module's deactivate disposer (if it + * returned one) then the host's unregister-by-pluginId sweep — the UI is + * immediately correct. HONEST LIMITATION: ESM cannot be un-fetched; the + * bytes linger in the module cache. Re-enable re-runs `activate` on the + * cached module (same URL ⇒ no re-fetch). + * 3. IMPORT newcomers: dynamic-import the URL (THE injected edge), call + * `activate(api)` with the plugin's own host API (forced pluginId + * attribution). A throwing/missing activate is LOUD: the plugin's FE + * half is marked failed (the backend half stays loaded) and the failure + * never crashes the host or blocks the other plugins. + * + * Syncs are serialized (a chain) — boot + extensions-changed + workspace + * switches can overlap, and import/unload ordering must stay coherent. + * No internal mocks: tests inject the import edge + host-API builder. + */ +import type { + FrontendCandidate, + FrontendLoadDecision, + ImportedFrontendModule, +} from "./load-diff.js"; +import { decideFrontendLoads } from "./load-diff.js"; +import type { FrontendHostAPI, FrontendPluginModule, FrontendPluginState } from "./types.js"; + +export interface FrontendDriverDeps { + /** + * THE edge: dynamic ESM import. Injected (the composition root binds + * `import(url)`; tests bind a fake) so nothing else in the driver touches + * the module system. + */ + readonly importModule: (url: string) => Promise<unknown>; + /** + * Build the plugin's host API — the composition root closes over the + * registry + surfaces ports + logger and FORCES the pluginId attribution. + */ + readonly buildHostApi: (pluginId: string) => FrontendHostAPI; + /** The unregister-by-pluginId sweep (the registry-side unload path). */ + readonly unregisterViewsByPlugin: (pluginId: string) => void; + readonly logger: { info: (msg: string) => void; warn: (msg: string) => void }; +} + +export interface FrontendDriver { + /** Run one sync pass. Serialized; never throws (failures are states). */ + sync(candidates: readonly FrontendCandidate[]): Promise<void>; + /** The per-plugin states the manager UI renders. */ + states(): ReadonlyMap<string, FrontendPluginState>; +} + +interface ImportedEntry extends ImportedFrontendModule { + readonly deactivate: (() => void) | undefined; +} + +export function createFrontendDriver(deps: FrontendDriverDeps): FrontendDriver { + /** pluginId → the imported module's entry. */ + const imported = new Map<string, ImportedEntry>(); + /** pluginId → its last-known FE state (for the manager UI). */ + const pluginStates = new Map<string, FrontendPluginState>(); + /** The sync chain (serialization across triggers). */ + let chain: Promise<void> = Promise.resolve(); + + /** Unload one plugin: module disposer FIRST, then the registry sweep. */ + function unload(id: string, entry: ImportedEntry): void { + try { + entry.deactivate?.(); + } catch (err) { + // A broken deactivate must not block the sweep — the registration + // ledger's force-clean philosophy (the views must not linger). + deps.logger.warn(`frontend-plugin "${id}": deactivate threw — ${String(err)}`); + } + try { + deps.unregisterViewsByPlugin(id); + } catch (err) { + deps.logger.warn(`frontend-plugin "${id}": unregister sweep threw — ${String(err)}`); + } + imported.delete(id); + pluginStates.delete(id); + deps.logger.info(`frontend-plugin "${id}": unloaded`); + } + + /** Import + activate one candidate. Failures are LOUD states, not throws. */ + async function load(candidate: FrontendCandidate): Promise<void> { + try { + const mod = (await deps.importModule(candidate.url)) as FrontendPluginModule | undefined; + const activate = mod?.default?.activate; + if (typeof activate !== "function") { + throw new Error("the module has no default.activate export"); + } + const disposer = activate(deps.buildHostApi(candidate.id)); + imported.set(candidate.id, { + url: candidate.url, + deactivate: typeof disposer === "function" ? disposer : undefined, + }); + pluginStates.set(candidate.id, { kind: "loaded" }); + deps.logger.info(`frontend-plugin "${candidate.id}": activated`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + pluginStates.set(candidate.id, { kind: "failed", error: message }); + deps.logger.warn(`frontend-plugin "${candidate.id}": activation failed — ${message}`); + } + } + + return { + async sync(candidates: readonly FrontendCandidate[]): Promise<void> { + const run = chain.then(() => runSync(candidates)); + // The NEXT sync waits for this one; a rejected run must not poison the + // chain (runSync itself never throws, but belt-and-suspenders). + chain = run.catch(() => {}); + await run; + }, + + states(): ReadonlyMap<string, FrontendPluginState> { + return new Map(pluginStates); + }, + }; + + async function runSync(candidates: readonly FrontendCandidate[]): Promise<void> { + const decision: FrontendLoadDecision = decideFrontendLoads(candidates, imported); + + // The handshake verdicts persist even when nothing else changes — the + // manager UI renders "incompatible frontend plugin" until the plugin's + // manifest (or the host) changes. + for (const bad of decision.incompatible) { + pluginStates.set(bad.id, { kind: "incompatible", apiVersion: bad.apiVersion }); + } + + for (const id of decision.toUnload) { + const entry = imported.get(id); + if (entry !== undefined) unload(id, entry); + } + for (const candidate of decision.toLoad) { + await load(candidate); + } + } +} diff --git a/src/core/plugin-abi/index.ts b/src/core/plugin-abi/index.ts new file mode 100644 index 0000000..3c97eca --- /dev/null +++ b/src/core/plugin-abi/index.ts @@ -0,0 +1,27 @@ +/** + * The frontend plugin ABI (plan 03) — the contract + lifecycle machinery for + * dynamically-imported frontend plugin modules. See `types.ts` (the surface), + * `version.ts` (the handshake), `load-diff.ts` (pure decisions), and + * `driver.ts` (the effectful host). The composition root (src/app) wires the + * driver to the registry, the surfaces ports, and the plugin HTTP edge. + */ +export { createFrontendDriver, type FrontendDriver, type FrontendDriverDeps } from "./driver.js"; +export { + decideFrontendLoads, + type FrontendCandidate, + type FrontendCandidatesInput, + type FrontendLoadDecision, + frontendCandidates, + type ImportedFrontendModule, + usablePluginIds, +} from "./load-diff.js"; +export type { + FrontendHostAPI, + FrontendPluginModule, + FrontendPluginState, + FrontendViewRegistration, +} from "./types.js"; +export { + FRONTEND_ABI_VERSION, + isFrontendCompatible, +} from "./version.js"; diff --git a/src/core/plugin-abi/load-diff.test.ts b/src/core/plugin-abi/load-diff.test.ts new file mode 100644 index 0000000..157b93f --- /dev/null +++ b/src/core/plugin-abi/load-diff.test.ts @@ -0,0 +1,167 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { + decideFrontendLoads, + frontendCandidates, + type ImportedFrontendModule, + usablePluginIds, +} from "./load-diff.js"; +import { FRONTEND_ABI_VERSION } from "./version.js"; + +const httpBase = "http://localhost:24203"; + +function listWith( + ...rows: readonly { + readonly id: string; + readonly apiVersion?: string; + readonly bust?: string; + }[] +): PluginListResponse { + return { + loaded: rows.map((r) => ({ + id: r.id, + name: r.id, + version: "1.0.0", + tier: "standard" as const, + trust: "local" as const, + locked: false, + dependsOn: [], + ...(r.apiVersion === undefined + ? {} + : { frontend: { apiVersion: r.apiVersion, entry: "frontend.js", bust: r.bust ?? "1" } }), + })), + disabledWithReason: [], + discovered: [], + missing: [], + }; +} + +describe("frontendCandidates", () => { + it("derives candidates from loaded ∧ frontend-declaring ∧ workspace-enabled rows", () => { + const candidates = frontendCandidates( + listWith( + { id: "a", apiVersion: "^1", bust: "111" }, + { id: "no-fe" }, + { id: "b", apiVersion: "^1", bust: "222" }, + ), + { workspaceId: "team-a", usableIds: new Set(["a", "b", "no-fe"]), httpBase }, + ); + expect(candidates).toEqual([ + { + id: "a", + apiVersion: "^1", + url: `${httpBase}/plugins/a/frontend?workspace=team-a&v=111`, + }, + { + id: "b", + apiVersion: "^1", + url: `${httpBase}/plugins/b/frontend?workspace=team-a&v=222`, + }, + ]); + }); + + it("excludes plugins disabled in the active workspace", () => { + const candidates = frontendCandidates(listWith({ id: "a", apiVersion: "^1" }), { + workspaceId: "team-a", + usableIds: new Set<string>(), + httpBase, + }); + expect(candidates).toEqual([]); + }); + + it("URL-encodes the workspace and bust", () => { + const candidates = frontendCandidates(listWith({ id: "a", apiVersion: "^1", bust: "1 2" }), { + workspaceId: "team a", + usableIds: new Set(["a"]), + httpBase, + }); + expect(candidates[0]?.url).toBe(`${httpBase}/plugins/a/frontend?workspace=team%20a&v=1%202`); + }); +}); + +describe("usablePluginIds", () => { + it("mirrors isPluginUsable: core usable unless disabled, others enabled-only", () => { + const usable = usablePluginIds([ + { pluginId: "core-one", tier: "core", state: "undefined", loaded: true, locked: true }, + { pluginId: "core-off", tier: "core", state: "disabled", loaded: true, locked: true }, + { pluginId: "std-on", tier: "standard", state: "enabled", loaded: true, locked: false }, + { pluginId: "std-blank", tier: "standard", state: "undefined", loaded: true, locked: false }, + { pluginId: "prov-on", tier: "providers", state: "enabled", loaded: true, locked: false }, + { pluginId: "prov-off", tier: "providers", state: "disabled", loaded: true, locked: false }, + ]); + expect([...usable].sort()).toEqual(["core-one", "prov-on", "std-on"]); + }); +}); + +describe("decideFrontendLoads — the load diff (boot / extensions-changed / workspace.set)", () => { + const c = (id: string, bust = "1"): { id: string; url: string; apiVersion: string } => ({ + id, + url: `${httpBase}/plugins/${id}/frontend?workspace=w&v=${bust}`, + apiVersion: "^1", + }); + + it("boot: every desired candidate imports (nothing imported yet)", () => { + const d = decideFrontendLoads([c("a"), c("b")], new Map()); + expect(d.toLoad.map((x) => x.id)).toEqual(["a", "b"]); + expect(d.toUnload).toEqual([]); + expect(d.incompatible).toEqual([]); + }); + + it("extensions-changed: a removed plugin unloads, a newcomer imports", () => { + const imported = new Map<string, ImportedFrontendModule>([ + ["a", { url: c("a").url }], + ["gone", { url: `${httpBase}/plugins/gone/frontend?workspace=w&v=1` }], + ]); + const d = decideFrontendLoads([c("a"), c("new")], imported); + expect(d.toLoad.map((x) => x.id)).toEqual(["new"]); + expect(d.toUnload).toEqual(["gone"]); + }); + + it("workspace.set: the enablement set change drives load AND unload", () => { + // The candidate list already reflects the NEW workspace (composition + // root re-derives per sync) — previously-imported ids absent from it unload. + const imported = new Map<string, ImportedFrontendModule>([ + ["a", { url: c("a").url }], + ["b", { url: c("b").url }], + ]); + const d = decideFrontendLoads([c("a")], imported); + expect(d.toLoad).toEqual([]); + expect(d.toUnload).toEqual(["b"]); + }); + + it("a stable desired set is a no-op (idempotent sync)", () => { + const imported = new Map<string, ImportedFrontendModule>([ + ["a", { url: c("a").url }], + ["b", { url: c("b").url }], + ]); + const d = decideFrontendLoads([c("a"), c("b")], imported); + expect(d.toLoad).toEqual([]); + expect(d.toUnload).toEqual([]); + }); + + it("a changed URL (backend reload → new bust) is unload + re-import", () => { + const imported = new Map<string, ImportedFrontendModule>([["a", { url: c("a", "old").url }]]); + const d = decideFrontendLoads([c("a", "new")], imported); + expect(d.toLoad.map((x) => x.id)).toEqual(["a"]); + expect(d.toUnload).toEqual(["a"]); + }); + + it("handshake failure → incompatible, never imported, still unloaded if it was", () => { + const bad = { ...c("bad"), apiVersion: "^2" }; + const imported = new Map<string, ImportedFrontendModule>([["bad", { url: bad.url }]]); + const d = decideFrontendLoads([bad], imported); + expect(d.toLoad).toEqual([]); + expect(d.incompatible).toEqual([{ id: "bad", apiVersion: "^2" }]); + // The previously-imported instance must be swept (the manifest changed + // under us — the UI must not keep running incompatible code). + expect(d.toUnload).toEqual(["bad"]); + }); + + it("the handshake checks against FRONTEND_ABI_VERSION", () => { + // A range that includes the host version passes — proving the version + // constant feeds the check. + expect(FRONTEND_ABI_VERSION).toBe("1"); + const d = decideFrontendLoads([c("ok")], new Map()); + expect(d.incompatible).toEqual([]); + }); +}); diff --git a/src/core/plugin-abi/load-diff.ts b/src/core/plugin-abi/load-diff.ts new file mode 100644 index 0000000..b846a40 --- /dev/null +++ b/src/core/plugin-abi/load-diff.ts @@ -0,0 +1,137 @@ +/** + * The frontend-module load DIFF (plan 03 §3.2) — pure decision logic. On + * boot, on `extensions-changed`, and on `workspace.set`, the composition + * root derives the DESIRED set of frontend plugins (loaded ∧ + * workspace-enabled ∧ frontend-declaring) from `GET /plugins` + the active + * workspace's enablement rows, and diffs it against the IMPORTED set: + * newcomers are `import()`-ed + activated; stragglers are deactivated + * (module disposer + unregister-by-pluginId sweep); a changed import URL + * (backend reload → new content bust) is an unload + re-import. + * + * The handshake runs HERE: a plugin whose `frontend.apiVersion` range does + * not include FRONTEND_ABI_VERSION is reported "incompatible" and is NEVER + * imported — it is also (re)unloaded if a previous version of it was + * imported. + */ +import type { PluginListResponse, WorkspacePluginRow } from "@dispatch/transport-contract"; +import { FRONTEND_ABI_VERSION, isFrontendCompatible } from "./version.js"; + +/** One desired frontend plugin, fully URL-resolved. */ +export interface FrontendCandidate { + readonly id: string; + /** The import URL: the backend route + workspace + the content bust. */ + readonly url: string; + /** The plugin's declared frontend ABI semver range (the handshake input). */ + readonly apiVersion: string; +} + +/** What the currently-imported modules look like to the diff. */ +export interface ImportedFrontendModule { + /** The URL the module was imported from (a change → unload + re-import). */ + readonly url: string; +} + +/** The diff verdict for one sync pass. */ +export interface FrontendLoadDecision { + /** Newcomers (or bust-changed plugins) to import + activate. */ + readonly toLoad: readonly FrontendCandidate[]; + /** Imported plugin ids no longer desired (or superseded by a new URL). */ + readonly toUnload: readonly string[]; + /** Handshake failures — "incompatible frontend plugin" in the manager UI. */ + readonly incompatible: readonly { readonly id: string; readonly apiVersion: string }[]; +} + +/** The composition-root inputs for candidate derivation. */ +export interface FrontendCandidatesInput { + /** The active workspace whose enablement set applies. */ + readonly workspaceId: string; + /** The plugin ids enabled in that workspace (see `usablePluginIds`). */ + readonly usableIds: ReadonlySet<string>; + /** The backend HTTP base (the import URL's origin). */ + readonly httpBase: string; +} + +/** + * The desired frontend-plugin set from a `GET /plugins` response: LOADED ∧ + * workspace-enabled ∧ frontend-declaring rows, one URL each. The URL is + * `/plugins/:id/frontend?workspace=<ws>&v=<bust>` — `v` is the content + * bust the additive list field carries (the entry's mtimeMs), so a + * backend-side reload (new mtime) produces a NEW URL → the diff re-imports. + */ +export function frontendCandidates( + list: PluginListResponse, + input: FrontendCandidatesInput, +): readonly FrontendCandidate[] { + const out: FrontendCandidate[] = []; + for (const row of list.loaded) { + if (row.frontend === undefined) continue; + if (!input.usableIds.has(row.id)) continue; + out.push({ + id: row.id, + apiVersion: row.frontend.apiVersion, + url: `${input.httpBase}/plugins/${encodeURIComponent(row.id)}/frontend?workspace=${encodeURIComponent(input.workspaceId)}&v=${encodeURIComponent(row.frontend.bust)}`, + }); + } + return out; +} + +/** + * Diff the desired set against the imported set. Handshake failures are + * NEVER in `toLoad` — and if such a plugin WAS imported (its manifest + * changed to an incompatible range), it lands in `toUnload`. + */ +export function decideFrontendLoads( + candidates: readonly FrontendCandidate[], + imported: ReadonlyMap<string, ImportedFrontendModule>, +): FrontendLoadDecision { + const toLoad: FrontendCandidate[] = []; + const incompatible: { id: string; apiVersion: string }[] = []; + const desired = new Set<string>(); + /** Imported ids whose URL changed (unload + re-import — fresh code). */ + const superseded: string[] = []; + + for (const candidate of candidates) { + if (!isFrontendCompatible(candidate.apiVersion)) { + incompatible.push({ id: candidate.id, apiVersion: candidate.apiVersion }); + continue; + } + desired.add(candidate.id); + const current = imported.get(candidate.id); + if (current === undefined) { + toLoad.push(candidate); + } else if (current.url !== candidate.url) { + // The backend reloaded the plugin (a new content bust → new URL): + // unload the OLD instance (its disposer + registry sweep run), then + // import the fresh code. + superseded.push(candidate.id); + toLoad.push(candidate); + } + } + + // Everything imported that is no longer a (handshake-passing) desired id, + // plus same-id candidates whose URL changed. + const toUnload = [...[...imported.keys()].filter((id) => !desired.has(id)), ...superseded]; + + return { toLoad, toUnload, incompatible }; +} + +/** + * The workspace-usable plugin ids from a `GET /workspaces/:id/plugins` + * response (plan-06 rows) — the FE mirror of the backend's + * `isPluginUsable`: tier "core" is usable unless explicitly `disabled`; + * every other tier is usable only when explicitly `enabled`. The response's + * `loaded: false` rows (records for unloaded plugins) cannot be desired + * anyway (candidates come from the LOADED set). + */ +export function usablePluginIds(rows: readonly WorkspacePluginRow[]): ReadonlySet<string> { + const usable = new Set<string>(); + for (const row of rows) { + if (row.tier === "core" ? row.state !== "disabled" : row.state === "enabled") { + usable.add(row.pluginId); + } + } + return usable; +} + +/** Re-export for the composition root's single-import convenience. */ +export { FRONTEND_ABI_VERSION, isFrontendCompatible }; diff --git a/src/core/plugin-abi/types.ts b/src/core/plugin-abi/types.ts new file mode 100644 index 0000000..14abf6e --- /dev/null +++ b/src/core/plugin-abi/types.ts @@ -0,0 +1,95 @@ +/** + * The frontend plugin ABI surface (plan 03 §3.2) — the ONLY contract between + * the host app and a dynamically-imported frontend plugin module. + * + * A frontend plugin is a SINGLE prebuilt ESM file (svelte externalized — + * resolved through the host's import map to the app's own Svelte 5 runtime) + * whose default export is `{ activate(api) }`. `activate` registers views / + * stashes ports and MAY return a deactivate disposer (plan 03's deactivate + * contract). Trust model: plugin code runs with full page privileges — no + * sandbox (documented non-goal); the trust signal is the manifest's `trust` + * field rendered in the manager UI. + * + * Views get data via SURFACES (the ui-contract philosophy: backend-declared + * data rendered generically) — never via the app store. `registerView` + * FORCES the importing plugin's pluginId attribution (the host wraps the + * registry call), which is what makes unload-time sweeps exact. + */ +import type { SurfaceSpec } from "@dispatch/ui-contract"; +import type { FRONTEND_ABI_VERSION } from "./version.js"; + +/** The registration input for one plugin view (as the ABI exposes it). */ +export interface FrontendViewRegistration { + /** Registry id — the same id a tile leaf's `view` field names. */ + readonly id: string; + /** Human label shown in panel dropdowns. */ + readonly label: string; + /** + * A Svelte 5 component (compiled by the plugin's build against the SHARED + * svelte runtime). Opaque here — the host casts it into the registry's + * heterogeneous component bag at the seam. + */ + readonly component: unknown; + /** Whether the view may appear in panel dropdowns (default true). */ + readonly panelEligible?: boolean; +} + +/** The per-plugin host API object handed to `activate`. */ +export interface FrontendHostAPI { + /** The host's ABI version (the other half of the semver handshake). */ + readonly abiVersion: typeof FRONTEND_ABI_VERSION; + /** + * Register a view into the SAME registry built-ins use. pluginId + * attribution is FORCED to the importing plugin (the host closes over it) + * — plugins cannot attribute views to another plugin. The returned + * disposer removes the view (call it from your deactivate). + */ + readonly registerView: (reg: FrontendViewRegistration) => () => void; + /** + * Talk to backend surfaces — the plugin-view data path. The app + * auto-subscribes every catalog entry, so `getSpec` typically returns + * data immediately for surfaces your plugin's backend half declares; + * subscribe/unsubscribe re-assert scopes explicitly. + */ + readonly surfaces: { + readonly subscribe: (surfaceId: string, conversationId?: string) => void; + readonly unsubscribe: (surfaceId: string, conversationId?: string) => void; + readonly invoke: ( + surfaceId: string, + actionId: string, + payload?: unknown, + conversationId?: string, + ) => void; + readonly getSpec: (surfaceId: string) => SurfaceSpec | null; + }; + readonly logger: { info: (msg: string) => void; warn: (msg: string) => void }; + // plan 04 ADDS `layout: { applyOps(...) }` here — an additive ABI bump + // (v1 plugins keep working; range-checked per the handshake). +} + +/** + * What `activate` may return: nothing, or the deactivate disposer (the + * plan-03 deactivate contract). A named alias keeps the plan grammar + * readable; the suppression is needed because biome flags `void` inside a + * union type — here it is the plan-specified shape. + */ +// biome-ignore lint/suspicious/noConfusingVoidType: plan-specified activate grammar — "may return a disposer" (the deactivate contract) +export type FrontendActivateResult = void | (() => void); + +/** The module shape a frontend plugin's entry must default-export. */ +export interface FrontendPluginModule { + readonly default: { + /** + * Register the plugin's views/ports. MAY return a deactivate disposer — + * run on unload/disable BEFORE the host's unregister-by-pluginId sweep + * (the sweep is the safety net, not the primary path). + */ + activate(api: FrontendHostAPI): FrontendActivateResult; + }; +} + +/** The per-plugin FRONTEND lifecycle state the manager UI renders. */ +export type FrontendPluginState = + | { readonly kind: "loaded" } + | { readonly kind: "incompatible"; readonly apiVersion: string } + | { readonly kind: "failed"; readonly error: string }; diff --git a/src/core/plugin-abi/version.test.ts b/src/core/plugin-abi/version.test.ts new file mode 100644 index 0000000..2620847 --- /dev/null +++ b/src/core/plugin-abi/version.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { FRONTEND_ABI_VERSION, isFrontendCompatible } from "./version.js"; + +describe("FRONTEND_ABI_VERSION", () => { + it("is 1 (plan 03)", () => { + expect(FRONTEND_ABI_VERSION).toBe("1"); + }); +}); + +describe("isFrontendCompatible — handshake accept/reject matrix", () => { + it("accepts the canonical authoring range ^1", () => { + expect(isFrontendCompatible("^1")).toBe(true); + expect(isFrontendCompatible("^1.0")).toBe(true); + expect(isFrontendCompatible("^1.0.0")).toBe(true); + }); + + it("accepts the bare major version and exact matches", () => { + expect(isFrontendCompatible("1")).toBe(true); + expect(isFrontendCompatible("1.0.0")).toBe(true); + }); + + it("accepts wildcard and >= ranges", () => { + expect(isFrontendCompatible("*")).toBe(true); + expect(isFrontendCompatible(">=1.0.0")).toBe(true); + }); + + it("rejects a next-major caret range", () => { + expect(isFrontendCompatible("^2")).toBe(false); + expect(isFrontendCompatible("^2.0.0")).toBe(false); + }); + + it("rejects an older pinned range", () => { + expect(isFrontendCompatible("0.9.0")).toBe(false); + expect(isFrontendCompatible("^0.1.0")).toBe(false); + }); + + it("rejects a tilde range of an older minor", () => { + expect(isFrontendCompatible("~0.9.0")).toBe(false); + expect(isFrontendCompatible("~1.0.0")).toBe(true); + }); + + it("rejects malformed ranges (fail closed, never throws)", () => { + expect(isFrontendCompatible("")).toBe(false); + expect(isFrontendCompatible("banana")).toBe(false); + expect(isFrontendCompatible("^abc")).toBe(false); + expect(isFrontendCompatible("1.0.0.0")).toBe(false); + }); + + it("rejects a future patch-level host (the host version pins the answer)", () => { + // The host version defaults to "1.0.0"; "~1.0.0" includes it. + expect(isFrontendCompatible("~1.0.0", "1.0.0")).toBe(true); + expect(isFrontendCompatible("~1.0.0", "1.1.0")).toBe(false); + }); +}); diff --git a/src/core/plugin-abi/version.ts b/src/core/plugin-abi/version.ts new file mode 100644 index 0000000..becae87 --- /dev/null +++ b/src/core/plugin-abi/version.ts @@ -0,0 +1,80 @@ +/** + * The frontend plugin ABI version + the semver handshake (plan 03 §3.2). + * + * FRONTEND_ABI_VERSION is bumped on BREAKING ABI changes only. A plugin's + * `manifest.frontend.apiVersion` is a semver RANGE that must include the + * host's version; a mismatch lists the plugin as "incompatible frontend + * plugin" in the manager UI and its module is NEVER imported. + * + * `isFrontendCompatible` mirrors the backend kernel's + * `isApiVersionCompatible` semantics (host/src/host/version.ts) so both + * sides of the handshake behave identically. It additionally accepts + * major-only versions/ranges ("1", "^1") — the ABI's version grammar is + * single-number, normalized here to x.y.z with zeros — because a frontend + * ABI ("1") is not a kernel release ("0.1.0") and authors declare `"^1"`. + */ + +export const FRONTEND_ABI_VERSION = "1"; + +/** The host version the handshake checks against (normalized to semver). */ +const HOST_VERSION = "1.0.0"; + +interface SemVer { + readonly major: number; + readonly minor: number; + readonly patch: number; +} + +/** Parse a 1–3 component version ("1", "1.0", "1.0.0") — missing parts are 0. */ +function parseSemver(version: string): SemVer | null { + const match = version.match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?$/); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2] ?? 0), + patch: Number(match[3] ?? 0), + }; +} + +function gte(a: SemVer, b: SemVer): boolean { + if (a.major !== b.major) return a.major > b.major; + if (a.minor !== b.minor) return a.minor > b.minor; + return a.patch >= b.patch; +} + +/** + * Whether the plugin's declared `frontend.apiVersion` range includes the + * host's FRONTEND_ABI_VERSION. A malformed range is NEVER compatible (a + * plugin-verify check catches it at authoring time; the handshake fails + * closed here). + */ +export function isFrontendCompatible(apiVersion: string, hostVersion = HOST_VERSION): boolean { + const range = apiVersion.trim(); + if (range === "*") return true; + const host = parseSemver(hostVersion); + if (host === null) return false; + + if (range.startsWith("^")) { + const min = parseSemver(range.slice(1)); + if (min === null) return false; + if (!gte(host, min)) return false; + if (min.major === 0) { + return host.major === 0 && host.minor === min.minor; + } + return host.major === min.major; + } + if (range.startsWith("~")) { + const min = parseSemver(range.slice(1)); + if (min === null) return false; + if (!gte(host, min)) return false; + return host.major === min.major && host.minor === min.minor; + } + if (range.startsWith(">=")) { + const min = parseSemver(range.slice(2)); + if (min === null) return false; + return gte(host, min); + } + const exact = parseSemver(range); + if (exact === null) return false; + return host.major === exact.major && host.minor === exact.minor && host.patch === exact.patch; +} diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts index f5696ac..6a17589 100644 --- a/src/features/layout/index.ts +++ b/src/features/layout/index.ts @@ -25,8 +25,10 @@ export { listPanelViews, listViews, type RegisteredView, + registerPluginView, registerView, unregisterView, + unregisterViewsByPlugin, type ViewComponent, type ViewRegistration, viewRegistry, diff --git a/src/features/layout/logic/registry.test.ts b/src/features/layout/logic/registry.test.ts index 443e50d..e5e947c 100644 --- a/src/features/layout/logic/registry.test.ts +++ b/src/features/layout/logic/registry.test.ts @@ -4,8 +4,10 @@ import { getView, listPanelViews, listViews, + registerPluginView, registerView, unregisterView, + unregisterViewsByPlugin, type ViewRegistration, viewRegistry, } from "./registry"; @@ -78,3 +80,69 @@ describe("view registry", () => { expect(ids.indexOf("z-first")).toBeLessThan(ids.indexOf("a-second")); }); }); + +// ── Plugin-view registration (plan 03) — forced attribution + the sweep ───── + +describe("registerPluginView / unregisterViewsByPlugin (plan 03)", () => { + it("forces the importing plugin's pluginId attribution", () => { + const dispose = registerPluginView( + { id: "abi-view", label: "ABI View", component: fakeComponent }, + "my-plugin", + ); + expect(getView("abi-view")?.pluginId).toBe("my-plugin"); + dispose(); + expect(getView("abi-view")).toBeUndefined(); + }); + + it("returns a working per-view disposer (the plugin deactivate contract)", () => { + const dispose = registerPluginView( + { id: "abi-view-2", label: "ABI View", component: fakeComponent }, + "my-plugin", + ); + dispose(); + dispose(); // idempotent + expect(getView("abi-view-2")).toBeUndefined(); + }); + + it("the sweep removes exactly that plugin's views (registry attribution)", () => { + registerPluginView({ id: "p1-a", label: "A", component: fakeComponent }, "plugin-1"); + registerPluginView({ id: "p1-b", label: "B", component: fakeComponent }, "plugin-1"); + registerPluginView({ id: "p2-a", label: "C", component: fakeComponent }, "plugin-2"); + registerView(registration({ id: "builtin" })); // no pluginId — never swept + + unregisterViewsByPlugin("plugin-1"); + expect(getView("p1-a")).toBeUndefined(); + expect(getView("p1-b")).toBeUndefined(); + expect(getView("p2-a")).not.toBeUndefined(); + expect(getView("builtin")).not.toBeUndefined(); + + unregisterViewsByPlugin("plugin-2"); + expect(getView("p2-a")).toBeUndefined(); + expect(getView("builtin")).not.toBeUndefined(); + }); + + it("the sweep is idempotent and a no-op for an unknown plugin", () => { + unregisterViewsByPlugin("nobody"); + registerPluginView({ id: "p3-a", label: "A", component: fakeComponent }, "plugin-3"); + unregisterViewsByPlugin("plugin-3"); + expect(() => unregisterViewsByPlugin("plugin-3")).not.toThrow(); + }); + + it("a duplicate id from a plugin throws LOUDLY (the driver marks it failed)", () => { + registerView(registration({ id: "taken" })); + expect(() => + registerPluginView({ id: "taken", label: "Taken", component: fakeComponent }, "my-plugin"), + ).toThrow(/duplicate view id/); + }); + + it("sweep removals are visible to registry subscribers (reactive)", () => { + const seen: string[][] = []; + const unsubscribe = viewRegistry.subscribe((views) => { + seen.push(views.map((v) => v.id)); + }); + registerPluginView({ id: "reactive", label: "R", component: fakeComponent }, "p"); + unregisterViewsByPlugin("p"); + unsubscribe(); + expect(seen.at(-1)).not.toContain("reactive"); + }); +}); diff --git a/src/features/layout/logic/registry.ts b/src/features/layout/logic/registry.ts index b072d2f..3a86cb9 100644 --- a/src/features/layout/logic/registry.ts +++ b/src/features/layout/logic/registry.ts @@ -82,6 +82,38 @@ export function unregisterView(id: string): void { if (byId.delete(id)) publish(); } +/** + * Register a PLUGIN view (plan 03) — the ABI-facing registration: the + * `pluginId` attribution is FORCED to the importing plugin (plugins cannot + * attribute views to another plugin or to no plugin), which is what makes + * `unregisterViewsByPlugin`'s unload sweep exact. Returns the per-view + * disposer (the plugin's deactivate contract). A duplicate id throws + * LOUDLY — the driver catches it and marks the plugin's FE half failed. + */ +export function registerPluginView( + registration: Omit<ViewRegistration, "pluginId">, + pluginId: string, +): () => void { + registerView({ ...registration, pluginId }); + return () => unregisterView(registration.id); +} + +/** + * Remove every view attributed to one plugin (the plan-03 unload sweep). + * Idempotent; the reactive registry re-renders open dropdowns, and tiles + * holding removed views fall back to `FallbackTile` (never a crash). + */ +export function unregisterViewsByPlugin(pluginId: string): void { + let changed = false; + for (const [id, view] of byId) { + if (view.pluginId === pluginId) { + byId.delete(id); + changed = true; + } + } + if (changed) publish(); +} + /** Look a view up by id (undefined → the renderer shows its fallback tile). */ export function getView(id: string): RegisteredView | undefined { return byId.get(id); diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts index 0f2abbc..7a9d1a0 100644 --- a/src/features/plugins/index.ts +++ b/src/features/plugins/index.ts @@ -10,9 +10,12 @@ export type { HighlightSets } from "./logic/highlight"; export { dependencyHighlightSets } from "./logic/highlight"; export { isLastProvider, unloadBlockReason } from "./logic/last-provider"; export { autoCheckBackendPrereqs } from "./logic/prereqs"; -export type { PluginNode, PluginSection } from "./logic/types"; +export type { + PluginNode, + PluginSection, +} from "./logic/types"; export { TIER_ORDER, tierLabel } from "./logic/types"; -export { groupByTier, pluginNodes, splitSections } from "./logic/view-model"; +export { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./logic/view-model"; export { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./logic/workspace-row"; export type { PluginsStore, WorkspacePluginsView } from "./store.svelte"; export { createPluginsStore } from "./store.svelte"; diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts index 702483b..d5fa4bb 100644 --- a/src/features/plugins/logic/types.ts +++ b/src/features/plugins/logic/types.ts @@ -25,6 +25,13 @@ export interface PluginNode { readonly dependsOn: readonly string[]; /** Why the plugin is not loaded (disabled-with-reason) or gone (missing). */ readonly reason: string | null; + /** + * The frontend-plugin (plan 03) issue line for the manager row, when the + * plugin's FE half is incompatible ("incompatible frontend plugin") or + * failed (`frontend failed: <error>`). Absent = the FE half is fine (or + * the plugin declares no frontend section). + */ + readonly frontendIssue?: string; readonly section: PluginSection; } diff --git a/src/features/plugins/logic/view-model.test.ts b/src/features/plugins/logic/view-model.test.ts index 680ba94..661cade 100644 --- a/src/features/plugins/logic/view-model.test.ts +++ b/src/features/plugins/logic/view-model.test.ts @@ -1,7 +1,7 @@ import type { PluginListResponse, PluginTier } from "@dispatch/transport-contract"; import { describe, expect, it } from "vitest"; import { emptyListResponse, node } from "./test-fixtures"; -import { groupByTier, pluginNodes, splitSections } from "./view-model"; +import { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./view-model"; function listResponse(overrides: Partial<PluginListResponse> = {}): PluginListResponse { return { @@ -225,3 +225,72 @@ describe("splitSections", () => { expect(sections.missing.map((n) => n.id)).toEqual(["gone"]); }); }); + +// ── withFrontendStates (plan 03) — the manager rows' FE-half issues ───────── + +describe("withFrontendStates", () => { + function listWith(...ids: readonly string[]): PluginListResponse { + return { + loaded: ids.map((id) => ({ + id, + name: id, + version: "1.0.0", + tier: "standard" as const, + trust: "local" as const, + locked: false, + dependsOn: [], + })), + disabledWithReason: [], + discovered: [], + missing: [], + }; + } + + it("renders the plan-mandated grammar for an incompatible FE half", () => { + const nodes = pluginNodes(listWith("bad")); + const states = new Map([["bad", { kind: "incompatible", apiVersion: "^2" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out[0]?.frontendIssue).toBe("incompatible frontend plugin"); + }); + + it("renders the activation error for a failed FE half", () => { + const nodes = pluginNodes(listWith("broken")); + const states = new Map([["broken", { kind: "failed", error: "boom" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out[0]?.frontendIssue).toBe("frontend failed: boom"); + }); + + it("a loaded FE half adds no issue line; absent states add none", () => { + const nodes = pluginNodes(listWith("ok", "no-fe")); + const states = new Map([["ok", { kind: "loaded" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out.find((n) => n.id === "ok")?.frontendIssue).toBeUndefined(); + expect(out.find((n) => n.id === "no-fe")?.frontendIssue).toBeUndefined(); + }); + + it("never annotates non-loaded sections (discovered / missing rows)", () => { + const response: PluginListResponse = { + loaded: [], + disabledWithReason: [], + discovered: [ + { + id: "dis", + name: "dis", + version: "1.0.0", + tier: "standard", + trust: "local", + dir: "/plugins/dis", + }, + ], + missing: [], + }; + const states = new Map([["dis", { kind: "failed", error: "boom" } as const]]); + const out = withFrontendStates(pluginNodes(response), states); + expect(out[0]?.frontendIssue).toBeUndefined(); + }); + + it("an empty state map is the identity (no allocation on the hot path)", () => { + const nodes = pluginNodes(listWith("a")); + expect(withFrontendStates(nodes, new Map())).toBe(nodes); + }); +}); diff --git a/src/features/plugins/logic/view-model.ts b/src/features/plugins/logic/view-model.ts index 20d7d68..5a66ffa 100644 --- a/src/features/plugins/logic/view-model.ts +++ b/src/features/plugins/logic/view-model.ts @@ -1,4 +1,5 @@ import type { PluginListResponse } from "@dispatch/transport-contract"; +import type { FrontendPluginState } from "../../../core/plugin-abi"; import { normalizeTier, type PluginNode, type PluginSection, TIER_ORDER } from "./types"; /** @@ -79,6 +80,34 @@ export function pluginNodes(response: PluginListResponse): readonly PluginNode[] } /** + * Merge the frontend-plugin (plan 03) per-plugin FE states into the node + * list — the manager rows' `frontendIssue` line. Pure input → output: + * - `incompatible` → the plan-mandated "incompatible frontend plugin" + * grammar (the backend apiVersion-mismatch wording, FE half); + * - `failed` → the activation error (the FE half failed; the backend half + * stays loaded). + * Loaded rows only — the FE states never apply to discovered/missing rows. + */ +export function withFrontendStates( + nodes: readonly PluginNode[], + states: ReadonlyMap<string, FrontendPluginState>, +): readonly PluginNode[] { + if (states.size === 0) return nodes; + return nodes.map((node) => { + if (node.section !== "loaded") return node; + const state = states.get(node.id); + if (state === undefined) return node; + const issue = + state.kind === "incompatible" + ? "incompatible frontend plugin" + : state.kind === "failed" + ? `frontend failed: ${state.error}` + : null; + return issue === null ? node : { ...node, frontendIssue: issue }; + }); +} + +/** * Cluster nodes for one manager table: tier groups in display order (Core → * Providers → Standard), each tier's rows sorted by id. Tiers with no rows * are omitted. Used by the loaded table's tier grouping (plan 07 §7.2). diff --git a/src/features/plugins/store.svelte.ts b/src/features/plugins/store.svelte.ts index 580293e..fe48940 100644 --- a/src/features/plugins/store.svelte.ts +++ b/src/features/plugins/store.svelte.ts @@ -3,6 +3,7 @@ import type { SetWorkspacePluginStateRequest, WorkspacePluginRow, } from "@dispatch/transport-contract"; +import type { FrontendPluginState } from "../../core/plugin-abi"; import type { PluginOpResult, PluginScanResult, PluginsHttp } from "./adapter/http"; import type { PluginNode } from "./logic/types"; import { pluginNodes } from "./logic/view-model"; @@ -70,6 +71,16 @@ export interface PluginsStore { * connected client converges. Fire-and-forget. */ onExtensionsChanged(): void; + + /** + * The frontend-plugin (plan 03) per-plugin FE states — reported by the + * plugin host after every driver sync. Rendered by the manager rows: + * `incompatible` → "incompatible frontend plugin" (the backend-mismatch + * grammar), `failed` → the activation error. Absent = no FE section. + */ + readonly frontendStates: ReadonlyMap<string, FrontendPluginState>; + /** The plugin host's post-sync report (the only writer). */ + setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void; } /** One open workspace modal's view (rows + materialized provider count). */ @@ -86,6 +97,7 @@ export function createPluginsStore(http: PluginsHttp): PluginsStore { let error = $state<string | null>(null); let lastScan = $state<PluginScanResult | null>(null); let workspaceView = $state<WorkspacePluginsView | null>(null); + let frontendStates = $state<ReadonlyMap<string, FrontendPluginState>>(new Map()); // Normalized nodes — derived so the graph logic re-runs only when the list // changes, not on every read. @@ -194,5 +206,13 @@ export function createPluginsStore(http: PluginsHttp): PluginsStore { const open = workspaceView?.workspaceId; if (open !== undefined) void this.openWorkspace(open); }, + + get frontendStates(): ReadonlyMap<string, FrontendPluginState> { + return frontendStates; + }, + + setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void { + frontendStates = states; + }, }; } diff --git a/src/features/plugins/ui/PluginManager.svelte b/src/features/plugins/ui/PluginManager.svelte index cd3d542..fd2a8a7 100644 --- a/src/features/plugins/ui/PluginManager.svelte +++ b/src/features/plugins/ui/PluginManager.svelte @@ -6,7 +6,7 @@ import { autoCheckBackendPrereqs } from "../logic/prereqs"; import { dependencyHighlightSets } from "../logic/highlight"; import { unloadBlockReason } from "../logic/last-provider"; - import { splitSections } from "../logic/view-model"; + import { splitSections, withFrontendStates } from "../logic/view-model"; import PluginTable from "./PluginTable.svelte"; import UnloadConfirmModal from "./UnloadConfirmModal.svelte"; @@ -17,9 +17,13 @@ }); // ── Derived sections + hover highlighting (§8) ──────────────────────────── - const sections = $derived(splitSections(plugins.nodes)); + // The FE-half states (plan 03) merge into the nodes here — one reactive + // derivation over both inputs (the normalized nodes + the plugin host's + // post-sync state map). + const nodes = $derived(withFrontendStates(plugins.nodes, plugins.frontendStates)); + const sections = $derived(splitSections(nodes)); let hoveredId = $state<string | null>(null); - const highlight = $derived(dependencyHighlightSets(hoveredId, plugins.nodes)); + const highlight = $derived(dependencyHighlightSets(hoveredId, nodes)); function highlightFor(id: string): "dep" | "dependent" | null { if (highlight.deps.has(id)) return "dep"; diff --git a/src/features/plugins/ui/PluginRow.svelte b/src/features/plugins/ui/PluginRow.svelte index 457bd29..73e0b18 100644 --- a/src/features/plugins/ui/PluginRow.svelte +++ b/src/features/plugins/ui/PluginRow.svelte @@ -83,6 +83,10 @@ {#if node.reason !== null} <span class="text-xs text-error">{node.reason}</span> {/if} + {#if node.frontendIssue !== undefined} + <!-- plan 03: the FE half's state — "incompatible frontend plugin" or the activation error --> + <span class="text-xs text-error">{node.frontendIssue}</span> + {/if} {#if node.dependsOn.length > 0} <span class="text-xs opacity-60">depends on: {node.dependsOn.join(", ")}</span> {/if} diff --git a/src/plugin-runtime/svelte-api.ts b/src/plugin-runtime/svelte-api.ts new file mode 100644 index 0000000..cfc1de5 --- /dev/null +++ b/src/plugin-runtime/svelte-api.ts @@ -0,0 +1,6 @@ +// The Svelte API surface exposed to dynamically-imported frontend plugins +// (plan 03): the import map maps the bare `svelte` specifier to this module's +// emitted chunk, whose star re-exports are the SAME module instances the app +// bundles (one rollup graph → one runtime). Entry-module exports are +// preserved in full, so plugins can import bindings the app never uses. +export * from "svelte"; diff --git a/src/plugin-runtime/svelte-internal-client.ts b/src/plugin-runtime/svelte-internal-client.ts new file mode 100644 index 0000000..cc6b4d6 --- /dev/null +++ b/src/plugin-runtime/svelte-internal-client.ts @@ -0,0 +1,12 @@ +// The compiled-runes runtime exposed to dynamically-imported frontend +// plugins (plan 03): the import map maps the bare `svelte/internal/client` +// specifier to this module's emitted chunk — the same runtime instance the +// app's own compiled components use. +// +// `svelte/internal/client` is intentionally untyped (the compiled-runes +// runtime — its API is compiler-private), so the re-export needs the +// suppression below. Nothing in the app imports THROUGH this module — the +// import map hands it to plugins; its entry exports are preserved in full. +// +// @ts-expect-error — svelte/internal/client ships no declaration file +export * from "svelte/internal/client"; |
