summaryrefslogtreecommitdiffhomepage
path: root/src/core
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-05 17:33:06 +0900
committerAdam Malczewski <[email protected]>2026-09-05 17:33:06 +0900
commit254f90a4081a3f15dfe54277da922579dbaee254 (patch)
treef5f4e409196109baf938cd9d557e244242df18ce /src/core
parent535215bfd1fee6087613108f42d28cd2807184d3 (diff)
downloaddispatch-web-254f90a4081a3f15dfe54277da922579dbaee254.tar.gz
dispatch-web-254f90a4081a3f15dfe54277da922579dbaee254.zip
feat(layout): plan 04 — ABI layout.applyOps + reset affordance
Diffstat (limited to 'src/core')
-rw-r--r--src/core/plugin-abi/driver.test.ts6
-rw-r--r--src/core/plugin-abi/load-diff.test.ts18
-rw-r--r--src/core/plugin-abi/types.ts22
-rw-r--r--src/core/plugin-abi/version.test.ts33
-rw-r--r--src/core/plugin-abi/version.ts29
5 files changed, 88 insertions, 20 deletions
diff --git a/src/core/plugin-abi/driver.test.ts b/src/core/plugin-abi/driver.test.ts
index e8c8e7e..b02c76b 100644
--- a/src/core/plugin-abi/driver.test.ts
+++ b/src/core/plugin-abi/driver.test.ts
@@ -41,7 +41,7 @@ function harness(): Harness {
return mod;
},
buildHostApi: (pluginId) => ({
- abiVersion: "1",
+ abiVersion: "1.1",
registerView: () => () => {},
surfaces: {
subscribe: () => {},
@@ -50,6 +50,10 @@ function harness(): Harness {
getSpec: () => null,
},
logger: { info: () => {}, warn: () => {} },
+ layout: {
+ applyOps: async () => ({ ok: false, error: "no layout provider active" }),
+ getTree: () => null,
+ },
...(pluginId === undefined ? {} : {}),
}),
unregisterViewsByPlugin: (pluginId) => sweeps.push(pluginId),
diff --git a/src/core/plugin-abi/load-diff.test.ts b/src/core/plugin-abi/load-diff.test.ts
index 157b93f..f8ebf6c 100644
--- a/src/core/plugin-abi/load-diff.test.ts
+++ b/src/core/plugin-abi/load-diff.test.ts
@@ -160,8 +160,24 @@ describe("decideFrontendLoads — the load diff (boot / extensions-changed / wor
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");
+ 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/types.ts b/src/core/plugin-abi/types.ts
index 14abf6e..bcb8139 100644
--- a/src/core/plugin-abi/types.ts
+++ b/src/core/plugin-abi/types.ts
@@ -15,6 +15,7 @@
* 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";
@@ -63,8 +64,25 @@ export interface FrontendHostAPI {
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).
+ /**
+ * 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;
+ };
}
/**
diff --git a/src/core/plugin-abi/version.test.ts b/src/core/plugin-abi/version.test.ts
index 2620847..ab4d34b 100644
--- a/src/core/plugin-abi/version.test.ts
+++ b/src/core/plugin-abi/version.test.ts
@@ -2,21 +2,26 @@ 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");
+ 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", () => {
- it("accepts the canonical authoring range ^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 and exact matches", () => {
+ it("accepts the bare major version (a major-compatibility claim: additive bumps never strand it)", () => {
expect(isFrontendCompatible("1")).toBe(true);
- expect(isFrontendCompatible("1.0.0")).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", () => {
@@ -34,9 +39,11 @@ describe("isFrontendCompatible — handshake accept/reject matrix", () => {
expect(isFrontendCompatible("^0.1.0")).toBe(false);
});
- it("rejects a tilde range of an older minor", () => {
+ 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(true);
+ 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)", () => {
@@ -46,9 +53,13 @@ describe("isFrontendCompatible — handshake accept/reject matrix", () => {
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);
+ 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
index becae87..e5a0168 100644
--- a/src/core/plugin-abi/version.ts
+++ b/src/core/plugin-abi/version.ts
@@ -1,7 +1,9 @@
/**
* 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
+ * 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.
@@ -12,17 +14,26 @@
* 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";
+export const FRONTEND_ABI_VERSION = "1.1";
-/** The host version the handshake checks against (normalized to semver). */
-const HOST_VERSION = "1.0.0";
+/**
+ * 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. */
@@ -33,6 +44,7 @@ function parseSemver(version: string): SemVer | null {
major: Number(match[1]),
minor: Number(match[2] ?? 0),
patch: Number(match[3] ?? 0),
+ explicitMinor: match[2] !== undefined,
};
}
@@ -59,7 +71,7 @@ export function isFrontendCompatible(apiVersion: string, hostVersion = HOST_VERS
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 === 0 && (min.explicitMinor ? host.minor === min.minor : true);
}
return host.major === min.major;
}
@@ -67,6 +79,9 @@ export function isFrontendCompatible(apiVersion: string, hostVersion = HOST_VERS
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(">=")) {
@@ -76,5 +91,9 @@ export function isFrontendCompatible(apiVersion: string, hostVersion = HOST_VERS
}
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;
}