From ef14700f6e701485fcae53935a86866dbea2feef Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:44:19 +0800 Subject: [PATCH 1/2] feat(runtime): add plugin composition foundation Migrate the feat-defined Context/Fiber kernel, plugin runtime contracts, composition loader, and public Runtime entrypoints. Keep Host control, persistence, package loading, and product wiring out of scope. Retain regression-tested foundation fixes for Service activation, Fiber transition and config semantics, Effect ownership and disposal, weak Plugin runtime caching, composition Tree failure invariants and inspection, safe Service record names, and contribution registration. Defer atomic Service-provider replacement and rollback, candidate Context Effect publication, and full asynchronous Effect failure policy to follow-up work that can define the required lifecycle contracts. Generated-by: Codex --- packages/runtime/package.json | 3 + .../plugin-composition-loader.test.ts | 654 +++++++++++ .../src/__tests__/plugin-kernel.test.ts | 658 +++++++++++ .../runtime/src/plugin-composition-loader.ts | 928 +++++++++++++++ packages/runtime/src/plugin-kernel.ts | 1037 +++++++++++++++++ packages/runtime/src/plugin-runtime.ts | 378 ++++++ 6 files changed, 3658 insertions(+) create mode 100644 packages/runtime/src/__tests__/plugin-composition-loader.test.ts create mode 100644 packages/runtime/src/__tests__/plugin-kernel.test.ts create mode 100644 packages/runtime/src/plugin-composition-loader.ts create mode 100644 packages/runtime/src/plugin-kernel.ts create mode 100644 packages/runtime/src/plugin-runtime.ts diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 82ef18114a..313b3930f8 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -77,6 +77,9 @@ "./path-containment": "./dist/path-containment.js", "./plan-mode": "./dist/plan-mode.js", "./plan-tools": "./dist/plan-tools.js", + "./plugin-composition-loader": "./dist/plugin-composition-loader.js", + "./plugin-kernel": "./dist/plugin-kernel.js", + "./plugin-runtime": "./dist/plugin-runtime.js", "./process-tree-terminator": "./dist/process-tree-terminator.js", "./provider-request-telemetry": "./dist/provider-request-telemetry.js", "./request-customization-fetch": "./dist/request-customization-fetch.js", diff --git a/packages/runtime/src/__tests__/plugin-composition-loader.test.ts b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts new file mode 100644 index 0000000000..16b97c4f40 --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts @@ -0,0 +1,654 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { Context, type Plugin } from '../plugin-kernel.js'; +import { MakaCompositionLoader } from '../plugin-composition-loader.js'; +import { + MakaPluginTransactionBuffer, + type MakaCompositionEntry, + type MakaPluginPackage, +} from '../plugin-runtime.js'; + +test('composition tree supports nested groups and repeated package instances', async () => { + const activations: string[] = []; + const plugin = ((ctx: Context, config: { label: string }) => { + activations.push(`${ctx.maka!.entryId}:${config.label}`); + ctx.effect(() => () => activations.push(`dispose:${ctx.maka!.entryId}`), 'fixture'); + }) as Plugin; + const loader = new MakaCompositionLoader(); + await loader.install(pkg('fixture', plugin)); + await loader.create('profile', { + id: 'group', + children: [ + entry('first', 'fixture', { label: 'one' }), + entry('second', 'fixture', { label: 'two' }), + ], + }); + assert.deepEqual(activations, ['first:one', 'second:two']); + assert.deepEqual( + loader.inspectTree('profile').map(({ id }) => id), + ['group'], + ); + assert.deepEqual( + loader.inspect('group').children.map(({ id }) => id), + ['first', 'second'], + ); + assert.equal(loader.root.kernelFibers().length, 3, 'root plus one real Fiber per package Entry'); + await loader.remove('first'); + assert.equal(loader.inspect('second').status, 'active'); + assert.ok(activations.includes('dispose:first')); + await loader.close(); +}); + +test('missing injected service enters pending and activates when provided', async () => { + let started = 0; + const plugin = Object.assign( + () => { + started += 1; + }, + { inject: ['fixtureService'] }, + ); + const loader = new MakaCompositionLoader(); + await loader.install(pkg('consumer', plugin)); + await loader.create('profile', entry('consumer-one', 'consumer')); + assert.equal(loader.inspect('consumer-one').status, 'pending'); + loader.root.provide('fixtureService', { value: 1 }); + await loader.awaitSettled(); + assert.equal(loader.inspect('consumer-one').status, 'active'); + assert.equal(started, 1); + await loader.close(); +}); + +test('config update uses the existing Fiber and preserves entry identity', async () => { + const values: number[] = []; + const plugin = (_ctx: Context, config: { value: number }) => { + values.push(config.value); + }; + const loader = new MakaCompositionLoader(); + await loader.install(pkg('configurable', plugin)); + const initial = await loader.create( + 'profile', + entry('configurable-one', 'configurable', { value: 1 }), + ); + const updated = await loader.update('configurable-one', { config: { value: 2 } }); + assert.equal(updated.id, initial.id); + assert.equal(updated.generation, initial.generation); + assert.equal(loader.snapshot().generation, 2); + assert.deepEqual(values, [1, 2]); + await loader.close(); +}); + +test('package reload is atomic when the candidate Fiber fails', async () => { + const live = new Set(); + const current = (ctx: Context) => { + live.add(ctx.maka!.entryId); + return () => live.delete(ctx.maka!.entryId); + }; + const failed = () => { + throw new Error('candidate exploded'); + }; + const loader = new MakaCompositionLoader(); + await loader.install(pkg('atomic', current)); + await loader.create('profile', entry('atomic-one', 'atomic')); + await assert.rejects(() => loader.install(pkg('atomic', failed)), /candidate exploded/u); + assert.equal(loader.inspect('atomic-one').status, 'active'); + assert.deepEqual([...live], ['atomic-one']); + await loader.close(); +}); + +test('package reload replaces only affected Entry subtrees', async () => { + const activations: string[] = []; + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('changing', (ctx: Context) => { + activations.push(`changing:${ctx.maka!.generation}`); + }), + ); + await loader.install( + pkg('stable', (ctx: Context) => { + activations.push(`stable:${ctx.maka!.generation}`); + }), + ); + await loader.create('profile', entry('changing-entry', 'changing')); + await loader.create('profile', entry('stable-entry', 'stable')); + const stableGeneration = loader.inspect('stable-entry').generation; + + await loader.install( + pkg('changing', (ctx: Context) => { + activations.push(`changed:${ctx.maka!.generation}`); + }), + ); + + assert.notEqual(loader.inspect('changing-entry').generation, undefined); + assert.equal(loader.inspect('stable-entry').generation, stableGeneration); + assert.equal(activations.filter((item) => item.startsWith('stable:')).length, 1); + await loader.close(); +}); + +test('package reload keeps the published generation when retirement cleanup fails', async (t) => { + t.mock.method(console, 'warn', () => undefined); + const activations: string[] = []; + const current = () => { + activations.push('current'); + return () => { + throw new Error('current cleanup failed'); + }; + }; + const replacement = () => { + activations.push('replacement'); + }; + const loader = new MakaCompositionLoader(); + await loader.install(pkg('cleanup-reload', current)); + await loader.create('profile', entry('cleanup-reload-entry', 'cleanup-reload')); + + await loader.install(pkg('cleanup-reload', replacement)); + + assert.deepEqual(activations, ['current', 'replacement']); + assert.equal(loader.inspect('cleanup-reload-entry').status, 'active'); + assert.equal(loader.package('cleanup-reload').host, replacement); + await loader.close(); +}); + +test('remove and close exhaust subtree cleanup across retirement failures', async (t) => { + t.mock.method(console, 'warn', () => undefined); + const createLoader = async (lifecycle: string[]) => { + const loader = new MakaCompositionLoader(); + await loader.install( + pkg( + 'cleanup', + (_ctx: Context, config: { readonly label: string; readonly fail?: boolean }) => { + return () => { + lifecycle.push(config.label); + if (config.fail) throw new Error(`${config.label} cleanup failed`); + }; + }, + ), + ); + await loader.create('profile', { + id: 'cleanup-group', + children: [ + entry('cleanup-first', 'cleanup', { label: 'first', fail: true }), + entry('cleanup-second', 'cleanup', { label: 'second' }), + ], + }); + return loader; + }; + + const removed: string[] = []; + const removeLoader = await createLoader(removed); + await removeLoader.remove('cleanup-group'); + assert.deepEqual(removed, ['second', 'first']); + await removeLoader.close(); + + const closed: string[] = []; + const closeLoader = await createLoader(closed); + await assert.rejects(closeLoader.close(), AggregateError); + assert.deepEqual(closed, ['second', 'first']); +}); + +test('disabled ancestors suppress insert, move, update, and package reload activation', async () => { + const activations: string[] = []; + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('disabled-child', (ctx: Context, config: { readonly value: string }) => { + activations.push(`${ctx.maka!.entryId}:${config.value}`); + }), + ); + await loader.create('profile', { id: 'disabled-parent', disabled: true }); + + await loader.create( + 'profile', + entry('inserted-child', 'disabled-child', { value: 'inserted' }), + 'disabled-parent', + ); + assert.equal(loader.inspect('inserted-child').disabled, true); + assert.equal(loader.inspect('inserted-child').status, 'disabled'); + + await loader.create('profile', entry('moved-child', 'disabled-child', { value: 'before-move' })); + assert.deepEqual(activations, ['moved-child:before-move']); + await loader.move('moved-child', 'disabled-parent'); + assert.equal(loader.inspect('moved-child').status, 'disabled'); + + await loader.replaceSubtree( + 'inserted-child', + entry('inserted-child', 'disabled-child', { value: 'replaced' }), + ); + await loader.update('inserted-child', { config: { value: 'updated' } }); + await loader.install( + pkg('disabled-child', (ctx: Context, config: { readonly value: string }) => { + activations.push(`${ctx.maka!.entryId}:reloaded:${config.value}`); + }), + ); + + assert.deepEqual(activations, ['moved-child:before-move']); + assert.equal(loader.inspect('inserted-child').status, 'disabled'); + assert.equal(loader.inspect('moved-child').status, 'disabled'); + await loader.close(); +}); + +test('insert commit failure disposes its unindexed Fiber exactly once', async () => { + let disposals = 0; + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('commit-failure', (ctx: Context) => { + ctx.makaTransaction!.stage( + 'first', + () => () => { + disposals += 1; + }, + ctx, + ); + ctx.makaTransaction!.stage( + 'failure', + () => { + throw new Error('registration failed'); + }, + ctx, + ); + }), + ); + + await assert.rejects( + loader.create('profile', entry('failed-entry', 'commit-failure')), + /registration failed/u, + ); + + assert.deepEqual(loader.inspectTree('profile'), []); + assert.equal(loader.root.kernelFibers().length, 1); + assert.equal(disposals, 1); + await loader.close(); + assert.equal(disposals, 1); +}); + +test('transaction commit failure rolls registrations back sequentially in LIFO order', async () => { + const lifecycle: string[] = []; + let laterDisposed = false; + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('ordered-rollback', (ctx: Context) => { + ctx.makaTransaction!.stage( + 'first', + () => () => { + lifecycle.push(laterDisposed ? 'first-after-later' : 'first-overlapped-later'); + }, + ctx, + ); + ctx.makaTransaction!.stage( + 'later', + () => async () => { + await Promise.resolve(); + laterDisposed = true; + lifecycle.push('later'); + }, + ctx, + ); + ctx.makaTransaction!.stage( + 'failure', + () => { + throw new Error('registration failed'); + }, + ctx, + ); + }), + ); + + await assert.rejects( + loader.create('profile', entry('ordered-rollback-entry', 'ordered-rollback')), + /registration failed/u, + ); + + assert.deepEqual(lifecycle, ['later', 'first-after-later']); + await loader.close(); +}); + +test('retirement cleanup failure does not roll back a published removal generation', async (t) => { + t.mock.method(console, 'warn', () => undefined); + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('retirement-failure', () => () => { + throw new Error('retirement failed'); + }), + ); + await loader.create('profile', entry('retired-entry', 'retirement-failure')); + const generation = loader.snapshot().generation; + + await loader.remove('retired-entry'); + + assert.equal(loader.snapshot().generation, generation + 1); + assert.deepEqual(loader.inspectTree('profile'), []); + await loader.close(); +}); + +test('retirement cleanup failure does not roll back a published structural update', async (t) => { + t.mock.method(console, 'warn', () => undefined); + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('retired-package', () => () => { + throw new Error('retirement failed'); + }), + ); + await loader.install(pkg('replacement-package', () => undefined)); + await loader.create('profile', entry('updated-entry', 'retired-package')); + const generation = loader.snapshot().generation; + + await loader.update('updated-entry', { packageId: 'replacement-package' }); + + assert.equal(loader.snapshot().generation, generation + 1); + assert.equal(loader.inspect('updated-entry').packageId, 'replacement-package'); + assert.equal(loader.inspect('updated-entry').status, 'active'); + await loader.close(); +}); + +test('contribution registrations are staged and owned by the entry Fiber', async () => { + const root = new Context(); + const registrations = new Set(); + const loader = new MakaCompositionLoader({ + root, + transaction: (context) => new MakaPluginTransactionBuffer(context), + }); + const plugin = (ctx: Context, config: { suffix: string }) => { + ctx.makaTransaction!.stage( + `fixture:${config.suffix}`, + () => { + registrations.add(config.suffix); + return () => { + registrations.delete(config.suffix); + }; + }, + ctx, + ); + }; + await loader.install(pkg('owner', plugin)); + await loader.create('profile', entry('entry-a', 'owner', { suffix: 'a' })); + await loader.create('profile', entry('entry-b', 'owner', { suffix: 'b' })); + assert.deepEqual([...registrations], ['a', 'b']); + await loader.remove('entry-a'); + assert.deepEqual([...registrations], ['b']); + await loader.close(); +}); + +test('snapshot replacement restores ordered roots and descendants', async () => { + const loader = new MakaCompositionLoader(); + await loader.install(pkg('snapshot', () => undefined)); + await loader.replaceSnapshot({ + schemaVersion: 1, + generation: 41, + roots: { + profile: [entry('profile-entry', 'snapshot')], + desktopUi: [{ id: 'ui-group', children: [entry('ui-entry', 'snapshot')] }], + sessions: { s1: [entry('session-entry', 'snapshot')] }, + }, + }); + assert.equal(loader.snapshot().generation >= 41, true); + assert.deepEqual( + loader.inspectTree().map(({ id }) => id), + ['profile-entry', 'ui-group', 'session-entry'], + ); + assert.equal(loader.inspect('ui-entry').parentId, 'ui-group'); + await loader.close(); +}); + +test('entry inject and intercept metadata retain the feat shallow-copy contract', async () => { + const dependencyCheck = () => true; + const interceptConfig = { select: () => true }; + const loader = new MakaCompositionLoader(); + + await loader.create('profile', { + id: 'metadata-group', + inject: { fixtureService: dependencyCheck }, + intercept: { fixtureService: interceptConfig }, + }); + + assert.equal(loader.inspect('metadata-group').status, 'active'); + await loader.close(); +}); + +test('replacement subtrees reject duplicate ids across different branches', async () => { + const loader = new MakaCompositionLoader(); + await loader.create('profile', { id: 'replacement-root' }); + + await assert.rejects( + loader.replaceSubtree('replacement-root', { + id: 'replacement-root', + children: [ + { id: 'left-branch', children: [{ id: 'repeated-child' }] }, + { id: 'right-branch', children: [{ id: 'repeated-child' }] }, + ], + }), + /Replacement subtree repeats entry repeated-child/u, + ); + + assert.deepEqual(loader.inspect('replacement-root').children, []); + await loader.close(); +}); + +test('snapshot preserves session ids that overlap object prototype properties', async () => { + const loader = new MakaCompositionLoader(); + await loader.create('session:__proto__', { id: 'special-session-entry' }); + + const snapshot = loader.snapshot(); + assert.equal(Object.hasOwn(snapshot.roots.sessions, '__proto__'), true); + assert.deepEqual( + snapshot.roots.sessions.__proto__?.map(({ id }) => id), + ['special-session-entry'], + ); + + await loader.replaceSnapshot(snapshot); + assert.deepEqual( + loader.inspectTree('session:__proto__').map(({ id }) => id), + ['special-session-entry'], + ); + await loader.close(); +}); + +test('inspecting a missing root does not mutate the composition snapshot', async () => { + const loader = new MakaCompositionLoader(); + const before = loader.snapshot(); + + assert.deepEqual(loader.inspectTree('session:missing'), []); + + assert.deepEqual(loader.snapshot(), before); + await loader.close(); +}); + +test('failed insert does not create an empty composition root', async () => { + const loader = new MakaCompositionLoader(); + const before = loader.snapshot(); + + await assert.rejects( + loader.create('session:ghost', { id: 'orphan' }, 'missing-parent'), + /Composition entry not found: missing-parent/u, + ); + + assert.deepEqual(loader.snapshot(), before); + await loader.close(); +}); + +test('structural updates preserve descendants added after the parent was created', async () => { + const loader = new MakaCompositionLoader(); + await loader.create('profile', { id: 'dynamic-group' }); + await loader.create('profile', { id: 'dynamic-child' }, 'dynamic-group'); + + await loader.disable('dynamic-group'); + + assert.equal(loader.inspect('dynamic-child').parentId, 'dynamic-group'); + assert.equal(loader.inspect('dynamic-child').disabled, true); + assert.deepEqual( + loader.snapshot().roots.profile[0]?.children?.map(({ id }) => id), + ['dynamic-child'], + ); + await loader.close(); +}); + +test('failed rebind leaves parent and position unchanged', async () => { + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('move-guard', (ctx: Context) => { + if (ctx.interceptConfig('moveGuard').length) throw new Error('target rejected move'); + }), + ); + await loader.create('profile', { id: 'target-parent', intercept: { moveGuard: true } }); + await loader.create('profile', entry('movable-entry', 'move-guard')); + const before = loader.snapshot(); + + await assert.rejects(loader.move('movable-entry', 'target-parent'), /target rejected move/u); + + assert.equal(loader.inspect('movable-entry').parentId, undefined); + assert.deepEqual(loader.snapshot(), before); + await loader.close(); +}); + +test('inspection includes package dependencies and live Fiber failures', async () => { + const loader = new MakaCompositionLoader(); + await loader.install( + pkg( + 'diagnostic-consumer', + Object.assign( + () => { + throw new Error('dependency activation failed'); + }, + { inject: ['packageService'] }, + ), + ), + ); + await loader.create('profile', entry('diagnostic-entry', 'diagnostic-consumer')); + + assert.deepEqual(loader.inspect('diagnostic-entry').waitingFor, ['packageService']); + loader.root.provide('packageService', { ready: true }); + await loader.awaitSettled(); + + const inspection = loader.inspect('diagnostic-entry'); + assert.equal(inspection.status, 'failed'); + assert.match(inspection.diagnostic ?? '', /dependency activation failed/u); + await loader.close(); +}); + +test('committed transactions reject late registration before acquiring resources', async (t) => { + t.mock.method(console, 'warn', () => undefined); + let registrations = 0; + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('late-transaction', (ctx: Context) => () => { + ctx.makaTransaction!.stage( + 'late-registration', + () => { + registrations += 1; + return () => undefined; + }, + ctx, + ); + }), + ); + await loader.create('profile', entry('late-transaction-entry', 'late-transaction')); + + await loader.remove('late-transaction-entry'); + + assert.equal(registrations, 0); + await loader.close(); +}); + +test('callable config remains inspectable after publication', async () => { + const config = () => 'callable'; + const loader = new MakaCompositionLoader(); + await loader.install(pkg('callable-config', () => undefined)); + + const inspection = await loader.create( + 'profile', + entry('callable-config-entry', 'callable-config', config), + ); + + assert.equal(inspection.config, config); + assert.equal(loader.inspect('callable-config-entry').config, config); + assert.equal(loader.snapshot().roots.profile[0]?.config, config); + await loader.close(); +}); + +test('callable intercept changes trigger structural Context replacement', async () => { + const first = () => 'first'; + const second = () => 'second'; + const seen: unknown[] = []; + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('callable-intercept', (ctx: Context) => { + seen.push(ctx.interceptConfig('fixture')[0]); + }), + ); + await loader.create('profile', { + id: 'callable-intercept-entry', + packageId: 'callable-intercept', + intercept: { fixture: first }, + }); + + await loader.update('callable-intercept-entry', { intercept: { fixture: second } }); + + assert.deepEqual(seen, [first, second]); + assert.equal(loader.snapshot().roots.profile[0]?.intercept?.fixture, second); + await loader.close(); +}); + +test('staging and commit failures do not retain newly created session roots', async () => { + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('activation-failure', () => { + throw new Error('activation failed'); + }), + ); + await loader.install( + pkg('commit-root-failure', (ctx: Context) => { + ctx.makaTransaction!.stage('failure', () => { + throw new Error('commit failed'); + }); + }), + ); + const before = loader.snapshot(); + + await assert.rejects( + loader.create('session:missing-package', entry('missing-package-entry', 'missing-package')), + /not installed/u, + ); + await assert.rejects( + loader.create( + 'session:activation-failure', + entry('activation-failure-entry', 'activation-failure'), + ), + /activation failed/u, + ); + await assert.rejects( + loader.create('session:commit-failure', entry('commit-failure-entry', 'commit-root-failure')), + /commit failed/u, + ); + + assert.deepEqual(loader.snapshot(), before); + await loader.close(); +}); + +test('composition apply batches EntryTree operations under one generation check', async () => { + const loader = new MakaCompositionLoader(); + await loader.install(pkg('batch', () => undefined)); + const initial = loader.snapshot().generation; + const changed = await loader.apply({ + baseGeneration: initial, + operations: [ + { type: 'insert', entry: entry('batch-a', 'batch') }, + { type: 'insert', parentId: 'batch-a', entry: { id: 'batch-group' } }, + { type: 'update', entryId: 'batch-group', patch: { disabled: true } }, + ], + }); + assert.deepEqual( + changed.map(({ id }) => id), + ['batch-a', 'batch-group', 'batch-group'], + ); + assert.equal(loader.inspect('batch-group').disabled, true); + await assert.rejects( + () => loader.apply({ baseGeneration: initial, operations: [] }), + /Composition generation changed/u, + ); + await loader.close(); +}); + +function pkg(packageId: string, host: Plugin): MakaPluginPackage { + return Object.freeze({ packageId, host }); +} + +function entry(id: string, packageId: string, config?: unknown): MakaCompositionEntry { + return Object.freeze({ id, packageId, ...(config === undefined ? {} : { config }) }); +} diff --git a/packages/runtime/src/__tests__/plugin-kernel.test.ts b/packages/runtime/src/__tests__/plugin-kernel.test.ts new file mode 100644 index 0000000000..1666187c3d --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-kernel.test.ts @@ -0,0 +1,658 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { Context, FiberState, Service, type Plugin } from '../plugin-kernel.js'; +import { registerPluginContribution } from '../plugin-runtime.js'; + +declare module '../plugin-kernel.js' { + interface Context { + fixture?: { readonly value: string }; + } +} + +test('injected plugins wait for Services and reload when ownership changes', async () => { + const root = new Context(); + const lifecycle: string[] = []; + const consumer = Object.assign( + (ctx: Context) => { + const value = ctx.fixture?.value; + lifecycle.push(`load:${value}`); + return () => lifecycle.push(`dispose:${value}`); + }, + { inject: ['fixture'] }, + ) satisfies Plugin; + + const fiber = root.plugin(consumer); + await fiber.await(); + assert.equal(fiber.state, FiberState.PENDING); + + const removeFirst = root.provide('fixture', { value: 'first' }); + await fiber.await(); + assert.equal(fiber.state, FiberState.ACTIVE); + assert.deepEqual(lifecycle, ['load:first']); + + await removeFirst(); + await fiber.await(); + assert.equal(fiber.state, FiberState.PENDING); + assert.deepEqual(lifecycle, ['load:first', 'dispose:first']); + + root.provide('fixture', { value: 'second' }); + await fiber.await(); + assert.equal(fiber.state, FiberState.ACTIVE); + assert.deepEqual(lifecycle, ['load:first', 'dispose:first', 'load:second']); + await root.fiber.dispose(); +}); + +test('Services provided by a plugin activate dependent plugins after the provider is active', async () => { + const root = new Context(); + let consumed: string | undefined; + const consumer = Object.assign( + (ctx: Context) => { + consumed = ctx.get<{ readonly value: string }>('pluginService')?.value; + }, + { inject: ['pluginService'] }, + ); + const consumerFiber = root.plugin(consumer); + await consumerFiber.await(); + assert.equal(consumerFiber.state, FiberState.PENDING); + + const providerFiber = root.plugin((ctx) => { + ctx.provide('pluginService', { value: 'ready' }); + }); + await providerFiber.await(); + await consumerFiber.await(); + + assert.equal(providerFiber.state, FiberState.ACTIVE); + assert.equal(consumerFiber.state, FiberState.ACTIVE); + assert.equal(consumed, 'ready'); + await root.fiber.dispose(); +}); + +test('a provider with multiple Services activates each dependent Fiber once', async () => { + const root = new Context(); + let activations = 0; + const consumer = root.plugin( + Object.assign( + () => { + activations += 1; + }, + { inject: ['firstService', 'secondService'] }, + ), + ); + await consumer.await(); + + const provider = root.plugin((ctx) => { + ctx.provide('firstService', { value: 1 }); + ctx.provide('secondService', { value: 2 }); + }); + await provider.await(); + await consumer.await(); + + assert.equal(provider.state, FiberState.ACTIVE); + assert.equal(consumer.state, FiberState.ACTIVE); + assert.equal(activations, 1); + await root.fiber.dispose(); +}); + +test('rapid Service updates coalesce dependent Fiber reloads around the latest value', async () => { + const root = new Context(); + root.provide('fixture', { value: 1 }); + const activations: number[] = []; + const consumer = root.plugin( + Object.assign( + (ctx: Context) => { + activations.push(ctx.get<{ readonly value: number }>('fixture')!.value); + }, + { inject: ['fixture'] }, + ), + ); + await consumer.await(); + + root.set('fixture', { value: 2 }); + root.set('fixture', { value: 3 }); + await consumer.await(); + + assert.deepEqual(activations, [1, 3]); + await root.fiber.dispose(); +}); + +test('Fiber await includes a Service refresh queued during activation', async () => { + const root = new Context(); + root.provide('fixture', { value: 1 }); + const activations: number[] = []; + let releaseFirst!: () => void; + let releaseSecond!: () => void; + let signalFirst!: () => void; + let signalSecond!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + const secondGate = new Promise((resolve) => { + releaseSecond = resolve; + }); + const firstStarted = new Promise((resolve) => { + signalFirst = resolve; + }); + const secondStarted = new Promise((resolve) => { + signalSecond = resolve; + }); + const consumer = root.plugin( + Object.assign( + async (ctx: Context) => { + const value = ctx.get<{ readonly value: number }>('fixture')!.value; + activations.push(value); + if (value === 1) { + signalFirst(); + await firstGate; + } else { + signalSecond(); + await secondGate; + } + }, + { inject: ['fixture'] }, + ), + ); + + await firstStarted; + const settled = consumer.await(); + root.set('fixture', { value: 2 }); + releaseFirst(); + await secondStarted; + + let completed = false; + void settled.then(() => { + completed = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(completed, false); + + releaseSecond(); + await settled; + assert.equal(consumer.state, FiberState.ACTIVE); + assert.deepEqual(activations, [1, 2]); + await root.fiber.dispose(); +}); + +test('Fiber await observes the final state after an intermediate transition rejects', async () => { + const root = new Context(); + const fiber = root.plugin((_ctx, config: string) => { + if (config === 'broken') throw new Error('broken transition'); + }, 'initial'); + await fiber.await(); + + const broken = fiber.update('broken'); + const recovered = fiber.update('recovered'); + await fiber.await(); + + await assert.rejects(broken, /broken transition/u); + await recovered; + assert.equal(fiber.state, FiberState.ACTIVE); + assert.equal(fiber.config, 'recovered'); + await root.fiber.dispose(); +}); + +test('fire-and-forget Fiber activation failures do not become unhandled rejections', async () => { + const root = new Context(); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + try { + const fiber = root.plugin(() => { + throw new Error('activation failed'); + }); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(unhandled, []); + await assert.rejects(fiber.await(), /activation failed/u); + } finally { + process.off('unhandledRejection', onUnhandled); + await root.fiber.dispose().catch(() => undefined); + } +}); + +test('asynchronous Effect setup and cleanup failures are observed internally', async () => { + const root = new Context(); + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + try { + const fiber = root.plugin((ctx) => { + ctx.effect(async function* () { + yield () => { + throw new Error('effect cleanup failed'); + }; + throw new Error('effect setup failed'); + }, 'failed-async-effect'); + }); + await fiber.await(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(unhandled, []); + assert.ok(fiber.error instanceof AggregateError); + } finally { + process.off('unhandledRejection', onUnhandled); + await root.fiber.dispose().catch(() => undefined); + } +}); + +test('synchronous Effect setup failures preserve the active Fiber contract', async () => { + const root = new Context(); + const fiber = root.plugin((ctx) => { + ctx.effect(() => { + throw new Error('synchronous effect setup failed'); + }, 'failed-sync-effect'); + }); + + await fiber.await(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(fiber.state, FiberState.ACTIVE); + assert.match(String(fiber.error), /synchronous effect setup failed/u); + await root.fiber.dispose(); +}); + +test('Service isolation keeps sibling implementations independent', async () => { + const root = new Context(); + root.provide('fixture', { value: 'root' }); + const isolated = root.isolate('fixture'); + isolated.provide('fixture', { value: 'isolated' }); + + assert.equal(root.get<{ value: string }>('fixture')?.value, 'root'); + assert.equal(isolated.get<{ value: string }>('fixture')?.value, 'isolated'); + assert.equal(root.extend().get<{ value: string }>('fixture')?.value, 'root'); + await root.fiber.dispose(); +}); + +test('Fiber update preserves identity and disposes Effects in reverse order', async () => { + const root = new Context(); + const lifecycle: string[] = []; + const plugin = (ctx: Context, config: { value: number }) => { + lifecycle.push(`load:${config.value}`); + ctx.effect(() => () => lifecycle.push(`first:${config.value}`), 'first'); + ctx.effect(() => () => lifecycle.push(`second:${config.value}`), 'second'); + }; + const fiber = root.plugin(plugin, { value: 1 }); + await fiber.await(); + const id = fiber.id; + + await fiber.update({ value: 2 }); + assert.equal(fiber.id, id); + assert.deepEqual(lifecycle, ['load:1', 'second:1', 'first:1', 'load:2']); + assert.deepEqual( + fiber.getEffects().map(({ label }) => label), + ['first', 'second'], + ); + await root.fiber.dispose(); +}); + +test('Fiber Proxy exposes getters backed by private state', async () => { + const root = new Context(); + const fiber = root.plugin({ name: 'named-plugin', apply: () => undefined }); + + assert.equal(fiber.name, 'named-plugin'); + await fiber.await(); + await root.fiber.dispose(); +}); + +test('Plugin objects sharing apply retain independent Runtime metadata', async () => { + const root = new Context(); + const activations: string[] = []; + const apply = (_ctx: Context, config: string) => { + activations.push(config); + }; + const plugin = (name: string, prefix: string): Plugin.Object => ({ + name, + apply, + Config: { + '~standard': { + validate: (value) => ({ value: `${prefix}:${String(value)}` }), + }, + }, + }); + + const first = root.plugin(plugin('first-plugin', 'first'), 'a'); + const second = root.plugin(plugin('second-plugin', 'second'), 'b'); + await Promise.all([first.await(), second.await()]); + + assert.equal(first.name, 'first-plugin'); + assert.equal(second.name, 'second-plugin'); + assert.deepEqual(activations, ['first:a', 'second:b']); + await root.fiber.dispose(); +}); + +test('Standard Schema validation preserves raw Fiber config across restarts', async () => { + const root = new Context(); + const values: string[] = []; + const plugin: Plugin.Object = { + Config: { + '~standard': { + validate: (value) => ({ value: `parsed:${String(value)}` }), + }, + }, + apply(_ctx, config) { + values.push(String(config)); + }, + }; + const fiber = root.plugin(plugin, 'raw'); + await fiber.await(); + await fiber.restart(); + + assert.equal(fiber.config, 'raw'); + assert.deepEqual(values, ['parsed:raw', 'parsed:raw']); + await root.fiber.dispose(); +}); + +test('Fiber update restores the previous active config after activation fails', async () => { + const root = new Context(); + const lifecycle: string[] = []; + const fiber = root.plugin( + (_ctx, config: { value: number }) => { + lifecycle.push(`load:${config.value}`); + if (config.value === 2) throw new Error('invalid update'); + return () => lifecycle.push(`dispose:${config.value}`); + }, + { value: 1 }, + ); + await fiber.await(); + + await assert.rejects(fiber.update({ value: 2 }), /invalid update/u); + + assert.equal(fiber.state, FiberState.ACTIVE); + assert.deepEqual(fiber.config, { value: 1 }); + assert.deepEqual(lifecycle, ['load:1', 'dispose:1', 'load:2', 'load:1']); + await root.fiber.dispose(); +}); + +test('concurrent Fiber updates serialize config activation and isolate rollback', async () => { + const values: string[] = []; + const root = new Context(); + const fiber = root.plugin((_ctx, config: string) => { + values.push(config); + if (config === 'broken') throw new Error('broken config'); + }, 'initial'); + await fiber.await(); + + await Promise.all([fiber.update('first'), fiber.update('second')]); + assert.deepEqual(values, ['initial', 'first', 'second']); + assert.equal(fiber.config, 'second'); + + const results = await Promise.allSettled([fiber.update('broken'), fiber.update('final')]); + assert.equal(results[0]?.status, 'rejected'); + assert.equal(results[1]?.status, 'fulfilled'); + assert.deepEqual(values, ['initial', 'first', 'second', 'broken', 'second', 'final']); + assert.equal(fiber.config, 'final'); + assert.equal(fiber.state, FiberState.ACTIVE); + await fiber.dispose(); + await root.fiber.dispose(); +}); + +test('Fiber cleanup exhausts Effects before reporting disposer failures', async () => { + const root = new Context(); + const lifecycle: string[] = []; + const fiber = root.plugin((ctx) => { + ctx.effect(() => () => lifecycle.push('first')); + ctx.effect(() => () => { + lifecycle.push('failing'); + throw new Error('cleanup failed'); + }); + ctx.effect(() => () => lifecycle.push('last')); + }); + await fiber.await(); + + const firstDispose = fiber.dispose(); + const concurrentDispose = fiber.dispose(); + assert.equal(concurrentDispose, firstDispose); + const firstError = await firstDispose.then( + () => undefined, + (error: unknown) => error, + ); + assert.ok(firstError instanceof AggregateError); + + const retryDispose = fiber.dispose(); + assert.equal(retryDispose, firstDispose); + const retryError = await retryDispose.then( + () => undefined, + (error: unknown) => error, + ); + assert.equal(retryError, firstError); + assert.deepEqual(lifecycle, ['last', 'failing', 'first']); + assert.equal(fiber.state, FiberState.DISPOSED); + assert.equal(root.kernelFibers().includes(fiber), false); + await root.fiber.dispose(); +}); + +test('concurrent Effect disposal shares the same completion task', async () => { + const root = new Context(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const dispose = root.effect(() => async () => gate, 'slow-cleanup'); + + const first = dispose(); + const second = dispose(); + assert.equal(second, first); + let completed = false; + void second.then(() => { + completed = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(completed, false); + + release(); + await first; + assert.equal(completed, true); + await root.fiber.dispose(); +}); + +test('Plugin Runtime metadata cache uses weak Plugin identities', async () => { + const root = new Context(); + assert.ok(root._kernel().runtimes instanceof WeakMap); + await root.fiber.dispose(); +}); + +test('accessors cannot shadow existing Context properties', async () => { + const root = new Context(); + assert.throws( + () => root.accessor('plugin', { get: () => 'hidden' }), + /Context property already exists: plugin/u, + ); + await root.fiber.dispose(); +}); + +test('child accessors cannot shadow metadata inherited from parent Contexts', async () => { + const root = new Context(); + const parent = root.extend({ inheritedMeta: 'visible' }); + const child = parent.extend(); + + assert.throws( + () => child.accessor('inheritedMeta', { get: () => 'hidden' }), + /Context property already exists: inheritedMeta/u, + ); + assert.equal(Reflect.get(child, 'inheritedMeta'), 'visible'); + await root.fiber.dispose(); +}); + +test('mixin validates every target before publishing accessors', async () => { + const root = new Context(); + + assert.throws( + () => root.mixin({ first: 1, second: 2 }, { first: 'mixed', second: 'plugin' }), + /Context property already exists: plugin/u, + ); + assert.equal(root._kernel().accessors.has('mixed'), false); + assert.equal(Reflect.get(root, 'mixed'), undefined); + await root.fiber.dispose(); +}); + +test('unknown Context property reads do not create Service labels', async () => { + const root = new Context(); + const labels = root._kernel().serviceLabels; + + for (let index = 0; index < 100; index += 1) { + assert.equal(Reflect.get(root, `unknownService${index}`), undefined); + } + assert.equal(labels.size, 0); + + const service = { value: 'available' }; + root.provide('futureService', service); + assert.equal(Reflect.get(root, 'futureService'), service); + assert.equal(labels.size, 1); + await root.fiber.dispose(); +}); + +test('event dispatch supports emit, parallel, serial, bail, and waterfall', async () => { + const root = new Context(); + const emitted: string[] = []; + root.on('emit', (value) => emitted.push(`one:${String(value)}`)); + root.on('emit', (value) => emitted.push(`two:${String(value)}`)); + root.emit('emit', 1); + assert.deepEqual(emitted, ['one:1', 'two:1']); + + const parallel: string[] = []; + root.on('parallel', async () => { + await Promise.resolve(); + parallel.push('one'); + }); + root.on('parallel', () => parallel.push('two')); + await root.parallel('parallel'); + assert.deepEqual(parallel.sort(), ['one', 'two']); + + const attempted: string[] = []; + root.on('parallel-error', () => { + attempted.push('throwing'); + throw new Error('synchronous listener failed'); + }); + root.on('parallel-error', () => attempted.push('following')); + await assert.rejects(root.parallel('parallel-error'), AggregateError); + assert.deepEqual(attempted, ['throwing', 'following']); + + root.on('serial', () => undefined); + root.on('serial', () => 'stop'); + root.on('serial', () => 'unreachable'); + assert.equal(await root.serial('serial'), 'stop'); + + root.on('bail', () => false); + root.on('bail', () => 42); + assert.equal(root.bail('bail'), 42); + + root.on('waterfall', (value, next) => `outer(${String((next as () => unknown)())}:${value})`); + root.on('waterfall', (value, next) => `inner(${String((next as () => unknown)())}:${value})`); + assert.equal( + root.waterfall('waterfall', 'x', () => 'base'), + 'outer(inner(base:x):x)', + ); + await root.fiber.dispose(); +}); + +test('event hooks are not published while their Fiber is unloading', async () => { + const root = new Context(); + let calls = 0; + const fiber = root.plugin((ctx) => () => { + ctx.on('late-hook', () => { + calls += 1; + }); + }); + await fiber.await(); + + await assert.rejects(fiber.dispose(), AggregateError); + root.emit('late-hook'); + + assert.equal(calls, 0); + await root.fiber.dispose(); +}); + +test('unloading Contexts cannot create escaping child Fibers', async () => { + const root = new Context(); + let childActivations = 0; + const fiber = root.plugin((ctx) => () => { + ctx.plugin(() => { + childActivations += 1; + }); + }); + await fiber.await(); + + await assert.rejects(fiber.dispose(), AggregateError); + assert.equal(childActivations, 0); + assert.deepEqual( + root.kernelFibers().map(({ id }) => id), + [0], + ); + await root.fiber.dispose(); +}); + +test('dispose requests immediately fence child Fiber creation and Service labels', async () => { + const root = new Context(); + let context!: Context; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const fiber = root.plugin(async (ctx, config: string) => { + context = ctx; + if (config === 'slow') await gate; + }, 'ready'); + await fiber.await(); + + const update = fiber.update('slow'); + await new Promise((resolve) => setImmediate(resolve)); + const dispose = fiber.dispose(); + + assert.throws(() => context.plugin(() => undefined), /Plugin Context is disposed/u); + assert.throws( + () => context.provide('lateService', { value: true }), + /Plugin Context is disposed/u, + ); + assert.equal(root._kernel().serviceLabels.has('lateService'), false); + + release(); + await update; + await dispose; + await root.fiber.dispose(); +}); + +test('late contribution registration is rejected before acquiring resources', async () => { + const root = new Context(); + let registrations = 0; + const fiber = root.plugin((ctx) => () => { + registerPluginContribution(ctx, 'late-contribution', () => { + registrations += 1; + return () => undefined; + }); + }); + await fiber.await(); + + await assert.rejects(fiber.dispose(), AggregateError); + assert.equal(registrations, 0); + await root.fiber.dispose(); +}); + +test('intercept configuration is inherited without mutating parent Contexts', async () => { + const root = new Context(); + const child = root.intercept('fixture', { child: true }); + const grandchild = child.intercept('fixture', { grandchild: true }); + + assert.deepEqual(root.interceptConfig('fixture'), []); + assert.deepEqual(child.interceptConfig('fixture'), [{ child: true }]); + assert.deepEqual(grandchild.interceptConfig('fixture'), [{ child: true }, { grandchild: true }]); + await root.fiber.dispose(); +}); + +test('Service records support names inherited from Object.prototype', async () => { + class FixtureService extends Service> { + merge(): Record { + return this.resolveConfig({ base: true }); + } + } + + const root = new Context(); + const intercepted = root.intercept('constructor', { intercepted: true }); + const isolated = intercepted.isolate('constructor'); + const service = new FixtureService(isolated, 'constructor'); + const bound = isolated.get('constructor'); + + assert.deepEqual(intercepted.interceptConfig('constructor'), [{ intercepted: true }]); + assert.ok(bound); + assert.deepEqual(bound.merge(), { base: true, intercepted: true }); + assert.deepEqual(service.merge(), { base: true, intercepted: true }); + await root.fiber.dispose(); +}); diff --git a/packages/runtime/src/plugin-composition-loader.ts b/packages/runtime/src/plugin-composition-loader.ts new file mode 100644 index 0000000000..25a7f2becd --- /dev/null +++ b/packages/runtime/src/plugin-composition-loader.ts @@ -0,0 +1,928 @@ +import { Context, type Fiber, type Inject, type Plugin } from './plugin-kernel.js'; +import { + fiberStateName, + type MakaCompositionEntry, + type MakaCompositionEntryInspection, + type MakaCompositionApplyInput, + type MakaCompositionSnapshot, + type MakaPluginMetadata, + type MakaPluginPackage, + type MakaPluginRootId, + MakaPluginRuntimeError, + MakaPluginTransactionBuffer, + type MakaPluginTransaction, + validateCompositionEntry, + validatePluginPackage, + validatePluginRootId, +} from './plugin-runtime.js'; + +interface LiveEntry { + spec: MakaCompositionEntry; + readonly rootId: MakaPluginRootId; + parent?: LiveEntry; + context: Context; + fiber?: Fiber; + generation?: number; + readonly children: LiveEntry[]; + diagnostic?: string; +} + +const FIBER_PENDING = 0; +const FIBER_FAILED = 3; + +interface LiveRoot { + readonly id: MakaPluginRootId; + readonly context: Context; + readonly entries: LiveEntry[]; +} + +export interface MakaCompositionLoaderOptions { + readonly root?: Context; + readonly transaction?: (context: Context) => MakaPluginTransaction | undefined; +} + +export class MakaCompositionLoader { + readonly root: Context; + readonly #packages = new Map(); + readonly #roots = new Map(); + readonly #entries = new Map(); + readonly #isolationLabels = new Map(); + readonly #transaction?: (context: Context) => MakaPluginTransaction | undefined; + #compositionGeneration = 0; + #fiberGeneration = 0; + #mutation: Promise = Promise.resolve(); + + constructor(options: MakaCompositionLoaderOptions = {}) { + this.root = options.root ?? new Context(); + this.#transaction = options.transaction; + } + + install(pkg: MakaPluginPackage): Promise { + return this.#mutate(async () => { + validatePluginPackage(pkg); + const previous = this.#packages.get(pkg.packageId); + this.#packages.set(pkg.packageId, freezePackage(pkg)); + if (!previous) return; + // A package reload is a runtime projection change, not a persistent + // version change. Replace only the affected Entry subtrees so unrelated + // Fibers retain their identity and effects. + await this.#reloadPackage(pkg.packageId, previous); + }); + } + + uninstall(packageId: string): Promise { + return this.#mutate(async () => { + if (!this.#packages.has(packageId)) { + throw new MakaPluginRuntimeError( + 'package_not_found', + `Plugin package is not installed: ${packageId}`, + ); + } + const user = [...this.#entries.values()].find((entry) => entry.spec.packageId === packageId); + if (user) { + throw new MakaPluginRuntimeError( + 'package_in_use', + `Plugin package is used by entry ${user.spec.id}`, + ); + } + this.#packages.delete(packageId); + }); + } + + create( + rootId: MakaPluginRootId, + entry: MakaCompositionEntry, + parentId?: string, + position = Infinity, + ): Promise { + return this.apply({ + operations: [{ type: 'insert', rootId, entry, parentId, position }], + }).then(([inspection]) => inspection!); + } + + update( + entryId: string, + patch: Partial>, + ): Promise { + return this.apply({ operations: [{ type: 'update', entryId, patch }] }).then( + ([inspection]) => inspection!, + ); + } + + move( + entryId: string, + newParentId?: string, + position = Infinity, + ): Promise { + return this.apply({ + operations: [{ type: 'move', entryId, parentId: newParentId, position }], + }).then(([inspection]) => inspection!); + } + + enable(entryId: string): Promise { + return this.update(entryId, { disabled: false }); + } + + disable(entryId: string): Promise { + return this.update(entryId, { disabled: true }); + } + + apply(input: MakaCompositionApplyInput): Promise { + return this.#mutate(async () => { + if ( + input.baseGeneration !== undefined && + input.baseGeneration !== this.#compositionGeneration + ) + throw new MakaPluginRuntimeError( + 'invalid_entry', + `Composition generation changed from ${input.baseGeneration} to ${this.#compositionGeneration}`, + ); + const before = this.snapshot(); + const inspections: MakaCompositionEntryInspection[] = []; + let appliedOperations = 0; + try { + for (const operation of input.operations) { + switch (operation.type) { + case 'insert': { + const entry = await this.#insert( + operation.rootId ?? this.#inferRoot(operation.parentId), + operation.entry, + operation.parentId, + operation.position, + ); + inspections.push(this.#inspect(entry)); + appliedOperations += 1; + break; + } + case 'update': { + const entry = await this.#update(operation.entryId, operation.patch); + inspections.push(this.#inspect(entry)); + appliedOperations += 1; + break; + } + case 'move': { + const entry = await this.#move( + operation.entryId, + operation.parentId, + operation.position, + ); + inspections.push(this.#inspect(entry)); + appliedOperations += 1; + break; + } + case 'remove': + await this.#remove(operation.entryId); + appliedOperations += 1; + break; + } + } + } catch (error) { + // A candidate can fail before changing the live tree. Rebuilding in + // that case would unnecessarily dispose the current Fiber and lose + // its registered contributions. + if (appliedOperations > 0) await this.#replaceSnapshot(before); + throw error; + } + if (input.operations.length > 0) this.#compositionGeneration += 1; + return Object.freeze(inspections); + }); + } + + replaceSubtree( + entryId: string, + entry: MakaCompositionEntry, + ): Promise { + return this.#mutate(async () => { + const current = this.#requireEntry(entryId); + if (entry.id !== entryId) { + throw new MakaPluginRuntimeError( + 'invalid_entry', + 'Replacement subtree must preserve entry id', + ); + } + validateCompositionEntry(entry); + const descendantIds = new Set(); + for (const item of walk(entry)) { + if (descendantIds.has(item.id)) { + throw new MakaPluginRuntimeError( + 'entry_exists', + `Replacement subtree repeats entry ${item.id}`, + ); + } + descendantIds.add(item.id); + const existing = this.#entries.get(item.id); + if (existing && !isWithin(existing, current)) { + throw new MakaPluginRuntimeError( + 'entry_exists', + `Composition entry already exists: ${item.id}`, + ); + } + } + return this.#replace(current, freezeEntry(entry)); + }); + } + + remove(entryId: string): Promise { + return this.apply({ operations: [{ type: 'remove', entryId }] }).then(() => undefined); + } + + inspectTree(rootId?: MakaPluginRootId): readonly MakaCompositionEntryInspection[] { + if (rootId) validatePluginRootId(rootId); + const selected = rootId ? this.#roots.get(rootId) : undefined; + const roots = rootId ? (selected ? [selected] : []) : [...this.#roots.values()]; + return Object.freeze( + roots.flatMap((root) => root.entries.map((entry) => this.#inspect(entry))), + ); + } + + inspect(entryId: string): MakaCompositionEntryInspection { + return this.#inspect(this.#requireEntry(entryId)); + } + + installedPackages(): readonly { readonly packageId: string }[] { + return Object.freeze( + [...this.#packages.values()] + .map(({ packageId }) => Object.freeze({ packageId })) + .sort((left, right) => left.packageId.localeCompare(right.packageId)), + ); + } + + package(packageId: string): MakaPluginPackage { + const pkg = this.#packages.get(packageId); + if (!pkg) { + throw new MakaPluginRuntimeError( + 'package_not_found', + `Plugin package is not installed: ${packageId}`, + ); + } + return pkg; + } + + async awaitSettled(): Promise { + while (true) { + const tasks = [...this.#entries.values()].flatMap((entry) => + entry.fiber?.inertia ? [entry.fiber.inertia] : [], + ); + if (!tasks.length) return; + await Promise.allSettled(tasks); + } + } + + snapshot(): MakaCompositionSnapshot { + const encode = (rootId: MakaPluginRootId): readonly MakaCompositionEntry[] => + Object.freeze((this.#roots.get(rootId)?.entries ?? []).map((entry) => serialize(entry))); + const sessions = Object.fromEntries( + [...this.#roots.values()].flatMap((root) => + root.id.startsWith('session:') + ? [[root.id.slice('session:'.length), encode(root.id)] as const] + : [], + ), + ); + return Object.freeze({ + schemaVersion: 1, + generation: this.#compositionGeneration, + roots: Object.freeze({ + profile: encode('profile'), + desktopUi: encode('desktop-ui'), + sessions: Object.freeze(sessions), + }), + }); + } + + replaceSnapshot(snapshot: MakaCompositionSnapshot): Promise { + return this.#mutate(() => this.#replaceSnapshot(snapshot)); + } + + async #replaceSnapshot(snapshot: MakaCompositionSnapshot): Promise { + if (snapshot.schemaVersion !== 1) + throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition snapshot'); + const specs = new Map([ + ['profile', snapshot.roots.profile], + ['desktop-ui', snapshot.roots.desktopUi], + ...Object.entries(snapshot.roots.sessions).map( + ([id, entries]) => [`session:${id}` as MakaPluginRootId, entries] as const, + ), + ]); + const stagedRoots = new Map(); + const stagedIds = new Set(); + try { + for (const [rootId, entries] of specs) { + validatePluginRootId(rootId); + const context = this.root.extend({ makaRootId: rootId }); + const root: LiveRoot = { id: rootId, context, entries: [] }; + stagedRoots.set(rootId, root); + for (const spec of entries) { + validateCompositionEntry(spec); + for (const item of walk(spec)) { + if (stagedIds.has(item.id)) + throw new MakaPluginRuntimeError( + 'entry_exists', + `Composition entry already exists: ${item.id}`, + ); + stagedIds.add(item.id); + } + root.entries.push(await this.#stage(spec, rootId, undefined, context, false)); + } + } + for (const root of stagedRoots.values()) + for (const entry of root.entries) await this.#commitSubtree(entry); + } catch (error) { + return rethrowAfterCleanup( + error, + () => + settleAll( + [...stagedRoots.values()].flatMap((root) => + [...root.entries].reverse().map((entry) => this.#dispose(entry)), + ), + 'Staged composition cleanup failed', + ), + 'Composition replacement and cleanup failed', + ); + } + const previous = [...this.#roots.values()]; + this.#roots.clear(); + this.#entries.clear(); + for (const [rootId, root] of stagedRoots) { + this.#roots.set(rootId, root); + for (const entry of root.entries) this.#index(entry); + } + this.#compositionGeneration = Math.max(this.#compositionGeneration, snapshot.generation); + await this.#retire( + settleAll( + previous.flatMap((root) => + [...root.entries].reverse().map((entry) => this.#dispose(entry)), + ), + 'Previous composition cleanup failed', + ), + 'Previous composition cleanup failed after publishing the replacement', + ); + } + + async close(): Promise { + await this.#mutate(async () => { + const roots = [...this.#roots.values()]; + this.#roots.clear(); + this.#entries.clear(); + const errors: unknown[] = []; + try { + await settleAll( + roots.flatMap((root) => [...root.entries].reverse().map((entry) => this.#dispose(entry))), + 'Composition entry cleanup failed', + ); + } catch (error) { + errors.push(error); + } + try { + await this.root.fiber.dispose(); + } catch (error) { + errors.push(error); + } + throwIfErrors(errors, 'Composition loader close failed'); + }); + } + + async #replace( + current: LiveEntry, + spec: MakaCompositionEntry, + ): Promise { + const parentContext = current.parent?.context ?? this.#root(current.rootId).context; + const candidate = await this.#stage( + spec, + current.rootId, + current.parent, + parentContext, + current.parent ? isDisabled(current.parent) : false, + ); + try { + await this.#commitSubtree(candidate); + } catch (error) { + current.diagnostic = diagnostic(error); + return rethrowAfterCleanup( + error, + () => this.#dispose(candidate), + `Entry ${current.spec.id} replacement and cleanup failed`, + ); + } + const siblings = current.parent?.children ?? this.#root(current.rootId).entries; + const index = siblings.indexOf(current); + this.#unindex(current); + siblings[index] = candidate; + this.#index(candidate); + await this.#retire( + this.#dispose(current), + `Entry ${current.spec.id} cleanup failed after publishing its replacement`, + ); + return this.#inspect(candidate); + } + + async #reloadPackage(packageId: string, previousPackage: MakaPluginPackage): Promise { + const affected = [...this.#entries.values()].filter( + (entry) => + entry.spec.packageId === packageId && + !ancestors(entry).some((ancestor) => ancestor.spec.packageId === packageId), + ); + if (!affected.length) return; + const candidates: { readonly current: LiveEntry; readonly replacement: LiveEntry }[] = []; + try { + for (const current of affected) { + const replacement = await this.#stage( + serialize(current), + current.rootId, + current.parent, + current.parent?.context ?? this.#root(current.rootId).context, + current.parent ? isDisabled(current.parent) : false, + ); + candidates.push({ current, replacement }); + } + for (const { replacement } of candidates) await this.#commitSubtree(replacement); + } catch (error) { + this.#packages.set(packageId, previousPackage); + return rethrowAfterCleanup( + error, + () => + settleAll( + candidates.map(({ replacement }) => this.#dispose(replacement)), + `Plugin package ${packageId} candidate cleanup failed`, + ), + `Plugin package ${packageId} reload and cleanup failed`, + ); + } + for (const { current, replacement } of candidates) { + const siblings = current.parent?.children ?? this.#root(current.rootId).entries; + const index = siblings.indexOf(current); + this.#unindex(current); + siblings[index] = replacement; + this.#index(replacement); + } + await this.#retire( + settleAll( + candidates.map(({ current }) => this.#dispose(current)), + `Plugin package ${packageId} previous generation cleanup failed`, + ), + `Plugin package ${packageId} cleanup failed after publishing the new generation`, + ); + } + + async #rebind(entry: LiveEntry, parent: LiveEntry | undefined, position: number): Promise { + const replacement = await this.#stage( + serialize(entry), + entry.rootId, + parent, + parent?.context ?? this.#root(entry.rootId).context, + parent ? isDisabled(parent) : false, + ); + try { + await this.#commitSubtree(replacement); + } catch (error) { + return rethrowAfterCleanup( + error, + () => this.#dispose(replacement), + `Entry ${entry.spec.id} move activation and cleanup failed`, + ); + } + const source = entry.parent?.children ?? this.#root(entry.rootId).entries; + const target = parent?.children ?? this.#root(entry.rootId).entries; + this.#unindex(entry); + source.splice(source.indexOf(entry), 1); + target.splice(Math.min(position, target.length), 0, replacement); + this.#index(replacement); + await this.#retire( + this.#dispose(entry), + `Entry ${entry.spec.id} cleanup failed after publishing its rebound Fiber`, + ); + } + + async #stage( + spec: MakaCompositionEntry, + rootId: MakaPluginRootId, + parent: LiveEntry | undefined, + parentContext: Context, + ancestorDisabled: boolean, + ): Promise { + let context = parentContext.extend({ makaEntryId: spec.id }); + for (const [service, label] of Object.entries(spec.isolate ?? {})) { + const symbol = label === true ? Symbol(`${spec.id}:${service}`) : this.#isolationLabel(label); + context = context.isolate(service, symbol); + } + for (const [service, config] of Object.entries(spec.intercept ?? {})) + context = context.intercept(service, config); + const live: LiveEntry = { spec: freezeEntry(spec), rootId, parent, context, children: [] }; + const disabled = ancestorDisabled || spec.disabled === true; + if (!disabled && spec.packageId) { + const pkg = this.#packages.get(spec.packageId); + if (!pkg) + throw new MakaPluginRuntimeError( + 'package_not_found', + `Plugin package is not installed: ${spec.packageId}`, + ); + if (!pkg.host) + throw new MakaPluginRuntimeError( + 'invalid_package', + `Plugin package has no Host plugin: ${spec.packageId}`, + ); + const generation = ++this.#fiberGeneration; + const metadata: MakaPluginMetadata = Object.freeze({ + rootId, + entryId: spec.id, + packageId: spec.packageId, + generation, + }); + context = context.extend({ maka: metadata }); + const transaction = this.#transaction?.(context) ?? new MakaPluginTransactionBuffer(context); + if (transaction) context = context.extend({ makaTransaction: transaction }); + live.context = context; + live.generation = generation; + const plugin = entryPlugin(pkg.host, spec.inject); + live.fiber = context.plugin(plugin, spec.config); + try { + await live.fiber.await(); + if (live.fiber.state === FIBER_FAILED) throw new Error(`Plugin Fiber failed: ${spec.id}`); + } catch (error) { + live.diagnostic = diagnostic(error); + const cleanupErrors: unknown[] = []; + try { + await live.fiber.dispose(); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + try { + await transaction?.rollback(); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + const cause = cleanupErrors.length + ? new AggregateError( + [error, ...cleanupErrors], + `Entry ${spec.id} activation and cleanup failed`, + ) + : error; + throw new MakaPluginRuntimeError( + 'activation_failed', + `Unable to activate entry ${spec.id}: ${diagnostic(error)}`, + { cause }, + ); + } + } + try { + for (const child of spec.children ?? []) + live.children.push(await this.#stage(child, rootId, live, live.context, disabled)); + } catch (error) { + return rethrowAfterCleanup( + error, + () => this.#dispose(live), + `Entry ${spec.id} staging and cleanup failed`, + ); + } + return live; + } + + async #commitSubtree(entry: LiveEntry): Promise { + await entry.context.makaTransaction?.commit(); + for (const child of entry.children) await this.#commitSubtree(child); + } + + async #dispose(entry: LiveEntry): Promise { + const errors: unknown[] = []; + try { + await settleAll( + [...entry.children].reverse().map((child) => this.#dispose(child)), + `Entry ${entry.spec.id} child cleanup failed`, + ); + } catch (error) { + errors.push(error); + } + try { + await entry.context.makaTransaction?.rollback(); + } catch (error) { + errors.push(error); + } + try { + await entry.fiber?.dispose(); + } catch (error) { + errors.push(error); + } + throwIfErrors(errors, `Entry ${entry.spec.id} cleanup failed`); + } + + async #retire(task: Promise, message: string): Promise { + try { + await task; + } catch (error) { + this.root.logger.warn(message, error); + } + } + + #root(rootId: MakaPluginRootId): LiveRoot { + validatePluginRootId(rootId); + let root = this.#roots.get(rootId); + if (!root) { + root = { id: rootId, context: this.root.extend({ makaRootId: rootId }), entries: [] }; + this.#roots.set(rootId, root); + } + return root; + } + + #inferRoot(parentId: string | undefined): MakaPluginRootId { + if (!parentId) return 'profile'; + return this.#requireEntry(parentId).rootId; + } + + async #insert( + rootId: MakaPluginRootId, + entry: MakaCompositionEntry, + parentId?: string, + position = Infinity, + ): Promise { + validatePluginRootId(rootId); + validateCompositionEntry(entry); + this.#assertUniqueSubtree(entry); + const parent = parentId ? this.#requireEntry(parentId) : undefined; + if (parent && parent.rootId !== rootId) + throw new MakaPluginRuntimeError( + 'invalid_entry', + 'Composition entries cannot move between roots', + ); + const createdRoot = !this.#roots.has(rootId); + const root = this.#root(rootId); + try { + const live = await this.#stage( + entry, + rootId, + parent, + parent?.context ?? root.context, + parent ? isDisabled(parent) : false, + ); + try { + await this.#commitSubtree(live); + } catch (error) { + await rethrowAfterCleanup( + error, + () => this.#dispose(live), + `Entry ${entry.id} commit and cleanup failed`, + ); + } + const siblings = parent?.children ?? root.entries; + siblings.splice(Math.min(position, siblings.length), 0, live); + this.#index(live); + return live; + } catch (error) { + if (createdRoot && root.entries.length === 0) this.#roots.delete(rootId); + throw error; + } + } + + async #update( + entryId: string, + patch: Partial>, + ): Promise { + const current = this.#requireEntry(entryId); + const next = freezeEntry({ + ...current.spec, + ...patch, + id: current.spec.id, + children: current.children.map(serialize), + }); + validateCompositionEntry(next); + const structural = + next.packageId !== current.spec.packageId || + !shallowCompositionEqual(next.inject, current.spec.inject) || + !shallowCompositionEqual(next.isolate, current.spec.isolate) || + !shallowCompositionEqual(next.intercept, current.spec.intercept); + if (!structural && current.fiber && next.disabled !== true && current.spec.disabled !== true) { + await current.fiber.update(next.config); + current.spec = next; + current.diagnostic = undefined; + return current; + } + return this.#replace(current, next).then((inspection) => this.#requireEntry(inspection.id)); + } + + async #move(entryId: string, newParentId?: string, position = Infinity): Promise { + const entry = this.#requireEntry(entryId); + const parent = newParentId ? this.#requireEntry(newParentId) : undefined; + if (parent && parent.rootId !== entry.rootId) + throw new MakaPluginRuntimeError( + 'invalid_entry', + 'Composition entries cannot move between roots', + ); + for (let ancestor = parent; ancestor; ancestor = ancestor.parent) + if (ancestor === entry) + throw new MakaPluginRuntimeError( + 'dependency_cycle', + `Entry ${entryId} cannot contain itself`, + ); + await this.#rebind(entry, parent, position); + return this.#requireEntry(entryId); + } + + async #remove(entryId: string): Promise { + const entry = this.#requireEntry(entryId); + const siblings = entry.parent?.children ?? this.#root(entry.rootId).entries; + siblings.splice(siblings.indexOf(entry), 1); + this.#unindex(entry); + await this.#retire( + this.#dispose(entry), + `Entry ${entry.spec.id} cleanup failed after removing it from the composition`, + ); + } + + #requireEntry(entryId: string): LiveEntry { + const entry = this.#entries.get(entryId); + if (!entry) + throw new MakaPluginRuntimeError( + 'entry_not_found', + `Composition entry not found: ${entryId}`, + ); + return entry; + } + + #assertUniqueSubtree(entry: MakaCompositionEntry): void { + const local = new Set(); + for (const item of walk(entry)) { + if (local.has(item.id) || this.#entries.has(item.id)) + throw new MakaPluginRuntimeError( + 'entry_exists', + `Composition entry already exists: ${item.id}`, + ); + local.add(item.id); + } + } + + #index(entry: LiveEntry): void { + this.#entries.set(entry.spec.id, entry); + for (const child of entry.children) this.#index(child); + } + + #unindex(entry: LiveEntry): void { + this.#entries.delete(entry.spec.id); + for (const child of entry.children) this.#unindex(child); + } + + #inspect(entry: LiveEntry): MakaCompositionEntryInspection { + const sourceInject = entry.fiber?.inject ?? entry.spec.inject; + const inject = Array.isArray(sourceInject) + ? sourceInject + : Object.keys((sourceInject as Readonly> | undefined) ?? {}); + const waitingFor = + entry.fiber?.state === FIBER_PENDING + ? inject.filter((name) => entry.context.get(name) === undefined) + : []; + return Object.freeze({ + id: entry.spec.id, + rootId: entry.rootId, + ...(entry.parent ? { parentId: entry.parent.spec.id } : {}), + ...(entry.spec.packageId ? { packageId: entry.spec.packageId } : {}), + ...(entry.spec.config === undefined ? {} : { config: entry.spec.config }), + disabled: isDisabled(entry), + status: isDisabled(entry) + ? 'disabled' + : entry.fiber + ? fiberStateName(entry.fiber.state) + : 'active', + ...(entry.generation === undefined ? {} : { generation: entry.generation }), + waitingFor: Object.freeze(waitingFor), + effects: Object.freeze(entry.fiber?.getEffects().map(({ label }) => label) ?? []), + children: Object.freeze(entry.children.map((child) => this.#inspect(child))), + ...((entry.diagnostic ?? entry.fiber?.error) + ? { diagnostic: entry.diagnostic ?? diagnostic(entry.fiber?.error) } + : {}), + }); + } + + #isolationLabel(label: string): symbol { + let symbol = this.#isolationLabels.get(label); + if (!symbol) { + symbol = Symbol(label); + this.#isolationLabels.set(label, symbol); + } + return symbol; + } + + #mutate(operation: () => Promise): Promise { + const result = this.#mutation.then(operation, operation); + this.#mutation = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} + +function entryPlugin(plugin: Plugin, inject: MakaCompositionEntry['inject']): Plugin { + const combined = mergeInject((plugin as Plugin.Base).inject, inject); + return { + name: (plugin as Plugin.Base).name ?? 'maka-entry', + ...(combined ? { inject: combined } : {}), + ...((plugin as Plugin.Base).Config ? { Config: (plugin as Plugin.Base).Config } : {}), + apply(ctx: Context, config: unknown) { + if (typeof plugin !== 'function') return plugin.apply(ctx, config as never); + if (isConstructor(plugin)) return Reflect.construct(plugin, [ctx, config]); + return (plugin as Plugin.Function)(ctx, config as never); + }, + }; +} + +function isConstructor(value: Function): boolean { + return /^class\s/u.test(Function.prototype.toString.call(value)); +} + +function ancestors(entry: LiveEntry): LiveEntry[] { + const result: LiveEntry[] = []; + for (let current = entry.parent; current; current = current.parent) result.push(current); + return result; +} + +function mergeInject( + left: Inject | undefined, + right: MakaCompositionEntry['inject'], +): Inject | undefined { + if (!left && !right) return undefined; + const output: Record = {}; + for (const source of [left, right]) { + if (Array.isArray(source)) for (const name of source) output[name] = null; + else Object.assign(output, source ?? {}); + } + return output; +} + +function freezePackage(pkg: MakaPluginPackage): MakaPluginPackage { + return Object.freeze({ ...pkg, contributions: Object.freeze([...(pkg.contributions ?? [])]) }); +} + +function freezeEntry(entry: MakaCompositionEntry): MakaCompositionEntry { + return Object.freeze({ + ...entry, + ...(entry.inject && !Array.isArray(entry.inject) + ? { inject: Object.freeze({ ...entry.inject }) } + : entry.inject + ? { inject: Object.freeze([...entry.inject]) } + : {}), + ...(entry.isolate ? { isolate: Object.freeze({ ...entry.isolate }) } : {}), + ...(entry.intercept ? { intercept: Object.freeze({ ...entry.intercept }) } : {}), + children: Object.freeze((entry.children ?? []).map(freezeEntry)), + }); +} + +function serialize(entry: LiveEntry): MakaCompositionEntry { + return freezeEntry({ ...entry.spec, children: entry.children.map(serialize) }); +} + +function shallowCompositionEqual(left: unknown, right: unknown): boolean { + if (Object.is(left, right)) return true; + if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false; + const leftEntries = Object.entries(left); + const rightEntries = Object.entries(right); + return ( + leftEntries.length === rightEntries.length && + leftEntries.every( + ([key, value]) => + Object.hasOwn(right, key) && + Object.is(value, (right as Readonly>)[key]), + ) + ); +} + +function* walk(entry: MakaCompositionEntry): Generator { + yield entry; + for (const child of entry.children ?? []) yield* walk(child); +} + +function isWithin(entry: LiveEntry, root: LiveEntry): boolean { + for (let current: LiveEntry | undefined = entry; current; current = current.parent) + if (current === root) return true; + return false; +} + +function isDisabled(entry: LiveEntry): boolean { + for (let current: LiveEntry | undefined = entry; current; current = current.parent) { + if (current.spec.disabled === true) return true; + } + return false; +} + +function diagnostic(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function settleAll(tasks: Iterable>, message: string): Promise { + const results = await Promise.allSettled(tasks); + const errors = results.flatMap((result) => (result.status === 'rejected' ? [result.reason] : [])); + throwIfErrors(errors, message); +} + +async function rethrowAfterCleanup( + error: unknown, + cleanup: () => Promise, + message: string, +): Promise { + try { + await cleanup(); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], message); + } + throw error; +} + +function throwIfErrors(errors: readonly unknown[], message: string): void { + if (errors.length) throw new AggregateError(errors, message); +} diff --git a/packages/runtime/src/plugin-kernel.ts b/packages/runtime/src/plugin-kernel.ts new file mode 100644 index 0000000000..4f5c203533 --- /dev/null +++ b/packages/runtime/src/plugin-kernel.ts @@ -0,0 +1,1037 @@ +export type Awaitable = T | PromiseLike; + +export type Disposable> = () => T; + +export type Inject = readonly string[] | Readonly>; + +export const enum FiberState { + PENDING, + LOADING, + ACTIVE, + FAILED, + DISPOSED, + UNLOADING, +} + +export interface EffectMeta { + readonly label: string; + readonly children: readonly EffectMeta[]; +} + +export interface StandardSchema { + readonly '~standard': { + validate( + value: unknown, + ): + | { readonly value: unknown; readonly issues?: undefined } + | { readonly issues: readonly { readonly message: string }[] } + | Promise< + | { readonly value: unknown; readonly issues?: undefined } + | { readonly issues: readonly { readonly message: string }[] } + >; + }; +} + +export type Plugin = Plugin.Function | Plugin.Constructor | Plugin.Object; + +export namespace Plugin { + export interface Base { + readonly name?: string; + readonly inject?: Inject; + readonly Config?: StandardSchema; + } + + export type Function = Base & ((ctx: Context, config: T) => unknown); + + export type Constructor = Base & (new (ctx: Context, config: T) => unknown); + + export interface Object extends Base { + apply(ctx: Context, config: T): unknown; + } +} + +export interface EventOptions { + readonly prepend?: boolean; + readonly global?: boolean; +} + +interface ServiceImplementation { + readonly name: string; + readonly label: symbol; + readonly fiber: Fiber; + value: unknown; + readonly check?: () => boolean; +} + +interface Hook { + readonly context: Context; + readonly listener: (...args: unknown[]) => unknown; + readonly global: boolean; +} + +interface Accessor { + readonly owner: Fiber; + readonly get: (this: Context, receiver: unknown) => unknown; + readonly set?: (this: Context, value: unknown, receiver: unknown) => boolean; +} + +interface PluginRuntime { + readonly callback: Function; + readonly fibers: Set; + readonly name?: string; + readonly Config?: StandardSchema; +} + +interface KernelState { + readonly root: Context; + readonly services: Map; + readonly serviceLabels: Map; + readonly runtimes: WeakMap; + readonly listeners: Map; + readonly accessors: Map; + readonly fibers: Set; + nextFiberId: number; + closed: boolean; +} + +const contextBrand = Symbol.for('maka.plugin-kernel.context'); +const effectMeta = Symbol('maka.plugin-kernel.effect-meta'); +const disposedFibers = new WeakSet(); + +export interface Logger { + readonly name: string; + error(value: unknown, ...values: unknown[]): void; + warn(value: unknown, ...values: unknown[]): void; + info(value: unknown, ...values: unknown[]): void; + debug(value: unknown, ...values: unknown[]): void; +} + +export interface LoggerService extends Logger { + (name?: string): Logger; +} + +export interface Context { + root: Context; + parent?: Context; + fiber: Fiber; + readonly logger: LoggerService; + [Context.filter]?: (listenerContext: Context) => boolean; +} + +export class Context { + static readonly effect = effectMeta; + static readonly filter = Symbol('maka.plugin-kernel.filter'); + + readonly [contextBrand] = true; + readonly #kernel: KernelState; + readonly #isolation: Readonly>; + readonly #intercepts: Readonly>; + readonly #proxy: Context; + + static is(value: unknown): value is Context { + return Boolean((value as { readonly [contextBrand]?: boolean } | undefined)?.[contextBrand]); + } + + constructor(); + constructor( + kernel?: KernelState, + parent?: Context, + fiber?: Fiber, + isolation?: Readonly>, + intercepts?: Readonly>, + meta?: object, + ); + constructor( + kernel?: KernelState, + parent?: Context, + fiber?: Fiber, + isolation?: Readonly>, + intercepts?: Readonly>, + meta: object = {}, + ) { + this.parent = parent; + this.#isolation = isolation ?? parent?._isolation() ?? freezeRecord(); + this.#intercepts = intercepts ?? parent?._intercepts() ?? freezeRecord(); + if (kernel) { + this.#kernel = kernel; + this.root = kernel.root; + this.fiber = fiber ?? parent?.fiber ?? kernel.root.fiber; + } else { + const placeholder = {} as KernelState; + this.#kernel = placeholder; + this.root = this; + const rootFiber = Fiber.root(this); + this.fiber = rootFiber; + Object.assign(placeholder, { + root: this, + services: new Map(), + serviceLabels: new Map(), + runtimes: new WeakMap(), + listeners: new Map(), + accessors: new Map(), + fibers: new Set([rootFiber]), + nextFiberId: 0, + closed: false, + } satisfies KernelState); + } + Object.assign(this, meta); + Object.defineProperty(this, 'logger', { + enumerable: true, + configurable: false, + value: createLoggerService(() => this.fiber.name), + }); + this.#proxy = new Proxy(this, contextProxy); + return this.#proxy; + } + + extend(meta: object = {}): this { + this.#assertOpen(); + return new Context( + this.#kernel, + this, + this.fiber, + this.#isolation, + this.#intercepts, + meta, + ) as this; + } + + isolate(name: string, label = Symbol(name)): this { + validateServiceName(name); + return new Context( + this.#kernel, + this, + this.fiber, + freezeRecord({ ...this.#isolation, [name]: label }), + this.#intercepts, + ) as this; + } + + intercept(name: string, config: unknown): this { + validateServiceName(name); + const existing = this.#intercepts[name] ?? []; + return new Context( + this.#kernel, + this, + this.fiber, + this.#isolation, + freezeRecord({ ...this.#intercepts, [name]: Object.freeze([...existing, config]) }), + ) as this; + } + + plugin

(plugin: P, config?: unknown): Fiber & PromiseLike { + this.#assertOpen(); + const callback = resolvePlugin(plugin); + let runtime = this.#kernel.runtimes.get(plugin); + if (!runtime) { + runtime = { + callback, + fibers: new Set(), + name: plugin.name, + Config: plugin.Config, + }; + this.#kernel.runtimes.set(plugin, runtime); + } + const fiber = new Fiber(this, plugin, config, normalizeInject(plugin.inject), runtime); + return new Proxy(fiber, { + get(target, property, receiver) { + if (property === 'then') { + return ( + onFulfilled?: ((value: Fiber) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike => + target + .await() + .then( + () => (onFulfilled ? onFulfilled(target) : (target as unknown as TResult1)), + onRejected ?? undefined, + ); + } + const value = Reflect.get(target, property, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }) as Fiber & PromiseLike; + } + + inject(inject: Inject, callback: Plugin.Function): Fiber & PromiseLike { + return this.plugin(Object.assign(callback, { inject })); + } + + effect(execute: () => unknown, label = 'anonymous'): Disposable> { + return this.fiber.effect(execute, label); + } + + provide(name: string, value?: unknown, check?: () => boolean): Disposable> { + this.#assertOpen(); + validateServiceName(name); + const label = this.#label(name); + if (this.#kernel.services.has(label)) { + throw new Error(`Service is already provided in this scope: ${name}`); + } + return this.effect( + () => { + const implementation: ServiceImplementation = { + name, + label, + value, + fiber: this.fiber, + check, + }; + this.#kernel.services.set(label, implementation); + this.#notifyService(name, label); + return async () => { + if (this.#kernel.services.get(label) !== implementation) return; + this.#kernel.services.delete(label); + await Promise.allSettled(this.#notifyService(name, label).map((fiber) => fiber.await())); + }; + }, + `ctx.provide(${JSON.stringify(name)})`, + ); + } + + get(name: string, strict = true): T | undefined { + const implementation = this.#implementation(name); + if (!implementation) return undefined; + if (strict && implementation.fiber.state !== FiberState.ACTIVE) return undefined; + if (implementation.check && !implementation.check.call(implementation.value)) return undefined; + return ( + implementation.value instanceof Service + ? implementation.value._bind(this.#proxy) + : implementation.value + ) as T; + } + + set(name: string, value: unknown): boolean { + const implementation = this.#implementation(name); + if (!implementation) throw new Error(`Cannot set missing Service: ${name}`); + if (implementation.fiber !== this.fiber) { + throw new Error(`Cannot mutate Service owned by another Fiber: ${name}`); + } + implementation.value = value; + this.#notifyService(name, implementation.label); + return true; + } + + accessor( + name: string, + options: { + readonly get: (this: Context, receiver: unknown) => unknown; + readonly set?: (this: Context, value: unknown, receiver: unknown) => boolean; + }, + ): Disposable> { + this.#assertAccessorAvailable(name); + return this.effect( + () => { + const accessor = { owner: this.fiber, ...options }; + this.#kernel.accessors.set(name, accessor); + return () => { + if (this.#kernel.accessors.get(name) === accessor) this.#kernel.accessors.delete(name); + }; + }, + `ctx.accessor(${JSON.stringify(name)})`, + ); + } + + mixin( + source: string | object, + names: readonly string[] | Readonly>, + ): void { + const entries = Array.isArray(names) + ? names.map((name) => [name, name] as const) + : Object.entries(names); + const targets = new Set(); + for (const [, targetName] of entries) { + if (targets.has(targetName)) + throw new Error(`Context property already exists: ${targetName}`); + targets.add(targetName); + this.#assertAccessorAvailable(targetName); + } + for (const [sourceName, targetName] of entries) { + this.accessor(targetName, { + get(receiver) { + const target = typeof source === 'string' ? this.get(source) : source; + const value = Reflect.get(target as object, sourceName, receiver ?? target); + return typeof value === 'function' ? value.bind(target) : value; + }, + set(value, receiver) { + const target = typeof source === 'string' ? this.get(source) : source; + return Reflect.set(target as object, sourceName, value, receiver ?? target); + }, + }); + } + } + + on( + name: PropertyKey, + listener: (...args: unknown[]) => unknown, + options: boolean | EventOptions = {}, + ): Disposable { + this.#assertOpen(); + const normalized = typeof options === 'boolean' ? { prepend: options } : options; + const hook: Hook = { context: this, listener, global: normalized.global === true }; + const hooks = this.#kernel.listeners.get(name) ?? []; + let active = true; + const unregister = () => { + if (!active) return false; + active = false; + const index = hooks.indexOf(hook); + if (index >= 0) hooks.splice(index, 1); + if (!hooks.length) this.#kernel.listeners.delete(name); + return index >= 0; + }; + this.effect( + () => { + if (normalized.prepend) hooks.unshift(hook); + else hooks.push(hook); + this.#kernel.listeners.set(name, hooks); + return unregister; + }, + `ctx.on(${String(name)})`, + ); + return unregister; + } + + once( + name: PropertyKey, + listener: (...args: unknown[]) => unknown, + options: boolean | EventOptions = {}, + ): Disposable { + let unregister: Disposable; + unregister = this.on( + name, + (...args) => { + unregister(); + return listener(...args); + }, + options, + ); + return unregister; + } + + emit(...input: unknown[]): void { + const { hooks, args } = this.#dispatch(input); + for (const hook of hooks) hook.listener(...args); + } + + async parallel(...input: unknown[]): Promise { + const { hooks, args } = this.#dispatch(input); + const settled = await Promise.allSettled( + hooks.map((hook) => Promise.resolve().then(() => hook.listener(...args))), + ); + const errors = settled + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(({ reason }) => reason); + if (errors.length) throw new AggregateError(errors); + } + + async serial(...input: unknown[]): Promise { + const { hooks, args } = this.#dispatch(input); + for (const hook of hooks) { + const result = await hook.listener(...args); + if (result !== undefined && result !== null && result !== false) return result; + } + } + + bail(...input: unknown[]): unknown { + const { hooks, args } = this.#dispatch(input); + for (const hook of hooks) { + const result = hook.listener(...args); + if (result !== undefined && result !== null && result !== false) return result; + } + } + + waterfall(...input: unknown[]): unknown { + const { hooks, args } = this.#dispatch(input); + const terminal = args.pop(); + if (typeof terminal !== 'function') + throw new TypeError('Waterfall requires a terminal callback'); + const callbacks = hooks.map(({ listener }) => listener); + const next = (): unknown => { + const callback = callbacks.shift() ?? terminal; + return callback(...args, next); + }; + return next(); + } + + interceptConfig(name: string): readonly unknown[] { + return this.#intercepts[name] ?? []; + } + + kernelFibers(): readonly Fiber[] { + return Object.freeze([...this.#kernel.fibers]); + } + + #dispatch(input: readonly unknown[]): { + readonly hooks: readonly Hook[]; + readonly args: unknown[]; + } { + const args = [...input]; + const thisArg = Context.is(args[0]) ? (args.shift() as Context) : undefined; + const name = args.shift(); + if (typeof name !== 'string' && typeof name !== 'symbol') { + throw new TypeError('Event name must be a string or symbol'); + } + const filter = thisArg?.[Context.filter]; + const hooks = (this.#kernel.listeners.get(name) ?? []).filter( + (hook) => hook.global || !filter || filter(hook.context), + ); + return { hooks, args }; + } + + #implementation(name: string): ServiceImplementation | undefined { + const label = this.#lookupLabel(name); + return label ? this.#kernel.services.get(label) : undefined; + } + + #lookupLabel(name: string): symbol | undefined { + return this.#isolation[name] ?? this.#kernel.serviceLabels.get(name); + } + + #label(name: string): symbol { + const isolated = this.#isolation[name]; + if (isolated) return isolated; + let label = this.#kernel.serviceLabels.get(name); + if (!label) { + label = Symbol(name); + this.#kernel.serviceLabels.set(name, label); + } + return label; + } + + #notifyService(name: string, label: symbol): Fiber[] { + return notifyService(this.#kernel, name, label); + } + + #assertOpen(): void { + if ( + this.#kernel.closed || + disposedFibers.has(this.fiber) || + this.fiber.state === FiberState.DISPOSED || + this.fiber.state === FiberState.UNLOADING + ) { + throw new Error('Plugin Context is disposed'); + } + } + + #assertAccessorAvailable(name: string): void { + this.#assertOpen(); + if ( + Reflect.has(this, name) || + hasAncestorProperty(this.parent, name) || + this.#kernel.accessors.has(name) + ) { + throw new Error(`Context property already exists: ${name}`); + } + } + + _kernel(): KernelState { + return this.#kernel; + } + + _label(name: string): symbol { + return this.#label(name); + } + + _isolation(): Readonly> { + return this.#isolation; + } + + _intercepts(): Readonly> { + return this.#intercepts; + } +} + +const contextProxy: ProxyHandler = { + get(target, property, receiver) { + if (Reflect.has(target, property)) { + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' && Object.hasOwn(Context.prototype, property) + ? value.bind(target) + : value; + } + const accessor = target._kernel().accessors.get(property); + if (accessor) return accessor.get.call(receiver as Context, receiver); + if (typeof property === 'string') { + const service = target.get(property); + if (service !== undefined) return service; + } + for (let ancestor = target.parent; ancestor; ancestor = ancestor.parent) { + if (Object.hasOwn(ancestor, property)) return Reflect.get(ancestor, property); + } + }, + set(target, property, value, receiver) { + if (Reflect.has(target, property)) return Reflect.set(target, property, value, receiver); + const accessor = target._kernel().accessors.get(property); + if (accessor?.set) return accessor.set.call(receiver as Context, value, receiver); + if (typeof property === 'string' && target.get(property, false) !== undefined) { + return target.set(property, value); + } + return Reflect.set(target, property, value, receiver); + }, + has(target, property) { + return ( + Reflect.has(target, property) || + target._kernel().accessors.has(property) || + (typeof property === 'string' && target.get(property, false) !== undefined) || + hasAncestorProperty(target.parent, property) + ); + }, +}; + +function hasAncestorProperty(context: Context | undefined, property: PropertyKey): boolean { + for (let ancestor = context; ancestor; ancestor = ancestor.parent) { + if (Object.hasOwn(ancestor, property)) return true; + } + return false; +} + +export class Fiber { + readonly id: number; + readonly ctx: Context; + readonly parent: Context; + readonly plugin?: Plugin; + readonly inject: Readonly>; + state: FiberState; + config: unknown; + inertia?: Promise; + error?: unknown; + + readonly #runtime?: PluginRuntime; + readonly #children = new Set(); + readonly #effects: Array> & { [effectMeta]?: EffectMeta }> = []; + readonly #services = new Map(); + #disposed = false; + #dependencyRefreshQueued = false; + #disposeTask?: Promise; + #transition: Promise = Promise.resolve(); + + static root(context: Context): Fiber { + return new Fiber(context, undefined, undefined, {}, undefined, true); + } + + constructor( + parent: Context, + plugin: Plugin | undefined, + config: unknown, + inject: Readonly>, + runtime: PluginRuntime | undefined, + root = false, + ) { + this.parent = parent; + this.plugin = plugin; + this.config = config; + this.inject = inject; + this.#runtime = runtime; + const kernel = parent._kernel(); + this.id = root ? 0 : ++kernel.nextFiberId; + this.state = root ? FiberState.ACTIVE : FiberState.PENDING; + this.ctx = root ? parent : new Context(kernel, parent, this, undefined, undefined); + if (!root) { + kernel.fibers.add(this); + runtime?.fibers.add(this); + parent.fiber.#children.add(this); + this.refreshDependencies(); + } + } + + get name(): string { + return ( + this.#runtime?.name || this.plugin?.name || (this.id === 0 ? 'root' : `plugin-${this.id}`) + ); + } + + requires(name: string): boolean { + return Object.hasOwn(this.inject, name); + } + + serviceLabel(name: string): symbol { + return this.ctx._label(name); + } + + refreshDependencies(): void { + if (this.#disposed || !this.plugin) return; + if (this.#dependencyRefreshQueued) return; + this.#dependencyRefreshQueued = true; + this.#enqueue(async () => { + this.#dependencyRefreshQueued = false; + await this.#refreshDependencies(); + }); + } + + async #refreshDependencies(): Promise { + if (this.#disposed || !this.plugin) return; + const next = new Map(); + for (const name of Object.keys(this.inject)) { + const implementation = this.ctx.get(name); + if (implementation === undefined) { + this.#services.clear(); + if (this.state === FiberState.ACTIVE || this.state === FiberState.FAILED) { + await this.#unload(FiberState.PENDING); + } else { + this.#setState(FiberState.PENDING); + } + return; + } + next.set(name, implementation); + } + const changed = + next.size !== this.#services.size || + [...next].some(([name, value]) => this.#services.get(name) !== value); + this.#services.clear(); + for (const [name, value] of next) this.#services.set(name, value); + if (this.state === FiberState.PENDING || this.state === FiberState.FAILED) { + await this.#load(); + } else if (this.state === FiberState.ACTIVE && changed) { + await this.#unload(FiberState.PENDING); + await this.#load(); + } + } + + effect(execute: () => unknown, label = 'anonymous'): Disposable> { + if (this.#disposed || this.state === FiberState.UNLOADING) { + throw new Error('Cannot create an Effect on an inactive Fiber'); + } + const disposers: Disposable>[] = []; + let disposeTask: Promise | undefined; + const collect = (value: unknown): void => { + if (typeof value === 'function') disposers.push(value as Disposable>); + else if (value !== undefined && value !== null) { + throw new TypeError('Plugin Effect must return a disposer'); + } + }; + const run = async (): Promise => { + const result = execute(); + if (isAsyncIterable(result)) { + for await (const value of result) collect(value); + } else if (isIterable(result)) { + for (const value of result) collect(value); + } else { + collect(await result); + } + }; + const setupTask = run(); + const dispose = Object.assign( + () => { + disposeTask ??= (async () => { + await setupTask.catch(() => undefined); + const errors: unknown[] = []; + try { + for (const cleanup of disposers.reverse()) { + try { + await cleanup(); + } catch (error) { + errors.push(error); + } + } + } finally { + const index = this.#effects.indexOf(dispose); + if (index >= 0) this.#effects.splice(index, 1); + } + if (errors.length) throw new AggregateError(errors, `Effect ${label} cleanup failed`); + })(); + return disposeTask; + }, + { [effectMeta]: Object.freeze({ label, children: Object.freeze([]) }) }, + ); + this.#effects.push(dispose); + void setupTask.catch(async (error) => { + const errors = [error]; + try { + await dispose(); + } catch (cleanupError) { + errors.push(cleanupError); + } + this.error = + errors.length === 1 + ? error + : new AggregateError(errors, `Effect ${label} setup and cleanup failed`); + }); + return dispose; + } + + getEffects(): readonly EffectMeta[] { + return Object.freeze( + this.#effects.flatMap((dispose) => (dispose[effectMeta] ? [dispose[effectMeta]] : [])), + ); + } + + async await(): Promise { + while (this.inertia) await this.inertia.catch(() => undefined); + if (this.state === FiberState.FAILED) throw this.error; + } + + async restart(): Promise { + if (this.#disposed) throw new Error('Cannot restart a disposed Fiber'); + await this.#enqueue(() => this.#restart()); + } + + async update(config: unknown): Promise { + if (this.#disposed) throw new Error('Cannot update a disposed Fiber'); + await this.#enqueue(async () => { + const previous = this.config; + this.config = config; + try { + await this.#restart(); + } catch (error) { + this.config = previous; + try { + await this.#restart(); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + `Fiber ${this.name} update and rollback failed`, + ); + } + throw error; + } + }); + } + + dispose(): Promise { + if (this.#disposeTask) return this.#disposeTask; + this.#disposed = true; + disposedFibers.add(this); + this.#disposeTask = this.#enqueue(async () => { + try { + await this.#unload(FiberState.DISPOSED); + } finally { + const kernel = this.parent._kernel(); + kernel.fibers.delete(this); + this.#runtime?.fibers.delete(this); + this.parent.fiber.#children.delete(this); + if (this.id === 0) kernel.closed = true; + } + }); + return this.#disposeTask; + } + + #enqueue(operation: () => Promise): Promise { + const task = this.#transition.then(operation, operation); + this.#transition = task.catch(() => undefined); + const settled = task.finally(() => { + if (this.inertia === settled) this.inertia = undefined; + }); + void settled.catch(() => undefined); + this.inertia = settled; + return settled; + } + + async #restart(): Promise { + await this.#unload(FiberState.PENDING); + if (this.#dependenciesAvailable()) await this.#load(); + } + + async #load(): Promise { + if (this.#disposed || !this.plugin || !this.#dependenciesAvailable()) return; + this.error = undefined; + this.#setState(FiberState.LOADING); + try { + const config = await validateConfig(this.#runtime?.Config, this.config); + const output = await invokePlugin(this.plugin, this.ctx, config); + if (typeof output === 'function') this.effect(() => output, `plugin:${this.name}`); + else if ( + output !== undefined && + output !== null && + !(typeof this.plugin === 'function' && isConstructor(this.plugin)) + ) { + throw new TypeError('Plugin must return a disposer or nothing'); + } + this.#setState(FiberState.ACTIVE); + } catch (error) { + const errors = [error]; + try { + await this.#disposeEffects(); + } catch (cleanupError) { + errors.push(cleanupError); + } + this.error = + errors.length === 1 + ? error + : new AggregateError(errors, `Fiber ${this.name} activation and cleanup failed`); + this.#setState(FiberState.FAILED); + throw this.error; + } + } + + async #unload(nextState: FiberState): Promise { + if (this.state === FiberState.DISPOSED) return; + this.#setState(FiberState.UNLOADING); + const childResults = await Promise.allSettled( + [...this.#children].reverse().map((child) => child.dispose()), + ); + const errors = childResults.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [], + ); + try { + await this.#disposeEffects(); + } catch (error) { + errors.push(error); + } + this.#setState(nextState); + if (errors.length) throw new AggregateError(errors, `Fiber ${this.name} cleanup failed`); + } + + async #disposeEffects(): Promise { + const errors: unknown[] = []; + for (const dispose of [...this.#effects].reverse()) { + try { + await dispose(); + } catch (error) { + errors.push(error); + } + } + if (errors.length) throw new AggregateError(errors, `Fiber ${this.name} Effect cleanup failed`); + } + + #dependenciesAvailable(): boolean { + return Object.keys(this.inject).every((name) => this.ctx.get(name) !== undefined); + } + + #setState(state: FiberState): void { + const previous = this.state; + this.state = state; + if (state === FiberState.ACTIVE) { + notifyProvidedServices(this.parent._kernel(), this); + } + if (previous !== state) this.ctx.emit('internal/status', this, previous); + } +} + +function notifyService(kernel: KernelState, name: string, label: symbol): Fiber[] { + const fibers = serviceConsumers(kernel, name, label); + for (const fiber of fibers) fiber.refreshDependencies(); + return fibers; +} + +function notifyProvidedServices(kernel: KernelState, provider: Fiber): void { + const fibers = new Set(); + for (const implementation of kernel.services.values()) { + if (implementation.fiber !== provider) continue; + for (const fiber of serviceConsumers(kernel, implementation.name, implementation.label)) { + fibers.add(fiber); + } + } + for (const fiber of fibers) fiber.refreshDependencies(); +} + +function serviceConsumers(kernel: KernelState, name: string, label: symbol): Fiber[] { + const fibers: Fiber[] = []; + for (const fiber of kernel.fibers) { + if (!fiber.requires(name) || fiber.serviceLabel(name) !== label) continue; + fibers.push(fiber); + } + return fibers; +} + +export abstract class Service { + readonly name: string; + readonly #contexts = new WeakMap(); + + constructor( + protected readonly ctx: Context, + name: string, + ) { + this.name = name; + ctx.provide(name, this); + } + + _bind(context: Context): this { + const cached = this.#contexts.get(context); + if (cached) return cached; + const bound = new Proxy(this, { + get: (target, property, receiver) => { + if (property === 'ctx') return context; + const value = Reflect.get(target, property, receiver); + return typeof value === 'function' ? value.bind(receiver) : value; + }, + }); + this.#contexts.set(context, bound); + return bound; + } + + protected resolveConfig(base?: T, head?: T): T { + const values = [base, ...this.ctx.interceptConfig(this.name), head].filter( + (value): value is T => value !== undefined, + ); + return Object.assign({}, ...values); + } +} + +function normalizeInject(inject: Inject | undefined): Readonly> { + if (!inject) return freezeRecord(); + if (Array.isArray(inject)) { + return freezeRecord(Object.fromEntries(inject.map((name) => [name, null]))); + } + return freezeRecord(inject as Readonly>); +} + +function freezeRecord(source?: Readonly>): Readonly> { + return Object.freeze(Object.assign(Object.create(null) as Record, source)); +} + +function resolvePlugin(plugin: Plugin): Function { + if (typeof plugin === 'function') return plugin; + if (plugin && typeof plugin.apply === 'function') return plugin.apply; + throw new TypeError('Plugin must be a function, class, or object with apply()'); +} + +async function invokePlugin(plugin: Plugin, context: Context, config: unknown): Promise { + if (typeof plugin === 'function') { + if (isConstructor(plugin)) return Reflect.construct(plugin, [context, config]); + return (plugin as Plugin.Function)(context, config); + } + return plugin.apply(context, config); +} + +function isConstructor(value: Function): boolean { + return /^class\s/u.test(Function.prototype.toString.call(value)); +} + +async function validateConfig( + schema: StandardSchema | undefined, + value: unknown, +): Promise { + if (!schema) return value; + const result = await schema['~standard'].validate(value); + if ('issues' in result && result.issues) { + throw new TypeError(result.issues.map(({ message }) => message).join('; ')); + } + return result.value; +} + +function validateServiceName(name: string): void { + if (!/^[A-Za-z][A-Za-z0-9._:-]*$/u.test(name)) + throw new TypeError(`Invalid Service name: ${name}`); +} + +function isIterable(value: unknown): value is Iterable { + return Boolean(value && typeof (value as Iterable)[Symbol.iterator] === 'function'); +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return Boolean( + value && typeof (value as AsyncIterable)[Symbol.asyncIterator] === 'function', + ); +} + +function createLoggerService(name: () => string): LoggerService { + const create = (explicit?: string): Logger => { + const loggerName = explicit || name(); + return { + name: loggerName, + error: (value, ...values) => console.error(`[${loggerName}]`, value, ...values), + warn: (value, ...values) => console.warn(`[${loggerName}]`, value, ...values), + info: (value, ...values) => console.info(`[${loggerName}]`, value, ...values), + debug: (value, ...values) => console.debug(`[${loggerName}]`, value, ...values), + }; + }; + const callable = ((explicit?: string) => create(explicit)) as LoggerService; + Object.defineProperties(callable, { + name: { value: 'logger' }, + error: { value: (value: unknown, ...values: unknown[]) => create().error(value, ...values) }, + warn: { value: (value: unknown, ...values: unknown[]) => create().warn(value, ...values) }, + info: { value: (value: unknown, ...values: unknown[]) => create().info(value, ...values) }, + debug: { value: (value: unknown, ...values: unknown[]) => create().debug(value, ...values) }, + }); + return callable; +} diff --git a/packages/runtime/src/plugin-runtime.ts b/packages/runtime/src/plugin-runtime.ts new file mode 100644 index 0000000000..10cb9b40e1 --- /dev/null +++ b/packages/runtime/src/plugin-runtime.ts @@ -0,0 +1,378 @@ +import type { Context, FiberState, Plugin } from './plugin-kernel.js'; + +const ID_PATTERN = /^[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*$/u; + +export type MakaPluginRootId = 'profile' | 'desktop-ui' | `session:${string}`; + +export interface MakaPluginPackage { + readonly packageId: string; + readonly host?: Plugin; + readonly client?: Plugin; + readonly contributions?: readonly MakaPluginContribution[]; +} + +export interface MakaPluginContribution { + readonly id: string; + readonly kind: 'tool' | 'ui' | 'hook' | 'service' | 'timer' | string; +} + +export interface MakaCompositionEntry { + readonly id: string; + readonly packageId?: string; + readonly config?: unknown; + readonly disabled?: boolean; + readonly inject?: readonly string[] | Readonly>; + readonly isolate?: Readonly>; + readonly intercept?: Readonly>; + readonly children?: readonly MakaCompositionEntry[]; +} + +export interface MakaCompositionSnapshot { + readonly schemaVersion: 1; + readonly generation: number; + readonly roots: { + readonly profile: readonly MakaCompositionEntry[]; + readonly desktopUi: readonly MakaCompositionEntry[]; + readonly sessions: Readonly>; + }; +} + +export type MakaCompositionOperation = + | { + readonly type: 'insert'; + readonly rootId?: MakaPluginRootId; + readonly parentId?: string; + readonly entry: MakaCompositionEntry; + readonly position?: number; + } + | { + readonly type: 'update'; + readonly entryId: string; + readonly patch: Partial>; + } + | { + readonly type: 'move'; + readonly entryId: string; + readonly parentId?: string; + readonly position?: number; + } + | { readonly type: 'remove'; readonly entryId: string }; + +export interface MakaCompositionApplyInput { + readonly baseGeneration?: number; + readonly operations: readonly MakaCompositionOperation[]; +} + +export type MakaCompositionEntryStatus = + | 'disabled' + | 'pending' + | 'loading' + | 'active' + | 'failed' + | 'unloading' + | 'disposed'; + +export interface MakaCompositionEntryInspection { + readonly id: string; + readonly rootId: MakaPluginRootId; + readonly parentId?: string; + readonly packageId?: string; + readonly config?: unknown; + readonly disabled: boolean; + readonly status: MakaCompositionEntryStatus; + readonly generation?: number; + readonly waitingFor: readonly string[]; + readonly effects: readonly string[]; + readonly children: readonly MakaCompositionEntryInspection[]; + readonly diagnostic?: string; +} + +export interface MakaPluginMountInput { + readonly entryId: string; + readonly rootId: string; + readonly packageId: string; + readonly config?: unknown; +} + +export interface MakaPluginMountInspection { + readonly entryId: string; + readonly rootId: string; + readonly packageId: string; + readonly enabled: boolean; + readonly status: MakaCompositionEntryStatus; + readonly current?: { readonly generation: number }; + readonly waitingFor: readonly string[]; + readonly pendingCleanupEffects: number; + readonly diagnostic?: { readonly message: string }; +} + +export interface MakaRuntimeCompositionEntry { + readonly entryId: string; + readonly packageId: string; + readonly generation: number; + readonly contributions: readonly MakaPluginContribution[]; +} + +export interface MakaRuntimeCompositionSnapshot { + readonly schemaVersion: 1; + readonly rootId: string; + readonly digest: `sha256:${string}`; + readonly entries: readonly MakaRuntimeCompositionEntry[]; +} + +export interface MakaPluginMetadata { + readonly rootId: MakaPluginRootId; + readonly entryId: string; + readonly packageId: string; + readonly generation: number; +} + +export interface MakaContributionIdentity { + readonly entryId: string; + readonly scopeId: string; + readonly extensionId: string; + readonly generation: number; +} + +export interface MakaContributionContext extends MakaContributionIdentity { + readonly signal: AbortSignal; + readonly runtimeContext: Context; + ownEffect(label: string, dispose: () => void | Promise): void; + dependency(packageId: string): T; +} + +export interface MakaPluginTransaction { + stage(label: string, register: () => () => void | Promise, owner?: Context): void; + commit(): void | Promise; + rollback(): void | Promise; +} + +declare module './plugin-kernel.js' { + interface Context { + maka?: MakaPluginMetadata; + makaTransaction?: MakaPluginTransaction; + } +} + +export class MakaPluginRuntimeError extends Error { + readonly name = 'MakaPluginRuntimeError'; + + constructor( + readonly code: + | 'invalid_package' + | 'package_exists' + | 'package_not_found' + | 'package_in_use' + | 'invalid_entry' + | 'entry_exists' + | 'entry_not_found' + | 'dependency_cycle' + | 'activation_failed', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +export function validatePluginPackage(pkg: MakaPluginPackage): void { + validatePluginId(pkg.packageId, 'packageId'); + if (!pkg.host && !pkg.client) { + throw new MakaPluginRuntimeError( + 'invalid_package', + `Plugin package ${pkg.packageId} has no host or client plugin`, + ); + } +} + +export function validateCompositionEntry(entry: MakaCompositionEntry): void { + validatePluginId(entry.id, 'entry id'); + if (entry.packageId !== undefined) { + validatePluginId(entry.packageId!, 'packageId'); + } + for (const key of Object.keys(entry.isolate ?? {})) validateServiceName(key); + for (const key of Object.keys(entry.intercept ?? {})) validateServiceName(key); + for (const dependency of Array.isArray(entry.inject) + ? entry.inject + : Object.keys(entry.inject ?? {})) { + validateServiceName(dependency); + } + const childIds = new Set(); + for (const child of entry.children ?? []) { + validateCompositionEntry(child); + if (childIds.has(child.id)) { + throw new MakaPluginRuntimeError( + 'entry_exists', + `Entry ${entry.id} repeats child ${child.id}`, + ); + } + childIds.add(child.id); + } +} + +export function validatePluginRootId(rootId: string): asserts rootId is MakaPluginRootId { + if ( + rootId !== 'profile' && + rootId !== 'desktop-ui' && + !(rootId.startsWith('session:') && rootId.length > 'session:'.length) + ) { + throw new MakaPluginRuntimeError('invalid_entry', `Invalid composition root: ${rootId}`); + } +} + +export function pluginIdentity(ctx: Context): MakaContributionIdentity { + const metadata = ctx.maka; + if (!metadata) { + throw new MakaPluginRuntimeError( + 'activation_failed', + 'Contribution registration requires a composition entry Context', + ); + } + return Object.freeze({ + entryId: metadata.entryId, + scopeId: metadata.rootId, + extensionId: metadata.packageId, + generation: metadata.generation, + }); +} + +export function ownPluginEffect( + ctx: Context, + label: string, + dispose: () => void | Promise, +): void { + attachPluginEffect(ctx, label, dispose); +} + +function attachPluginEffect( + ctx: Context, + label: string, + dispose: () => void | Promise, +): () => Promise { + return ctx.effect(() => dispose, label); +} + +function registerPluginEffect( + ctx: Context, + label: string, + register: () => () => void | Promise, +): () => Promise { + let contributionDispose: (() => void | Promise) | undefined; + const release = ctx.effect(() => () => contributionDispose?.(), label); + try { + contributionDispose = register(); + return release; + } catch (error) { + void release().catch(() => undefined); + throw error; + } +} + +export function registerPluginContribution( + ctx: Context, + label: string, + register: () => () => void | Promise, +): void { + if (ctx.makaTransaction) { + ctx.makaTransaction.stage(label, register, ctx); + return; + } + registerPluginEffect(ctx, label, register); +} + +export class MakaPluginTransactionBuffer implements MakaPluginTransaction { + readonly #registrations: Array<{ + readonly label: string; + readonly register: () => () => void | Promise; + readonly owner: Context; + }> = []; + #state: 'staging' | 'committed' | 'rolled_back' = 'staging'; + + constructor(private readonly context: Context) {} + + stage(label: string, register: () => () => void | Promise, owner = this.context): void { + if (this.#state === 'committed') { + registerPluginEffect(owner, label, register); + return; + } + if (this.#state === 'rolled_back') { + throw new MakaPluginRuntimeError( + 'activation_failed', + `Cannot stage contribution after transaction is ${this.#state}`, + ); + } + this.#registrations.push({ label, register, owner }); + } + + async commit(): Promise { + if (this.#state === 'committed') return; + if (this.#state === 'rolled_back') { + throw new MakaPluginRuntimeError( + 'activation_failed', + 'Cannot commit a rolled back transaction', + ); + } + const registered: Array<() => Promise> = []; + try { + for (const item of this.#registrations) { + registered.push(registerPluginEffect(item.owner, item.label, item.register)); + } + this.#state = 'committed'; + this.#registrations.length = 0; + } catch (error) { + this.#state = 'rolled_back'; + this.#registrations.length = 0; + const cleanupErrors: unknown[] = []; + for (const dispose of registered.reverse()) { + try { + await dispose(); + } catch (cleanupError) { + cleanupErrors.push(cleanupError); + } + } + if (cleanupErrors.length) { + throw new AggregateError( + [error, ...cleanupErrors], + 'Plugin transaction commit and rollback failed', + ); + } + throw error; + } + } + + rollback(): void { + if (this.#state !== 'staging') return; + this.#state = 'rolled_back'; + this.#registrations.length = 0; + } +} + +export function fiberStateName(state: FiberState): MakaCompositionEntryStatus { + return ['pending', 'loading', 'active', 'failed', 'disposed', 'unloading'][ + state + ] as MakaCompositionEntryStatus; +} + +export function isCanonicalPluginId(value: unknown): value is string { + return typeof value === 'string' && value.length <= 128 && ID_PATTERN.test(value); +} + +export const isCanonicalExtensionId = isCanonicalPluginId; + +export function isCanonicalExtensionScopeId(value: unknown): value is string { + return ( + typeof value === 'string' && value.length <= 128 && /^[A-Za-z0-9][A-Za-z0-9._:-]*$/u.test(value) + ); +} + +function validatePluginId(value: unknown, label: string): asserts value is string { + if (!isCanonicalPluginId(value)) { + throw new MakaPluginRuntimeError('invalid_entry', `Invalid ${label}`); + } +} + +function validateServiceName(value: string): void { + if (!/^[A-Za-z][A-Za-z0-9._:-]{0,255}$/u.test(value)) { + throw new MakaPluginRuntimeError('invalid_entry', `Invalid service name: ${value}`); + } +} From 7a7acf58b48a6ffcabe19248c3ea681ea20387e2 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:49:26 +0800 Subject: [PATCH 2/2] fix(runtime): harden composition foundation invariants Keep inherited Context metadata authoritative over same-named Services, surface Service health-check failures through Fiber state, and make live snapshot/subtree replacement advance composition generations. Reject duplicate package installation until atomic Service-provider replacement has a dedicated lifecycle contract. Generated-by: Codex --- .../plugin-composition-loader.test.ts | 136 +++++++++--------- .../src/__tests__/plugin-kernel.test.ts | 43 ++++++ .../runtime/src/plugin-composition-loader.ts | 88 +++--------- packages/runtime/src/plugin-kernel.ts | 27 +++- 4 files changed, 160 insertions(+), 134 deletions(-) diff --git a/packages/runtime/src/__tests__/plugin-composition-loader.test.ts b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts index 16b97c4f40..6284f43819 100644 --- a/packages/runtime/src/__tests__/plugin-composition-loader.test.ts +++ b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts @@ -58,6 +58,24 @@ test('missing injected service enters pending and activates when provided', asyn await loader.close(); }); +test('composition metadata wins over same-named root Services', async () => { + const root = new Context(); + root.provide('maka', { hijacked: true }); + let seenEntryId: string | undefined; + const loader = new MakaCompositionLoader({ root }); + await loader.install( + pkg('metadata-owner', (ctx: Context) => { + seenEntryId = ctx.maka?.entryId; + }), + ); + + await loader.create('profile', entry('metadata-entry', 'metadata-owner')); + + assert.equal(seenEntryId, 'metadata-entry'); + assert.deepEqual(root.get('maka'), { hijacked: true }); + await loader.close(); +}); + test('config update uses the existing Fiber and preserves entry identity', async () => { const values: number[] = []; const plugin = (_ctx: Context, config: { value: number }) => { @@ -77,77 +95,26 @@ test('config update uses the existing Fiber and preserves entry identity', async await loader.close(); }); -test('package reload is atomic when the candidate Fiber fails', async () => { +test('duplicate package install is rejected without replacing live code', async () => { const live = new Set(); const current = (ctx: Context) => { live.add(ctx.maka!.entryId); return () => live.delete(ctx.maka!.entryId); }; - const failed = () => { - throw new Error('candidate exploded'); - }; + const replacement = () => undefined; const loader = new MakaCompositionLoader(); await loader.install(pkg('atomic', current)); await loader.create('profile', entry('atomic-one', 'atomic')); - await assert.rejects(() => loader.install(pkg('atomic', failed)), /candidate exploded/u); + await assert.rejects( + () => loader.install(pkg('atomic', replacement)), + /Plugin package is already installed: atomic/u, + ); assert.equal(loader.inspect('atomic-one').status, 'active'); + assert.equal(loader.package('atomic').host, current); assert.deepEqual([...live], ['atomic-one']); await loader.close(); }); -test('package reload replaces only affected Entry subtrees', async () => { - const activations: string[] = []; - const loader = new MakaCompositionLoader(); - await loader.install( - pkg('changing', (ctx: Context) => { - activations.push(`changing:${ctx.maka!.generation}`); - }), - ); - await loader.install( - pkg('stable', (ctx: Context) => { - activations.push(`stable:${ctx.maka!.generation}`); - }), - ); - await loader.create('profile', entry('changing-entry', 'changing')); - await loader.create('profile', entry('stable-entry', 'stable')); - const stableGeneration = loader.inspect('stable-entry').generation; - - await loader.install( - pkg('changing', (ctx: Context) => { - activations.push(`changed:${ctx.maka!.generation}`); - }), - ); - - assert.notEqual(loader.inspect('changing-entry').generation, undefined); - assert.equal(loader.inspect('stable-entry').generation, stableGeneration); - assert.equal(activations.filter((item) => item.startsWith('stable:')).length, 1); - await loader.close(); -}); - -test('package reload keeps the published generation when retirement cleanup fails', async (t) => { - t.mock.method(console, 'warn', () => undefined); - const activations: string[] = []; - const current = () => { - activations.push('current'); - return () => { - throw new Error('current cleanup failed'); - }; - }; - const replacement = () => { - activations.push('replacement'); - }; - const loader = new MakaCompositionLoader(); - await loader.install(pkg('cleanup-reload', current)); - await loader.create('profile', entry('cleanup-reload-entry', 'cleanup-reload')); - - await loader.install(pkg('cleanup-reload', replacement)); - - assert.deepEqual(activations, ['current', 'replacement']); - assert.equal(loader.inspect('cleanup-reload-entry').status, 'active'); - assert.equal(loader.package('cleanup-reload').host, replacement); - await loader.close(); -}); - test('remove and close exhaust subtree cleanup across retirement failures', async (t) => { t.mock.method(console, 'warn', () => undefined); const createLoader = async (lifecycle: string[]) => { @@ -185,7 +152,7 @@ test('remove and close exhaust subtree cleanup across retirement failures', asyn assert.deepEqual(closed, ['second', 'first']); }); -test('disabled ancestors suppress insert, move, update, and package reload activation', async () => { +test('disabled ancestors suppress insert, move, update, and subtree replacement activation', async () => { const activations: string[] = []; const loader = new MakaCompositionLoader(); await loader.install( @@ -213,11 +180,6 @@ test('disabled ancestors suppress insert, move, update, and package reload activ entry('inserted-child', 'disabled-child', { value: 'replaced' }), ); await loader.update('inserted-child', { config: { value: 'updated' } }); - await loader.install( - pkg('disabled-child', (ctx: Context, config: { readonly value: string }) => { - activations.push(`${ctx.maka!.entryId}:reloaded:${config.value}`); - }), - ); assert.deepEqual(activations, ['moved-child:before-move']); assert.equal(loader.inspect('inserted-child').status, 'disabled'); @@ -378,7 +340,7 @@ test('snapshot replacement restores ordered roots and descendants', async () => sessions: { s1: [entry('session-entry', 'snapshot')] }, }, }); - assert.equal(loader.snapshot().generation >= 41, true); + assert.equal(loader.snapshot().generation, 41); assert.deepEqual( loader.inspectTree().map(({ id }) => id), ['profile-entry', 'ui-group', 'session-entry'], @@ -387,6 +349,29 @@ test('snapshot replacement restores ordered roots and descendants', async () => await loader.close(); }); +test('live snapshot and subtree replacement publish a fresh composition generation', async () => { + const loader = new MakaCompositionLoader(); + await loader.create('profile', { id: 'before' }); + const staleGeneration = loader.snapshot().generation; + + await loader.replaceSnapshot({ + schemaVersion: 1, + generation: staleGeneration, + roots: { profile: [{ id: 'after' }], desktopUi: [], sessions: {} }, + }); + + assert.equal(loader.snapshot().generation, staleGeneration + 1); + await assert.rejects( + () => loader.apply({ baseGeneration: staleGeneration, operations: [] }), + /Composition generation changed/u, + ); + + const beforeSubtreeReplacement = loader.snapshot().generation; + await loader.replaceSubtree('after', { id: 'after', children: [{ id: 'child' }] }); + assert.equal(loader.snapshot().generation, beforeSubtreeReplacement + 1); + await loader.close(); +}); + test('entry inject and intercept metadata retain the feat shallow-copy contract', async () => { const dependencyCheck = () => true; const interceptConfig = { select: () => true }; @@ -645,6 +630,27 @@ test('composition apply batches EntryTree operations under one generation check' await loader.close(); }); +test('failed composition batches restore the prior generation exactly', async () => { + const loader = new MakaCompositionLoader(); + await loader.create('profile', { id: 'stable-entry' }); + const before = loader.snapshot(); + + await assert.rejects( + () => + loader.apply({ + baseGeneration: before.generation, + operations: [ + { type: 'insert', entry: { id: 'temporary-entry' } }, + { type: 'update', entryId: 'missing-entry', patch: { disabled: true } }, + ], + }), + /Composition entry not found: missing-entry/u, + ); + + assert.deepEqual(loader.snapshot(), before); + await loader.close(); +}); + function pkg(packageId: string, host: Plugin): MakaPluginPackage { return Object.freeze({ packageId, host }); } diff --git a/packages/runtime/src/__tests__/plugin-kernel.test.ts b/packages/runtime/src/__tests__/plugin-kernel.test.ts index 1666187c3d..3af7731b5b 100644 --- a/packages/runtime/src/__tests__/plugin-kernel.test.ts +++ b/packages/runtime/src/__tests__/plugin-kernel.test.ts @@ -67,6 +67,38 @@ test('Services provided by a plugin activate dependent plugins after the provide await root.fiber.dispose(); }); +test('Service health-check failures move consumers to failed and allow recovery', async () => { + const root = new Context(); + let healthy = true; + let activations = 0; + root.provide('checkedService', { value: 1 }, () => { + if (!healthy) throw new Error('Service health check failed'); + return true; + }); + const consumer = root.plugin( + Object.assign( + () => { + activations += 1; + }, + { inject: ['checkedService'] }, + ), + ); + await consumer.await(); + assert.equal(consumer.state, FiberState.ACTIVE); + + healthy = false; + root.set('checkedService', { value: 2 }); + await assert.rejects(consumer.await(), /Service health check failed/u); + assert.equal(consumer.state, FiberState.FAILED); + + healthy = true; + root.set('checkedService', { value: 3 }); + await consumer.await(); + assert.equal(consumer.state, FiberState.ACTIVE); + assert.equal(activations, 2); + await root.fiber.dispose(); +}); + test('a provider with multiple Services activates each dependent Fiber once', async () => { const root = new Context(); let activations = 0; @@ -472,6 +504,17 @@ test('child accessors cannot shadow metadata inherited from parent Contexts', as await root.fiber.dispose(); }); +test('Services cannot shadow metadata inherited from parent Contexts', async () => { + const root = new Context(); + root.provide('entryMetadata', { value: 'service' }); + const metadata = { value: 'metadata' }; + const child = root.extend({ entryMetadata: metadata }).extend(); + + assert.equal(Reflect.get(child, 'entryMetadata'), metadata); + assert.deepEqual(child.get('entryMetadata'), { value: 'service' }); + await root.fiber.dispose(); +}); + test('mixin validates every target before publishing accessors', async () => { const root = new Context(); diff --git a/packages/runtime/src/plugin-composition-loader.ts b/packages/runtime/src/plugin-composition-loader.ts index 25a7f2becd..21174768c8 100644 --- a/packages/runtime/src/plugin-composition-loader.ts +++ b/packages/runtime/src/plugin-composition-loader.ts @@ -60,13 +60,13 @@ export class MakaCompositionLoader { install(pkg: MakaPluginPackage): Promise { return this.#mutate(async () => { validatePluginPackage(pkg); - const previous = this.#packages.get(pkg.packageId); + if (this.#packages.has(pkg.packageId)) { + throw new MakaPluginRuntimeError( + 'package_exists', + `Plugin package is already installed: ${pkg.packageId}`, + ); + } this.#packages.set(pkg.packageId, freezePackage(pkg)); - if (!previous) return; - // A package reload is a runtime projection change, not a persistent - // version change. Replace only the affected Entry subtrees so unrelated - // Fibers retain their identity and effects. - await this.#reloadPackage(pkg.packageId, previous); }); } @@ -180,7 +180,7 @@ export class MakaCompositionLoader { // A candidate can fail before changing the live tree. Rebuilding in // that case would unnecessarily dispose the current Fiber and lose // its registered contributions. - if (appliedOperations > 0) await this.#replaceSnapshot(before); + if (appliedOperations > 0) await this.#replaceSnapshot(before, 'rollback'); throw error; } if (input.operations.length > 0) this.#compositionGeneration += 1; @@ -218,7 +218,9 @@ export class MakaCompositionLoader { ); } } - return this.#replace(current, freezeEntry(entry)); + const inspection = await this.#replace(current, freezeEntry(entry)); + this.#compositionGeneration += 1; + return inspection; }); } @@ -290,12 +292,17 @@ export class MakaCompositionLoader { } replaceSnapshot(snapshot: MakaCompositionSnapshot): Promise { - return this.#mutate(() => this.#replaceSnapshot(snapshot)); + return this.#mutate(() => this.#replaceSnapshot(snapshot, 'publish')); } - async #replaceSnapshot(snapshot: MakaCompositionSnapshot): Promise { + async #replaceSnapshot( + snapshot: MakaCompositionSnapshot, + generationMode: 'publish' | 'rollback', + ): Promise { if (snapshot.schemaVersion !== 1) throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition snapshot'); + const previousGeneration = this.#compositionGeneration; + const pristine = previousGeneration === 0 && this.#entries.size === 0 && this.#roots.size === 0; const specs = new Map([ ['profile', snapshot.roots.profile], ['desktop-ui', snapshot.roots.desktopUi], @@ -346,7 +353,12 @@ export class MakaCompositionLoader { this.#roots.set(rootId, root); for (const entry of root.entries) this.#index(entry); } - this.#compositionGeneration = Math.max(this.#compositionGeneration, snapshot.generation); + this.#compositionGeneration = + generationMode === 'rollback' + ? snapshot.generation + : pristine + ? snapshot.generation + : Math.max(previousGeneration, snapshot.generation) + 1; await this.#retire( settleAll( previous.flatMap((root) => @@ -415,54 +427,6 @@ export class MakaCompositionLoader { return this.#inspect(candidate); } - async #reloadPackage(packageId: string, previousPackage: MakaPluginPackage): Promise { - const affected = [...this.#entries.values()].filter( - (entry) => - entry.spec.packageId === packageId && - !ancestors(entry).some((ancestor) => ancestor.spec.packageId === packageId), - ); - if (!affected.length) return; - const candidates: { readonly current: LiveEntry; readonly replacement: LiveEntry }[] = []; - try { - for (const current of affected) { - const replacement = await this.#stage( - serialize(current), - current.rootId, - current.parent, - current.parent?.context ?? this.#root(current.rootId).context, - current.parent ? isDisabled(current.parent) : false, - ); - candidates.push({ current, replacement }); - } - for (const { replacement } of candidates) await this.#commitSubtree(replacement); - } catch (error) { - this.#packages.set(packageId, previousPackage); - return rethrowAfterCleanup( - error, - () => - settleAll( - candidates.map(({ replacement }) => this.#dispose(replacement)), - `Plugin package ${packageId} candidate cleanup failed`, - ), - `Plugin package ${packageId} reload and cleanup failed`, - ); - } - for (const { current, replacement } of candidates) { - const siblings = current.parent?.children ?? this.#root(current.rootId).entries; - const index = siblings.indexOf(current); - this.#unindex(current); - siblings[index] = replacement; - this.#index(replacement); - } - await this.#retire( - settleAll( - candidates.map(({ current }) => this.#dispose(current)), - `Plugin package ${packageId} previous generation cleanup failed`, - ), - `Plugin package ${packageId} cleanup failed after publishing the new generation`, - ); - } - async #rebind(entry: LiveEntry, parent: LiveEntry | undefined, position: number): Promise { const replacement = await this.#stage( serialize(entry), @@ -826,12 +790,6 @@ function isConstructor(value: Function): boolean { return /^class\s/u.test(Function.prototype.toString.call(value)); } -function ancestors(entry: LiveEntry): LiveEntry[] { - const result: LiveEntry[] = []; - for (let current = entry.parent; current; current = current.parent) result.push(current); - return result; -} - function mergeInject( left: Inject | undefined, right: MakaCompositionEntry['inject'], diff --git a/packages/runtime/src/plugin-kernel.ts b/packages/runtime/src/plugin-kernel.ts index 4f5c203533..710cc9e67b 100644 --- a/packages/runtime/src/plugin-kernel.ts +++ b/packages/runtime/src/plugin-kernel.ts @@ -549,15 +549,15 @@ const contextProxy: ProxyHandler = { ? value.bind(target) : value; } + for (let ancestor = target.parent; ancestor; ancestor = ancestor.parent) { + if (Object.hasOwn(ancestor, property)) return Reflect.get(ancestor, property); + } const accessor = target._kernel().accessors.get(property); if (accessor) return accessor.get.call(receiver as Context, receiver); if (typeof property === 'string') { const service = target.get(property); if (service !== undefined) return service; } - for (let ancestor = target.parent; ancestor; ancestor = ancestor.parent) { - if (Object.hasOwn(ancestor, property)) return Reflect.get(ancestor, property); - } }, set(target, property, value, receiver) { if (Reflect.has(target, property)) return Reflect.set(target, property, value, receiver); @@ -662,7 +662,26 @@ export class Fiber { if (this.#disposed || !this.plugin) return; const next = new Map(); for (const name of Object.keys(this.inject)) { - const implementation = this.ctx.get(name); + let implementation: unknown; + try { + implementation = this.ctx.get(name); + } catch (error) { + const errors = [error]; + this.#services.clear(); + if (this.state === FiberState.ACTIVE || this.state === FiberState.FAILED) { + try { + await this.#unload(FiberState.PENDING); + } catch (cleanupError) { + errors.push(cleanupError); + } + } + this.error = + errors.length === 1 + ? error + : new AggregateError(errors, `Fiber ${this.name} dependency check and cleanup failed`); + this.#setState(FiberState.FAILED); + return; + } if (implementation === undefined) { this.#services.clear(); if (this.state === FiberState.ACTIVE || this.state === FiberState.FAILED) {