Unified · Split
| @@ -14,8 +14,8 @@ this is the smallest and fastest planner in the experiment. for production, its · 13 unmodified lines | ||
| 14 | 14 | |
| 15 | 15 | ## proved |
| 16 | 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 | |
| 17 | +- 23 tests cover dependency order, blocking, rollback, cycles, unchanged instance reuse, order-insensitive declarations, activation implementation replacement, cleanup reporting, unusual ids and slots, immutable snapshots, and a 20,000-plugin stack-safe chain | |
| 18 | +- manifest validation enforces semver, prevents entrypoint path escape, and keeps mobile declarative | |
| 19 | 19 | - the demo exercises activation, replacement, teardown, and a 250-plugin chain |
| 20 | 20 | |
| 21 | 21 | ## run |
| @@ -19,8 +19,8 @@ export interface PluginActivationContext { · 18 unmodified lines | ||
| 19 | 19 | |
| 20 | 20 | export interface PluginRuntimeSnapshot { |
| 21 | 21 | readonly active: ReadonlyArray<string>; |
| 22 | − readonly blocked: Readonly<Record<string, string>>; | |
| 23 | − readonly contributions: Readonly<Record<string, ReadonlyArray<Contribution>>>; | |
| 22 | + readonly blocked: Readonly<Partial<Record<string, string>>>; | |
| 23 | + readonly contributions: Readonly<Partial<Record<string, ReadonlyArray<Contribution>>>>; | |
| 24 | 24 | } |
| 25 | 25 | |
| 26 | 26 | export interface PluginRuntime { |
| @@ -36,6 +36,10 @@ export interface PluginRuntimeOptions { · 9 unmodified lines | ||
| 36 | 36 | readonly phase: "activate" | "deactivate"; |
| 37 | 37 | readonly pluginId: string; |
| 38 | 38 | }) => void; |
| 39 | + readonly onCleanupError?: (event: { | |
| 40 | + readonly phase: "retire" | "rollback"; | |
| 41 | + readonly error: unknown; | |
| 42 | + }) => void; | |
| 39 | 43 | } |
| 40 | 44 | |
| 41 | 45 | export type PluginRuntimeFactory = (options?: PluginRuntimeOptions) => PluginRuntime; |
| @@ -40,9 +40,25 @@ describe("PluginManifest", () => { · 39 unmodified lines | ||
| 40 | 40 | |
| 41 | 41 | it("rejects malformed versions and capability ids", () => { |
| 42 | 42 | expect(() => decodeManifest({ ...validManifest, version: "next" })).toThrow(); |
| 43 | + expect(() => decodeManifest({ ...validManifest, version: "01.2.3" })).toThrow(); | |
| 44 | + expect(() => decodeManifest({ ...validManifest, version: "1.2.3-.." })).toThrow(); | |
| 45 | + expect(decodeManifest({ ...validManifest, version: "1.2.3+build.7" }).version).toBe( | |
| 46 | + "1.2.3+build.7", | |
| 47 | + ); | |
| 43 | 48 | expect(() => decodeManifest({ ...validManifest, requires: ["t3.commands"] })).toThrow(); |
| 44 | 49 | }); |
| 45 | 50 | |
| 51 | + it("rejects entrypoints that escape the plugin directory", () => { | |
| 52 | + for (const server of ["./../outside.js", "./dist/../../outside.js"]) { | |
| 53 | + expect(() => | |
| 54 | + decodeManifest({ | |
| 55 | + ...validManifest, | |
| 56 | + entrypoints: { ...validManifest.entrypoints, server }, | |
| 57 | + }), | |
| 58 | + ).toThrow(); | |
| 59 | + } | |
| 60 | + }); | |
| 61 | + | |
| 46 | 62 | it("keeps mobile declarative by excluding a mobile executable entrypoint", () => { |
| 47 | 63 | const decoded = decodeManifest({ |
| 48 | 64 | ...validManifest, |
| @@ -5,12 +5,16 @@ const NamespacedId = Schema.String.check( · 4 unmodified lines | ||
| 5 | 5 | ); |
| 6 | 6 | |
| 7 | 7 | const SemanticVersion = Schema.String.check( |
| 8 | − Schema.isPattern(/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/), | |
| 8 | + Schema.isPattern( | |
| 9 | + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/, | |
| 10 | + ), | |
| 9 | 11 | ); |
| 10 | 12 | |
| 11 | 13 | const CapabilityId = Schema.String.check(Schema.isPattern(/^[a-z0-9][a-z0-9.-]*@[1-9]\d*$/)); |
| 12 | 14 | |
| 13 | −const RelativeEntrypoint = Schema.String.check(Schema.isPattern(/^\.\/[A-Za-z0-9_./-]+$/)); | |
| 15 | +const RelativeEntrypoint = Schema.String.check( | |
| 16 | + Schema.isPattern(/^\.\/(?!(?:\.\.(?:\/|$)|.*\/\.\.(?:\/|$)))[A-Za-z0-9_./-]+$/), | |
| 17 | +); | |
| 14 | 18 | const Permission = Schema.String.check(Schema.isPattern(/^[a-z][a-z-]*:.+$/)); |
| 15 | 19 | |
| 16 | 20 | const ContributionCatalog = Schema.Struct({ |
| @@ -150,8 +150,8 @@ const planComposition = (definitions: ReadonlyArray<PluginDefinition>): Composit · 149 unmodified lines | ||
| 150 | 150 | }; |
| 151 | 151 | |
| 152 | 152 | const sameDefinitionShape = (previous: PluginDefinition, desired: PluginDefinition) => { |
| 153 | − const previousRequires = previous.requires ?? []; | |
| 154 | − const desiredRequires = desired.requires ?? []; | |
| 153 | + const previousRequires = [...(previous.requires ?? [])].sort(); | |
| 154 | + const desiredRequires = [...(desired.requires ?? [])].sort(); | |
| 155 | 155 | const previousProvidedServices = previous.provides ?? {}; |
| 156 | 156 | const desiredProvidedServices = desired.provides ?? {}; |
| 157 | 157 | const previousProvides = Object.keys(previousProvidedServices); |
| @@ -159,20 +159,18 @@ const sameDefinitionShape = (previous: PluginDefinition, desired: PluginDefiniti · 1 unmodified line | ||
| 159 | 159 | |
| 160 | 160 | return ( |
| 161 | 161 | previous.version === desired.version && |
| 162 | + Object.is(previous.activate, desired.activate) && | |
| 162 | 163 | previousRequires.length === desiredRequires.length && |
| 163 | 164 | previousRequires.every((capability, index) => capability === desiredRequires[index]) && |
| 164 | 165 | previousProvides.length === desiredProvides.length && |
| 165 | 166 | previousProvides.every( |
| 166 | − (capability, index) => | |
| 167 | − capability === desiredProvides[index] && | |
| 167 | + (capability) => | |
| 168 | + Object.hasOwn(desiredProvidedServices, capability) && | |
| 168 | 169 | Object.is(previousProvidedServices[capability], desiredProvidedServices[capability]), |
| 169 | 170 | ) |
| 170 | 171 | ); |
| 171 | 172 | }; |
| 172 | 173 | |
| 173 | −const messageOf = (error: unknown) => | |
| 174 | − error instanceof Error ? error.message : "plugin activation failed"; | |
| 175 | − | |
| 176 | 174 | export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRuntime => { |
| 177 | 175 | let current = emptyComposition(); |
| 178 | 176 | let disposed = false; |
| @@ -216,6 +214,16 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 37 unmodified lines | ||
| 216 | 214 | return errors; |
| 217 | 215 | }; |
| 218 | 216 | |
| 217 | + const reportCleanupErrors = (phase: "retire" | "rollback", errors: ReadonlyArray<unknown>) => { | |
| 218 | + for (const error of errors) { | |
| 219 | + try { | |
| 220 | + options.onCleanupError?.({ phase, error }); | |
| 221 | + } catch { | |
| 222 | + // Cleanup reporting must not replace an activation failure or invalidate a commit. | |
| 223 | + } | |
| 224 | + } | |
| 225 | + }; | |
| 226 | + | |
| 219 | 227 | const stage = async ( |
| 220 | 228 | definitions: ReadonlyArray<PluginDefinition>, |
| 221 | 229 | previous: Composition, |
| @@ -303,8 +311,6 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 81 unmodified lines | ||
| 303 | 311 | activating = false; |
| 304 | 312 | } |
| 305 | 313 | |
| 306 | − instance.activated = true; | |
| 307 | − options.onLifecycle?.({ phase: "activate", pluginId: definition.id }); | |
| 308 | 314 | for (const [capability, service] of Object.entries(definition.provides ?? {})) { |
| 309 | 315 | services.set(capability, service); |
| 310 | 316 | } |
| @@ -323,12 +329,7 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 12 unmodified lines | ||
| 323 | 329 | }; |
| 324 | 330 | } catch (activationError) { |
| 325 | 331 | 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 | + reportCleanupErrors("rollback", cleanupErrors); | |
| 332 | 333 | throw activationError; |
| 333 | 334 | } |
| 334 | 335 | }; |
| @@ -352,11 +353,26 @@ export const createPureRuntime: PluginRuntimeFactory = (options = {}): PluginRun · 17 unmodified lines | ||
| 352 | 353 | const candidate = await stage(desired, previous); |
| 353 | 354 | current = candidate; |
| 354 | 355 | |
| 356 | + const lifecycleErrors: Array<unknown> = []; | |
| 357 | + for (const instance of candidate.instances) { | |
| 358 | + if (instance.activated) continue; | |
| 359 | + instance.activated = true; | |
| 360 | + try { | |
| 361 | + options.onLifecycle?.({ phase: "activate", pluginId: instance.definition.id }); | |
| 362 | + } catch (error) { | |
| 363 | + lifecycleErrors.push(error); | |
| 364 | + } | |
| 365 | + } | |
| 366 | + | |
| 355 | 367 | const retained = new Set(candidate.instances); |
| 356 | 368 | const retired = previous.instances.filter((instance) => !retained.has(instance)); |
| 357 | 369 | const cleanupErrors = await deactivateAll(retired); |
| 358 | − if (cleanupErrors.length > 0) { | |
| 359 | − throw new AggregateError(cleanupErrors, "previous composition deactivation failed"); | |
| 370 | + reportCleanupErrors("retire", cleanupErrors); | |
| 371 | + if (lifecycleErrors.length > 0) { | |
| 372 | + throw new AggregateError( | |
| 373 | + lifecycleErrors, | |
| 374 | + "committed composition lifecycle reporting failed", | |
| 375 | + ); | |
| 360 | 376 | } |
| 361 | 377 | return candidate.snapshot; |
| 362 | 378 | }); |
| @@ -116,6 +116,78 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 115 unmodified lines | ||
| 116 | 116 | expect(disposals).toBe(1); |
| 117 | 117 | }); |
| 118 | 118 | |
| 119 | + it("treats reordered requirements and providers as the same definition", async () => { | |
| 120 | + let activations = 0; | |
| 121 | + const firstService = {}; | |
| 122 | + const secondService = {}; | |
| 123 | + const activate: PluginDefinition["activate"] = () => { | |
| 124 | + activations += 1; | |
| 125 | + }; | |
| 126 | + const dependencies: ReadonlyArray<PluginDefinition> = [ | |
| 127 | + { | |
| 128 | + id: "acme.first-dependency", | |
| 129 | + version: "1.0.0", | |
| 130 | + provides: { "acme.first@1": true }, | |
| 131 | + activate() {}, | |
| 132 | + }, | |
| 133 | + { | |
| 134 | + id: "acme.second-dependency", | |
| 135 | + version: "1.0.0", | |
| 136 | + provides: { "acme.second@1": true }, | |
| 137 | + activate() {}, | |
| 138 | + }, | |
| 139 | + ]; | |
| 140 | + const runtime = createRuntime(); | |
| 141 | + await runtime.reconcile([ | |
| 142 | + ...dependencies, | |
| 143 | + { | |
| 144 | + id: "acme.stable-order", | |
| 145 | + version: "1.0.0", | |
| 146 | + requires: ["acme.first@1", "acme.second@1"], | |
| 147 | + provides: { "acme.output-one@1": firstService, "acme.output-two@1": secondService }, | |
| 148 | + activate, | |
| 149 | + }, | |
| 150 | + ]); | |
| 151 | + | |
| 152 | + await runtime.reconcile([ | |
| 153 | + ...dependencies, | |
| 154 | + { | |
| 155 | + id: "acme.stable-order", | |
| 156 | + version: "1.0.0", | |
| 157 | + requires: ["acme.second@1", "acme.first@1"], | |
| 158 | + provides: { "acme.output-two@1": secondService, "acme.output-one@1": firstService }, | |
| 159 | + activate, | |
| 160 | + }, | |
| 161 | + ]); | |
| 162 | + | |
| 163 | + expect(activations).toBe(1); | |
| 164 | + await runtime.dispose(); | |
| 165 | + }); | |
| 166 | + | |
| 167 | + it("restarts a plugin when its activation implementation changes", async () => { | |
| 168 | + const runtime = createRuntime(); | |
| 169 | + const first: PluginDefinition = { | |
| 170 | + id: "acme.implementation", | |
| 171 | + version: "1.0.0", | |
| 172 | + activate(context) { | |
| 173 | + context.register("commands", { id: "implementation", label: "first" }); | |
| 174 | + }, | |
| 175 | + }; | |
| 176 | + const second: PluginDefinition = { | |
| 177 | + id: "acme.implementation", | |
| 178 | + version: "1.0.0", | |
| 179 | + activate(context) { | |
| 180 | + context.register("commands", { id: "implementation", label: "second" }); | |
| 181 | + }, | |
| 182 | + }; | |
| 183 | + | |
| 184 | + await runtime.reconcile([first]); | |
| 185 | + const snapshot = await runtime.reconcile([second]); | |
| 186 | + | |
| 187 | + expect(contributionLabels(snapshot, "commands")).toEqual(["second"]); | |
| 188 | + await runtime.dispose(); | |
| 189 | + }); | |
| 190 | + | |
| 119 | 191 | it("restarts a changed provider and its dependents without touching independent plugins", async () => { |
| 120 | 192 | let independentActivations = 0; |
| 121 | 193 | const lifecycle: Array<string> = []; |
| @@ -237,11 +309,16 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 115 unmodified lines | ||
| 237 | 309 | await runtime.dispose(); |
| 238 | 310 | }); |
| 239 | 311 | |
| 240 | − it("does not report a failed candidate as activated", async () => { | |
| 312 | + it("does not report a rolled-back candidate as activated", async () => { | |
| 241 | 313 | const lifecycle: Array<string> = []; |
| 242 | 314 | const runtime = createRuntime({ |
| 243 | 315 | onLifecycle: ({ phase, pluginId }) => lifecycle.push(`${phase}:${pluginId}`), |
| 244 | 316 | }); |
| 317 | + const staged: PluginDefinition = { | |
| 318 | + id: "acme.staged", | |
| 319 | + version: "1.0.0", | |
| 320 | + activate() {}, | |
| 321 | + }; | |
| 245 | 322 | const broken: PluginDefinition = { |
| 246 | 323 | id: "acme.broken", |
| 247 | 324 | version: "1.0.0", |
| @@ -250,12 +327,76 @@ export function defineRuntimeContract(name: string, createRuntime: PluginRuntime · 2 unmodified lines | ||
| 250 | 327 | }, |
| 251 | 328 | }; |
| 252 | 329 | |
| 253 | − await expect(runtime.reconcile([broken])).rejects.toThrow("activation failed"); | |
| 330 | + await expect(runtime.reconcile([staged, broken])).rejects.toThrow("activation failed"); | |
| 254 | 331 | |
| 255 | 332 | expect(lifecycle).toEqual([]); |
| 256 | 333 | await runtime.dispose(); |
| 257 | 334 | }); |
| 258 | 335 | |
| 336 | + it("preserves activation failures when rollback cleanup also fails", async () => { | |
| 337 | + const activationError = new Error("activation failed"); | |
| 338 | + const cleanupError = new Error("rollback cleanup failed"); | |
| 339 | + const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; | |
| 340 | + const runtime = createRuntime({ | |
| 341 | + onCleanupError: (event) => { | |
| 342 | + cleanupEvents.push(event); | |
| 343 | + throw new Error("cleanup observer failed"); | |
| 344 | + }, | |
| 345 | + }); | |
| 346 | + const broken: PluginDefinition = { | |
| 347 | + id: "acme.rollback-error", | |
| 348 | + version: "1.0.0", | |
| 349 | + activate(context) { | |
| 350 | + context.onDispose(() => { | |
| 351 | + throw cleanupError; | |
| 352 | + }); | |
| 353 | + throw activationError; | |
| 354 | + }, | |
| 355 | + }; | |
| 356 | + | |
| 357 | + await expect(runtime.reconcile([broken])).rejects.toBe(activationError); | |
| 358 | + expect(cleanupEvents).toEqual([{ phase: "rollback", error: cleanupError }]); | |
| 359 | + await runtime.dispose(); | |
| 360 | + }); | |
| 361 | + | |
| 362 | + it("returns the committed snapshot when retiring an old plugin reports cleanup errors", async () => { | |
| 363 | + const cleanupError = new Error("retirement cleanup failed"); | |
| 364 | + const cleanupEvents: Array<{ readonly phase: string; readonly error: unknown }> = []; | |
| 365 | + const runtime = createRuntime({ | |
| 366 | + onCleanupError: (event) => { | |
| 367 | + cleanupEvents.push(event); | |
| 368 | + throw new Error("cleanup observer failed"); | |
| 369 | + }, | |
| 370 | + }); | |
| 371 | + await runtime.reconcile([ | |
| 372 | + { | |
| 373 | + id: "acme.retirement-error", | |
| 374 | + version: "1.0.0", | |
| 375 | + activate(context) { | |
| 376 | + context.register("commands", { id: "retirement", label: "old" }); | |
| 377 | + context.onDispose(() => { | |
| 378 | + throw cleanupError; | |
| 379 | + }); | |
| 380 | + }, | |
| 381 | + }, | |
| 382 | + ]); | |
| 383 | + | |
| 384 | + const snapshot = await runtime.reconcile([ | |
| 385 | + { | |
| 386 | + id: "acme.retirement-error", | |
| 387 | + version: "2.0.0", | |
| 388 | + activate(context) { | |
| 389 | + context.register("commands", { id: "retirement", label: "new" }); | |
| 390 | + }, | |
| 391 | + }, | |
| 392 | + ]); | |
| 393 | + | |
| 394 | + expect(contributionLabels(snapshot, "commands")).toEqual(["new"]); | |
| 395 | + expect(runtime.snapshot()).toBe(snapshot); | |
| 396 | + expect(cleanupEvents).toEqual([{ phase: "retire", error: cleanupError }]); | |
| 397 | + await runtime.dispose(); | |
| 398 | + }); | |
| 399 | + | |
| 259 | 400 | it("attempts every plugin cleanup when one finalizer fails", async () => { |
| 260 | 401 | const disposed: Array<string> = []; |
| 261 | 402 | const runtime = createRuntime(); |