summaryrefslogtreecommitdiffhomepage
path: root/src/core/plugin-abi
diff options
context:
space:
mode:
Diffstat (limited to 'src/core/plugin-abi')
-rw-r--r--src/core/plugin-abi/driver.test.ts210
-rw-r--r--src/core/plugin-abi/driver.ts140
-rw-r--r--src/core/plugin-abi/index.ts27
-rw-r--r--src/core/plugin-abi/load-diff.test.ts183
-rw-r--r--src/core/plugin-abi/load-diff.ts137
-rw-r--r--src/core/plugin-abi/types.ts113
-rw-r--r--src/core/plugin-abi/version.test.ts65
-rw-r--r--src/core/plugin-abi/version.ts99
8 files changed, 974 insertions, 0 deletions
diff --git a/src/core/plugin-abi/driver.test.ts b/src/core/plugin-abi/driver.test.ts
new file mode 100644
index 0000000..43d0af5
--- /dev/null
+++ b/src/core/plugin-abi/driver.test.ts
@@ -0,0 +1,210 @@
+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.1",
+ registerView: () => () => {},
+ surfaces: {
+ subscribe: () => {},
+ unsubscribe: () => {},
+ invoke: () => {},
+ getSpec: () => null,
+ },
+ logger: { info: () => {}, warn: () => {} },
+ layout: {
+ applyOps: async () => ({ ok: false, error: "no layout provider active" }),
+ getTree: () => null,
+ },
+ ...(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]);
+ // 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..f8ebf6c
--- /dev/null
+++ b/src/core/plugin-abi/load-diff.test.ts
@@ -0,0 +1,183 @@
+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.1");
+ const d = decideFrontendLoads([c("ok")], new Map());
+ expect(d.incompatible).toEqual([]);
+ });
+
+ it("the additive bump keeps v1 plugins loading (plan 04: ^1 spans 1.1)", () => {
+ // c() declares apiVersion "^1" — a bare-major range is a
+ // major-compatibility claim, so the 1.1 host (layout member added) still
+ // loads every v1 plugin.
+ const d = decideFrontendLoads([c("v1-plugin")], new Map());
+ expect(d.toLoad.map((x) => x.id)).toEqual(["v1-plugin"]);
+ expect(d.incompatible).toEqual([]);
+ });
+
+ it("plugins requiring the 1.1 ABI (^1.1) load on the 1.1 host", () => {
+ const layout = { ...c("layout-plugin"), apiVersion: "^1.1" };
+ const d = decideFrontendLoads([layout], new Map());
+ expect(d.toLoad.map((x) => x.id)).toEqual(["layout-plugin"]);
+ 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..bcb8139
--- /dev/null
+++ b/src/core/plugin-abi/types.ts
@@ -0,0 +1,113 @@
+/**
+ * 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 { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract";
+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 };
+ /**
+ * Edit the ACTIVE workspace's layout through the provider (plan 04 §4.5 —
+ * the additive ABI bump; v1 plugins keep working, a plugin REQUIRING the
+ * layout member declares `frontend.apiVersion: "^1.1"`). A thin adapter
+ * over `POST /workspaces/:id/layout/ops` (the same kit `applyOps` the
+ * service exposes — one code path, two doors):
+ * - the ops are the `@dispatch/layout-contract` vocabulary (prefer the
+ * idempotent `ensure*` ops; address by node ID, never by numeric path);
+ * - the batch is ATOMIC — any failed op persists nothing and the result
+ * names it (`failedOp`);
+ * - no layout provider active (the route 404s) → the typed no-provider
+ * error `{ ok: false, error: "no layout provider active" }` — handle it;
+ * the provider may also load later (retry on workspace switches).
+ */
+ readonly layout: {
+ readonly applyOps: (ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>;
+ /** The ACTIVE workspace's tree; `null` in no-provider mode. */
+ readonly getTree: () => LayoutTree | null;
+ };
+}
+
+/**
+ * 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..ab4d34b
--- /dev/null
+++ b/src/core/plugin-abi/version.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import { FRONTEND_ABI_VERSION, isFrontendCompatible } from "./version.js";
+
+describe("FRONTEND_ABI_VERSION", () => {
+ it("is 1.1 (plan 04 — the additive layout member bumped the minor)", () => {
+ expect(FRONTEND_ABI_VERSION).toBe("1.1");
+ });
+});
+
+describe("isFrontendCompatible — handshake accept/reject matrix (host 1.1.0)", () => {
+ it("accepts the canonical authoring range ^1 (v1 plugins keep working — the plan-04 promise)", () => {
+ expect(isFrontendCompatible("^1")).toBe(true);
+ expect(isFrontendCompatible("^1.0")).toBe(true);
+ expect(isFrontendCompatible("^1.0.0")).toBe(true);
+ });
+
+ it("accepts the bare major version (a major-compatibility claim: additive bumps never strand it)", () => {
+ expect(isFrontendCompatible("1")).toBe(true);
+ expect(isFrontendCompatible("~1")).toBe(true);
+ });
+
+ it("accepts plugins that REQUIRE the 1.1 ABI (^1.1 — the layout-declaring grammar)", () => {
+ expect(isFrontendCompatible("^1.1")).toBe(true);
+ expect(isFrontendCompatible("^1.1.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/pin at an OLDER explicit minor (explicit pins reject additive bumps)", () => {
+ expect(isFrontendCompatible("~0.9.0")).toBe(false);
+ expect(isFrontendCompatible("~1.0.0")).toBe(false);
+ expect(isFrontendCompatible("1.0.0")).toBe(false);
+ expect(isFrontendCompatible("^1.0")).not.toBe(false); // caret 1.x tracks the major
+ });
+
+ 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("the host version pins the answer (injectable for tests)", () => {
+ // At a hypothetical 1.0.0 host, ^1.1 (needing the layout member) excludes;
+ // at 1.1.0 it includes — the gate is real, not decorative.
+ expect(isFrontendCompatible("^1.1", "1.0.0")).toBe(false);
+ expect(isFrontendCompatible("^1.1", "1.1.0")).toBe(true);
+ // "~1.0.0" pins minor 0: excludes a 1.1.0 host, includes a 1.0.x host.
+ expect(isFrontendCompatible("~1.0.0", "1.1.0")).toBe(false);
+ expect(isFrontendCompatible("~1.0.0", "1.0.0")).toBe(true);
+ });
+});
diff --git a/src/core/plugin-abi/version.ts b/src/core/plugin-abi/version.ts
new file mode 100644
index 0000000..e5a0168
--- /dev/null
+++ b/src/core/plugin-abi/version.ts
@@ -0,0 +1,99 @@
+/**
+ * The frontend plugin ABI version + the semver handshake (plan 03 §3.2).
+ *
+ * FRONTEND_ABI_VERSION is bumped on BREAKING ABI changes only — an ADDITIVE
+ * member bumps the MINOR (plan 04 added `layout`: "1" → "1.1"; v1 plugins
+ * keep working, see the bare-major rule below). 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"`.
+ * A BARE-major range ("1", "^1", "~1" — no explicit minor) is a
+ * MAJOR-compatibility claim: it accepts every additive (minor) bump of that
+ * major, which is what makes "^1"/"1" plugins keep working on the 1.1 ABI
+ * (an author pinning minor/patch declares it explicitly: "1.0.0", "~1.0").
+ */
+
+export const FRONTEND_ABI_VERSION = "1.1";
+
+/**
+ * The host version the handshake checks against (normalized to semver) —
+ * tracks FRONTEND_ABI_VERSION's major.minor at a 0 patch.
+ */
+const HOST_VERSION = "1.1.0";
+
+interface SemVer {
+ readonly major: number;
+ readonly minor: number;
+ readonly patch: number;
+ /** The range string named the minor EXPLICITLY (a pin, not a bare major). */
+ readonly explicitMinor: boolean;
+}
+
+/** 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),
+ explicitMinor: match[2] !== undefined,
+ };
+}
+
+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 && (min.explicitMinor ? host.minor === min.minor : true);
+ }
+ 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;
+ // A BARE "~1" is a major-compatibility claim (any 1.x.y); an explicit
+ // minor ("~1.0") pins the minor, as tilde always has.
+ if (!min.explicitMinor) return host.major === min.major;
+ 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;
+ // A BARE major ("1") names the major only — it accepts that major's
+ // additive bumps (the "v1 plugins keep working" rule). An explicit
+ // version ("1.0", "1.0.0") pins exactly, as before.
+ if (!exact.explicitMinor) return host.major === exact.major;
+ return host.major === exact.major && host.minor === exact.minor && host.patch === exact.patch;
+}