History

spike: isolate pure plugin reconciler

t3-code[bot] committed 2 months ago

Commit a065b7b1 parent bcfd48511 files · +1027 −0Browse files

Unified · Split

11 files
  1. packages/plugin-runtime-poc/README.md +29 −0
  2. packages/plugin-runtime-poc/package.json +17 −0
  3. packages/plugin-runtime-poc/src/contract.ts +41 −0
  4. packages/plugin-runtime-poc/src/demo.ts +77 −0
  5. packages/plugin-runtime-poc/src/manifest.test.ts +63 −0
  6. packages/plugin-runtime-poc/src/manifest.ts +42 −0
  7. packages/plugin-runtime-poc/src/pureRuntime.test.ts +29 −0
  8. packages/plugin-runtime-poc/src/pureRuntime.ts +378 −0
  9. packages/plugin-runtime-poc/src/runtimeContract.ts +331 −0
  10. packages/plugin-runtime-poc/tsconfig.json +7 −0
  11. pnpm-lock.yaml +13 −0
packages/plugin-runtime-poc/README.md+29−0
@@ -0,0 +1,29 @@
1+# pure plugin reconciler poc
2+
3+this branch isolates the framework-free implementation from the three-way plugin runtime experiment.
4+
5+## shape
6+
7+- an iterative capability graph plans activation and targeted restarts
8+- plugin instances own explicit lifo finalizer stacks
9+- contributions are staged per plugin and published as frozen snapshots
10+- failed candidates are cleaned up without replacing the live composition
11+- cleanup attempts every dependent-first instance and aggregates failures
12+
13+this is the smallest and fastest planner in the experiment. for production, its graph algorithm should drive effect scopes rather than keeping manual finalizer ownership.
14+
15+## proved
16+
17+- 18 tests cover dependency order, blocking, rollback, cycles, unchanged instance reuse, provider-value replacement, unusual ids and slots, cleanup failures, immutable snapshots, and a 20,000-plugin stack-safe chain
18+- manifest validation keeps mobile declarative
19+- the demo exercises activation, replacement, teardown, and a 250-plugin chain
20+
21+## run
22+
23+```sh
24+vp test run packages/plugin-runtime-poc/src
25+vp run --filter @t3tools/plugin-runtime-pure-poc typecheck
26+vp run --filter @t3tools/plugin-runtime-pure-poc demo
27+```
28+
29+this is an isolated runtime poc. it does not load packages, add a sandbox, expose plugin rpc, or wire product ui.
packages/plugin-runtime-poc/package.json+17−0
@@ -0,0 +1,17 @@
1+{
2+ "name": "@t3tools/plugin-runtime-pure-poc",
3+ "private": true,
4+ "type": "module",
5+ "scripts": {
6+ "test": "vp test run",
7+ "typecheck": "tsgo --noEmit",
8+ "demo": "node src/demo.ts"
9+ },
10+ "dependencies": {
11+ "effect": "4.0.0-beta.103"
12+ },
13+ "devDependencies": {
14+ "@types/node": "24.12.4",
15+ "vite-plus": "catalog:"
16+ }
17+}
packages/plugin-runtime-poc/src/contract.ts+41−0
@@ -0,0 +1,41 @@
1+export interface Contribution {
2+ readonly id: string;
3+ readonly label: string;
4+}
5+
6+export interface PluginDefinition {
7+ readonly id: string;
8+ readonly version: string;
9+ readonly requires?: ReadonlyArray<string>;
10+ readonly provides?: Readonly<Record<string, unknown>>;
11+ readonly activate: (context: PluginActivationContext) => void | Promise<void>;
12+}
13+
14+export interface PluginActivationContext {
15+ readonly resolve: <Service>(capability: string) => Service;
16+ readonly register: (slot: string, contribution: Contribution) => void;
17+ readonly onDispose: (finalizer: () => void | Promise<void>) => void;
18+}
19+
20+export interface PluginRuntimeSnapshot {
21+ readonly active: ReadonlyArray<string>;
22+ readonly blocked: Readonly<Record<string, string>>;
23+ readonly contributions: Readonly<Record<string, ReadonlyArray<Contribution>>>;
24+}
25+
26+export interface PluginRuntime {
27+ readonly reconcile: (
28+ definitions: ReadonlyArray<PluginDefinition>,
29+ ) => Promise<PluginRuntimeSnapshot>;
30+ readonly snapshot: () => PluginRuntimeSnapshot;
31+ readonly dispose: () => Promise<void>;
32+}
33+
34+export interface PluginRuntimeOptions {
35+ readonly onLifecycle?: (event: {
36+ readonly phase: "activate" | "deactivate";
37+ readonly pluginId: string;
38+ }) => void;
39+}
40+
41+export type PluginRuntimeFactory = (options?: PluginRuntimeOptions) => PluginRuntime;
packages/plugin-runtime-poc/src/demo.ts+77−0
@@ -0,0 +1,77 @@
1+import type { PluginDefinition, PluginRuntimeSnapshot } from "./contract.ts";
2+import { createPureRuntime } from "./pureRuntime.ts";
3+
4+const commandLabels = (snapshot: PluginRuntimeSnapshot) =>
5+ snapshot.contributions.commands?.map((command) => command.label) ?? [];
6+
7+const database = (version: string): PluginDefinition => ({
8+ id: "demo.database",
9+ version,
10+ provides: { "demo.database@1": { version } },
11+ activate(context) {
12+ context.register("status", { id: "database", label: `database ${version}` });
13+ },
14+});
15+
16+const issues: PluginDefinition = {
17+ id: "demo.issues",
18+ version: "1.0.0",
19+ requires: ["demo.database@1"],
20+ activate(context) {
21+ const service = context.resolve<{ readonly version: string }>("demo.database@1");
22+ context.register("commands", {
23+ id: "create-issue",
24+ label: `create issue with database ${service.version}`,
25+ });
26+ },
27+};
28+
29+const chain = (length: number): ReadonlyArray<PluginDefinition> =>
30+ Array.from({ length }, (_, index) => ({
31+ id: `benchmark.plugin.${index}`,
32+ version: "1.0.0",
33+ ...(index === 0 ? {} : { requires: [`benchmark.capability.${index - 1}`] }),
34+ provides: { [`benchmark.capability.${index}`]: index },
35+ activate() {},
36+ }));
37+
38+const lifecycle: Array<string> = [];
39+const runtime = createPureRuntime({
40+ onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
41+});
42+const initial = await runtime.reconcile([issues, database("1.0.0")]);
43+const upgraded = await runtime.reconcile([issues, database("2.0.0")]);
44+await runtime.reconcile([]);
45+
46+const benchmark = createPureRuntime();
47+const benchmarkDefinitions = chain(250);
48+const startedAt = process.hrtime.bigint();
49+await benchmark.reconcile(benchmarkDefinitions);
50+const activationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
51+const noopStartedAt = process.hrtime.bigint();
52+await benchmark.reconcile(benchmarkDefinitions);
53+const noopMs = Number(process.hrtime.bigint() - noopStartedAt) / 1_000_000;
54+const upgradedDefinitions = benchmarkDefinitions.map((definition, index) =>
55+ index === 0 ? { ...definition, version: "2.0.0" } : definition,
56+);
57+const restartStartedAt = process.hrtime.bigint();
58+await benchmark.reconcile(upgradedDefinitions);
59+const restartMs = Number(process.hrtime.bigint() - restartStartedAt) / 1_000_000;
60+await benchmark.dispose();
61+
62+process.stdout.write(
63+ `${JSON.stringify(
64+ {
65+ variant: "pure-reconciler",
66+ initialCommands: commandLabels(initial),
67+ upgradedCommands: commandLabels(upgraded),
68+ lifecycle,
69+ chain250ActivationMs: Math.round(activationMs * 100) / 100,
70+ chain250NoopMs: Math.round(noopMs * 100) / 100,
71+ chain250RootRestartMs: Math.round(restartMs * 100) / 100,
72+ },
73+ null,
74+ 2,
75+ )}\n`,
76+);
77+await runtime.dispose();
packages/plugin-runtime-poc/src/manifest.test.ts+63−0
@@ -0,0 +1,63 @@
1+import * as Schema from "effect/Schema";
2+import { describe, expect, it } from "vite-plus/test";
3+
4+import { PluginManifest } from "./manifest.ts";
5+
6+const decodeManifest = Schema.decodeUnknownSync(PluginManifest);
7+
8+const validManifest = {
9+ id: "com.acme.linear",
10+ version: "1.2.0",
11+ engines: { t3: "^0.1.0" },
12+ entrypoints: {
13+ server: "./dist/server.js",
14+ web: "./dist/web.js",
15+ },
16+ requires: ["t3.commands@1", "t3.secrets@1"],
17+ provides: ["com.acme.linear@1"],
18+ permissions: ["network:https://api.linear.app", "secrets:linear-token"],
19+ contributes: {
20+ commands: ["linear.create-issue"],
21+ settings: ["linear.settings"],
22+ views: ["thread.right-panel"],
23+ },
24+};
25+
26+describe("PluginManifest", () => {
27+ it("decodes a namespaced multi-surface plugin manifest", () => {
28+ expect(decodeManifest(validManifest)).toEqual(validManifest);
29+ });
30+
31+ it("rejects unnamespaced plugin and contribution ids", () => {
32+ expect(() => decodeManifest({ ...validManifest, id: "linear" })).toThrow();
33+ expect(() =>
34+ decodeManifest({
35+ ...validManifest,
36+ contributes: { ...validManifest.contributes, commands: ["create-issue"] },
37+ }),
38+ ).toThrow();
39+ });
40+
41+ it("rejects malformed versions and capability ids", () => {
42+ expect(() => decodeManifest({ ...validManifest, version: "next" })).toThrow();
43+ expect(() => decodeManifest({ ...validManifest, requires: ["t3.commands"] })).toThrow();
44+ });
45+
46+ it("keeps mobile declarative by excluding a mobile executable entrypoint", () => {
47+ const decoded = decodeManifest({
48+ ...validManifest,
49+ surfaces: ["web", "desktop", "mobile"],
50+ contributes: { ...validManifest.contributes, mobileCards: ["linear.summary"] },
51+ });
52+
53+ expect(decoded.surfaces).toEqual(["web", "desktop", "mobile"]);
54+ expect(decoded.contributes.mobileCards).toEqual(["linear.summary"]);
55+ expect(decoded.entrypoints).not.toHaveProperty("mobile");
56+ expect(() =>
57+ decodeManifest({
58+ ...validManifest,
59+ entrypoints: { ...validManifest.entrypoints, mobile: "./dist/mobile.js" },
60+ }),
61+ ).toThrow();
62+ });
63+});
packages/plugin-runtime-poc/src/manifest.ts+42−0
@@ -0,0 +1,42 @@
1+import * as Schema from "effect/Schema";
2+
3+const NamespacedId = Schema.String.check(
4+ Schema.isPattern(/^[a-z0-9][a-z0-9-]*(?:\.[a-z0-9][a-z0-9-]*)+$/),
5+);
6+
7+const SemanticVersion = Schema.String.check(
8+ Schema.isPattern(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/),
9+);
10+
11+const CapabilityId = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9.-]*@[1-9]\d*$/));
12+
13+const RelativeEntrypoint = Schema.String.check(Schema.isPattern(/^\.\/[A-Za-z0-9_./-]+$/));
14+const Permission = Schema.String.check(Schema.isPattern(/^[a-z][a-z-]*:.+$/));
15+
16+const ContributionCatalog = Schema.Struct({
17+ commands: Schema.optional(Schema.Array(NamespacedId)),
18+ settings: Schema.optional(Schema.Array(NamespacedId)),
19+ views: Schema.optional(Schema.Array(NamespacedId)),
20+ mobileCards: Schema.optional(Schema.Array(NamespacedId)),
21+});
22+
23+export const PluginManifest = Schema.Struct({
24+ id: NamespacedId,
25+ version: SemanticVersion,
26+ engines: Schema.Struct({
27+ t3: Schema.String.check(Schema.isNonEmpty()),
28+ }),
29+ surfaces: Schema.optional(Schema.Array(Schema.Literals(["web", "desktop", "mobile"]))),
30+ entrypoints: Schema.Struct({
31+ server: Schema.optional(RelativeEntrypoint),
32+ web: Schema.optional(RelativeEntrypoint),
33+ desktop: Schema.optional(RelativeEntrypoint),
34+ }).annotate({ parseOptions: { onExcessProperty: "error" } }),
35+ requires: Schema.optional(Schema.Array(CapabilityId)),
36+ optional: Schema.optional(Schema.Array(CapabilityId)),
37+ provides: Schema.optional(Schema.Array(CapabilityId)),
38+ permissions: Schema.optional(Schema.Array(Permission)),
39+ contributes: ContributionCatalog,
40+});
41+
42+export type PluginManifest = typeof PluginManifest.Type;
packages/plugin-runtime-poc/src/pureRuntime.test.ts+29−0
@@ -0,0 +1,29 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import type { PluginDefinition } from "./contract.ts";
4+import { createPureRuntime } from "./pureRuntime.ts";
5+import { defineRuntimeContract } from "./runtimeContract.ts";
6+
7+defineRuntimeContract("pure reconciliation runtime", createPureRuntime);
8+
9+describe("pure reconciliation planner", () => {
10+ it("plans a deep acyclic dependency chain without using the call stack", async () => {
11+ const runtime = createPureRuntime();
12+ const pluginCount = 20_000;
13+ const definitions: Array<PluginDefinition> = Array.from(
14+ { length: pluginCount },
15+ (_, index) => ({
16+ id: `plugin-${index}`,
17+ version: "1.0.0",
18+ ...(index === 0 ? {} : { requires: [`capability-${index - 1}`] }),
19+ provides: { [`capability-${index}`]: index },
20+ activate() {},
21+ }),
22+ );
23+
24+ const snapshot = await runtime.reconcile(definitions.toReversed());
25+
26+ expect(snapshot.active).toHaveLength(pluginCount);
27+ await runtime.dispose();
28+ });
29+});
packages/plugin-runtime-poc/src/pureRuntime.ts+378−0
@@ -0,0 +1,378 @@
1+import type {
2+ Contribution,
3+ PluginDefinition,
4+ PluginRuntime,
5+ PluginRuntimeFactory,
6+ PluginRuntimeSnapshot,
7+} from "./contract.ts";
8+
9+type Finalizer = () => void | Promise<void>;
10+
11+interface PluginInstance {
12+ readonly definition: PluginDefinition;
13+ readonly contributions: Map<string, Array<Contribution>>;
14+ readonly finalizers: Array<Finalizer>;
15+ activated: boolean;
16+}
17+
18+interface Composition {
19+ readonly instances: ReadonlyArray<PluginInstance>;
20+ readonly snapshot: PluginRuntimeSnapshot;
21+}
22+
23+interface CompositionPlan {
24+ readonly active: ReadonlyArray<PluginDefinition>;
25+ readonly blocked: Readonly<Record<string, string>>;
26+ readonly providers: ReadonlyMap<string, PluginDefinition>;
27+}
28+
29+const createSnapshot = (
30+ instances: ReadonlyArray<PluginInstance>,
31+ blocked: Readonly<Record<string, string>>,
32+): PluginRuntimeSnapshot => {
33+ const contributions: Record<string, Array<Contribution>> = Object.create(null);
34+
35+ for (const instance of instances) {
36+ for (const [slot, registrations] of instance.contributions) {
37+ const items = Object.hasOwn(contributions, slot) ? contributions[slot] : [];
38+ if (items === undefined) continue;
39+ items.push(...registrations);
40+ contributions[slot] = items;
41+ }
42+ }
43+
44+ const frozenContributions: Record<string, ReadonlyArray<Contribution>> = Object.create(null);
45+ for (const [slot, registrations] of Object.entries(contributions)) {
46+ frozenContributions[slot] = Object.freeze([...registrations]);
47+ }
48+
49+ const frozenBlocked: Record<string, string> = Object.assign(Object.create(null), blocked);
50+
51+ return Object.freeze({
52+ active: Object.freeze(instances.map(({ definition }) => definition.id)),
53+ blocked: Object.freeze(frozenBlocked),
54+ contributions: Object.freeze(frozenContributions),
55+ });
56+};
57+
58+const emptyComposition = (): Composition => ({
59+ instances: [],
60+ snapshot: createSnapshot([], {}),
61+});
62+
63+const planComposition = (definitions: ReadonlyArray<PluginDefinition>): CompositionPlan => {
64+ const byId = new Map<string, PluginDefinition>();
65+ const providers = new Map<string, PluginDefinition>();
66+
67+ for (const definition of definitions) {
68+ if (byId.has(definition.id)) {
69+ throw new Error(`duplicate plugin id: ${definition.id}`);
70+ }
71+ byId.set(definition.id, definition);
72+
73+ for (const capability of Object.keys(definition.provides ?? {})) {
74+ const previous = providers.get(capability);
75+ if (previous !== undefined) {
76+ throw new Error(`duplicate provider for ${capability}: ${previous.id}, ${definition.id}`);
77+ }
78+ providers.set(capability, definition);
79+ }
80+ }
81+
82+ interface VisitFrame {
83+ readonly definition: PluginDefinition;
84+ requirementIndex: number;
85+ reason: string | undefined;
86+ }
87+
88+ const states = new Map<string, "visiting" | "visited">();
89+ const path: Array<string> = [];
90+ const pathIndexes = new Map<string, number>();
91+ const active: Array<PluginDefinition> = [];
92+ const blocked: Record<string, string> = Object.create(null);
93+
94+ for (const root of definitions) {
95+ if (states.has(root.id)) continue;
96+
97+ const stack: Array<VisitFrame> = [{ definition: root, requirementIndex: 0, reason: undefined }];
98+ states.set(root.id, "visiting");
99+ pathIndexes.set(root.id, path.length);
100+ path.push(root.id);
101+
102+ while (stack.length > 0) {
103+ const frame = stack.at(-1);
104+ if (frame === undefined) break;
105+ const requirements = frame.definition.requires ?? [];
106+ const capability = requirements[frame.requirementIndex];
107+
108+ if (capability !== undefined) {
109+ const provider = providers.get(capability);
110+ if (provider === undefined) {
111+ frame.reason ??= `missing dependency: ${capability}`;
112+ frame.requirementIndex += 1;
113+ continue;
114+ }
115+
116+ const state = states.get(provider.id);
117+ if (state === "visiting") {
118+ const cycleStart = pathIndexes.get(provider.id);
119+ if (cycleStart === undefined) throw new Error(`missing dependency path: ${provider.id}`);
120+ const cycle = [...path.slice(cycleStart), provider.id];
121+ throw new Error(`dependency cycle detected: ${cycle.join(" -> ")}`);
122+ }
123+ if (state === undefined) {
124+ stack.push({ definition: provider, requirementIndex: 0, reason: undefined });
125+ states.set(provider.id, "visiting");
126+ pathIndexes.set(provider.id, path.length);
127+ path.push(provider.id);
128+ continue;
129+ }
130+
131+ if (Object.hasOwn(blocked, provider.id)) {
132+ frame.reason ??= `dependency unavailable: ${capability}`;
133+ }
134+ frame.requirementIndex += 1;
135+ continue;
136+ }
137+
138+ stack.pop();
139+ path.pop();
140+ pathIndexes.delete(frame.definition.id);
141+ states.set(frame.definition.id, "visited");
142+ if (frame.reason === undefined) {
143+ active.push(frame.definition);
144+ } else {
145+ blocked[frame.definition.id] = frame.reason;
146+ }
147+ }
148+ }
149+ return { active, blocked, providers };
150+};
151+
152+const sameDefinitionShape = (previous: PluginDefinition, desired: PluginDefinition) => {
153+ const previousRequires = previous.requires ?? [];
154+ const desiredRequires = desired.requires ?? [];
155+ const previousProvidedServices = previous.provides ?? {};
156+ const desiredProvidedServices = desired.provides ?? {};
157+ const previousProvides = Object.keys(previousProvidedServices);
158+ const desiredProvides = Object.keys(desiredProvidedServices);
159+
160+ return (
161+ previous.version === desired.version &&
162+ previousRequires.length === desiredRequires.length &&
163+ previousRequires.every((capability, index) => capability === desiredRequires[index]) &&
164+ previousProvides.length === desiredProvides.length &&
165+ previousProvides.every(
166+ (capability, index) =>
167+ capability === desiredProvides[index] &&
168+ Object.is(previousProvidedServices[capability], desiredProvidedServices[capability]),
169+ )
170+ );
171+};
172+
173+const messageOf = (error: unknown) =>
174+ error instanceof Error ? error.message : "plugin activation failed";
175+
176+export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRuntime => {
177+ let current = emptyComposition();
178+ let disposed = false;
179+ let transition = Promise.resolve();
180+
181+ const deactivate = async (instance: PluginInstance): Promise<Array<unknown>> => {
182+ const errors: Array<unknown> = [];
183+
184+ if (instance.activated) {
185+ try {
186+ options.onLifecycle?.({
187+ phase: "deactivate",
188+ pluginId: instance.definition.id,
189+ });
190+ } catch (error) {
191+ errors.push(error);
192+ }
193+ }
194+
195+ for (let index = instance.finalizers.length - 1; index >= 0; index -= 1) {
196+ const finalizer = instance.finalizers[index];
197+ if (finalizer === undefined) continue;
198+ try {
199+ await finalizer();
200+ } catch (error) {
201+ errors.push(error);
202+ }
203+ }
204+
205+ return errors;
206+ };
207+
208+ const deactivateAll = async (
209+ instances: ReadonlyArray<PluginInstance>,
210+ ): Promise<Array<unknown>> => {
211+ const errors: Array<unknown> = [];
212+ for (let index = instances.length - 1; index >= 0; index -= 1) {
213+ const instance = instances[index];
214+ if (instance !== undefined) errors.push(...(await deactivate(instance)));
215+ }
216+ return errors;
217+ };
218+
219+ const stage = async (
220+ definitions: ReadonlyArray<PluginDefinition>,
221+ previous: Composition,
222+ ): Promise<Composition> => {
223+ const plan = planComposition(definitions);
224+ const previousById = new Map(
225+ previous.instances.map((instance) => [instance.definition.id, instance]),
226+ );
227+ const restart = new Set<string>();
228+
229+ for (const definition of plan.active) {
230+ const existing = previousById.get(definition.id);
231+ if (existing === undefined || !sameDefinitionShape(existing.definition, definition)) {
232+ restart.add(definition.id);
233+ }
234+ }
235+
236+ for (const definition of plan.active) {
237+ if (
238+ (definition.requires ?? []).some((capability) => {
239+ const provider = plan.providers.get(capability);
240+ return provider !== undefined && restart.has(provider.id);
241+ })
242+ ) {
243+ restart.add(definition.id);
244+ }
245+ }
246+
247+ const services = new Map<string, unknown>();
248+ const staged: Array<PluginInstance> = [];
249+ const stagedById = new Map<string, PluginInstance>();
250+
251+ for (const definition of plan.active) {
252+ if (restart.has(definition.id)) continue;
253+ const instance = previousById.get(definition.id);
254+ if (instance === undefined) {
255+ throw new Error(`missing reusable plugin instance: ${definition.id}`);
256+ }
257+ for (const [capability, service] of Object.entries(instance.definition.provides ?? {})) {
258+ services.set(capability, service);
259+ }
260+ }
261+
262+ try {
263+ for (const definition of plan.active) {
264+ if (!restart.has(definition.id)) continue;
265+
266+ const instance: PluginInstance = {
267+ definition,
268+ contributions: new Map(),
269+ finalizers: [],
270+ activated: false,
271+ };
272+ staged.push(instance);
273+ stagedById.set(definition.id, instance);
274+
275+ let activating = true;
276+ const assertActivating = () => {
277+ if (!activating) {
278+ throw new Error(`activation context for ${definition.id} is no longer active`);
279+ }
280+ };
281+
282+ try {
283+ await definition.activate({
284+ resolve: <Service>(capability: string): Service => {
285+ assertActivating();
286+ if (!services.has(capability)) {
287+ throw new Error(`plugin ${definition.id} could not resolve ${capability}`);
288+ }
289+ return services.get(capability) as Service;
290+ },
291+ register: (slot, contribution) => {
292+ assertActivating();
293+ const items = instance.contributions.get(slot) ?? [];
294+ items.push(Object.freeze({ id: contribution.id, label: contribution.label }));
295+ instance.contributions.set(slot, items);
296+ },
297+ onDispose: (finalizer) => {
298+ assertActivating();
299+ instance.finalizers.push(finalizer);
300+ },
301+ });
302+ } finally {
303+ activating = false;
304+ }
305+
306+ instance.activated = true;
307+ options.onLifecycle?.({ phase: "activate", pluginId: definition.id });
308+ for (const [capability, service] of Object.entries(definition.provides ?? {})) {
309+ services.set(capability, service);
310+ }
311+ }
312+
313+ const instances = plan.active.map((definition) => {
314+ const instance = stagedById.get(definition.id) ?? previousById.get(definition.id);
315+ if (instance === undefined) {
316+ throw new Error(`missing plugin instance after staging: ${definition.id}`);
317+ }
318+ return instance;
319+ });
320+ return {
321+ instances,
322+ snapshot: createSnapshot(instances, plan.blocked),
323+ };
324+ } catch (activationError) {
325+ const cleanupErrors = await deactivateAll(staged);
326+ if (cleanupErrors.length > 0) {
327+ throw new Error(
328+ `${messageOf(activationError)}; ${cleanupErrors.length} rollback finalizer(s) also failed`,
329+ { cause: activationError },
330+ );
331+ }
332+ throw activationError;
333+ }
334+ };
335+
336+ const runExclusive = <Result>(operation: () => Promise<Result>): Promise<Result> => {
337+ const result = transition.then(operation);
338+ transition = result.then(
339+ () => undefined,
340+ () => undefined,
341+ );
342+ return result;
343+ };
344+
345+ return {
346+ reconcile: (definitions) => {
347+ const desired = [...definitions];
348+ return runExclusive(async () => {
349+ if (disposed) throw new Error("plugin runtime is disposed");
350+
351+ const previous = current;
352+ const candidate = await stage(desired, previous);
353+ current = candidate;
354+
355+ const retained = new Set(candidate.instances);
356+ const retired = previous.instances.filter((instance) => !retained.has(instance));
357+ const cleanupErrors = await deactivateAll(retired);
358+ if (cleanupErrors.length > 0) {
359+ throw new AggregateError(cleanupErrors, "previous composition deactivation failed");
360+ }
361+ return candidate.snapshot;
362+ });
363+ },
364+ snapshot: () => current.snapshot,
365+ dispose: () =>
366+ runExclusive(async () => {
367+ if (disposed) return;
368+ disposed = true;
369+
370+ const previous = current;
371+ current = emptyComposition();
372+ const cleanupErrors = await deactivateAll(previous.instances);
373+ if (cleanupErrors.length > 0) {
374+ throw new AggregateError(cleanupErrors, "plugin runtime disposal failed");
375+ }
376+ }),
377+ };
378+};
packages/plugin-runtime-poc/src/runtimeContract.ts+331−0
@@ -0,0 +1,331 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import type { PluginDefinition, PluginRuntimeFactory, PluginRuntimeSnapshot } from "./contract.ts";
4+
5+const contributionLabels = (snapshot: PluginRuntimeSnapshot, slot: string) =>
6+ snapshot.contributions[slot]?.map((item) => item.label) ?? [];
7+
8+const provider = (version = "1.0.0"): PluginDefinition => ({
9+ id: "acme.database",
10+ version,
11+ provides: { "acme.database@1": { name: `database-${version}` } },
12+ activate(context) {
13+ context.register("status", { id: "database", label: `database ${version}` });
14+ },
15+});
16+
17+const consumer = (): PluginDefinition => ({
18+ id: "acme.issues",
19+ version: "1.0.0",
20+ requires: ["acme.database@1"],
21+ activate(context) {
22+ const database = context.resolve<{ readonly name: string }>("acme.database@1");
23+ context.register("commands", { id: "create-issue", label: database.name });
24+ },
25+});
26+
27+export function defineRuntimeContract(name: string, createRuntime: PluginRuntimeFactory) {
28+ describe(name, () => {
29+ it("activates providers before consumers regardless of manifest order", async () => {
30+ const runtime = createRuntime();
31+
32+ const snapshot = await runtime.reconcile([consumer(), provider()]);
33+
34+ expect(snapshot.active).toEqual(["acme.database", "acme.issues"]);
35+ expect(contributionLabels(snapshot, "commands")).toEqual(["database-1.0.0"]);
36+ await runtime.dispose();
37+ });
38+
39+ it("blocks missing dependencies without blocking independent plugins", async () => {
40+ const runtime = createRuntime();
41+ const independent: PluginDefinition = {
42+ id: "acme.clock",
43+ version: "1.0.0",
44+ activate(context) {
45+ context.register("status", { id: "clock", label: "clock" });
46+ },
47+ };
48+
49+ const snapshot = await runtime.reconcile([consumer(), independent]);
50+
51+ expect(snapshot.active).toEqual(["acme.clock"]);
52+ expect(snapshot.blocked["acme.issues"]).toContain("acme.database@1");
53+ expect(contributionLabels(snapshot, "status")).toEqual(["clock"]);
54+ await runtime.dispose();
55+ });
56+
57+ it("deactivates dependents before providers", async () => {
58+ const lifecycle: Array<string> = [];
59+ const runtime = createRuntime({
60+ onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
61+ });
62+ await runtime.reconcile([provider(), consumer()]);
63+ lifecycle.length = 0;
64+
65+ await runtime.reconcile([]);
66+
67+ expect(lifecycle).toEqual(["deactivate:acme.issues", "deactivate:acme.database"]);
68+ await runtime.dispose();
69+ });
70+
71+ it("runs plugin finalizers in reverse registration order", async () => {
72+ const disposed: Array<string> = [];
73+ const runtime = createRuntime();
74+ await runtime.reconcile([
75+ {
76+ id: "acme.finalizers",
77+ version: "1.0.0",
78+ activate(context) {
79+ context.onDispose(() => {
80+ disposed.push("first");
81+ });
82+ context.onDispose(() => {
83+ disposed.push("second");
84+ });
85+ },
86+ },
87+ ]);
88+
89+ await runtime.reconcile([]);
90+
91+ expect(disposed).toEqual(["second", "first"]);
92+ await runtime.dispose();
93+ });
94+
95+ it("keeps unchanged plugin scopes alive across reconciliation", async () => {
96+ let activations = 0;
97+ let disposals = 0;
98+ const stable: PluginDefinition = {
99+ id: "acme.stable",
100+ version: "1.0.0",
101+ activate(context) {
102+ activations += 1;
103+ context.onDispose(() => {
104+ disposals += 1;
105+ });
106+ },
107+ };
108+ const runtime = createRuntime();
109+
110+ await runtime.reconcile([stable]);
111+ await runtime.reconcile([stable]);
112+
113+ expect(activations).toBe(1);
114+ expect(disposals).toBe(0);
115+ await runtime.dispose();
116+ expect(disposals).toBe(1);
117+ });
118+
119+ it("restarts a changed provider and its dependents without touching independent plugins", async () => {
120+ let independentActivations = 0;
121+ const lifecycle: Array<string> = [];
122+ const independent: PluginDefinition = {
123+ id: "acme.independent",
124+ version: "1.0.0",
125+ activate() {
126+ independentActivations += 1;
127+ },
128+ };
129+ const runtime = createRuntime({
130+ onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
131+ });
132+ await runtime.reconcile([consumer(), independent, provider()]);
133+ lifecycle.length = 0;
134+
135+ const snapshot = await runtime.reconcile([consumer(), independent, provider("2.0.0")]);
136+
137+ expect(independentActivations).toBe(1);
138+ expect(lifecycle).not.toContain("activate:acme.independent");
139+ expect(lifecycle).not.toContain("deactivate:acme.independent");
140+ expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([
141+ "activate:acme.database",
142+ "activate:acme.issues",
143+ ]);
144+ expect(lifecycle.filter((event) => event.startsWith("deactivate:"))).toEqual([
145+ "deactivate:acme.issues",
146+ "deactivate:acme.database",
147+ ]);
148+ expect(contributionLabels(snapshot, "commands")).toEqual(["database-2.0.0"]);
149+ await runtime.dispose();
150+ });
151+
152+ it("returns deeply frozen snapshots", async () => {
153+ const runtime = createRuntime();
154+
155+ const snapshot = await runtime.reconcile([provider()]);
156+ const status = snapshot.contributions.status;
157+
158+ expect(Object.isFrozen(snapshot)).toBe(true);
159+ expect(Object.isFrozen(snapshot.active)).toBe(true);
160+ expect(Object.isFrozen(snapshot.blocked)).toBe(true);
161+ expect(Object.isFrozen(snapshot.contributions)).toBe(true);
162+ expect(Object.isFrozen(status)).toBe(true);
163+ expect(Object.isFrozen(status?.[0])).toBe(true);
164+ await runtime.dispose();
165+ });
166+
167+ it("treats every plugin id and contribution slot as data", async () => {
168+ const runtime = createRuntime();
169+ const unusualProvider: PluginDefinition = {
170+ id: "toString",
171+ version: "1.0.0",
172+ provides: { "acme.unusual@1": "available" },
173+ activate(context) {
174+ context.register("__proto__", { id: "provider", label: "provider" });
175+ },
176+ };
177+ const unusualConsumer: PluginDefinition = {
178+ id: "constructor",
179+ version: "1.0.0",
180+ requires: ["acme.unusual@1"],
181+ activate(context) {
182+ context.resolve("acme.unusual@1");
183+ context.register("toString", { id: "consumer", label: "consumer" });
184+ },
185+ };
186+ const unusualBlocked: PluginDefinition = {
187+ id: "__proto__",
188+ version: "1.0.0",
189+ requires: ["acme.missing@1"],
190+ activate() {
191+ throw new Error("blocked plugin activated");
192+ },
193+ };
194+
195+ const snapshot = await runtime.reconcile([unusualConsumer, unusualProvider, unusualBlocked]);
196+
197+ expect(snapshot.active).toEqual(["toString", "constructor"]);
198+ expect(Object.hasOwn(snapshot.blocked, "__proto__")).toBe(true);
199+ expect(snapshot.blocked.__proto__).toContain("acme.missing@1");
200+ expect(Object.hasOwn(snapshot.contributions, "__proto__")).toBe(true);
201+ expect(Object.hasOwn(snapshot.contributions, "toString")).toBe(true);
202+ expect(contributionLabels(snapshot, "__proto__")).toEqual(["provider"]);
203+ expect(contributionLabels(snapshot, "toString")).toEqual(["consumer"]);
204+ await runtime.dispose();
205+ });
206+
207+ it("restarts dependents when a provided value changes", async () => {
208+ const lifecycle: Array<string> = [];
209+ const runtime = createRuntime({
210+ onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
211+ });
212+ const serviceProvider = (name: string): PluginDefinition => ({
213+ id: "acme.service",
214+ version: "1.0.0",
215+ provides: { "acme.service@1": { name } },
216+ activate() {},
217+ });
218+ const serviceConsumer: PluginDefinition = {
219+ id: "acme.service-consumer",
220+ version: "1.0.0",
221+ requires: ["acme.service@1"],
222+ activate(context) {
223+ const service = context.resolve<{ readonly name: string }>("acme.service@1");
224+ context.register("commands", { id: "service", label: service.name });
225+ },
226+ };
227+ await runtime.reconcile([serviceProvider("first"), serviceConsumer]);
228+ lifecycle.length = 0;
229+
230+ const snapshot = await runtime.reconcile([serviceProvider("second"), serviceConsumer]);
231+
232+ expect(contributionLabels(snapshot, "commands")).toEqual(["second"]);
233+ expect(lifecycle.filter((event) => event.startsWith("activate:"))).toEqual([
234+ "activate:acme.service",
235+ "activate:acme.service-consumer",
236+ ]);
237+ await runtime.dispose();
238+ });
239+
240+ it("does not report a failed candidate as activated", async () => {
241+ const lifecycle: Array<string> = [];
242+ const runtime = createRuntime({
243+ onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`),
244+ });
245+ const broken: PluginDefinition = {
246+ id: "acme.broken",
247+ version: "1.0.0",
248+ activate() {
249+ throw new Error("activation failed");
250+ },
251+ };
252+
253+ await expect(runtime.reconcile([broken])).rejects.toThrow("activation failed");
254+
255+ expect(lifecycle).toEqual([]);
256+ await runtime.dispose();
257+ });
258+
259+ it("attempts every plugin cleanup when one finalizer fails", async () => {
260+ const disposed: Array<string> = [];
261+ const runtime = createRuntime();
262+ await runtime.reconcile([
263+ {
264+ id: "acme.first-cleanup",
265+ version: "1.0.0",
266+ activate(context) {
267+ context.onDispose(() => {
268+ disposed.push("first");
269+ });
270+ },
271+ },
272+ {
273+ id: "acme.second-cleanup",
274+ version: "1.0.0",
275+ activate(context) {
276+ context.onDispose(() => {
277+ disposed.push("second");
278+ throw new Error("cleanup failed");
279+ });
280+ },
281+ },
282+ ]);
283+
284+ await expect(runtime.dispose()).rejects.toThrow();
285+
286+ expect(disposed).toEqual(["second", "first"]);
287+ });
288+
289+ it("keeps the old plugin active when a replacement fails", async () => {
290+ const runtime = createRuntime();
291+ await runtime.reconcile([provider()]);
292+ const broken: PluginDefinition = {
293+ ...provider("2.0.0"),
294+ async activate(context) {
295+ context.register("status", { id: "database", label: "database 2.0.0" });
296+ throw new Error("candidate failed");
297+ },
298+ };
299+
300+ await expect(runtime.reconcile([broken])).rejects.toThrow("candidate failed");
301+
302+ expect(runtime.snapshot().active).toEqual(["acme.database"]);
303+ expect(contributionLabels(runtime.snapshot(), "status")).toEqual(["database 1.0.0"]);
304+ await runtime.dispose();
305+ });
306+
307+ it("rejects dependency cycles without disturbing the current composition", async () => {
308+ const runtime = createRuntime();
309+ await runtime.reconcile([provider()]);
310+ const alpha: PluginDefinition = {
311+ id: "acme.alpha",
312+ version: "1.0.0",
313+ requires: ["acme.beta@1"],
314+ provides: { "acme.alpha@1": true },
315+ activate() {},
316+ };
317+ const beta: PluginDefinition = {
318+ id: "acme.beta",
319+ version: "1.0.0",
320+ requires: ["acme.alpha@1"],
321+ provides: { "acme.beta@1": true },
322+ activate() {},
323+ };
324+
325+ await expect(runtime.reconcile([alpha, beta])).rejects.toThrow("cycle");
326+
327+ expect(runtime.snapshot().active).toEqual(["acme.database"]);
328+ await runtime.dispose();
329+ });
330+ });
331+}
packages/plugin-runtime-poc/tsconfig.json+7−0
@@ -0,0 +1,7 @@
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "compilerOptions": {
4+ "types": ["node"]
5+ },
6+ "include": ["src"]
7+}
pnpm-lock.yaml+13−0
@@ -830,6 +830,19 @@ importers: · 829 unmodified lines
830830 specifier: 'catalog:'
831831 version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
832832
833+ packages/plugin-runtime-poc:
834+ dependencies:
835+ effect:
836+ specifier: 4.0.0-beta.103
837+ version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)
838+ devDependencies:
839+ '@types/node':
840+ specifier: 24.12.4
841+ version: 24.12.4
842+ vite-plus:
843+ specifier: 'catalog:'
844+ version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
845+
833846 packages/shared:
834847 dependencies:
835848 '@noble/curves':