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..6284f43819 --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts @@ -0,0 +1,660 @@ +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('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 }) => { + 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('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 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', 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('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 subtree replacement 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' } }); + + 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); + 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('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 }; + 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(); +}); + +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 }); +} + +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..3af7731b5b --- /dev/null +++ b/packages/runtime/src/__tests__/plugin-kernel.test.ts @@ -0,0 +1,701 @@ +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('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; + 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('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(); + + 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..21174768c8 --- /dev/null +++ b/packages/runtime/src/plugin-composition-loader.ts @@ -0,0 +1,886 @@ +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); + 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)); + }); + } + + 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, 'rollback'); + 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}`, + ); + } + } + const inspection = await this.#replace(current, freezeEntry(entry)); + this.#compositionGeneration += 1; + return inspection; + }); + } + + 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, 'publish')); + } + + 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], + ...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 = + generationMode === 'rollback' + ? snapshot.generation + : pristine + ? snapshot.generation + : Math.max(previousGeneration, snapshot.generation) + 1; + 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 #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 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..710cc9e67b --- /dev/null +++ b/packages/runtime/src/plugin-kernel.ts @@ -0,0 +1,1056 @@ +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; + } + 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; + } + }, + 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)) { + 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) { + 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}`); + } +}