From 27e19b381895be88575597bcc9f67de2ff058c70 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:52:43 +0800 Subject: [PATCH 1/5] feat(runtime): add plugin platform foundation --- package-lock.json | 1 + .../runtime-host-operator-command.test.ts | 72 ++ packages/cli/src/cli-core.ts | 16 + packages/cli/src/runtime-host-cli.ts | 107 +- .../cli/src/runtime-host-plugin-command.ts | 138 +++ packages/runtime-host/package.json | 1 + .../src/__tests__/plugin-platform.test.ts | 977 +++++++++++++++ packages/runtime-host/src/protocol/index.ts | 4 +- .../runtime-host/src/protocol/operations.ts | 9 + .../src/protocol/plugin-platform.ts | 615 ++++++++++ .../src/server/execution-composition.ts | 19 + .../src/server/extension-bundle.ts | 264 ++++ .../src/server/extension-package-manifest.ts | 324 +++++ packages/runtime-host/src/server/index.ts | 42 + .../src/server/operation-dispatcher.ts | 5 + .../src/server/plugin-composition-patch.ts | 63 + .../src/server/plugin-composition-store.ts | 171 +++ .../src/server/plugin-package-loader.ts | 157 +++ .../src/server/plugin-package-store.ts | 570 +++++++++ .../src/server/plugin-platform-coordinator.ts | 287 +++++ .../src/server/plugin-platform.ts | 1074 +++++++++++++++++ .../plugin-composition-loader.test.ts | 236 +++- .../runtime/src/plugin-composition-loader.ts | 254 +++- packages/runtime/src/plugin-runtime.ts | 296 ++++- 24 files changed, 5624 insertions(+), 78 deletions(-) create mode 100644 packages/cli/src/runtime-host-plugin-command.ts create mode 100644 packages/runtime-host/src/__tests__/plugin-platform.test.ts create mode 100644 packages/runtime-host/src/protocol/plugin-platform.ts create mode 100644 packages/runtime-host/src/server/extension-bundle.ts create mode 100644 packages/runtime-host/src/server/extension-package-manifest.ts create mode 100644 packages/runtime-host/src/server/plugin-composition-patch.ts create mode 100644 packages/runtime-host/src/server/plugin-composition-store.ts create mode 100644 packages/runtime-host/src/server/plugin-package-loader.ts create mode 100644 packages/runtime-host/src/server/plugin-package-store.ts create mode 100644 packages/runtime-host/src/server/plugin-platform-coordinator.ts create mode 100644 packages/runtime-host/src/server/plugin-platform.ts diff --git a/package-lock.json b/package-lock.json index 7348cd358a..191a720b98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14313,6 +14313,7 @@ "@maka/runtime": "0.1.0", "@maka/storage": "0.1.0", "ws": "^8.21.3", + "yaml": "^2.9.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts index e014f1219b..75dcaef8c0 100644 --- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts @@ -34,6 +34,7 @@ import { type RuntimeHostAccessIssueOptions, } from '../runtime-host-access-command.js'; import { parseRuntimeHostCommand } from '../runtime-host-cli.js'; +import { runRuntimeHostPluginCli } from '../runtime-host-plugin-command.js'; import { runRuntimeHostProjectCli } from '../runtime-host-project-command.js'; import { createRuntimeHostServiceReadyEvent } from '../runtime-host-service-command.js'; @@ -144,6 +145,24 @@ describe('Runtime Host operator commands', () => { prefer: false, }, ); + assert.deepEqual( + parseRuntimeHostCommand(['plugin', 'inspect', '--scope', 'profile', '--limit', '16']), + { + kind: 'runtime-host-plugin', + action: 'inspect', + rootId: 'profile', + limit: 16, + }, + ); + assert.deepEqual( + parseRuntimeHostCommand(['plugin', 'export', 'fixture-plugin', './fixture.maka-extension']), + { + kind: 'runtime-host-plugin', + action: 'export', + subject: 'fixture-plugin', + targetPath: './fixture.maka-extension', + }, + ); assert.deepEqual( parseRuntimeHostCommand([ 'project', @@ -481,6 +500,59 @@ describe('Runtime Host operator commands', () => { ['project-1', 'project-1'], ); }); + + test('uses every Plugin Platform surface through the Runtime Host', async () => { + const requests: unknown[] = []; + let closeCount = 0; + const connection = { + request: async (operation: string, input: unknown) => { + requests.push({ operation, input }); + return {}; + }, + close: async () => { + closeCount += 1; + }, + } as unknown as RuntimeHostConnection; + const overrides = { + connect: async () => connection, + readText: async () => '{"operations":[{"type":"remove","entryId":"entry-one"}]}', + write: () => undefined, + }; + const commands = [ + { rootPath: '/srv/maka', action: 'status' as const }, + { rootPath: '/srv/maka', action: 'list' as const }, + { rootPath: '/srv/maka', action: 'inspect' as const, rootId: 'profile' }, + { rootPath: '/srv/maka', action: 'failures' as const }, + { rootPath: '/srv/maka', action: 'install' as const, subject: './plugin' }, + { rootPath: '/srv/maka', action: 'uninstall' as const, subject: 'plugin' }, + { rootPath: '/srv/maka', action: 'reload' as const, subject: 'plugin' }, + { + rootPath: '/srv/maka', + action: 'export' as const, + subject: 'plugin', + targetPath: './plugin.maka-extension', + }, + { rootPath: '/srv/maka', action: 'apply' as const, subject: './operations.json' }, + ]; + for (const command of commands) { + assert.equal(await runRuntimeHostPluginCli(command, overrides), 0); + } + assert.deepEqual( + requests.map((request) => (request as { operation: string }).operation), + [ + 'plugin.platform.query', + 'plugin.platform.query', + 'plugin.platform.query', + 'plugin.platform.query', + 'plugin.package.install', + 'plugin.package.uninstall', + 'plugin.package.reload', + 'plugin.package.export', + 'plugin.composition.apply', + ], + ); + assert.equal(closeCount, commands.length); + }); }); function presetOptions( diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 7a7f1881fa..7d995b3a05 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -160,6 +160,10 @@ function helpText(cliCommand: string): string { ` ${cliCommand} runtime-host access revoke --credential `, ` ${cliCommand} runtime-host project list [--root ]`, ` ${cliCommand} runtime-host project add [--prefer] [--root ]`, + ` ${cliCommand} runtime-host plugin status|list|inspect|failures [--root ]`, + ` ${cliCommand} runtime-host plugin install|uninstall|reload [--root ]`, + ` ${cliCommand} runtime-host plugin export [--root ]`, + ` ${cliCommand} runtime-host plugin apply [--root ]`, ` ${cliCommand} runtime-host profile list`, ` ${cliCommand} runtime-host profile set --id --name --tls-url --expected-root [--credential-env ]`, ` ${cliCommand} runtime-host profile set --id --name --ssh-destination --ssh-remote-port --expected-root [--ssh-port ] [--credential-env ]`, @@ -674,6 +678,18 @@ export async function runMakaCli( prefer: command.prefer, }); } + case 'runtime-host-plugin': { + const { runRuntimeHostPluginCli } = await import('./runtime-host-plugin-command.js'); + return runRuntimeHostPluginCli({ + rootPath: command.rootPath ?? dataRoots.workspaceRoot, + action: command.action, + ...(command.subject ? { subject: command.subject } : {}), + ...(command.targetPath ? { targetPath: command.targetPath } : {}), + ...(command.rootId ? { rootId: command.rootId } : {}), + ...(command.cursor === undefined ? {} : { cursor: command.cursor }), + ...(command.limit === undefined ? {} : { limit: command.limit }), + }); + } case 'runtime-host-capability-provider-serve': { const { runRuntimeHostCapabilityProviderCli } = await import( './runtime-host-capability-provider-command.js' diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index d2bc06b030..bf01979cbe 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -267,6 +267,25 @@ export type RuntimeHostCliCommand = path: string; prefer: boolean; } + | { + kind: 'runtime-host-plugin'; + rootPath?: string; + action: + | 'status' + | 'list' + | 'inspect' + | 'failures' + | 'install' + | 'uninstall' + | 'reload' + | 'export' + | 'apply'; + subject?: string; + targetPath?: string; + rootId?: string; + cursor?: number; + limit?: number; + } | { kind: 'runtime-host-capability-provider-serve'; url: string; @@ -325,6 +344,7 @@ export function parseRuntimeHostCommand(argv: string[]): RuntimeHostCliCommand { if (argv[0] === 'service') return parseServiceManagementCommand(argv.slice(1)); if (argv[0] === 'access') return parseAccessCommand(argv.slice(1)); if (argv[0] === 'project') return parseProjectCommand(argv.slice(1)); + if (argv[0] === 'plugin') return parsePluginCommand(argv.slice(1)); if (argv[0] === 'capability-provider') { return parseCapabilityProviderCommand(argv.slice(1)); } @@ -332,7 +352,7 @@ export function parseRuntimeHostCommand(argv: string[]): RuntimeHostCliCommand { return error( argv[0] ? `Unexpected runtime-host command: ${argv[0]}` - : 'runtime-host requires the activate, connect, serve, setup, service, access, project, profile, or capability-provider command', + : 'runtime-host requires the activate, connect, serve, setup, service, access, project, plugin, profile, or capability-provider command', ); } @@ -1345,6 +1365,91 @@ function parseProjectCommand(argv: string[]): RuntimeHostCliCommand { }; } +function parsePluginCommand(argv: string[]): RuntimeHostCliCommand { + const action = argv[0]; + const actions = [ + 'status', + 'list', + 'inspect', + 'failures', + 'install', + 'uninstall', + 'reload', + 'export', + 'apply', + ] as const; + if (!actions.includes(action as (typeof actions)[number])) { + return error( + action + ? `Unexpected runtime-host plugin command: ${action}` + : 'runtime-host plugin requires an action', + ); + } + let rootPath: string | undefined; + let rootId: string | undefined; + let cursor: number | undefined; + let limit: number | undefined; + const positional: string[] = []; + for (let index = 1; index < argv.length; index += 1) { + const argument = argv[index]; + if ( + argument === '--root' || + argument === '--scope' || + argument === '--cursor' || + argument === '--limit' + ) { + const parsed = optionValue(argv, index, argument); + if (typeof parsed !== 'string') return parsed; + if (argument === '--root') rootPath = parsed; + else if (argument === '--scope') rootId = parsed; + else { + const numeric = Number(parsed); + if ( + !Number.isSafeInteger(numeric) || + numeric < 0 || + (argument === '--limit' && (numeric < 1 || numeric > 64)) + ) { + return error(`${argument} requires a non-negative integer`); + } + if (argument === '--cursor') cursor = numeric; + else limit = numeric; + } + index += 1; + continue; + } + positional.push(argument ?? ''); + } + const selected = action as (typeof actions)[number]; + const expected = + selected === 'export' + ? 2 + : ['install', 'uninstall', 'reload', 'apply'].includes(selected) + ? 1 + : 0; + if (positional.length !== expected) { + return error( + `runtime-host plugin ${selected} requires ${expected} target${expected === 1 ? '' : 's'}`, + ); + } + if (rootId && selected !== 'inspect') return error('--scope is only valid for plugin inspect'); + if ( + (cursor !== undefined || limit !== undefined) && + !['list', 'inspect', 'failures'].includes(selected) + ) { + return error('--cursor and --limit require a paged Plugin query'); + } + return { + kind: 'runtime-host-plugin', + action: selected, + ...(rootPath ? { rootPath } : {}), + ...(positional[0] ? { subject: positional[0] } : {}), + ...(positional[1] ? { targetPath: positional[1] } : {}), + ...(rootId ? { rootId } : {}), + ...(cursor === undefined ? {} : { cursor }), + ...(limit === undefined ? {} : { limit }), + }; +} + function parseProfileCommand(argv: string[]): RuntimeHostCliCommand { const action = argv[0]; if (action === 'list') { diff --git a/packages/cli/src/runtime-host-plugin-command.ts b/packages/cli/src/runtime-host-plugin-command.ts new file mode 100644 index 0000000000..fe2f6be937 --- /dev/null +++ b/packages/cli/src/runtime-host-plugin-command.ts @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { connectExistingRuntimeHost, type RuntimeHostConnection } from '@maka/runtime-host/client'; +import { RUNTIME_HOST_PROTOCOL_VERSION } from '@maka/runtime-host/protocol'; +import { readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const PROTOCOL = { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, +} as const; + +export interface RuntimeHostPluginCommand { + readonly rootPath: string; + readonly action: + | 'status' + | 'list' + | 'inspect' + | 'failures' + | 'install' + | 'uninstall' + | 'reload' + | 'export' + | 'apply'; + readonly subject?: string; + readonly targetPath?: string; + readonly rootId?: string; + readonly cursor?: number; + readonly limit?: number; +} + +interface RuntimeHostPluginCommandDeps { + readonly connect: (rootPath: string) => Promise; + readonly readText: (path: string) => Promise; + readonly write: (value: string) => void; +} + +export async function runRuntimeHostPluginCli( + command: RuntimeHostPluginCommand, + overrides: Partial = {}, +): Promise { + const deps = { ...defaultDeps(), ...overrides }; + const connection = await deps.connect(command.rootPath); + try { + const result = await execute(connection, command, deps.readText); + deps.write(`${JSON.stringify(result, null, 2)}\n`); + return 0; + } finally { + await connection.close(); + } +} + +async function execute( + connection: RuntimeHostConnection, + command: RuntimeHostPluginCommand, + readText: (path: string) => Promise, +): Promise { + const paging = { + ...(command.cursor === undefined ? {} : { cursor: command.cursor }), + ...(command.limit === undefined ? {} : { limit: command.limit }), + }; + switch (command.action) { + case 'status': + return await connection.request('plugin.platform.query', { view: 'status' }); + case 'list': + return await connection.request('plugin.platform.query', { view: 'packages', ...paging }); + case 'inspect': + return await connection.request('plugin.platform.query', { + view: 'entries', + ...paging, + ...(command.rootId ? { rootId: command.rootId } : {}), + } as never); + case 'failures': + return await connection.request('plugin.platform.query', { view: 'failures', ...paging }); + case 'install': + return await connection.request('plugin.package.install', { + sourcePath: resolve(requireSubject(command)), + }); + case 'uninstall': + return await connection.request('plugin.package.uninstall', { + extensionId: requireSubject(command), + }); + case 'reload': + return await connection.request('plugin.package.reload', { + extensionId: requireSubject(command), + }); + case 'export': + return await connection.request('plugin.package.export', { + extensionId: requireSubject(command), + targetPath: resolve(command.targetPath ?? missing('Plugin export target path')), + }); + case 'apply': { + const decoded = JSON.parse(await readText(resolve(requireSubject(command)))) as unknown; + return await connection.request('plugin.composition.apply', decoded as never); + } + } +} + +function requireSubject(command: RuntimeHostPluginCommand): string { + return command.subject ?? missing(`Plugin ${command.action} target`); +} + +function missing(label: string): never { + throw new Error(`${label} is missing`); +} + +function defaultDeps(): RuntimeHostPluginCommandDeps { + return { + connect: connectLocalOwner, + readText: (path) => readFile(path, 'utf8'), + write: (value) => process.stdout.write(value), + }; +} + +async function connectLocalOwner(rootPath: string): Promise { + const result = await connectExistingRuntimeHost({ rootPath, protocol: PROTOCOL }); + if (result.kind !== 'connected') { + throw new Error(`Runtime Host service is not available (${result.kind})`); + } + return result.connection; +} diff --git a/packages/runtime-host/package.json b/packages/runtime-host/package.json index 2a830bef1b..7f804f972f 100644 --- a/packages/runtime-host/package.json +++ b/packages/runtime-host/package.json @@ -31,6 +31,7 @@ "@maka/runtime": "0.1.0", "@maka/storage": "0.1.0", "ws": "^8.21.3", + "yaml": "^2.9.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/runtime-host/src/__tests__/plugin-platform.test.ts b/packages/runtime-host/src/__tests__/plugin-platform.test.ts new file mode 100644 index 0000000000..93cae14c74 --- /dev/null +++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts @@ -0,0 +1,977 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { MakaCompositionLoader } from '@maka/runtime/plugin-composition-loader'; +import { + decodePluginCompositionApplyInput, + decodeRequestFrame, + decodeResponseFrame, +} from '../protocol/index.js'; +import { + HostPluginCompositionStore, + HostPluginCompositionStoreError, + type PersistedPluginComposition, +} from '../server/plugin-composition-store.js'; +import { HostPluginPlatformCoordinator } from '../server/plugin-platform-coordinator.js'; +import { TrustedPluginPackageLoader } from '../server/plugin-package-loader.js'; +import { PluginPackageStore } from '../server/plugin-package-store.js'; +import { HostPluginPlatform } from '../server/plugin-platform.js'; + +test('Plugin Platform installs, activates, persists, and recovers a generic package', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-platform-')); + try { + const source = await writeFixturePackage(root, 'fixture-package', 'first', { + composition: [ + { + type: 'insert', + rootId: 'profile', + entry: { id: 'fixture-entry', packageId: 'fixture-package' }, + }, + ], + }); + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + + assert.deepEqual(await platform.installPackage(source), { extensionId: 'fixture-package' }); + const published = platform.composition.package('fixture-package'); + assert.deepEqual(published.contributions, [{ id: 'first', kind: 'foundation-test' }]); + const bundle = join(root, 'fixture-package.maka-extension'); + await platform.packages.export('fixture-package', bundle); + const imported = new HostPluginPlatform(join(root, 'import-control')); + await imported.recover(); + assert.deepEqual(await imported.installPackage(bundle), { extensionId: 'fixture-package' }); + assert.equal(imported.inspect('profile')[0]?.id, 'fixture-entry'); + await imported.close(); + await platform.close(); + + const recovered = new HostPluginPlatform(join(root, 'control')); + await recovered.recover(); + assert.equal(recovered.inspect('profile')[0]?.status, 'active'); + assert.equal(recovered.desiredComposition().generation, 1); + assert.deepEqual(recovered.composition.package('fixture-package').contributions, [ + { id: 'first', kind: 'foundation-test' }, + ]); + assert.deepEqual(Object.keys((await recovered.store.read()) ?? {}).sort(), [ + 'generation', + 'overlays', + 'packageLayers', + 'schemaVersion', + ]); + await recovered.close(); + + const generationRoot = join(root, 'control', 'plugin-generations-v1'); + assert.deepEqual(await readdir(generationRoot).catch(() => []), []); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Plugin Platform coordinator keeps package and composition operations generic', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-protocol-')); + try { + const source = await writeFixturePackage(root, 'protocol-package', 'generic', { + composition: [ + { + type: 'insert', + entry: { id: 'protocol-entry', packageId: 'protocol-package' }, + }, + ], + }); + const platform = new HostPluginPlatform(join(root, 'control')); + const coordinator = new HostPluginPlatformCoordinator(platform); + await platform.recover(); + + const installed = await coordinator.handlers['plugin.package.install']( + { sourcePath: source }, + null as never, + ); + assert.deepEqual(installed, { + ok: true, + result: { extensionId: 'protocol-package' }, + }); + const queried = await coordinator.handlers['plugin.platform.query']( + { view: 'packages' }, + null as never, + ); + assert.equal(queried.ok, true); + if (queried.ok && queried.result.view === 'packages') { + assert.deepEqual( + queried.result.items.map(({ extensionId }) => extensionId), + ['protocol-package'], + ); + } + const entries = await coordinator.handlers['plugin.platform.query']( + { view: 'entries', rootId: 'profile' }, + null as never, + ); + assert.equal(entries.ok && entries.result.view === 'entries', true); + if (entries.ok && entries.result.view === 'entries') { + assert.equal(entries.result.items[0]?.id, 'protocol-entry'); + } + assert.deepEqual( + await coordinator.handlers['plugin.package.reload']( + { extensionId: 'protocol-package' }, + null as never, + ), + { ok: true, result: {} }, + ); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('package Composition layers override in install order and unwind on uninstall', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-layers-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'layer-base', 'base', { + manifest: { + configuration: { + properties: { theme: { type: 'string', default: 'base' } }, + }, + }, + composition: [ + { + type: 'insert', + entry: { + id: 'layer-entry', + packageId: 'layer-base', + config: { theme: 'base' }, + }, + }, + ], + }), + ); + const overrideSource = await writeFixturePackage(root, 'layer-override', 'override', { + composition: [ + { type: 'update', entryId: 'layer-entry', patch: { config: { theme: 'override' } } }, + ], + }); + await platform.installPackage(overrideSource); + const tailSource = await writeFixturePackage(root, 'layer-tail', 'tail', { + composition: [ + { type: 'update', entryId: 'layer-entry', patch: { config: { theme: 'tail' } } }, + ], + }); + await platform.installPackage(tailSource); + + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { + theme: 'tail', + }); + await platform.installPackage(overrideSource); + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { theme: 'tail' }); + await platform.uninstallPackage('layer-tail'); + await platform.uninstallPackage('layer-override'); + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { theme: 'base' }); + await platform.installPackage(overrideSource); + await platform.apply({ + operations: [ + { type: 'update', entryId: 'layer-entry', patch: { config: { theme: 'user' } } }, + ], + }); + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { theme: 'user' }); + await platform.uninstallPackage('layer-override'); + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { theme: 'user' }); + assert.deepEqual((await platform.store.read())?.packageLayers, ['layer-base']); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('invalid package Composition patch is rejected before package publication', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-invalid-patch-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + const source = await writeFixturePackage(root, 'invalid-patch', 'invalid', { + composition: [{}], + }); + await assert.rejects(() => platform.installPackage(source), /Composition patch is invalid/u); + assert.deepEqual(await platform.packages.identities(), []); + assert.deepEqual(platform.desiredComposition().roots.profile, []); + + const semanticSource = await writeFixturePackage(root, 'invalid-layer', 'invalid', { + composition: [ + { + type: 'insert', + entry: { id: 'missing-package-entry', packageId: 'missing-package' }, + }, + ], + }); + await assert.rejects(() => platform.installPackage(semanticSource), /missing-package/u); + assert.deepEqual(await platform.packages.identities(), []); + assert.deepEqual(platform.desiredComposition().roots.profile, []); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('failed package replacement restores both stored bytes and live Runtime package', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-rollback-')); + try { + const source = await writeFixturePackage(root, 'rollback-package', 'stable'); + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + await platform.installPackage(source); + await platform.apply({ + operations: [ + { + type: 'insert', + entry: { id: 'rollback-entry', packageId: 'rollback-package' }, + }, + ], + }); + const before = platform.composition.package('rollback-package').contributions; + const invalid = await writeFixturePackage(root, 'rollback-package', 'replacement', { + runtimePackageId: 'wrong-package', + directorySuffix: 'invalid', + }); + + await assert.rejects(() => platform.installPackage(invalid), /does not match manifest/u); + assert.deepEqual(platform.composition.package('rollback-package').contributions, before); + assert.equal( + (await platform.packages.load('rollback-package')).manifest.id, + 'rollback-package', + ); + await platform.close(); + + const recovered = new HostPluginPlatform(join(root, 'control')); + await recovered.recover(); + assert.deepEqual(recovered.composition.package('rollback-package').contributions, before); + await recovered.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('package replacement recovery follows the durable Composition generation', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-generation-')); + try { + for (const mode of ['before', 'after'] as const) { + const control = join(root, mode); + const store = new AmbiguousCompositionStore(control); + const initial = new HostPluginPlatform(control, { store }); + await initial.recover(); + await initial.installPackage( + await writeFixturePackage(root, `generation-${mode}`, 'stable', { + composition: [ + { + type: 'insert', + entry: { id: `entry-${mode}`, packageId: `generation-${mode}` }, + }, + ], + }), + ); + store.mode = mode; + const replacement = await writeFixturePackage(root, `generation-${mode}`, 'replacement', { + directorySuffix: mode, + composition: [ + { + type: 'insert', + entry: { id: `entry-${mode}`, packageId: `generation-${mode}` }, + }, + ], + }); + await assert.rejects(() => initial.installPackage(replacement), /commit outcome is unknown/u); + assert.equal( + initial.composition.package(`generation-${mode}`).contributions?.[0]?.id, + 'stable', + 'Runtime convergence waits until the authority outcome is known', + ); + await initial.close(); + + const recovered = new HostPluginPlatform(control); + await recovered.recover(); + assert.equal( + recovered.composition.package(`generation-${mode}`).contributions?.[0]?.id, + mode === 'after' ? 'replacement' : 'stable', + ); + await recovered.close(); + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Plugin Platform protocol rejects open and malformed generic composition shapes', () => { + assert.equal( + decodeRequestFrame({ + requestId: 'plugin-reload', + operation: 'plugin.package.reload', + input: { extensionId: 'fixture-package' }, + }).operation, + 'plugin.package.reload', + ); + assert.deepEqual( + decodeRequestFrame({ + requestId: 'plugin-request', + operation: 'plugin.composition.apply', + input: { + baseGeneration: 4, + operations: [ + { + type: 'insert', + rootId: 'session:one', + entry: { + id: 'fixture-entry', + packageId: 'fixture-package', + config: { enabled: true }, + intercept: { policy: { nested: true } }, + }, + }, + ], + }, + }).operation, + 'plugin.composition.apply', + ); + assert.throws(() => + decodeRequestFrame({ + requestId: 'plugin-request', + operation: 'plugin.package.install', + input: { sourcePath: '/tmp/package', unexpected: true }, + }), + ); + assert.throws(() => + decodeResponseFrame({ + requestId: 'plugin-request', + operation: 'plugin.platform.query', + ok: true, + result: { + view: 'entries', + items: [], + nextCursor: 'invalid', + }, + }), + ); +}); + +test('durable overlays may accumulate beyond one command frame without oversized responses', () => { + const input = { + operations: Array.from({ length: 700 }, (_, index) => ({ + type: 'insert' as const, + entry: { id: `large-entry-${index}`, config: { value: 'x'.repeat(900) } }, + })), + }; + assert.throws(() => decodePluginCompositionApplyInput(input), /byte limit/u); + assert.equal(decodePluginCompositionApplyInput(input, 2 * 1024 * 1024).operations.length, 700); + assert.doesNotThrow(() => + decodeResponseFrame({ + requestId: 'large-apply', + operation: 'plugin.composition.apply', + ok: true, + result: { generation: 700 }, + }), + ); +}); + +test('failed desired-state persistence leaves Runtime composition unchanged', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-persistence-')); + try { + const control = join(root, 'control'); + const store = new FailingCompositionStore(control); + const source = await writeFixturePackage(root, 'persistent-package', 'stable'); + const platform = new HostPluginPlatform(control, { store }); + await platform.recover(); + await platform.installPackage(source); + await platform.apply({ + operations: [ + { + type: 'insert', + entry: { id: 'persistent-entry', packageId: 'persistent-package' }, + }, + ], + }); + const before = platform.composition.compositionState(); + store.fail = true; + + await assert.rejects( + () => + platform.apply({ + baseGeneration: before.generation, + operations: [{ type: 'update', entryId: 'persistent-entry', patch: { disabled: true } }], + }), + /Runtime state was not changed/u, + ); + assert.deepEqual(platform.composition.compositionState(), before); + assert.equal(platform.inspect('profile')[0]?.status, 'active'); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('recovery loads installed packages that do not yet have an Entry', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-unused-package-')); + try { + const control = join(root, 'control'); + const source = await writeFixturePackage(root, 'unused-package', 'available'); + const initial = new HostPluginPlatform(control); + await initial.recover(); + await initial.installPackage(source); + await initial.close(); + + const recovered = new HostPluginPlatform(control); + await recovered.recover(); + await recovered.apply({ + operations: [{ type: 'insert', entry: { id: 'later-entry', packageId: 'unused-package' } }], + }); + assert.equal(recovered.inspect('profile')[0]?.status, 'active'); + await recovered.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('immutable package generation is owned by package lifetime across repeated Entries', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-generation-owner-')); + try { + const control = join(root, 'control'); + const platform = new HostPluginPlatform(control); + await platform.recover(); + await platform.installPackage(await writeFixturePackage(root, 'shared-package', 'shared')); + await platform.apply({ + operations: [ + { type: 'insert', entry: { id: 'shared-one', packageId: 'shared-package' } }, + { type: 'insert', entry: { id: 'shared-two', packageId: 'shared-package' } }, + ], + }); + const generations = join(control, 'plugin-generations-v1'); + assert.equal((await readdir(generations)).length, 1); + + await platform.apply({ operations: [{ type: 'remove', entryId: 'shared-one' }] }); + assert.equal((await readdir(generations)).length, 1); + assert.equal(platform.composition.inspect('shared-two').status, 'active'); + + await platform.apply({ operations: [{ type: 'remove', entryId: 'shared-two' }] }); + await platform.uninstallPackage('shared-package'); + assert.deepEqual(await readdir(generations).catch(() => []), []); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('unknown desired-state commit outcome fences mutation without inventing a rollback', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-unknown-commit-')); + try { + const control = join(root, 'control'); + const store = new UnknownCommitCompositionStore(control); + const platform = new HostPluginPlatform(control, { store }); + await platform.recover(); + store.fail = true; + + await assert.rejects( + () => platform.apply({ operations: [{ type: 'insert', entry: { id: 'uncertain-entry' } }] }), + /commit outcome is unknown/u, + ); + assert.deepEqual(platform.composition.compositionState().roots.profile, []); + await assert.rejects( + () => platform.apply({ operations: [{ type: 'remove', entryId: 'uncertain-entry' }] }), + /fenced/u, + ); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a queued mutation rechecks the fence after an unknown commit outcome', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-queued-fence-')); + try { + const control = join(root, 'control'); + const store = new DeferredUnknownCompositionStore(control); + const platform = new HostPluginPlatform(control, { store }); + await platform.recover(); + store.fail = true; + + const first = platform.apply({ + operations: [{ type: 'insert', entry: { id: 'first-uncertain' } }], + }); + await store.entered; + const second = platform.apply({ + operations: [{ type: 'insert', entry: { id: 'second-must-not-run' } }], + }); + store.release(); + + await assert.rejects(() => first, /commit outcome is unknown/u); + await assert.rejects(() => second, /fenced/u); + assert.deepEqual(platform.desiredComposition().roots.profile, []); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('failed uninstall keeps Package layers and desired state unchanged', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-uninstall-plan-')); + try { + const control = join(root, 'control'); + const platform = new HostPluginPlatform(control); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'uninstall-plan', 'installed', { + composition: [ + { + type: 'insert', + entry: { id: 'package-default', packageId: 'uninstall-plan' }, + }, + ], + }), + ); + await platform.apply({ + operations: [{ type: 'insert', entry: { id: 'user-entry', packageId: 'uninstall-plan' } }], + }); + const authority = await platform.store.read(); + const desired = platform.desiredComposition(); + + await assert.rejects(() => platform.uninstallPackage('uninstall-plan'), /used by desired/u); + assert.deepEqual(await platform.store.read(), authority); + assert.deepEqual(platform.desiredComposition(), desired); + assert.equal(platform.inspect('profile').length, 2); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('composition authority commits before Runtime convergence and exposes divergence', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-divergence-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + const coordinator = new HostPluginPlatformCoordinator(platform); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'failing-package', 'failing', { throwOnApply: true }), + ); + + await assert.rejects( + () => + platform.apply({ + operations: [ + { type: 'insert', entry: { id: 'desired-failure', packageId: 'failing-package' } }, + ], + }), + /desired Plugin composition was committed/iu, + ); + assert.equal(platform.desiredComposition().roots.profile[0]?.id, 'desired-failure'); + assert.deepEqual(platform.composition.compositionState().roots.profile, []); + const queried = await coordinator.handlers['plugin.platform.query']( + { view: 'failures' }, + null as never, + ); + assert.equal(queried.ok, true); + if (queried.ok && queried.result.view === 'failures') { + assert.equal(queried.result.items[0]?.entryId, 'desired-failure'); + } + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('recovery is fail-open for Host and isolates a broken desired Entry', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-partial-recovery-')); + try { + const control = join(root, 'control'); + const initial = new HostPluginPlatform(control); + await initial.recover(); + await initial.installPackage(await writeFixturePackage(root, 'healthy-package', 'healthy')); + await initial.close(); + await new HostPluginCompositionStore(control).replace({ + schemaVersion: 1, + generation: 5, + packageLayers: [], + overlays: [ + { + type: 'insert', + entry: { id: 'healthy-entry', packageId: 'healthy-package', config: {} }, + }, + { + type: 'insert', + entry: { id: 'broken-entry', packageId: 'missing-package', config: {} }, + }, + ], + }); + + const recovered = new HostPluginPlatform(control); + await recovered.recover(); + assert.equal(recovered.inspect('profile')[0]?.id, 'healthy-entry'); + assert.equal(recovered.desiredComposition().generation, 5); + assert.deepEqual( + recovered.desiredComposition().roots.profile.map(({ id }) => id), + ['healthy-entry', 'broken-entry'], + ); + assert.equal( + recovered.failures().some(({ entryId }) => entryId === 'broken-entry'), + true, + ); + await recovered.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('corrupt Plugin authority fails closed locally without failing Host recovery', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-corrupt-authority-')); + try { + const control = join(root, 'control'); + await mkdir(control, { recursive: true }); + await writeFile(join(control, 'plugin-composition-v2.json'), '{not-json'); + const platform = new HostPluginPlatform(control); + const coordinator = new HostPluginPlatformCoordinator(platform); + + await platform.recover(); + const queried = await coordinator.handlers['plugin.platform.query']( + { view: 'status' }, + null as never, + ); + assert.equal(queried.ok, false); + if (!queried.ok) assert.equal(queried.error.code, 'persistence_failed'); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('a package that fails Runtime loading can still be uninstalled for repair', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-corrupt-package-removal-')); + try { + const control = join(root, 'control'); + const source = await writeFixturePackage(root, 'broken-package', 'broken', { + runtimePackageId: 'wrong-package', + }); + await new PluginPackageStore(control).install(source); + const platform = new HostPluginPlatform(control); + await platform.recover(); + assert.equal( + platform.failures().some(({ extensionId }) => extensionId === 'broken-package'), + true, + ); + + await platform.uninstallPackage('broken-package'); + assert.deepEqual(await platform.packages.identities(), []); + assert.equal( + platform.failures().some(({ extensionId }) => extensionId === 'broken-package'), + false, + ); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Manifest configuration is enforced before desired state is committed', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-config-contract-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'configured-package', 'configured', { + manifest: { + configuration: { + properties: { enabled: { type: 'boolean' } }, + required: ['enabled'], + }, + }, + }), + ); + + await assert.rejects( + () => + platform.apply({ + operations: [ + { type: 'insert', entry: { id: 'configured-entry', packageId: 'configured-package' } }, + ], + }), + (error: unknown) => + error instanceof Error && + error.cause instanceof Error && + /missing required key/u.test(error.cause.message), + ); + assert.deepEqual(platform.desiredComposition().roots.profile, []); + assert.deepEqual(platform.composition.compositionState().roots.profile, []); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Manifest configuration defaults are committed to desired and live Entries', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-config-defaults-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'defaulted-package', 'defaulted', { + manifest: { + configuration: { + properties: { enabled: { type: 'boolean', default: true } }, + }, + }, + }), + ); + + await platform.apply({ + operations: [ + { type: 'insert', entry: { id: 'defaulted-entry', packageId: 'defaulted-package' } }, + ], + }); + assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { enabled: true }); + assert.deepEqual(platform.composition.compositionState().roots.profile[0]?.config, { + enabled: true, + }); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Manifest dependencies gate activation and protect required packages', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-dependencies-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'dependent-package', 'dependent', { + manifest: { dependencies: [{ id: 'required-package' }] }, + }), + ); + await assert.rejects( + () => + platform.apply({ + operations: [ + { type: 'insert', entry: { id: 'dependent-entry', packageId: 'dependent-package' } }, + ], + }), + /Plugin composition mutation failed/u, + ); + assert.deepEqual(platform.desiredComposition().roots.profile, []); + + await platform.installPackage(await writeFixturePackage(root, 'required-package', 'required')); + await platform.apply({ + operations: [ + { type: 'insert', entry: { id: 'required-entry', packageId: 'required-package' } }, + { type: 'insert', entry: { id: 'dependent-entry', packageId: 'dependent-package' } }, + ], + }); + await assert.rejects( + () => + platform.apply({ + operations: [{ type: 'remove', entryId: 'required-entry' }], + }), + /Plugin composition mutation failed/u, + ); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('package storage repairs an owner-death previous generation', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-recovery-')); + try { + const control = join(root, 'control'); + const platform = new HostPluginPlatform(control); + await platform.recover(); + await platform.installPackage(await writeFixturePackage(root, 'recover-package', 'recover')); + await platform.close(); + const packages = join(control, 'plugin-packages-v2'); + await rename(join(packages, 'recover-package'), join(packages, '.previous-owner-death')); + + const recovered = new HostPluginPlatform(control); + await recovered.recover(); + assert.equal((await recovered.packages.load('recover-package')).extensionId, 'recover-package'); + await recovered.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Plugin Platform close aggregates every resource failure', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-close-')); + try { + const control = join(root, 'control'); + const packages = new PluginPackageStore(control); + const composition = new FailingCloseCompositionLoader(); + const packageLoader = new FailingClosePackageLoader(control, packages); + const platform = new HostPluginPlatform(control, { composition, packages, packageLoader }); + await platform.recover(); + await assert.rejects( + () => platform.close(), + (error: unknown) => error instanceof AggregateError && error.errors.length === 2, + ); + assert.equal(composition.closeAttempted, true); + assert.equal(packageLoader.closeAttempted, true); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +async function writeFixturePackage( + root: string, + packageId: string, + contributionId: string, + options: { + readonly runtimePackageId?: string; + readonly directorySuffix?: string; + readonly throwOnApply?: boolean; + readonly manifest?: Readonly>; + readonly composition?: readonly unknown[]; + } = {}, +): Promise { + const source = join( + root, + `source-${packageId}${options.directorySuffix ? `-${options.directorySuffix}` : ''}`, + ); + await mkdir(source, { recursive: true }); + await writeFile( + join(source, 'maka.extension.json'), + JSON.stringify({ + schemaVersion: 1, + id: packageId, + runtime: { entry: 'index.mjs' }, + ...(options.composition ? { composition: { patch: 'maka.composition.yml' } } : {}), + ...(options.manifest ?? {}), + }), + ); + if (options.composition) { + await writeFile(join(source, 'maka.composition.yml'), JSON.stringify(options.composition)); + } + await writeFile( + join(source, 'index.mjs'), + `export default Object.freeze({ + packageId: ${JSON.stringify(options.runtimePackageId ?? packageId)}, + contributions: Object.freeze([{ id: ${JSON.stringify(contributionId)}, kind: 'foundation-test' }]), + host: Object.freeze({ apply(ctx) { + ${options.throwOnApply ? "throw new Error('fixture activation failed');" : ''} + ctx.effect(() => () => undefined, 'fixture'); + } }), + });\n`, + ); + return source; +} + +class FailingCompositionStore extends HostPluginCompositionStore { + fail = false; + + override async replace(state: PersistedPluginComposition): Promise { + if (this.fail) throw new Error('injected persistence failure'); + await super.replace(state); + } +} + +class UnknownCommitCompositionStore extends HostPluginCompositionStore { + fail = false; + + override async replace(state: PersistedPluginComposition): Promise { + if (this.fail) { + throw new HostPluginCompositionStoreError( + 'commit_outcome_unknown', + 'injected unknown commit outcome', + ); + } + await super.replace(state); + } +} + +class AmbiguousCompositionStore extends HostPluginCompositionStore { + mode: 'before' | 'after' | undefined; + + override async replace(state: PersistedPluginComposition): Promise { + const mode = this.mode; + this.mode = undefined; + if (mode === 'before') { + throw new HostPluginCompositionStoreError( + 'commit_outcome_unknown', + 'injected unknown commit before authority publication', + ); + } + await super.replace(state); + if (mode === 'after') { + throw new HostPluginCompositionStoreError( + 'commit_outcome_unknown', + 'injected unknown commit after authority publication', + ); + } + } +} + +class DeferredUnknownCompositionStore extends HostPluginCompositionStore { + fail = false; + readonly entered: Promise; + readonly #signalEntered: () => void; + readonly #gate: Promise; + readonly #release: () => void; + + constructor(controlDirectory: string) { + super(controlDirectory); + let signalEntered!: () => void; + let release!: () => void; + this.entered = new Promise((resolve) => { + signalEntered = resolve; + }); + this.#gate = new Promise((resolve) => { + release = resolve; + }); + this.#signalEntered = signalEntered; + this.#release = release; + } + + release(): void { + this.#release(); + } + + override async replace(state: PersistedPluginComposition): Promise { + if (!this.fail) return await super.replace(state); + this.#signalEntered(); + await this.#gate; + throw new HostPluginCompositionStoreError( + 'commit_outcome_unknown', + 'injected deferred unknown commit outcome', + ); + } +} + +class FailingCloseCompositionLoader extends MakaCompositionLoader { + closeAttempted = false; + + override async close(): Promise { + this.closeAttempted = true; + throw new Error('injected composition close failure'); + } +} + +class FailingClosePackageLoader extends TrustedPluginPackageLoader { + closeAttempted = false; + + override async close(): Promise { + this.closeAttempted = true; + throw new Error('injected package loader close failure'); + } +} diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b735d3549d..3bf31ed5c4 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -94,7 +94,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 64 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 65 as const; +// 65: Plugin package and Entry composition operations become Host-owned protocol +// surfaces. Older peers cannot safely exchange these strict operation shapes. // 64: execution.inspect drops the retired resolve operation. Older peers still // know execution.inspect.resolve and would send it only to fail mid-connection, // so removing it needs its own handshake boundary. diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index d9176c2657..0936c017ec 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -40,6 +40,7 @@ import { NETWORK_PROXY_OPERATION_SPECS } from './network-proxy.js'; import { OAUTH_OPERATION_SPECS } from './oauth.js'; import { PLAN_OPERATION_SPECS } from './plan.js'; import { PEER_MESH_OPERATION_SPECS } from './peer-mesh.js'; +import { PLUGIN_PLATFORM_OPERATION_SPECS } from './plugin-platform.js'; import { PROJECT_CATALOG_OPERATION_SPECS } from './project-catalog.js'; import { composeOperationSpecMaps, @@ -161,6 +162,7 @@ export * from './memory.js'; export * from './network-proxy.js'; export * from './oauth.js'; export * from './plan.js'; +export * from './plugin-platform.js'; export * from './project-catalog.js'; export * from './runtime-policy.js'; export * from './runtime-resource.js'; @@ -215,6 +217,7 @@ export const HOST_OPERATION_SPECS = composeOperationSpecMaps( NETWORK_PROXY_OPERATION_SPECS, CONFIGURATION_OPERATION_SPECS, WORKHUB_COORDINATION_OPERATION_SPECS, + PLUGIN_PLATFORM_OPERATION_SPECS, ); export type OperationSpecMap = typeof HOST_OPERATION_SPECS; @@ -273,6 +276,12 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'plan.control', 'plan.query', 'plan.turn.start', + 'plugin.composition.apply', + 'plugin.package.export', + 'plugin.package.install', + 'plugin.package.reload', + 'plugin.package.uninstall', + 'plugin.platform.query', 'pricing.mutate', 'pricing.query', 'project.catalog.mutate', diff --git a/packages/runtime-host/src/protocol/plugin-platform.ts b/packages/runtime-host/src/protocol/plugin-platform.ts new file mode 100644 index 0000000000..a2cd7e1f82 --- /dev/null +++ b/packages/runtime-host/src/protocol/plugin-platform.ts @@ -0,0 +1,615 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + validateCompositionEntry, + validatePluginRootId, + type MakaCompositionApplyInput, + type MakaCompositionEntry, + type MakaCompositionEntryInspection, + type MakaCompositionOperation, + type MakaPluginRootId, +} from '@maka/runtime/plugin-runtime'; +import { + requireCount, + requireEncodedByteLimit, + requireExactRecord, + requireId, + requireRecord, + requireShapedRecord, + requireString, +} from './codec.js'; +import { invalidProtocolFrame } from './errors.js'; +import { defineHostPathOperation, defineOperation } from './operation-spec.js'; + +const QUERY_ERRORS = [ + 'host_not_ready', + 'host_draining', + 'operation_unavailable', + 'invalid_request', + 'persistence_failed', + 'internal_failure', +] as const; +const MUTATE_ERRORS = [ + ...QUERY_ERRORS, + 'not_found', + 'operation_conflict', + 'commit_outcome_unknown', +] as const; +const MAX_FRAME_BYTES = 512 * 1024; + +export interface PluginPackageProjection { + readonly extensionId: string; + readonly displayName: string; + readonly description?: string; + readonly dependencies: readonly string[]; +} + +export interface PluginPlatformQueryInput { + readonly view: 'status' | 'packages' | 'entries' | 'failures'; + readonly rootId?: MakaPluginRootId; + readonly cursor?: number; + readonly limit?: number; +} + +export type PluginPlatformQueryResult = + | { + readonly view: 'status'; + readonly generation: number; + readonly packageCount: number; + readonly entryCount: number; + readonly failureCount: number; + } + | { + readonly view: 'packages'; + readonly items: readonly PluginPackageProjection[]; + readonly nextCursor: number | null; + } + | { + readonly view: 'entries'; + readonly items: readonly MakaCompositionEntryInspection[]; + readonly nextCursor: number | null; + } + | { + readonly view: 'failures'; + readonly items: readonly PluginPlatformFailureProjection[]; + readonly nextCursor: number | null; + }; + +export interface PluginPlatformFailureProjection { + readonly entryId?: string; + readonly extensionId?: string; + readonly diagnostic: string; +} + +export interface PluginPackageInstallInput { + readonly sourcePath: string; +} + +export interface PluginPackageInstallResult { + readonly extensionId: string; +} + +export interface PluginPackageUninstallInput { + readonly extensionId: string; +} + +export interface PluginPackageExportInput extends PluginPackageUninstallInput { + readonly targetPath: string; +} + +export interface PluginPackageExportResult { + readonly targetPath: string; +} + +export interface PluginCompositionApplyResult { + readonly generation: number; +} + +export const PLUGIN_PLATFORM_OPERATION_SPECS = { + 'plugin.platform.query': defineOperation< + PluginPlatformQueryInput, + PluginPlatformQueryResult, + (typeof QUERY_ERRORS)[number] + >({ + mode: 'query', + availability: 'ready', + errors: QUERY_ERRORS, + decodeInput: decodePluginPlatformQueryInput, + decodeOutput: decodePluginPlatformQueryResult, + }), + 'plugin.package.install': defineHostPathOperation< + PluginPackageInstallInput, + PluginPackageInstallResult, + (typeof MUTATE_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATE_ERRORS, + decodeInput: (value) => { + const input = requireExactRecord(value, 'Plugin package install input', ['sourcePath']); + return { sourcePath: requireString(input.sourcePath, 'Plugin package source path', 4096) }; + }, + decodeOutput: decodePluginPackageInstallResult, + }), + 'plugin.package.uninstall': defineOperation< + PluginPackageUninstallInput, + Record, + (typeof MUTATE_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATE_ERRORS, + decodeInput: decodePluginPackageUninstallInput, + decodeOutput: (value) => { + requireExactRecord(value, 'Plugin package uninstall result', []); + return {}; + }, + }), + 'plugin.package.reload': defineOperation< + PluginPackageUninstallInput, + Record, + (typeof MUTATE_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATE_ERRORS, + decodeInput: decodePluginPackageUninstallInput, + decodeOutput: (value) => { + requireExactRecord(value, 'Plugin package reload result', []); + return {}; + }, + }), + 'plugin.package.export': defineHostPathOperation< + PluginPackageExportInput, + PluginPackageExportResult, + (typeof MUTATE_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATE_ERRORS, + decodeInput: (value) => { + const input = requireExactRecord(value, 'Plugin package export input', [ + 'extensionId', + 'targetPath', + ]); + return { + extensionId: requireId(input.extensionId, 'Plugin package identity'), + targetPath: requireString(input.targetPath, 'Plugin package export path', 4096), + }; + }, + decodeOutput: (value) => { + const output = requireExactRecord(value, 'Plugin package export result', ['targetPath']); + return { targetPath: requireString(output.targetPath, 'Plugin package export path', 4096) }; + }, + }), + 'plugin.composition.apply': defineOperation< + MakaCompositionApplyInput, + PluginCompositionApplyResult, + (typeof MUTATE_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATE_ERRORS, + decodeInput: decodePluginCompositionApplyInput, + decodeOutput: (value) => { + const output = requireExactRecord(value, 'Plugin composition apply result', ['generation']); + return { generation: requireCount(output.generation, 'Plugin composition generation') }; + }, + }), +} as const; + +function decodePluginPlatformQueryInput(value: unknown): PluginPlatformQueryInput { + const input = requireShapedRecord( + value, + 'Plugin Platform query input', + ['view'], + ['rootId', 'cursor', 'limit'], + ); + if (!['status', 'packages', 'entries', 'failures'].includes(input.view as string)) { + throw invalidProtocolFrame('Invalid Plugin Platform query view'); + } + const view = input.view as PluginPlatformQueryInput['view']; + const cursor = + input.cursor === undefined + ? undefined + : requireCount(input.cursor, 'Plugin Platform query cursor'); + const limit = + input.limit === undefined + ? undefined + : requireCount(input.limit, 'Plugin Platform query limit'); + if (limit !== undefined && (limit < 1 || limit > 64)) { + throw invalidProtocolFrame('Invalid Plugin Platform query limit'); + } + if ( + view === 'status' && + (input.rootId !== undefined || cursor !== undefined || limit !== undefined) + ) { + throw invalidProtocolFrame('Plugin Platform status query does not accept paging'); + } + if (input.rootId !== undefined && view !== 'entries') { + throw invalidProtocolFrame('Plugin root identity is only valid for Entry queries'); + } + let rootId: MakaPluginRootId | undefined; + if (input.rootId !== undefined) { + const decoded = requireString(input.rootId, 'Plugin root identity', 256); + try { + validatePluginRootId(decoded); + rootId = decoded; + } catch { + throw invalidProtocolFrame('Invalid Plugin root identity'); + } + } + return { + view, + ...(rootId ? { rootId } : {}), + ...(cursor === undefined ? {} : { cursor }), + ...(limit === undefined ? {} : { limit }), + }; +} + +function decodePluginPlatformQueryResult(value: unknown): PluginPlatformQueryResult { + const record = requireRecord(value, 'Plugin Platform query result'); + const view = record.view; + let decoded: PluginPlatformQueryResult; + if (view === 'status') { + const output = requireExactRecord(record, 'Plugin Platform status result', [ + 'view', + 'generation', + 'packageCount', + 'entryCount', + 'failureCount', + ]); + decoded = { + view, + generation: requireCount(output.generation, 'Plugin composition generation'), + packageCount: requireCount(output.packageCount, 'Plugin package count'), + entryCount: requireCount(output.entryCount, 'Plugin Entry count'), + failureCount: requireCount(output.failureCount, 'Plugin failure count'), + }; + } else { + const output = requireExactRecord(record, 'Plugin Platform page result', [ + 'view', + 'items', + 'nextCursor', + ]); + if ( + !Array.isArray(output.items) || + output.items.length > 64 || + !['packages', 'entries', 'failures'].includes(view as string) + ) { + throw invalidProtocolFrame('Invalid Plugin Platform page'); + } + const nextCursor = + output.nextCursor === null + ? null + : requireCount(output.nextCursor, 'Plugin Platform next cursor'); + decoded = + view === 'packages' + ? { view, items: output.items.map(decodePackageProjection), nextCursor } + : view === 'entries' + ? { view, items: decodeInspections(output.items), nextCursor } + : { view: 'failures', items: output.items.map(decodePlatformFailure), nextCursor }; + } + requireEncodedByteLimit(decoded, 'Plugin Platform query result', MAX_FRAME_BYTES); + return decoded; +} + +function decodePlatformFailure(value: unknown): PluginPlatformFailureProjection { + const failure = requireShapedRecord( + value, + 'Plugin Platform failure', + ['diagnostic'], + ['entryId', 'extensionId'], + ); + if (failure.entryId === undefined && failure.extensionId === undefined) { + throw invalidProtocolFrame('Plugin Platform failure has no identity'); + } + return { + ...(failure.entryId === undefined + ? {} + : { entryId: requireId(failure.entryId, 'Plugin Entry identity') }), + ...(failure.extensionId === undefined + ? {} + : { extensionId: requireId(failure.extensionId, 'Plugin package identity') }), + diagnostic: requireString(failure.diagnostic, 'Plugin Platform diagnostic', 4096), + }; +} + +function decodePackageProjection(value: unknown): PluginPackageProjection { + const item = requireShapedRecord( + value, + 'Plugin package projection', + ['extensionId', 'displayName', 'dependencies'], + ['description'], + ); + if (!Array.isArray(item.dependencies)) throw invalidProtocolFrame('Invalid Plugin dependencies'); + return { + extensionId: requireId(item.extensionId, 'Plugin package identity'), + displayName: requireString(item.displayName, 'Plugin display name', 512), + ...(item.description === undefined + ? {} + : { description: requireString(item.description, 'Plugin description', 4096) }), + dependencies: item.dependencies.map((dependency) => + requireId(dependency, 'Plugin dependency identity'), + ), + }; +} + +function decodePluginPackageInstallResult(value: unknown): PluginPackageInstallResult { + const output = requireExactRecord(value, 'Plugin package install result', ['extensionId']); + return { extensionId: requireId(output.extensionId, 'Plugin package identity') }; +} + +function decodePluginPackageUninstallInput(value: unknown): PluginPackageUninstallInput { + const input = requireExactRecord(value, 'Plugin package uninstall input', ['extensionId']); + return { extensionId: requireId(input.extensionId, 'Plugin package identity') }; +} + +export function decodePluginCompositionApplyInput( + value: unknown, + maxBytes = MAX_FRAME_BYTES, +): MakaCompositionApplyInput { + const input = requireShapedRecord( + value, + 'Plugin composition apply input', + ['operations'], + ['baseGeneration'], + ); + if (!Array.isArray(input.operations) || input.operations.length === 0) { + throw invalidProtocolFrame('Invalid Plugin composition operations'); + } + const decoded = { + ...(input.baseGeneration === undefined + ? {} + : { baseGeneration: requireCount(input.baseGeneration, 'Plugin composition generation') }), + operations: input.operations.map(decodeCompositionOperation), + }; + requireEncodedByteLimit(decoded, 'Plugin composition apply input', maxBytes); + return decoded; +} + +function decodeCompositionOperation(value: unknown): MakaCompositionOperation { + const operation = requireRecord(value, 'Plugin composition operation'); + switch (operation.type) { + case 'insert': { + const input = requireShapedRecord( + operation, + 'Plugin insert operation', + ['type', 'entry'], + ['rootId', 'parentId', 'position'], + ); + return { + type: 'insert', + ...(input.rootId === undefined ? {} : { rootId: decodeRootId(input.rootId) }), + ...(input.parentId === undefined + ? {} + : { parentId: requireId(input.parentId, 'Plugin parent Entry identity') }), + entry: decodeCompositionEntry(input.entry), + ...(input.position === undefined + ? {} + : { position: requireCount(input.position, 'Plugin Entry position') }), + }; + } + case 'update': { + const input = requireExactRecord(operation, 'Plugin update operation', [ + 'type', + 'entryId', + 'patch', + ]); + return { + type: 'update', + entryId: requireId(input.entryId, 'Plugin Entry identity'), + patch: decodeEntryPatch(input.patch), + }; + } + case 'move': { + const input = requireShapedRecord( + operation, + 'Plugin move operation', + ['type', 'entryId'], + ['parentId', 'position'], + ); + return { + type: 'move', + entryId: requireId(input.entryId, 'Plugin Entry identity'), + ...(input.parentId === undefined + ? {} + : { parentId: requireId(input.parentId, 'Plugin parent Entry identity') }), + ...(input.position === undefined + ? {} + : { position: requireCount(input.position, 'Plugin Entry position') }), + }; + } + case 'remove': { + const input = requireExactRecord(operation, 'Plugin remove operation', ['type', 'entryId']); + return { type: 'remove', entryId: requireId(input.entryId, 'Plugin Entry identity') }; + } + default: + throw invalidProtocolFrame('Invalid Plugin composition operation type'); + } +} + +function decodeCompositionEntry(value: unknown): MakaCompositionEntry { + const entry = requireShapedRecord( + value, + 'Plugin composition Entry', + ['id'], + ['packageId', 'config', 'disabled', 'inject', 'isolate', 'intercept', 'children'], + ); + const decoded: MakaCompositionEntry = { + id: requireId(entry.id, 'Plugin Entry identity'), + ...(entry.packageId === undefined + ? {} + : { packageId: requireId(entry.packageId, 'Plugin package identity') }), + ...(entry.config === undefined ? {} : { config: decodeScalarRecord(entry.config, 'config') }), + ...(entry.disabled === undefined ? {} : { disabled: requireBoolean(entry.disabled) }), + ...(entry.inject === undefined ? {} : { inject: decodeInject(entry.inject) }), + ...(entry.isolate === undefined ? {} : { isolate: decodeIsolate(entry.isolate) }), + ...(entry.intercept === undefined + ? {} + : { intercept: decodeJsonRecord(entry.intercept, 'intercept') }), + ...(entry.children === undefined ? {} : { children: decodeEntries(entry.children) }), + }; + try { + validateCompositionEntry(decoded); + } catch { + throw invalidProtocolFrame('Invalid Plugin composition Entry'); + } + return decoded; +} + +function decodeEntryPatch(value: unknown): Partial> { + const patch = requireShapedRecord( + value, + 'Plugin Entry patch', + [], + ['packageId', 'config', 'disabled', 'inject', 'isolate', 'intercept'], + ); + return { + ...(patch.packageId === undefined + ? {} + : { packageId: requireId(patch.packageId, 'Plugin package identity') }), + ...(patch.config === undefined ? {} : { config: decodeScalarRecord(patch.config, 'config') }), + ...(patch.disabled === undefined ? {} : { disabled: requireBoolean(patch.disabled) }), + ...(patch.inject === undefined ? {} : { inject: decodeInject(patch.inject) }), + ...(patch.isolate === undefined ? {} : { isolate: decodeIsolate(patch.isolate) }), + ...(patch.intercept === undefined + ? {} + : { intercept: decodeJsonRecord(patch.intercept, 'intercept') }), + }; +} + +function decodeInspections(value: unknown): readonly MakaCompositionEntryInspection[] { + if (!Array.isArray(value)) throw invalidProtocolFrame('Invalid Plugin Entry inspections'); + return value.map((item) => { + const inspection = requireShapedRecord( + item, + 'Plugin Entry inspection', + ['id', 'rootId', 'disabled', 'status', 'waitingFor', 'effects', 'children'], + ['parentId', 'packageId', 'config', 'generation', 'diagnostic'], + ); + const statuses = [ + 'disabled', + 'pending', + 'loading', + 'active', + 'failed', + 'unloading', + 'disposed', + ]; + if (!statuses.includes(inspection.status as string)) { + throw invalidProtocolFrame('Invalid Plugin Entry status'); + } + if (!Array.isArray(inspection.waitingFor) || !Array.isArray(inspection.effects)) { + throw invalidProtocolFrame('Invalid Plugin Entry inspection details'); + } + return { + id: requireId(inspection.id, 'Plugin Entry identity'), + rootId: decodeRootId(inspection.rootId), + ...(inspection.parentId === undefined + ? {} + : { parentId: requireId(inspection.parentId, 'Plugin parent Entry identity') }), + ...(inspection.packageId === undefined + ? {} + : { packageId: requireId(inspection.packageId, 'Plugin package identity') }), + ...(inspection.config === undefined + ? {} + : { config: decodeScalarRecord(inspection.config, 'config') }), + disabled: requireBoolean(inspection.disabled), + status: inspection.status as MakaCompositionEntryInspection['status'], + ...(inspection.generation === undefined + ? {} + : { generation: requireCount(inspection.generation, 'Plugin Fiber generation') }), + waitingFor: inspection.waitingFor.map((item) => requireId(item, 'Plugin dependency')), + effects: inspection.effects.map((item) => requireString(item, 'Plugin Effect label', 512)), + children: decodeInspections(inspection.children), + ...(inspection.diagnostic === undefined + ? {} + : { diagnostic: requireString(inspection.diagnostic, 'Plugin diagnostic', 4096) }), + }; + }); +} + +function decodeEntries(value: unknown): readonly MakaCompositionEntry[] { + if (!Array.isArray(value)) throw invalidProtocolFrame('Invalid Plugin Entry list'); + return value.map(decodeCompositionEntry); +} + +function decodeRootId(value: unknown): MakaPluginRootId { + const rootId = requireString(value, 'Plugin root identity', 256); + try { + validatePluginRootId(rootId); + return rootId; + } catch { + throw invalidProtocolFrame('Invalid Plugin root identity'); + } +} + +function decodeInject(value: unknown): readonly string[] | Readonly> { + if (Array.isArray(value)) return value.map((item) => requireId(item, 'Plugin injection')); + return decodeJsonRecord(value, 'inject'); +} + +function decodeIsolate(value: unknown): Readonly> { + const record = requireRecord(value, 'Plugin Entry isolate'); + const output: Record = {}; + for (const [key, item] of Object.entries(record)) { + requireId(key, 'Plugin Entry isolate key'); + if (item !== true && (typeof item !== 'string' || !item)) { + throw invalidProtocolFrame('Invalid Plugin Entry isolate value'); + } + output[key] = item; + } + return output; +} + +function decodeJsonRecord(value: unknown, label: string): Readonly> { + const record = requireRecord(value, `Plugin Entry ${label}`); + requireEncodedByteLimit(record, `Plugin Entry ${label}`, 64 * 1024); + try { + return structuredClone(record); + } catch { + throw invalidProtocolFrame(`Invalid Plugin Entry ${label}`); + } +} + +function decodeScalarRecord( + value: unknown, + label: string, +): Readonly> { + const record = requireRecord(value, `Plugin Entry ${label}`); + const output: Record = {}; + for (const [key, item] of Object.entries(record)) { + requireId(key, `Plugin Entry ${label} key`); + if ( + typeof item === 'string' || + typeof item === 'boolean' || + (typeof item === 'number' && Number.isFinite(item)) + ) + output[key] = item; + else throw invalidProtocolFrame(`Invalid Plugin Entry ${label} value`); + } + return output; +} + +function requireBoolean(value: unknown): boolean { + if (typeof value !== 'boolean') throw invalidProtocolFrame('Invalid Plugin Entry disabled flag'); + return value; +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index bbfd74ac82..18deef3f8f 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -152,6 +152,8 @@ import { } from './project-directory-authority.js'; import { HostProjectCatalogCoordinator } from './project-catalog-coordinator.js'; import { HostProjectMembershipGate } from './project-membership-gate.js'; +import { HostPluginPlatformCoordinator } from './plugin-platform-coordinator.js'; +import { HostPluginPlatform } from './plugin-platform.js'; import { RootAdmissionOwner } from './root-admission-owner.js'; import { RootTurnCoordinator } from './root-turn-coordinator.js'; import { RuntimePolicyActivationGate } from './runtime-policy-activation-gate.js'; @@ -194,6 +196,7 @@ import { export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition { readonly workspaceExecution: RuntimeHostWorkspaceExecutionComposition; + readonly plugins: HostPluginPlatform; } export interface CreateExecutionRuntimeHostCompositionOptions { @@ -245,7 +248,10 @@ export async function createExecutionRuntimeHostComposition( let unsubscribeUsageChanges: (() => void) | undefined; let workspaceExecution: RuntimeHostWorkspaceExecutionComposition | undefined; let goalExecutions: HostGoalExecutionCoordinator | undefined; + let pluginPlatform: HostPluginPlatform | undefined; try { + pluginPlatform = new HostPluginPlatform(context.owner.controlDirectory); + const pluginPlatformCoordinator = new HostPluginPlatformCoordinator(pluginPlatform); const openedProjectCatalog = storage.projectCatalog; const runtimePolicyStores = storage.runtimePolicy; const oauthCredentials = new HostOAuthExecutionAuthority(runtimePolicyStores); @@ -1483,6 +1489,13 @@ export async function createExecutionRuntimeHostComposition( ); let recoverySessions: Awaited> = []; domainModules = [ + createRuntimeHostDomainModule({ + id: 'plugin-platform', + handlers: [pluginPlatformCoordinator.handlers], + recovery: { state: () => pluginPlatform!.recover() }, + drain: [() => pluginPlatform!.beginDrain()], + close: [() => pluginPlatform!.close()], + }), createRuntimeHostDomainModule({ id: 'memory', handlers: [requireMemory(memory).handlers], @@ -1738,6 +1751,7 @@ export async function createExecutionRuntimeHostComposition( handlers, moduleIds: Object.freeze(domainModules.map(({ id }) => id)), workspaceExecution: requireWorkspaceExecution(workspaceExecution), + plugins: pluginPlatform, continuity: continuityCoordinator, clientCapabilities, hostChanges, @@ -1750,6 +1764,11 @@ export async function createExecutionRuntimeHostComposition( }; } catch (error) { const errors: unknown[] = [error]; + try { + await pluginPlatform?.close(); + } catch (closeError) { + errors.push(closeError); + } goalExecutions?.beginDrain(); try { await workspaceExecution?.close(); diff --git a/packages/runtime-host/src/server/extension-bundle.ts b/packages/runtime-host/src/server/extension-bundle.ts new file mode 100644 index 0000000000..40a665511e --- /dev/null +++ b/packages/runtime-host/src/server/extension-bundle.ts @@ -0,0 +1,264 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createHash, randomUUID } from 'node:crypto'; +import { constants } from 'node:fs'; +import { copyFile, mkdir, open, readdir, realpath, rm, stat } from 'node:fs/promises'; +import { dirname, isAbsolute, join, posix, resolve } from 'node:path'; + +const MAX_FILES = 256; +const MAX_FILE_BYTES = 8 * 1024 * 1024; +const MAX_BUNDLE_BYTES = 16 * 1024 * 1024; + +interface BundleFile { + readonly path: string; + readonly sha256: string; + readonly content: string; +} + +interface ExtensionBundleDocument { + readonly schemaVersion: 1; + readonly digest: string; + readonly files: readonly BundleFile[]; +} + +export class ExtensionBundleError extends Error { + readonly name = 'ExtensionBundleError'; + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +export async function exportExtensionBundle(sourceRoot: string, targetPath: string): Promise { + if (!isAbsolute(targetPath)) throw invalid('Extension bundle targetPath must be absolute'); + const files = await readDirectory(sourceRoot); + const document: ExtensionBundleDocument = Object.freeze({ + schemaVersion: 1, + digest: packageDigest(files), + files: Object.freeze( + files.map((file) => + Object.freeze({ + path: file.path, + sha256: createHash('sha256').update(file.content).digest('hex'), + content: file.content.toString('base64'), + }), + ), + ), + }); + const encoded = Buffer.from(`${JSON.stringify(document)}\n`, 'utf8'); + if (encoded.byteLength > MAX_BUNDLE_BYTES * 2) + throw invalid('Encoded Extension bundle is too large'); + await mkdir(dirname(targetPath), { recursive: true, mode: 0o700 }); + const temporary = `${targetPath}.${randomUUID()}.tmp`; + let handle: Awaited> | undefined; + try { + handle = await open(temporary, 'wx', 0o600); + await handle.writeFile(encoded); + await handle.sync(); + await handle.close(); + handle = undefined; + await copyFile(temporary, targetPath, constants.COPYFILE_EXCL); + } catch (error) { + throw invalid('Unable to export Extension bundle', error); + } finally { + await handle?.close().catch(() => undefined); + await rm(temporary, { force: true }).catch(() => undefined); + } +} + +export async function materializeExtensionPackage( + sourcePath: string, + controlDirectory: string, +): Promise<{ readonly root: string; readonly dispose: () => Promise }> { + if (!isAbsolute(sourcePath)) throw invalid('Extension package sourcePath must be absolute'); + const canonical = await realpath(resolve(sourcePath)).catch((error) => { + throw invalid('Extension package source is unavailable', error); + }); + const metadata = await stat(canonical); + if (metadata.isDirectory()) return { root: canonical, dispose: async () => undefined }; + if (!metadata.isFile()) + throw invalid('Extension package source must be a directory or bundle file'); + if (metadata.size > MAX_BUNDLE_BYTES * 2) + throw invalid('Extension bundle exceeds its size limit'); + const handle = await open(canonical, constants.O_RDONLY | constants.O_NOFOLLOW); + let document: ExtensionBundleDocument; + try { + document = decodeBundle(JSON.parse((await handle.readFile()).toString('utf8'))); + } catch (error) { + if (error instanceof ExtensionBundleError) throw error; + throw invalid('Extension bundle is invalid', error); + } finally { + await handle.close(); + } + const imports = join(controlDirectory, 'bundle-imports-v1'); + const root = join(imports, randomUUID()); + await mkdir(root, { recursive: true, mode: 0o700 }); + try { + for (const file of document.files) { + const target = join(root, ...file.path.split('/')); + await mkdir(dirname(target), { recursive: true, mode: 0o700 }); + const output = await open(target, 'wx', 0o600); + try { + await output.writeFile(Buffer.from(file.content, 'base64')); + } finally { + await output.close(); + } + } + return { root, dispose: () => rm(root, { recursive: true, force: true }) }; + } catch (error) { + await rm(root, { recursive: true, force: true }).catch(() => undefined); + throw invalid('Unable to materialize Extension bundle', error); + } +} + +async function readDirectory( + rootValue: string, +): Promise { + const root = await realpath(rootValue); + if (!(await stat(root)).isDirectory()) + throw invalid('Extension bundle source is not a directory'); + const paths: string[] = []; + await collect(root, '', paths); + if (paths.length === 0 || paths.length > MAX_FILES) + throw invalid('Extension bundle file count is invalid'); + let total = 0; + const files: { path: string; content: Buffer }[] = []; + for (const path of paths.sort()) { + const handle = await open( + join(root, ...path.split('/')), + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + try { + const metadata = await handle.stat(); + if (!metadata.isFile() || metadata.size > MAX_FILE_BYTES) + throw invalid(`Extension bundle file is invalid: ${path}`); + const content = await handle.readFile(); + total += content.byteLength; + if (total > MAX_BUNDLE_BYTES) throw invalid('Extension bundle payload is too large'); + files.push({ path, content }); + } finally { + await handle.close(); + } + } + return files; +} + +async function collect(root: string, directory: string, paths: string[]): Promise { + const entries = await readdir(directory ? join(root, ...directory.split('/')) : root, { + withFileTypes: true, + }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (entry.name === '.git') continue; + const path = directory ? `${directory}/${entry.name}` : entry.name; + safePath(path); + if (entry.isSymbolicLink()) throw invalid(`Extension bundle may not contain symlinks: ${path}`); + if (entry.isDirectory()) await collect(root, path, paths); + else if (entry.isFile()) paths.push(path); + else throw invalid(`Extension bundle contains an unsupported entry: ${path}`); + if (paths.length > MAX_FILES) throw invalid('Extension bundle contains too many files'); + } +} + +function decodeBundle(value: unknown): ExtensionBundleDocument { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw invalid('Extension bundle must be an object'); + const record = value as Record; + if ( + Object.keys(record).sort().join() !== 'digest,files,schemaVersion' || + record.schemaVersion !== 1 || + !Array.isArray(record.files) || + record.files.length === 0 || + record.files.length > MAX_FILES + ) { + throw invalid('Extension bundle fields are invalid'); + } + let total = 0; + const paths = new Set(); + const files = record.files.map((value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw invalid('Extension bundle file is invalid'); + const file = value as Record; + if ( + Object.keys(file).sort().join() !== 'content,path,sha256' || + typeof file.path !== 'string' || + typeof file.content !== 'string' || + typeof file.sha256 !== 'string' + ) + throw invalid('Extension bundle file fields are invalid'); + const path = safePath(file.path); + if (paths.has(path)) throw invalid(`Extension bundle repeats file: ${path}`); + paths.add(path); + const content = Buffer.from(file.content, 'base64'); + total += content.byteLength; + if ( + content.byteLength > MAX_FILE_BYTES || + total > MAX_BUNDLE_BYTES || + createHash('sha256').update(content).digest('hex') !== file.sha256 + ) { + throw invalid(`Extension bundle file integrity failed: ${path}`); + } + return { path, content }; + }); + if (typeof record.digest !== 'string' || packageDigest(files) !== record.digest) + throw invalid('Extension bundle digest is invalid'); + return Object.freeze({ + schemaVersion: 1, + digest: record.digest, + files: Object.freeze( + files.map((file) => + Object.freeze({ + path: file.path, + sha256: createHash('sha256').update(file.content).digest('hex'), + content: file.content.toString('base64'), + }), + ), + ), + }); +} + +function packageDigest(files: readonly { path: string; content: Buffer }[]): string { + const hash = createHash('sha256'); + for (const file of files) { + const path = Buffer.from(file.path, 'utf8'); + const length = Buffer.allocUnsafe(8); + length.writeBigUInt64BE(BigInt(path.byteLength)); + hash.update(length).update(path); + length.writeBigUInt64BE(BigInt(file.content.byteLength)); + hash.update(length).update(file.content); + } + return `sha256-${hash.digest('hex')}`; +} + +function safePath(value: string): string { + if ( + !value || + value.length > 512 || + value.includes('\\') || + value.startsWith('/') || + posix.normalize(value) !== value || + value.split('/').some((part) => !part || part === '.' || part === '..') + ) { + throw invalid('Extension bundle path is invalid'); + } + return value; +} + +function invalid(message: string, cause?: unknown): ExtensionBundleError { + return new ExtensionBundleError(message, { cause }); +} diff --git a/packages/runtime-host/src/server/extension-package-manifest.ts b/packages/runtime-host/src/server/extension-package-manifest.ts new file mode 100644 index 0000000000..a4d3f7ba62 --- /dev/null +++ b/packages/runtime-host/src/server/extension-package-manifest.ts @@ -0,0 +1,324 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { isCanonicalExtensionId } from '@maka/runtime/plugin-runtime'; + +export const EXTENSION_PACKAGE_MANIFEST_FILE = 'maka.extension.json'; +const MAX_MANIFEST_BYTES = 256 * 1024; +const KEY_PATTERN = /^[A-Za-z][A-Za-z0-9._-]{0,127}$/u; + +export type ExtensionConfigurationScalar = string | number | boolean; + +export interface ExtensionPackageDependency { + readonly id: string; +} + +export interface ExtensionConfigurationProperty { + readonly type: 'string' | 'number' | 'boolean'; + readonly title?: string; + readonly description?: string; + readonly default?: ExtensionConfigurationScalar; + readonly enum?: readonly ExtensionConfigurationScalar[]; + readonly secret: boolean; +} + +export interface ExtensionConfigurationSchema { + readonly properties: Readonly>; + readonly required: readonly string[]; +} + +export interface ExtensionPackageManifest { + readonly schemaVersion: 1; + readonly id: string; + readonly displayName: string; + readonly description: string; + readonly dependencies: readonly ExtensionPackageDependency[]; + readonly configuration: ExtensionConfigurationSchema; + readonly runtime?: ExtensionPackageRuntime; + readonly composition?: ExtensionPackageComposition; +} + +export interface ExtensionPackageRuntime { + readonly entry: string; +} + +export interface ExtensionPackageComposition { + readonly patch: string; +} + +export class ExtensionPackageManifestError extends Error { + readonly name = 'ExtensionPackageManifestError'; + constructor(message: string, options?: ErrorOptions) { + super(message, options); + } +} + +export async function loadExtensionPackageManifest( + root: string, +): Promise { + let encoded: Buffer; + try { + encoded = await readFile(join(root, EXTENSION_PACKAGE_MANIFEST_FILE)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw invalid('Unable to read unified Extension manifest', error); + } + if (encoded.byteLength > MAX_MANIFEST_BYTES) { + throw invalid('Unified Extension manifest exceeds its size limit'); + } + try { + return decodeExtensionPackageManifest(JSON.parse(encoded.toString('utf8'))); + } catch (error) { + if (error instanceof ExtensionPackageManifestError) throw error; + throw invalid('Unified Extension manifest is invalid JSON', error); + } +} + +export function decodeExtensionPackageManifest(value: unknown): ExtensionPackageManifest { + const source = record(value, 'Extension manifest'); + exactOptional( + source, + ['schemaVersion', 'id'], + ['displayName', 'description', 'dependencies', 'configuration', 'runtime', 'composition'], + ); + if (source.schemaVersion !== 1) throw invalid('Extension manifest schemaVersion must be 1'); + const id = extensionId(source.id); + const displayName = + source.displayName === undefined ? id : text(source.displayName, 'displayName', 128); + const description = + source.description === undefined ? '' : boundedDescription(source.description); + const dependencies = decodeDependencies(source.dependencies); + const configuration = decodeConfigurationSchema(source.configuration); + const runtime = decodeRuntime(source.runtime); + const composition = decodeComposition(source.composition); + return Object.freeze({ + schemaVersion: 1, + id, + displayName, + description, + dependencies, + configuration, + ...(runtime === undefined ? {} : { runtime }), + ...(composition === undefined ? {} : { composition }), + }); +} + +function decodeComposition(value: unknown): ExtensionPackageComposition | undefined { + if (value === undefined) return undefined; + const composition = record(value, 'composition'); + exactOptional(composition, ['patch'], []); + return Object.freeze({ patch: packagePath(composition.patch, 'composition.patch') }); +} + +function decodeRuntime(value: unknown): ExtensionPackageRuntime | undefined { + if (value === undefined) return undefined; + const runtime = record(value, 'runtime'); + if (runtime.entry === undefined) return undefined; + return Object.freeze({ entry: packagePath(runtime.entry, 'runtime.entry') }); +} + +function packagePath(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + value.includes('\\') || + value.startsWith('/') || + value.split('/').some((part) => !part || part === '.' || part === '..') + ) { + throw invalid(`Extension manifest ${label} is invalid`); + } + return value; +} + +export function validateExtensionConfiguration( + schema: ExtensionConfigurationSchema, + value: unknown, +): Readonly> { + const input = value === undefined ? {} : record(value, 'Extension configuration'); + const unknown = Object.keys(input).find((key) => !Object.hasOwn(schema.properties, key)); + if (unknown) throw invalid(`Extension configuration key is not declared: ${unknown}`); + const result: Record = {}; + for (const [key, property] of Object.entries(schema.properties)) { + const configured = input[key] ?? property.default; + if (configured === undefined) { + if (schema.required.includes(key)) { + throw invalid(`Extension configuration is missing required key: ${key}`); + } + continue; + } + if (typeof configured !== property.type || !isScalar(configured)) { + throw invalid(`Extension configuration type is invalid for key: ${key}`); + } + if (property.enum && !property.enum.some((candidate) => candidate === configured)) { + throw invalid(`Extension configuration value is not allowed for key: ${key}`); + } + result[key] = configured; + } + const encoded = JSON.stringify(result); + if (Buffer.byteLength(encoded, 'utf8') > 64 * 1024) { + throw invalid('Extension configuration exceeds its size limit'); + } + return Object.freeze(result); +} + +function decodeDependencies(value: unknown): readonly ExtensionPackageDependency[] { + if (value === undefined) return Object.freeze([]); + if (!Array.isArray(value) || value.length > 64) + throw invalid('Extension dependencies are invalid'); + const ids = new Set(); + const dependencies = value.map((item, index) => { + const dependency = record(item, `dependencies[${index}]`); + exactOptional(dependency, ['id'], []); + const id = extensionId(dependency.id); + if (ids.has(id)) throw invalid(`Extension dependency repeats: ${id}`); + ids.add(id); + return Object.freeze({ id }); + }); + return Object.freeze(dependencies.sort((left, right) => left.id.localeCompare(right.id))); +} + +function decodeConfigurationSchema(value: unknown): ExtensionConfigurationSchema { + if (value === undefined) + return Object.freeze({ properties: Object.freeze({}), required: Object.freeze([]) }); + const schema = record(value, 'configuration'); + exactOptional(schema, ['properties'], ['required']); + const propertiesSource = record(schema.properties, 'configuration properties'); + if (Object.keys(propertiesSource).length > 128) + throw invalid('Too many Extension configuration properties'); + const properties: Record = {}; + for (const [key, value] of Object.entries(propertiesSource)) { + if (!KEY_PATTERN.test(key)) throw invalid(`Extension configuration key is invalid: ${key}`); + const property = record(value, `configuration.properties.${key}`); + exactOptional(property, ['type'], ['title', 'description', 'default', 'enum', 'secret']); + if (property.type !== 'string' && property.type !== 'number' && property.type !== 'boolean') { + throw invalid(`Extension configuration property type is invalid: ${key}`); + } + const type = property.type; + const defaultValue = property.default; + if (defaultValue !== undefined && (typeof defaultValue !== type || !isScalar(defaultValue))) { + throw invalid(`Extension configuration default is invalid: ${key}`); + } + let values: readonly ExtensionConfigurationScalar[] | undefined; + if (property.enum !== undefined) { + if ( + !Array.isArray(property.enum) || + property.enum.length === 0 || + property.enum.length > 64 || + property.enum.some((item) => typeof item !== type || !isScalar(item)) + ) + throw invalid(`Extension configuration enum is invalid: ${key}`); + values = Object.freeze([...new Set(property.enum as ExtensionConfigurationScalar[])]); + if ( + defaultValue !== undefined && + !values.includes(defaultValue as ExtensionConfigurationScalar) + ) { + throw invalid(`Extension configuration default is outside enum: ${key}`); + } + } + properties[key] = Object.freeze({ + type, + ...(property.title === undefined + ? {} + : { title: text(property.title, 'configuration title', 128) }), + ...(property.description === undefined + ? {} + : { description: text(property.description, 'configuration description', 1024) }), + ...(defaultValue === undefined + ? {} + : { default: defaultValue as ExtensionConfigurationScalar }), + ...(values ? { enum: values } : {}), + secret: property.secret === true, + }); + } + const required = schema.required === undefined ? [] : schema.required; + if ( + !Array.isArray(required) || + required.length > Object.keys(properties).length || + required.some((key) => typeof key !== 'string' || !Object.hasOwn(properties, key)) || + new Set(required).size !== required.length + ) + throw invalid('Extension configuration required keys are invalid'); + return Object.freeze({ + properties: Object.freeze(properties), + required: Object.freeze(required as string[]), + }); +} + +function exactOptional( + value: Record, + required: readonly string[], + optional: readonly string[], +): void { + const allowed = new Set([...required, ...optional]); + if ( + required.some((key) => !Object.hasOwn(value, key)) || + Object.keys(value).some((key) => !allowed.has(key)) + ) { + throw invalid('Extension manifest fields are invalid'); + } +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw invalid(`${label} must be an object`); + return value as Record; +} + +function extensionId(value: unknown): string { + if (!isCanonicalExtensionId(value)) throw invalid('Extension manifest id is invalid'); + return value; +} + +function text(value: unknown, label: string, maxBytes: number): string { + if ( + typeof value !== 'string' || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > maxBytes || + /[\0\r\n]/u.test(value) + ) { + throw invalid(`Extension manifest ${label} is invalid`); + } + return value; +} + +function boundedDescription(value: unknown): string { + if ( + typeof value !== 'string' || + Buffer.byteLength(value, 'utf8') > 4096 || + value.includes('\0') + ) { + throw invalid('Extension manifest description is invalid'); + } + return value; +} + +function isScalar(value: unknown): value is ExtensionConfigurationScalar { + return ( + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ); +} + +function invalid(message: string, cause?: unknown): ExtensionPackageManifestError { + return new ExtensionPackageManifestError(message, { cause }); +} diff --git a/packages/runtime-host/src/server/index.ts b/packages/runtime-host/src/server/index.ts index 8a058addf3..b5dae0e8d2 100644 --- a/packages/runtime-host/src/server/index.ts +++ b/packages/runtime-host/src/server/index.ts @@ -34,3 +34,45 @@ export { readRuntimeHostAccessCredentialMetadata, type RuntimeHostAccessCredentialMetadata, } from './access-credential-metadata.js'; +export { + ExtensionBundleError, + exportExtensionBundle, + materializeExtensionPackage, +} from './extension-bundle.js'; +export { + EXTENSION_PACKAGE_MANIFEST_FILE, + ExtensionPackageManifestError, + decodeExtensionPackageManifest, + loadExtensionPackageManifest, + validateExtensionConfiguration, + type ExtensionConfigurationProperty, + type ExtensionConfigurationScalar, + type ExtensionConfigurationSchema, + type ExtensionPackageDependency, + type ExtensionPackageComposition, + type ExtensionPackageManifest, + type ExtensionPackageRuntime, +} from './extension-package-manifest.js'; +export { + PluginCompositionPatchError, + loadPluginCompositionPatch, +} from './plugin-composition-patch.js'; +export { + HostPluginCompositionStore, + HostPluginCompositionStoreError, + type PersistedPluginComposition, +} from './plugin-composition-store.js'; +export { PluginPackageLoaderError, TrustedPluginPackageLoader } from './plugin-package-loader.js'; +export { + PluginPackageStore, + PluginPackageStoreError, + type InstalledPluginPackage, + type PreparedPluginPackageInstall, +} from './plugin-package-store.js'; +export { + HostPluginPlatform, + HostPluginPlatformError, + type HostPluginPlatformFailure, + type HostPluginPlatformOptions, +} from './plugin-platform.js'; +export { HostPluginPlatformCoordinator } from './plugin-platform-coordinator.js'; diff --git a/packages/runtime-host/src/server/operation-dispatcher.ts b/packages/runtime-host/src/server/operation-dispatcher.ts index 8208c5ad08..f0367c5179 100644 --- a/packages/runtime-host/src/server/operation-dispatcher.ts +++ b/packages/runtime-host/src/server/operation-dispatcher.ts @@ -144,6 +144,7 @@ export type WebSearchOperationKey = Extract; export type ConfigurationOperationKey = Extract; export type WorkHubCoordinationOperationKey = Extract; +export type PluginPlatformOperationKey = Extract; export type DomainOperationHandlerMap = Pick; export type TurnOperationHandlerMap = Pick; export type ContextOperationHandlerMap = Pick; @@ -214,6 +215,10 @@ export type WorkHubCoordinationOperationHandlerMap = Pick< OperationHandlerMap, WorkHubCoordinationOperationKey >; +export type PluginPlatformOperationHandlerMap = Pick< + OperationHandlerMap, + PluginPlatformOperationKey +>; export type AccessAuthorityOperationHandlerMap = Pick< OperationHandlerMap, keyof typeof ACCESS_AUTHORITY_OPERATION_SPECS diff --git a/packages/runtime-host/src/server/plugin-composition-patch.ts b/packages/runtime-host/src/server/plugin-composition-patch.ts new file mode 100644 index 0000000000..5e5db76602 --- /dev/null +++ b/packages/runtime-host/src/server/plugin-composition-patch.ts @@ -0,0 +1,63 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { MakaCompositionApplyInput } from '@maka/runtime/plugin-runtime'; +import { parse } from 'yaml'; +import { decodePluginCompositionApplyInput } from '../protocol/plugin-platform.js'; +import type { InstalledPluginPackage } from './plugin-package-store.js'; + +const MAX_PATCH_BYTES = 512 * 1024; + +export class PluginCompositionPatchError extends Error { + readonly name = 'PluginCompositionPatchError'; +} + +/** Reads the declarative Composition layer shipped by one installed package. */ +export async function loadPluginCompositionPatch( + installed: InstalledPluginPackage, +): Promise { + const relativePath = installed.manifest.composition?.patch; + if (!relativePath) return undefined; + let encoded: Buffer; + try { + encoded = await readFile(join(installed.root, ...relativePath.split('/'))); + } catch (error) { + throw invalid(`Unable to read Plugin Composition patch: ${relativePath}`, error); + } + if (encoded.byteLength > MAX_PATCH_BYTES) { + throw invalid(`Plugin Composition patch exceeds its size limit: ${relativePath}`); + } + let value: unknown; + try { + value = parse(encoded.toString('utf8')); + } catch (error) { + throw invalid(`Plugin Composition patch is invalid YAML: ${relativePath}`, error); + } + try { + return decodePluginCompositionApplyInput({ operations: value }); + } catch (error) { + throw invalid(`Plugin Composition patch is invalid: ${relativePath}`, error); + } +} + +function invalid(message: string, cause?: unknown): PluginCompositionPatchError { + return new PluginCompositionPatchError(message, { cause }); +} diff --git a/packages/runtime-host/src/server/plugin-composition-store.ts b/packages/runtime-host/src/server/plugin-composition-store.ts new file mode 100644 index 0000000000..d19b91e99a --- /dev/null +++ b/packages/runtime-host/src/server/plugin-composition-store.ts @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { + isCanonicalExtensionId, + type MakaCompositionOperation, +} from '@maka/runtime/plugin-runtime'; +import { decodePluginCompositionApplyInput } from '../protocol/plugin-platform.js'; + +const FILE_NAME = 'plugin-composition-v2.json'; +const MAX_BYTES = 2 * 1024 * 1024; + +/** Durable inputs from which the desired Entry Tree is rebuilt. */ +export interface PersistedPluginComposition { + readonly schemaVersion: 1; + readonly generation: number; + readonly packageLayers: readonly string[]; + readonly overlays: readonly MakaCompositionOperation[]; +} + +export class HostPluginCompositionStoreError extends Error { + readonly name = 'HostPluginCompositionStoreError'; + + constructor( + readonly code: 'persistence_failed' | 'invalid_state' | 'commit_outcome_unknown', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +export class HostPluginCompositionStore { + readonly path: string; + + constructor(controlDirectory: string) { + this.path = join(controlDirectory, FILE_NAME); + } + + async read(): Promise { + let encoded: Buffer; + try { + encoded = await readFile(this.path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw persistence('Unable to read Plugin Composition', error); + } + if (encoded.byteLength > MAX_BYTES) throw invalid('Plugin Composition exceeds its size limit'); + try { + return decode(JSON.parse(encoded.toString('utf8'))); + } catch (error) { + if (error instanceof HostPluginCompositionStoreError) throw error; + throw invalid('Plugin Composition is invalid JSON', error); + } + } + + async replace(composition: PersistedPluginComposition): Promise { + const normalized = decode(composition); + const encoded = Buffer.from(`${JSON.stringify(normalized)}\n`, 'utf8'); + if (encoded.byteLength > MAX_BYTES) throw invalid('Plugin Composition exceeds its size limit'); + const directory = dirname(this.path); + const temporary = join(directory, `.${FILE_NAME}.${randomUUID()}.tmp`); + let handle: Awaited> | undefined; + let published = false; + try { + await mkdir(directory, { recursive: true, mode: 0o700 }); + handle = await open(temporary, 'wx', 0o600); + await handle.writeFile(encoded); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporary, this.path); + published = true; + const directoryHandle = await open(directory, 'r'); + try { + await directoryHandle.sync(); + } finally { + await directoryHandle.close(); + } + } catch (error) { + if (published) { + throw new HostPluginCompositionStoreError( + 'commit_outcome_unknown', + 'Plugin Composition was renamed but its directory sync was not confirmed', + { cause: error }, + ); + } + throw persistence('Unable to persist Plugin Composition', error); + } finally { + await handle?.close().catch(() => undefined); + await rm(temporary, { force: true }).catch(() => undefined); + } + } +} + +function decode(value: unknown): PersistedPluginComposition { + const root = record(value, 'Plugin Composition'); + exact(root, ['schemaVersion', 'generation', 'packageLayers', 'overlays']); + if ( + root.schemaVersion !== 1 || + !Number.isSafeInteger(root.generation) || + (root.generation as number) < 0 + ) { + throw invalid('Plugin Composition header is invalid'); + } + if ( + !Array.isArray(root.packageLayers) || + root.packageLayers.length > 256 || + root.packageLayers.some((item) => !isCanonicalExtensionId(item)) || + new Set(root.packageLayers).size !== root.packageLayers.length + ) { + throw invalid('Plugin Composition package layers are invalid'); + } + if (!Array.isArray(root.overlays) || root.overlays.length > 4096) { + throw invalid('Plugin Composition overlays are invalid'); + } + const overlays = + root.overlays.length === 0 + ? Object.freeze([]) + : Object.freeze( + decodePluginCompositionApplyInput({ operations: root.overlays }, MAX_BYTES).operations, + ); + return Object.freeze({ + schemaVersion: 1, + generation: root.generation as number, + packageLayers: Object.freeze([...(root.packageLayers as string[])]), + overlays, + }); +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw invalid(`${label} must be an object`); + return value as Record; +} + +function exact(value: Record, keys: readonly string[]): void { + if ( + keys.some((key) => !Object.hasOwn(value, key)) || + Object.keys(value).some((key) => !keys.includes(key)) + ) { + throw invalid('Plugin Composition fields are invalid'); + } +} + +function invalid(message: string, cause?: unknown): HostPluginCompositionStoreError { + return new HostPluginCompositionStoreError('invalid_state', message, { cause }); +} + +function persistence(message: string, cause?: unknown): HostPluginCompositionStoreError { + return new HostPluginCompositionStoreError('persistence_failed', message, { cause }); +} diff --git a/packages/runtime-host/src/server/plugin-package-loader.ts b/packages/runtime-host/src/server/plugin-package-loader.ts new file mode 100644 index 0000000000..db26f55d1e --- /dev/null +++ b/packages/runtime-host/src/server/plugin-package-loader.ts @@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import { cp, mkdir, rm } from 'node:fs/promises'; +import { join, relative } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + type MakaPluginPackage, + MakaPluginRuntimeError, + validatePluginPackage, +} from '@maka/runtime/plugin-runtime'; +import { + type InstalledPluginPackage, + PluginPackageStore, + PluginPackageStoreError, +} from './plugin-package-store.js'; + +const GENERATION_DIRECTORY = 'plugin-generations-v1'; +const GENERATION_PATH = Symbol('maka.pluginGenerationPath'); + +export class PluginPackageLoaderError extends Error { + readonly name = 'PluginPackageLoaderError'; + + constructor( + readonly code: 'not_found' | 'invalid_package' | 'load_failed', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +/** Loads trusted packages from immutable generation directories. */ +export class TrustedPluginPackageLoader { + readonly #generations: string; + readonly #owned = new Set(); + + constructor( + controlDirectory: string, + readonly store: PluginPackageStore, + ) { + this.#generations = join(controlDirectory, GENERATION_DIRECTORY); + } + + async load(extensionId: string): Promise { + let installed; + try { + installed = await this.store.load(extensionId); + } catch (error) { + throw translate(error); + } + return await this.loadInstalled(installed); + } + + async loadInstalled(installed: InstalledPluginPackage): Promise { + const generation = join(this.#generations, `${installed.extensionId}-${randomUUID()}`); + try { + await mkdir(this.#generations, { recursive: true, mode: 0o700 }); + await cp(installed.root, generation, { + recursive: true, + force: false, + errorOnExist: true, + preserveTimestamps: false, + }); + const entry = join(generation, relative(installed.root, installed.entry)); + const imported = (await import(pathToFileURL(entry).href)) as Record; + const candidate = imported.default ?? imported.plugin; + if (!candidate || typeof candidate !== 'object') { + throw invalid('Plugin Runtime entry must export a MakaPluginPackage as default'); + } + const pkg = candidate as MakaPluginPackage; + validatePluginPackage(pkg); + if (pkg.packageId !== installed.extensionId) { + throw invalid( + `Plugin Runtime packageId ${pkg.packageId} does not match manifest ${installed.extensionId}`, + ); + } + if (!pkg.host) throw invalid('Trusted Host package must export a host Plugin'); + const owned = freezeGeneration(pkg, generation); + this.#owned.add(generation); + return owned; + } catch (error) { + await rm(generation, { recursive: true, force: true }).catch(() => undefined); + throw translate(error); + } + } + + async collectGarbage(): Promise { + this.#owned.clear(); + await rm(this.#generations, { recursive: true, force: true }); + } + + async release(pkg: MakaPluginPackage): Promise { + const generation = (pkg as MakaPluginPackage & { readonly [GENERATION_PATH]?: string })[ + GENERATION_PATH + ]; + if (!generation || !this.#owned.delete(generation)) return; + await rm(generation, { recursive: true, force: true }); + } + + async close(): Promise { + this.#owned.clear(); + await rm(this.#generations, { recursive: true, force: true }); + } +} + +function freezeGeneration(pkg: MakaPluginPackage, generation: string): MakaPluginPackage { + return Object.freeze({ + ...pkg, + [GENERATION_PATH]: generation, + ...(pkg.contributions + ? { + contributions: Object.freeze(pkg.contributions.map((item) => Object.freeze({ ...item }))), + } + : {}), + }); +} + +function invalid(message: string, cause?: unknown): PluginPackageLoaderError { + return new PluginPackageLoaderError('invalid_package', message, { cause }); +} + +function translate(error: unknown): PluginPackageLoaderError { + if (error instanceof PluginPackageLoaderError) return error; + if (error instanceof PluginPackageStoreError) { + return new PluginPackageLoaderError( + error.code === 'not_found' + ? 'not_found' + : error.code === 'invalid_package' + ? 'invalid_package' + : 'load_failed', + error.message, + { cause: error }, + ); + } + if (error instanceof MakaPluginRuntimeError) return invalid(error.message, error); + return new PluginPackageLoaderError('load_failed', 'Unable to load Plugin package', { + cause: error, + }); +} diff --git a/packages/runtime-host/src/server/plugin-package-store.ts b/packages/runtime-host/src/server/plugin-package-store.ts new file mode 100644 index 0000000000..89b188ce07 --- /dev/null +++ b/packages/runtime-host/src/server/plugin-package-store.ts @@ -0,0 +1,570 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { randomUUID } from 'node:crypto'; +import type { Dirent } from 'node:fs'; +import { mkdir, open, readFile, readdir, realpath, rename, rm, stat } from 'node:fs/promises'; +import { dirname, join, posix } from 'node:path'; +import { isCanonicalExtensionId } from '@maka/runtime/plugin-runtime'; +import { exportExtensionBundle, materializeExtensionPackage } from './extension-bundle.js'; +import { + EXTENSION_PACKAGE_MANIFEST_FILE, + type ExtensionPackageManifest, + loadExtensionPackageManifest, +} from './extension-package-manifest.js'; + +const STORE_DIRECTORY = 'plugin-packages-v2'; +const MAX_FILES = 256; +const MAX_FILE_BYTES = 8 * 1024 * 1024; +const MAX_PACKAGE_BYTES = 16 * 1024 * 1024; + +interface PackageFile { + readonly path: string; + readonly content: Buffer; +} + +export interface InstalledPluginPackage { + readonly extensionId: string; + readonly root: string; + readonly entry: string; + readonly manifest: ExtensionPackageManifest; +} + +export interface PreparedPluginPackageInstall { + readonly installed: InstalledPluginPackage; + publish(baseGeneration: number, nextGeneration: number): Promise; + commit(): Promise; + rollback(): Promise; +} + +interface PackageInstallTransaction { + readonly schemaVersion: 1; + readonly extensionId: string; + readonly baseGeneration: number; + readonly nextGeneration: number; +} + +export class PluginPackageStoreError extends Error { + readonly name = 'PluginPackageStoreError'; + + constructor( + readonly code: + | 'not_found' + | 'invalid_package' + | 'persistence_failed' + | 'commit_outcome_unknown', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +/** Atomic, root-private storage for trusted in-process Plugin packages. */ +export class PluginPackageStore { + readonly root: string; + readonly #controlDirectory: string; + + constructor(controlDirectory: string) { + this.#controlDirectory = controlDirectory; + this.root = join(controlDirectory, STORE_DIRECTORY); + } + + async install(sourcePath: string): Promise { + const prepared = await this.prepareInstall(sourcePath); + await prepared.publish(0, 1); + await prepared.commit(); + return await this.load(prepared.installed.extensionId); + } + + /** Repairs or removes package-store transaction remnants after owner death. */ + async recover(authorityGeneration = 0): Promise { + let entries: Dirent[]; + try { + entries = await readdir(this.root, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return; + throw persistence('Unable to recover Plugin package storage', error); + } + let changed = false; + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (!entry.isDirectory() || !entry.name.startsWith('.')) continue; + const path = join(this.root, entry.name); + if (entry.name.startsWith('.install-')) { + await this.#recoverInstall(path, authorityGeneration); + changed = true; + continue; + } + if (entry.name.startsWith('.previous-')) { + try { + const files = await readPackage(path); + const decoded = await decodePackage(path, files); + const target = join(this.root, decoded.manifest.id); + try { + await stat(target); + await rm(path, { recursive: true, force: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + await rename(path, target); + } + changed = true; + continue; + } catch (error) { + throw persistence(`Unable to recover Plugin package transaction ${entry.name}`, error); + } + } + if ( + entry.name.startsWith('.staging-') || + entry.name.startsWith('.rejected-') || + entry.name.startsWith('.removed-') + ) { + await rm(path, { recursive: true, force: true }); + changed = true; + } + } + if (changed) await syncDirectory(this.root); + } + + async identities(): Promise { + let entries: Dirent[]; + try { + entries = await readdir(this.root, { withFileTypes: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return Object.freeze([]); + throw persistence('Unable to list Plugin package identities', error); + } + return Object.freeze( + entries + .filter((entry) => entry.isDirectory() && isCanonicalExtensionId(entry.name)) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)), + ); + } + + async prepareInstall(sourcePath: string): Promise { + const source = await materializeExtensionPackage(sourcePath, this.#controlDirectory); + try { + const files = await readPackage(source.root); + const decoded = await decodePackage(source.root, files); + const target = join(this.root, decoded.manifest.id); + const transaction = join(this.root, `.install-${randomUUID()}`); + const staging = join(transaction, 'candidate'); + const previous = join(transaction, 'previous'); + let movedPrevious = false; + let published = false; + let settled = false; + try { + await mkdir(this.root, { recursive: true, mode: 0o700 }); + await mkdir(transaction, { mode: 0o700 }); + await mkdir(staging, { mode: 0o700 }); + for (const file of files) await writeFile(staging, file); + await syncTree(staging, files); + await syncDirectory(transaction); + await syncDirectory(this.root); + const installed = freezeInstalled(staging, decoded); + return Object.freeze({ + installed, + publish: async (baseGeneration: number, nextGeneration: number) => { + if (settled || published) + throw persistence('Plugin package install is already settled'); + if ( + !Number.isSafeInteger(baseGeneration) || + !Number.isSafeInteger(nextGeneration) || + baseGeneration < 0 || + nextGeneration !== baseGeneration + 1 + ) { + throw invalid('Plugin package install generations are invalid'); + } + await writeTransaction(transaction, { + schemaVersion: 1, + extensionId: decoded.manifest.id, + baseGeneration, + nextGeneration, + }); + try { + await rename(target, previous) + .then(() => { + movedPrevious = true; + }) + .catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + await rename(staging, target); + published = true; + await syncDirectory(this.root); + } catch (error) { + throw new PluginPackageStoreError( + 'commit_outcome_unknown', + `Plugin package publication outcome is unknown: ${decoded.manifest.id}`, + { cause: error }, + ); + } + }, + commit: async () => { + if (settled) return; + if (!published) throw persistence('Plugin package install was not published'); + settled = true; + await rm(transaction, { recursive: true, force: true }).catch(() => undefined); + }, + rollback: async () => { + if (settled) return; + settled = true; + if (!published) { + await rm(transaction, { recursive: true, force: true }); + return; + } + await rollbackPublishedInstall(this.root, target, transaction, movedPrevious); + }, + }); + } catch (error) { + if (published) { + try { + await rollbackPublishedInstall(this.root, target, transaction, movedPrevious); + } catch (rollbackError) { + if (rollbackError instanceof PluginPackageStoreError) throw rollbackError; + throw new PluginPackageStoreError( + 'commit_outcome_unknown', + `Plugin package installation outcome is unknown: ${decoded.manifest.id}`, + { cause: new AggregateError([error, rollbackError]) }, + ); + } + } else { + await rm(transaction, { recursive: true, force: true }).catch(() => undefined); + } + if (error instanceof PluginPackageStoreError) throw error; + throw persistence(`Unable to install Plugin package ${decoded.manifest.id}`, error); + } + } finally { + await source.dispose(); + } + } + + async #recoverInstall(transactionRoot: string, authorityGeneration: number): Promise { + const transaction = await readTransaction(transactionRoot); + const target = join(this.root, transaction.extensionId); + const candidate = join(transactionRoot, 'candidate'); + const previous = join(transactionRoot, 'previous'); + const candidateExists = await exists(candidate); + const targetExists = await exists(target); + const previousExists = await exists(previous); + if (authorityGeneration === transaction.baseGeneration) { + if (!candidateExists && targetExists) { + await rm(target, { recursive: true, force: true }); + } + if (previousExists) await rename(previous, target); + await syncDirectory(this.root); + await rm(transactionRoot, { recursive: true, force: true }); + return; + } + if (authorityGeneration >= transaction.nextGeneration) { + if (candidateExists || !targetExists) { + throw persistence( + `Plugin package transaction does not match committed authority: ${transaction.extensionId}`, + ); + } + await rm(transactionRoot, { recursive: true, force: true }); + return; + } + throw persistence( + `Plugin package transaction generation is ambiguous: ${transaction.extensionId}`, + ); + } + + async list(): Promise { + const installed: InstalledPluginPackage[] = []; + for (const extensionId of await this.identities()) installed.push(await this.load(extensionId)); + return Object.freeze(installed); + } + + async load(extensionId: string): Promise { + requireIdentity(extensionId); + const root = join(this.root, extensionId); + try { + if (!(await stat(root)).isDirectory()) throw invalid('Installed package is not a directory'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new PluginPackageStoreError( + 'not_found', + `Plugin package is not installed: ${extensionId}`, + ); + } + if (error instanceof PluginPackageStoreError) throw error; + throw persistence(`Unable to read Plugin package ${extensionId}`, error); + } + const files = await readPackage(root); + const decoded = await decodePackage(root, files); + if (decoded.manifest.id !== extensionId) { + throw invalid(`Installed Plugin identity does not match its directory: ${extensionId}`); + } + return freezeInstalled(root, decoded); + } + + async export(extensionId: string, targetPath: string): Promise { + const installed = await this.load(extensionId); + await exportExtensionBundle(installed.root, targetPath); + } + + async uninstall(extensionId: string): Promise { + await this.load(extensionId); + const target = join(this.root, extensionId); + const removed = join(this.root, `.removed-${extensionId}-${randomUUID()}`); + let published = false; + try { + await rename(target, removed); + published = true; + await syncDirectory(this.root); + await rm(removed, { recursive: true, force: true }).catch(() => undefined); + } catch (error) { + if (published) { + throw new PluginPackageStoreError( + 'commit_outcome_unknown', + `Plugin package uninstall outcome is unknown: ${extensionId}`, + { cause: error }, + ); + } + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw persistence(`Unable to uninstall Plugin package ${extensionId}`, error); + } + } + } +} + +async function writeTransaction( + root: string, + transaction: PackageInstallTransaction, +): Promise { + const handle = await open(join(root, 'transaction.json'), 'wx', 0o600); + try { + await handle.writeFile(`${JSON.stringify(transaction)}\n`, 'utf8'); + await handle.sync(); + } finally { + await handle.close(); + } + await syncDirectory(root); + await syncDirectory(dirname(root)); +} + +async function readTransaction(root: string): Promise { + let value: unknown; + try { + value = JSON.parse(await readFile(join(root, 'transaction.json'), 'utf8')); + } catch (error) { + throw persistence('Unable to read Plugin package install transaction', error); + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw persistence('Plugin package install transaction is invalid'); + } + const record = value as Record; + if ( + Object.keys(record).length !== 4 || + record.schemaVersion !== 1 || + !isCanonicalExtensionId(record.extensionId) || + !Number.isSafeInteger(record.baseGeneration) || + !Number.isSafeInteger(record.nextGeneration) || + (record.baseGeneration as number) < 0 || + record.nextGeneration !== (record.baseGeneration as number) + 1 + ) { + throw persistence('Plugin package install transaction is invalid'); + } + return Object.freeze({ + schemaVersion: 1, + extensionId: record.extensionId as string, + baseGeneration: record.baseGeneration as number, + nextGeneration: record.nextGeneration as number, + }); +} + +async function rollbackPublishedInstall( + storeRoot: string, + target: string, + transactionRoot: string, + movedPrevious: boolean, +): Promise { + const rejected = join(transactionRoot, 'rejected'); + try { + await rename(target, rejected).catch((error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }); + if (movedPrevious) await rename(join(transactionRoot, 'previous'), target); + await rename(rejected, join(transactionRoot, 'candidate')).catch( + (error: NodeJS.ErrnoException) => { + if (error.code !== 'ENOENT') throw error; + }, + ); + await syncDirectory(storeRoot); + await rm(transactionRoot, { recursive: true, force: true }); + await syncDirectory(storeRoot); + } catch (error) { + throw new PluginPackageStoreError( + 'commit_outcome_unknown', + 'Plugin package rollback outcome is unknown', + { cause: error }, + ); + } +} + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw error; + } +} + +async function decodePackage( + root: string, + files: readonly PackageFile[], +): Promise<{ readonly manifest: ExtensionPackageManifest; readonly entry: string }> { + if (!files.some((file) => file.path === EXTENSION_PACKAGE_MANIFEST_FILE)) { + throw invalid(`Plugin package is missing ${EXTENSION_PACKAGE_MANIFEST_FILE}`); + } + const manifest = await loadExtensionPackageManifest(root); + if (!manifest) throw invalid(`Plugin package is missing ${EXTENSION_PACKAGE_MANIFEST_FILE}`); + if (!manifest.runtime?.entry) throw invalid('Plugin package has no trusted Runtime entry'); + if (!files.some((file) => file.path === manifest.runtime!.entry)) { + throw invalid(`Plugin Runtime entry does not exist: ${manifest.runtime.entry}`); + } + if (manifest.composition && !files.some((file) => file.path === manifest.composition!.patch)) { + throw invalid(`Plugin Composition patch does not exist: ${manifest.composition.patch}`); + } + return Object.freeze({ manifest, entry: manifest.runtime.entry }); +} + +async function readPackage(rootValue: string): Promise { + let root: string; + try { + root = await realpath(rootValue); + } catch (error) { + throw invalid('Plugin package source is unavailable', error); + } + const paths: string[] = []; + await collect(root, '', paths); + if (paths.length === 0 || paths.length > MAX_FILES) { + throw invalid('Plugin package file count is invalid'); + } + let total = 0; + const files: PackageFile[] = []; + for (const path of paths.sort()) { + const handle = await open(join(root, ...path.split('/')), 'r'); + try { + const metadata = await handle.stat(); + if (!metadata.isFile() || metadata.size > MAX_FILE_BYTES) { + throw invalid(`Plugin package file is invalid: ${path}`); + } + const content = await handle.readFile(); + total += content.byteLength; + if (total > MAX_PACKAGE_BYTES) throw invalid('Plugin package is too large'); + files.push(Object.freeze({ path, content })); + } finally { + await handle.close(); + } + } + return Object.freeze(files); +} + +async function collect(root: string, directory: string, paths: string[]): Promise { + const entries = await readdir(directory ? join(root, ...directory.split('/')) : root, { + withFileTypes: true, + }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + if (entry.name === '.git') continue; + const path = safePath(directory ? `${directory}/${entry.name}` : entry.name); + if (entry.isSymbolicLink()) throw invalid(`Plugin package may not contain symlinks: ${path}`); + if (entry.isDirectory()) await collect(root, path, paths); + else if (entry.isFile()) paths.push(path); + else throw invalid(`Plugin package contains an unsupported entry: ${path}`); + if (paths.length > MAX_FILES) throw invalid('Plugin package contains too many files'); + } +} + +async function writeFile(root: string, file: PackageFile): Promise { + const target = join(root, ...file.path.split('/')); + await mkdir(dirname(target), { recursive: true, mode: 0o700 }); + const handle = await open(target, 'wx', 0o600); + try { + await handle.writeFile(file.content); + await handle.sync(); + } finally { + await handle.close(); + } +} + +async function syncTree(root: string, files: readonly PackageFile[]): Promise { + const directories = new Set([root]); + for (const file of files) { + let current = dirname(join(root, ...file.path.split('/'))); + while (current.startsWith(root)) { + directories.add(current); + if (current === root) break; + current = dirname(current); + } + } + for (const directory of [...directories].sort((a, b) => b.length - a.length)) { + await syncDirectory(directory); + } +} + +async function syncDirectory(directory: string): Promise { + const handle = await open(directory, 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } +} + +function freezeInstalled( + root: string, + decoded: { readonly manifest: ExtensionPackageManifest; readonly entry: string }, +): InstalledPluginPackage { + return Object.freeze({ + extensionId: decoded.manifest.id, + root, + entry: join(root, ...decoded.entry.split('/')), + manifest: decoded.manifest, + }); +} + +function safePath(value: string): string { + if ( + !value || + value.length > 512 || + value.includes('\\') || + value.startsWith('/') || + posix.normalize(value) !== value || + value.split('/').some((part) => !part || part === '.' || part === '..') + ) { + throw invalid('Plugin package path is invalid'); + } + return value; +} + +function requireIdentity(extensionId: string): void { + if (!isCanonicalExtensionId(extensionId)) throw invalid('Plugin package identity is invalid'); +} + +function invalid(message: string, cause?: unknown): PluginPackageStoreError { + return new PluginPackageStoreError('invalid_package', message, { cause }); +} + +function persistence(message: string, cause?: unknown): PluginPackageStoreError { + return new PluginPackageStoreError('persistence_failed', message, { cause }); +} diff --git a/packages/runtime-host/src/server/plugin-platform-coordinator.ts b/packages/runtime-host/src/server/plugin-platform-coordinator.ts new file mode 100644 index 0000000000..47514f5861 --- /dev/null +++ b/packages/runtime-host/src/server/plugin-platform-coordinator.ts @@ -0,0 +1,287 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + MakaPluginRuntimeError, + type MakaCompositionApplyInput, + type MakaCompositionEntryInspection, +} from '@maka/runtime/plugin-runtime'; +import type { + OperationOutcome, + PluginPackageExportInput, + PluginPackageInstallInput, + PluginPackageProjection, + PluginPackageUninstallInput, + PluginPlatformFailureProjection, + PluginPlatformQueryInput, + PluginPlatformQueryResult, +} from '../protocol/index.js'; +import { ExtensionBundleError } from './extension-bundle.js'; +import { ExtensionPackageManifestError } from './extension-package-manifest.js'; +import type { PluginPlatformOperationHandlerMap } from './operation-dispatcher.js'; +import { PluginCompositionPatchError } from './plugin-composition-patch.js'; +import { PluginPackageLoaderError } from './plugin-package-loader.js'; +import { PluginPackageStoreError } from './plugin-package-store.js'; +import { HostPluginPlatform, HostPluginPlatformError } from './plugin-platform.js'; + +export class HostPluginPlatformCoordinator { + readonly handlers: PluginPlatformOperationHandlerMap = { + 'plugin.platform.query': (input) => this.#query(input), + 'plugin.package.install': (input) => this.#install(input), + 'plugin.package.uninstall': (input) => this.#uninstall(input), + 'plugin.package.reload': (input) => this.#reload(input), + 'plugin.package.export': (input) => this.#export(input), + 'plugin.composition.apply': (input) => this.#apply(input), + }; + + constructor(readonly platform: HostPluginPlatform) {} + + async #query( + input: PluginPlatformQueryInput, + ): Promise> { + try { + return await this.platform.read(async () => { + const identities = await this.platform.packages.identities(); + const failures = this.platform.failures(); + if (input.view === 'status') { + const entryCount = countInspections(this.platform.inspect()); + return { + ok: true, + result: { + view: 'status', + generation: this.platform.desiredComposition().generation, + packageCount: identities.length, + entryCount, + failureCount: failures.length, + }, + }; + } + if (input.view === 'entries') { + const inspections = flattenInspections(this.platform.inspect(input.rootId)); + return { ok: true, result: boundedPage('entries', inspections, input) }; + } + if (input.view === 'failures') { + return { ok: true, result: boundedPage('failures', failures, input) }; + } + const packages = []; + for (const extensionId of identities) { + const { manifest } = await this.platform.packages.load(extensionId); + packages.push({ + extensionId, + displayName: manifest.displayName, + ...(manifest.description ? { description: manifest.description } : {}), + dependencies: manifest.dependencies.map(({ id }) => id), + }); + } + return { + ok: true, + result: boundedPage('packages', packages, input), + }; + }); + } catch (error) { + return failure(error); + } + } + + async #install( + input: PluginPackageInstallInput, + ): Promise> { + try { + return { ok: true, result: await this.platform.installPackage(input.sourcePath) }; + } catch (error) { + return failure(error); + } + } + + async #uninstall( + input: PluginPackageUninstallInput, + ): Promise> { + try { + await this.platform.uninstallPackage(input.extensionId); + return { ok: true, result: {} }; + } catch (error) { + return failure(error); + } + } + + async #reload( + input: PluginPackageUninstallInput, + ): Promise> { + try { + await this.platform.reloadPackage(input.extensionId); + return { ok: true, result: {} }; + } catch (error) { + return failure(error); + } + } + + async #export( + input: PluginPackageExportInput, + ): Promise> { + try { + await this.platform.read(() => + this.platform.packages.export(input.extensionId, input.targetPath), + ); + return { ok: true, result: { targetPath: input.targetPath } }; + } catch (error) { + return failure(error); + } + } + + async #apply( + input: MakaCompositionApplyInput, + ): Promise> { + try { + await this.platform.apply(input); + return { + ok: true, + result: { generation: this.platform.desiredComposition().generation }, + }; + } catch (error) { + return failure(error); + } + } +} + +function countInspections(inspections: readonly MakaCompositionEntryInspection[]): number { + return inspections.reduce( + (total, inspection) => total + 1 + countInspections(inspection.children), + 0, + ); +} + +function flattenInspections( + inspections: readonly MakaCompositionEntryInspection[], +): readonly MakaCompositionEntryInspection[] { + const flattened: MakaCompositionEntryInspection[] = []; + const visit = (items: readonly MakaCompositionEntryInspection[]): void => { + for (const item of items) { + flattened.push(Object.freeze({ ...item, children: Object.freeze([]) })); + visit(item.children); + } + }; + visit(inspections); + return Object.freeze(flattened); +} + +function boundedPage( + view: 'packages', + values: readonly PluginPackageProjection[], + input: PluginPlatformQueryInput, +): Extract; +function boundedPage( + view: 'entries', + values: readonly MakaCompositionEntryInspection[], + input: PluginPlatformQueryInput, +): Extract; +function boundedPage( + view: 'failures', + values: readonly PluginPlatformFailureProjection[], + input: PluginPlatformQueryInput, +): Extract; +function boundedPage( + view: 'packages' | 'entries' | 'failures', + values: readonly T[], + input: PluginPlatformQueryInput, +): PluginPlatformQueryResult { + const cursor = input.cursor ?? 0; + const limit = input.limit ?? 32; + if (cursor > values.length) + throw new MakaPluginRuntimeError('invalid_entry', 'Invalid query cursor'); + const items: T[] = []; + for (let index = cursor; index < values.length && items.length < limit; index += 1) { + const candidate = [...items, values[index] as T]; + if ( + Buffer.byteLength(JSON.stringify({ view, items: candidate, nextCursor: index + 1 }), 'utf8') > + 480 * 1024 + ) { + break; + } + items.push(values[index] as T); + } + if (cursor < values.length && items.length === 0) { + throw new MakaPluginRuntimeError('invalid_entry', 'Plugin Platform page item is too large'); + } + const next = cursor + items.length; + return Object.freeze({ + view, + items: Object.freeze(items), + nextCursor: next < values.length ? next : null, + }) as PluginPlatformQueryResult; +} + +function failure( + error: unknown, +): OperationOutcome { + if (error instanceof HostPluginPlatformError) { + if (error.code === 'closed') return failed('host_draining', error.message); + if (error.code === 'persistence_failed') return failed('persistence_failed', error.message); + if (error.code === 'recovery_failed') return failed('persistence_failed', error.message); + if (error.code === 'commit_outcome_unknown') { + return failed('commit_outcome_unknown', error.message); + } + if (error.code === 'mutation_failed' && error.cause) return failure(error.cause); + return failed('internal_failure', error.message); + } + if (error instanceof PluginPackageStoreError) { + if (error.code === 'not_found') return failed('not_found', error.message); + if (error.code === 'invalid_package') return failed('invalid_request', error.message); + if (error.code === 'commit_outcome_unknown') { + return failed('commit_outcome_unknown', error.message); + } + return failed('persistence_failed', error.message); + } + if (error instanceof PluginPackageLoaderError) { + if (error.code === 'not_found') return failed('not_found', error.message); + if (error.code === 'invalid_package') return failed('invalid_request', error.message); + return failed('persistence_failed', error.message); + } + if ( + error instanceof ExtensionBundleError || + error instanceof ExtensionPackageManifestError || + error instanceof PluginCompositionPatchError + ) { + return failed('invalid_request', error.message); + } + if (error instanceof MakaPluginRuntimeError) { + switch (error.code) { + case 'package_not_found': + case 'entry_not_found': + return failed('not_found', error.message); + case 'package_exists': + case 'package_in_use': + case 'entry_exists': + return failed('operation_conflict', error.message); + case 'invalid_package': + case 'invalid_entry': + case 'dependency_cycle': + return failed('invalid_request', error.message); + default: + return failed('internal_failure', error.message); + } + } + return failed('internal_failure', 'Plugin Platform operation failed'); +} + +function failed( + code: string, + message: string, +): OperationOutcome { + return { ok: false, error: { code, message } } as OperationOutcome; +} diff --git a/packages/runtime-host/src/server/plugin-platform.ts b/packages/runtime-host/src/server/plugin-platform.ts new file mode 100644 index 0000000000..59446525ed --- /dev/null +++ b/packages/runtime-host/src/server/plugin-platform.ts @@ -0,0 +1,1074 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + MakaCompositionLoader, + type MakaCompositionRecoveryFailure, +} from '@maka/runtime/plugin-composition-loader'; +import { + applyCompositionState, + MakaPluginRuntimeError, + type MakaCompositionApplyInput, + type MakaCompositionEntry, + type MakaCompositionEntryInspection, + type MakaCompositionOperation, + type MakaCompositionState, + type MakaPluginPackage, + type MakaPluginRootId, +} from '@maka/runtime/plugin-runtime'; +import type { ExtensionPackageManifest } from './extension-package-manifest.js'; +import { validateExtensionConfiguration } from './extension-package-manifest.js'; +import { loadPluginCompositionPatch } from './plugin-composition-patch.js'; +import { + HostPluginCompositionStore, + HostPluginCompositionStoreError, + type PersistedPluginComposition, +} from './plugin-composition-store.js'; +import { TrustedPluginPackageLoader } from './plugin-package-loader.js'; +import { PluginPackageStore, PluginPackageStoreError } from './plugin-package-store.js'; + +export class HostPluginPlatformError extends Error { + readonly name = 'HostPluginPlatformError'; + + constructor( + readonly code: + | 'closed' + | 'persistence_failed' + | 'commit_outcome_unknown' + | 'recovery_failed' + | 'mutation_failed', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + } +} + +export interface HostPluginPlatformOptions { + readonly composition?: MakaCompositionLoader; + readonly packages?: PluginPackageStore; + readonly packageLoader?: TrustedPluginPackageLoader; + readonly store?: HostPluginCompositionStore; +} + +export interface HostPluginPlatformFailure { + readonly entryId?: string; + readonly extensionId?: string; + readonly diagnostic: string; +} + +interface CompositionEntryRecord { + readonly entry: MakaCompositionEntry; + readonly rootId: MakaPluginRootId; + readonly disabled: boolean; +} + +/** + * Runtime Host's sole authority for trusted Plugin packages and Entry composition. + * Package layers and user overlays are durable; the desired Entry Tree is derived from them. + */ +export class HostPluginPlatform { + readonly composition: MakaCompositionLoader; + readonly packages: PluginPackageStore; + readonly packageLoader: TrustedPluginPackageLoader; + readonly store: HostPluginCompositionStore; + + #authority: PersistedPluginComposition = emptyCompositionAuthority(); + #desired: MakaCompositionState = emptyCompositionState(); + #mutation: Promise = Promise.resolve(); + #closed = false; + #draining = false; + #poisoned?: Error; + #diverged = false; + #failures: readonly HostPluginPlatformFailure[] = Object.freeze([]); + + constructor( + readonly controlDirectory: string, + options: HostPluginPlatformOptions = {}, + ) { + this.composition = options.composition ?? new MakaCompositionLoader(); + this.packages = options.packages ?? new PluginPackageStore(controlDirectory); + this.packageLoader = + options.packageLoader ?? new TrustedPluginPackageLoader(controlDirectory, this.packages); + this.store = options.store ?? new HostPluginCompositionStore(controlDirectory); + } + + async recover(): Promise { + if (this.#closed) throw new HostPluginPlatformError('closed', 'Plugin Platform is closed'); + await this.#serialize(async () => { + try { + const storedAuthority = (await this.store.read()) ?? emptyCompositionAuthority(); + await this.packages.recover(storedAuthority.generation); + await this.packageLoader.collectGarbage(); + const packageFailures: HostPluginPlatformFailure[] = []; + for (const extensionId of await this.packages.identities()) { + try { + await this.composition.install(await this.packageLoader.load(extensionId)); + } catch (error) { + packageFailures.push( + Object.freeze({ + extensionId, + diagnostic: boundedDiagnostic(error), + }), + ); + } + } + const desired = await this.#normalizeCompositionConfigurations( + await this.#composePersistedAuthority(storedAuthority), + ); + const entryFailures = await this.#recoverDesiredRuntime(desired); + this.#failures = Object.freeze([ + ...packageFailures, + ...entryFailures.map((failure) => + Object.freeze({ entryId: failure.entryId, diagnostic: failure.diagnostic }), + ), + ]); + this.#diverged = entryFailures.length > 0; + this.#authority = storedAuthority; + this.#desired = desired; + } catch (error) { + this.#poisoned = asError(error); + // Plugin recovery is fail-open for the Host. Mutations and Plugin + // queries remain fenced until the persisted authority is repaired. + } + }); + } + + async installPackage(sourcePath: string): Promise<{ readonly extensionId: string }> { + this.#assertMutable(); + return await this.#serializeMutable(async () => { + let prepared; + try { + prepared = await this.packages.prepareInstall(sourcePath); + } catch (error) { + if (error instanceof PluginPackageStoreError && error.code === 'commit_outcome_unknown') { + throw this.#fenceUnknownPackageOutcome(error, 'preparation'); + } + throw error; + } + let loaded: MakaPluginPackage | undefined; + let previous: MakaPluginPackage | undefined; + let authorityCommitted = false; + let runtimeAdopted = false; + try { + const compositionPatch = await loadPluginCompositionPatch(prepared.installed); + loaded = await this.packageLoader.loadInstalled(prepared.installed); + const alreadyInstalled = this.composition + .installedPackages() + .some(({ packageId }) => packageId === prepared.installed.extensionId); + if (alreadyInstalled) previous = this.composition.package(prepared.installed.extensionId); + const layerPlan = await this.#planPackageLayer( + prepared.installed.extensionId, + compositionPatch, + prepared.installed.manifest, + ); + await prepared.publish(this.#authority.generation, layerPlan.planned.generation); + await this.#commitDesiredAuthority( + layerPlan.planned, + layerPlan.packageLayers, + this.#authority.overlays, + ); + authorityCommitted = true; + await prepared.commit(); + this.#clearPackageFailure(prepared.installed.extensionId); + if (alreadyInstalled) await this.composition.reload(loaded); + else await this.composition.install(loaded); + runtimeAdopted = true; + const failures = await this.composition.recoverComposition(layerPlan.planned); + await this.#publishEntryFailures(failures); + if (failures.length > 0) { + throw new Error(failures.map(({ diagnostic }) => diagnostic).join('; ')); + } + if (previous) await this.#releaseGeneration(previous); + return Object.freeze({ extensionId: prepared.installed.extensionId }); + } catch (error) { + if (authorityCommitted) { + this.#diverged = true; + if (loaded && !runtimeAdopted) { + await this.packageLoader.release(loaded).catch(() => undefined); + } + if (previous && runtimeAdopted) await this.#releaseGeneration(previous); + throw new HostPluginPlatformError( + 'mutation_failed', + 'Plugin package authority was committed but Runtime convergence failed', + { cause: error }, + ); + } + if (error instanceof HostPluginPlatformError && error.code === 'commit_outcome_unknown') { + if (loaded) await this.packageLoader.release(loaded).catch(() => undefined); + throw error; + } + try { + await prepared.rollback(); + } catch (rollbackError) { + if (loaded) await this.packageLoader.release(loaded).catch(() => undefined); + if ( + rollbackError instanceof PluginPackageStoreError && + rollbackError.code === 'commit_outcome_unknown' + ) { + throw this.#fenceUnknownPackageOutcome(rollbackError, 'rollback'); + } + this.#poisoned = asError(rollbackError); + this.#draining = true; + throw new HostPluginPlatformError( + 'persistence_failed', + 'Plugin package installation and stored-package rollback both failed', + { cause: new AggregateError([error, rollbackError]) }, + ); + } + if (loaded) await this.packageLoader.release(loaded).catch(() => undefined); + throw error; + } + }); + } + + async reloadPackage(extensionId: string): Promise { + this.#assertMutable(); + await this.#serializeMutable(async () => { + const previous = this.composition.package(extensionId); + const loaded = await this.packageLoader.load(extensionId); + try { + await this.#validateDesired(this.desiredComposition()); + await this.composition.reload(loaded); + } catch (error) { + await this.packageLoader.release(loaded).catch(() => undefined); + throw error; + } + await this.#releaseGeneration(previous); + this.#clearPackageFailure(extensionId); + if (this.#diverged) await this.#convergeDesired(); + }); + } + + async uninstallPackage(extensionId: string): Promise { + this.#assertMutable(); + await this.#serializeMutable(async () => { + let planned: MakaCompositionState | undefined; + let packageLayers = this.#authority.packageLayers; + if (this.#authority.packageLayers.includes(extensionId)) { + packageLayers = this.#authority.packageLayers.filter((item) => item !== extensionId); + planned = await this.#composeLayers(packageLayers, this.#authority.overlays); + } + const candidate = planned ?? this.#desired; + const desiredUser = compositionEntries(candidate).find( + (entry) => entry.packageId === extensionId, + ); + if (desiredUser) { + throw new MakaPluginRuntimeError( + 'package_in_use', + `Plugin package is used by desired entry ${desiredUser.id}`, + ); + } + const dependent = await this.#desiredPackageDependent(extensionId, candidate); + if (dependent) { + throw new MakaPluginRuntimeError( + 'package_in_use', + `Plugin package is required by desired entry ${dependent.id}`, + ); + } + if (planned) { + await this.#replaceDesiredComposition(planned, packageLayers, this.#authority.overlays); + } + const installedInRuntime = this.composition + .installedPackages() + .some(({ packageId }) => packageId === extensionId); + const pkg = installedInRuntime ? this.composition.package(extensionId) : undefined; + if (pkg) await this.composition.uninstall(extensionId); + try { + await this.packages.uninstall(extensionId); + this.#clearPackageFailure(extensionId); + if (pkg) await this.#releaseGeneration(pkg); + } catch (error) { + if (error instanceof PluginPackageStoreError && error.code === 'commit_outcome_unknown') { + this.#poisoned = error; + this.#draining = true; + throw new HostPluginPlatformError( + 'commit_outcome_unknown', + 'Plugin package uninstall outcome is unknown; Plugin Platform was fenced', + { cause: error }, + ); + } + if (pkg) { + let restored: MakaPluginPackage | undefined; + try { + restored = await this.packageLoader.load(extensionId); + await this.composition.install(restored); + await this.#releaseGeneration(pkg); + } catch (rollbackError) { + if (restored) await this.packageLoader.release(restored).catch(() => undefined); + this.#poisoned = asError(rollbackError); + } + } + throw error; + } + }); + } + + async apply( + input: MakaCompositionApplyInput, + ): Promise { + this.#assertMutable(); + return await this.#serializeMutable(async () => { + const desired = this.#desired; + let normalizedInput: MakaCompositionApplyInput; + let planned: MakaCompositionState; + try { + normalizedInput = await this.#normalizeApplyInput(desired, input); + planned = applyCompositionState(desired, normalizedInput); + await this.#validateDesired(planned); + } catch (error) { + throw new HostPluginPlatformError('mutation_failed', 'Plugin composition mutation failed', { + cause: error, + }); + } + const next = compositionAuthority( + planned.generation, + this.#authority.packageLayers, + Object.freeze([...this.#authority.overlays, ...normalizedInput.operations]), + ); + try { + await this.store.replace(next); + this.#authority = next; + this.#desired = planned; + } catch (error) { + if ( + error instanceof HostPluginCompositionStoreError && + error.code === 'commit_outcome_unknown' + ) { + this.#poisoned = error; + this.#draining = true; + throw new HostPluginPlatformError( + 'commit_outcome_unknown', + 'Plugin composition commit outcome is unknown; Plugin Platform was fenced', + { cause: error }, + ); + } + throw new HostPluginPlatformError( + 'persistence_failed', + 'Plugin composition persistence failed; Runtime state was not changed', + { cause: error }, + ); + } + + let convergenceFailures: readonly MakaCompositionRecoveryFailure[] | undefined; + try { + if (this.#diverged) { + const failures = await this.composition.recoverComposition(planned); + await this.#publishEntryFailures(failures); + if (failures.length > 0) { + convergenceFailures = failures; + throw new Error(failures.map(({ diagnostic }) => diagnostic).join('; ')); + } + return this.composition.inspectTree(); + } + const inspections = await this.composition.apply(normalizedInput); + this.#failures = Object.freeze( + this.#failures.filter((failure) => failure.entryId === undefined), + ); + return inspections; + } catch (error) { + this.#diverged = true; + if (!convergenceFailures) { + await this.#publishEntryFailures(operationFailures(normalizedInput, error)); + } + throw new HostPluginPlatformError( + 'mutation_failed', + 'Desired Plugin composition was committed but Runtime convergence failed', + { cause: error }, + ); + } + }); + } + + desiredComposition(): MakaCompositionState { + return this.#desired; + } + + failures(): readonly HostPluginPlatformFailure[] { + return this.#failures; + } + + inspect(rootId?: MakaPluginRootId): readonly MakaCompositionEntryInspection[] { + return this.composition.inspectTree(rootId); + } + + read(operation: () => T | Promise): Promise { + this.#assertOpen(); + return this.#serialize(async () => await operation()); + } + + beginDrain(): void { + this.#draining = true; + } + + async close(): Promise { + if (this.#closed) return; + this.#closed = true; + const errors: unknown[] = []; + try { + await this.#mutation; + } catch (error) { + errors.push(error); + } + try { + await this.composition.close(); + } catch (error) { + errors.push(error); + } + try { + await this.packageLoader.close(); + } catch (error) { + errors.push(error); + } + if (errors.length > 0) { + throw new AggregateError(errors, 'Unable to close every Plugin Platform resource'); + } + } + + async #planPackageLayer( + extensionId: string, + patch: MakaCompositionApplyInput | undefined, + manifest: ExtensionPackageManifest, + ): Promise<{ + readonly planned: MakaCompositionState; + readonly packageLayers: readonly string[]; + }> { + const previousIndex = this.#authority.packageLayers.indexOf(extensionId); + const packageLayers = this.#authority.packageLayers.filter((item) => item !== extensionId); + const nextIndex = previousIndex < 0 ? packageLayers.length : previousIndex; + packageLayers.splice(nextIndex, 0, extensionId); + const planned = await this.#composeLayers(packageLayers, this.#authority.overlays, { + extensionId, + patch, + manifest, + }); + return { planned, packageLayers }; + } + + async #composeLayers( + packageLayers: readonly string[], + overlays: readonly MakaCompositionOperation[], + override?: { + readonly extensionId: string; + readonly patch: MakaCompositionApplyInput | undefined; + readonly manifest: ExtensionPackageManifest; + }, + ): Promise { + let working = emptyCompositionState(); + for (const extensionId of packageLayers) { + const patch = + override?.extensionId === extensionId + ? override.patch + : await loadPluginCompositionPatch(await this.packages.load(extensionId)); + if (!patch) continue; + const normalized = await this.#normalizeApplyInput(working, patch, override?.manifest); + working = applyCompositionState(working, normalized); + } + if (overlays.length > 0) { + const normalized = await this.#normalizeApplyInput( + working, + { operations: overlays }, + override?.manifest, + ); + working = applyCompositionState(working, normalized); + } + await this.#validateDesired(working, override?.manifest); + return compositionWithGeneration(working, this.#desired.generation + 1); + } + + /** Rebuilds the desired Entry Tree without trusting a stored materialized projection. */ + async #composePersistedAuthority( + authority: PersistedPluginComposition, + ): Promise { + let working = emptyCompositionState(); + for (const extensionId of authority.packageLayers) { + const patch = await loadPluginCompositionPatch(await this.packages.load(extensionId)); + if (patch) working = applyCompositionState(working, patch); + } + if (authority.overlays.length > 0) { + working = applyCompositionState(working, { operations: authority.overlays }); + } + return compositionWithGeneration(working, authority.generation); + } + + async #replaceDesiredComposition( + planned: MakaCompositionState, + packageLayers: readonly string[], + overlays: readonly MakaCompositionOperation[], + ): Promise { + await this.#commitDesiredAuthority(planned, packageLayers, overlays); + const failures = await this.composition.recoverComposition(planned); + await this.#publishEntryFailures(failures); + this.#diverged = failures.length > 0; + if (failures.length > 0) { + throw new HostPluginPlatformError( + 'mutation_failed', + 'Desired Plugin composition was committed but Runtime convergence failed', + { cause: new Error(failures.map(({ diagnostic }) => diagnostic).join('; ')) }, + ); + } + } + + async #commitDesiredAuthority( + planned: MakaCompositionState, + packageLayers: readonly string[], + overlays: readonly MakaCompositionOperation[], + ): Promise { + const next = compositionAuthority(planned.generation, packageLayers, overlays); + try { + await this.store.replace(next); + this.#authority = next; + this.#desired = planned; + } catch (error) { + if ( + error instanceof HostPluginCompositionStoreError && + error.code === 'commit_outcome_unknown' + ) { + this.#poisoned = error; + this.#draining = true; + throw new HostPluginPlatformError( + 'commit_outcome_unknown', + 'Plugin composition commit outcome is unknown; Plugin Platform was fenced', + { cause: error }, + ); + } + throw new HostPluginPlatformError( + 'persistence_failed', + 'Plugin composition persistence failed; Runtime state was not changed', + { cause: error }, + ); + } + } + + async #validateDesired( + state: MakaCompositionState, + manifestOverride?: ExtensionPackageManifest, + ): Promise { + const records = compositionEntryRecords(state); + for (const record of records) { + await this.#validateEntry(record.entry, !record.disabled, manifestOverride); + await this.#validateActiveDependencies(record, records, manifestOverride); + } + } + + async #normalizeApplyInput( + desired: MakaCompositionState, + input: MakaCompositionApplyInput, + manifestOverride?: ExtensionPackageManifest, + ): Promise { + if (input.baseGeneration !== undefined && input.baseGeneration !== desired.generation) { + throw new MakaPluginRuntimeError( + 'invalid_entry', + `Composition generation changed from ${input.baseGeneration} to ${desired.generation}`, + ); + } + let working = desired; + const operations: MakaCompositionOperation[] = []; + for (const operation of input.operations) { + let normalized: MakaCompositionOperation; + if (operation.type === 'insert') { + normalized = Object.freeze({ + ...operation, + entry: await this.#normalizeEntryConfiguration(operation.entry, true, manifestOverride), + }); + } else if (operation.type === 'update') { + const current = findCompositionEntry(working, operation.entryId); + if (!current) { + throw new MakaPluginRuntimeError( + 'entry_not_found', + `Composition entry not found: ${operation.entryId}`, + ); + } + const effective = Object.freeze({ ...current, ...operation.patch }); + const configured = await this.#normalizeEntryConfiguration( + effective, + false, + manifestOverride, + ); + normalized = Object.freeze({ + ...operation, + patch: Object.freeze({ ...operation.patch, config: configured.config }), + }); + } else { + normalized = operation; + } + operations.push(normalized); + const advanced = applyCompositionState(working, { operations: [normalized] }); + working = compositionWithGeneration(advanced, desired.generation); + } + return Object.freeze({ + ...(input.baseGeneration === undefined ? {} : { baseGeneration: input.baseGeneration }), + operations: Object.freeze(operations), + }); + } + + async #normalizeCompositionConfigurations( + state: MakaCompositionState, + ): Promise { + const normalize = async (entry: MakaCompositionEntry): Promise => { + let configured = entry; + try { + configured = await this.#normalizeEntryConfiguration(entry, false); + } catch { + // Recovery records malformed or unavailable package configuration as + // an Entry failure below instead of failing the Runtime Host. + } + return Object.freeze({ + ...configured, + children: Object.freeze(await Promise.all((entry.children ?? []).map(normalize))), + }); + }; + const sessions = await Promise.all( + Object.entries(state.roots.sessions).map( + async ([scopeId, entries]) => + [scopeId, Object.freeze(await Promise.all(entries.map(normalize)))] as const, + ), + ); + return Object.freeze({ + schemaVersion: 1, + generation: state.generation, + roots: Object.freeze({ + profile: Object.freeze(await Promise.all(state.roots.profile.map(normalize))), + desktopUi: Object.freeze(await Promise.all(state.roots.desktopUi.map(normalize))), + sessions: Object.freeze(Object.fromEntries(sessions)), + }), + }); + } + + async #normalizeEntryConfiguration( + entry: MakaCompositionEntry, + recursive = true, + manifestOverride?: ExtensionPackageManifest, + ): Promise { + const config = entry.packageId + ? validateExtensionConfiguration( + (await this.#packageManifest(entry.packageId, manifestOverride)).configuration, + entry.config, + ) + : scalarConfiguration(entry.config); + return Object.freeze({ + ...entry, + config, + ...(recursive + ? { + children: Object.freeze( + await Promise.all( + (entry.children ?? []).map((child) => + this.#normalizeEntryConfiguration(child, true, manifestOverride), + ), + ), + ), + } + : {}), + }); + } + + async #desiredValidationFailures( + state: MakaCompositionState, + ): Promise { + const failures: MakaCompositionRecoveryFailure[] = []; + const records = compositionEntryRecords(state); + for (const record of records) { + try { + await this.#validateEntry(record.entry, !record.disabled); + await this.#validateActiveDependencies(record, records); + } catch (error) { + failures.push( + Object.freeze({ entryId: record.entry.id, diagnostic: boundedDiagnostic(error) }), + ); + } + } + return Object.freeze(failures); + } + + async #validateEntry( + entry: MakaCompositionEntry, + active = entry.disabled !== true, + manifestOverride?: ExtensionPackageManifest, + ): Promise { + if (!entry.packageId) return; + const manifests = new Map(); + const visiting = new Set(); + const visited = new Set(); + const visit = async (extensionId: string): Promise => { + if (visited.has(extensionId)) return; + if (visiting.has(extensionId)) { + throw new MakaPluginRuntimeError( + 'dependency_cycle', + `Plugin package dependency cycle includes ${extensionId}`, + ); + } + visiting.add(extensionId); + let manifest = manifests.get(extensionId); + if (!manifest) { + manifest = await this.#packageManifest(extensionId, manifestOverride); + manifests.set(extensionId, manifest); + } + for (const dependency of manifest.dependencies) await visit(dependency.id); + visiting.delete(extensionId); + visited.add(extensionId); + }; + const manifest = await this.#packageManifest(entry.packageId, manifestOverride); + validateExtensionConfiguration(manifest.configuration, entry.config); + if (active) await visit(entry.packageId); + } + + async #validateActiveDependencies( + record: CompositionEntryRecord, + records: readonly CompositionEntryRecord[], + manifestOverride?: ExtensionPackageManifest, + ): Promise { + if (record.disabled || !record.entry.packageId) return; + const manifest = await this.#packageManifest(record.entry.packageId, manifestOverride); + for (const dependency of manifest.dependencies) { + if ( + !records.some( + (candidate) => + candidate.rootId === record.rootId && + !candidate.disabled && + candidate.entry.packageId === dependency.id, + ) + ) { + throw new MakaPluginRuntimeError( + 'package_not_found', + `Required dependency ${dependency.id} is not active in ${record.rootId}`, + ); + } + } + } + + async #packageManifest( + extensionId: string, + manifestOverride?: ExtensionPackageManifest, + ): Promise { + return manifestOverride?.id === extensionId + ? manifestOverride + : (await this.packages.load(extensionId)).manifest; + } + + async #convergeDesired(): Promise { + const desired = this.desiredComposition(); + const failures = await this.#recoverDesiredRuntime(desired); + await this.#publishEntryFailures(failures); + } + + async #recoverDesiredRuntime( + desired: MakaCompositionState, + ): Promise { + let failures = new Map( + (await this.#desiredValidationFailures(desired)).map((failure) => [failure.entryId, failure]), + ); + for (;;) { + const recovered = await this.composition.recoverComposition( + withoutEntries(desired, new Set(failures.keys())), + ); + for (const failure of recovered) failures.set(failure.entryId, failure); + const expanded = await this.#expandDependencyFailures(desired, [...failures.values()]); + if (expanded.length === failures.size) return Object.freeze([...failures.values()]); + failures = new Map(expanded.map((failure) => [failure.entryId, failure])); + } + } + + async #expandDependencyFailures( + state: MakaCompositionState, + initial: readonly MakaCompositionRecoveryFailure[], + ): Promise { + const failures = new Map(initial.map((failure) => [failure.entryId, failure])); + const records = compositionEntryRecords(state); + let changed = true; + while (changed) { + changed = false; + for (const record of records) { + if (record.disabled || !record.entry.packageId || failures.has(record.entry.id)) continue; + const manifest = (await this.packages.load(record.entry.packageId)).manifest; + for (const dependency of manifest.dependencies) { + const candidates = records.filter( + (candidate) => + candidate.rootId === record.rootId && + !candidate.disabled && + candidate.entry.packageId === dependency.id, + ); + if (candidates.length > 0 && candidates.every(({ entry }) => failures.has(entry.id))) { + failures.set( + record.entry.id, + Object.freeze({ + entryId: record.entry.id, + diagnostic: `Required dependency ${dependency.id} failed in ${record.rootId}`, + }), + ); + changed = true; + break; + } + } + } + } + return Object.freeze([...failures.values()]); + } + + async #desiredPackageDependent( + extensionId: string, + desired: MakaCompositionState = this.desiredComposition(), + ): Promise { + const dependsOn = async (packageId: string, visited: Set): Promise => { + if (packageId === extensionId) return true; + if (visited.has(packageId)) return false; + visited.add(packageId); + const manifest = (await this.packages.load(packageId)).manifest; + for (const dependency of manifest.dependencies) { + if (await dependsOn(dependency.id, visited)) return true; + } + return false; + }; + for (const entry of compositionEntries(desired)) { + if ( + entry.packageId && + entry.packageId !== extensionId && + entry.disabled !== true && + (await dependsOn(entry.packageId, new Set())) + ) { + return entry; + } + } + return undefined; + } + + async #publishEntryFailures(failures: readonly MakaCompositionRecoveryFailure[]): Promise { + const packageFailures = this.#failures.filter((failure) => failure.entryId === undefined); + this.#failures = Object.freeze([ + ...packageFailures, + ...failures.map((failure) => + Object.freeze({ entryId: failure.entryId, diagnostic: failure.diagnostic }), + ), + ]); + this.#diverged = failures.length > 0; + } + + async #releaseGeneration(pkg: MakaPluginPackage): Promise { + try { + await this.packageLoader.release(pkg); + } catch (error) { + this.composition.root.logger.warn('Unable to remove retired Plugin generation', error); + } + } + + #clearPackageFailure(extensionId: string): void { + this.#failures = Object.freeze( + this.#failures.filter((failure) => failure.extensionId !== extensionId), + ); + } + + #assertOpen(): void { + if (this.#closed) throw new HostPluginPlatformError('closed', 'Plugin Platform is closed'); + if (this.#poisoned) { + throw new HostPluginPlatformError('recovery_failed', 'Plugin Platform is fenced', { + cause: this.#poisoned, + }); + } + } + + #assertMutable(): void { + this.#assertOpen(); + if (this.#draining) throw new HostPluginPlatformError('closed', 'Plugin Platform is draining'); + } + + #fenceUnknownPackageOutcome( + error: PluginPackageStoreError, + operation: string, + ): HostPluginPlatformError { + this.#poisoned = error; + this.#draining = true; + return new HostPluginPlatformError( + 'commit_outcome_unknown', + `Plugin package ${operation} outcome is unknown; Plugin Platform was fenced`, + { cause: error }, + ); + } + + #serializeMutable(operation: () => Promise): Promise { + return this.#serialize(async () => { + this.#assertMutable(); + return await operation(); + }); + } + + #serialize(operation: () => Promise): Promise { + const result = this.#mutation.then(operation, operation); + this.#mutation = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} + +function emptyCompositionAuthority(): PersistedPluginComposition { + return Object.freeze({ + schemaVersion: 1, + generation: 0, + packageLayers: Object.freeze([]), + overlays: Object.freeze([]), + }); +} + +function emptyCompositionState(): MakaCompositionState { + return Object.freeze({ + schemaVersion: 1, + generation: 0, + roots: Object.freeze({ + profile: Object.freeze([]), + desktopUi: Object.freeze([]), + sessions: Object.freeze({}), + }), + }); +} + +function compositionAuthority( + generation: number, + packageLayers: readonly string[], + overlays: readonly MakaCompositionOperation[], +): PersistedPluginComposition { + return Object.freeze({ + schemaVersion: 1, + generation, + packageLayers: Object.freeze([...packageLayers]), + overlays: Object.freeze(structuredClone(overlays)), + }); +} + +function compositionEntries(state: MakaCompositionState): readonly MakaCompositionEntry[] { + const walk = (entries: readonly MakaCompositionEntry[]): MakaCompositionEntry[] => + entries.flatMap((entry) => [entry, ...walk(entry.children ?? [])]); + return [ + ...walk(state.roots.profile), + ...walk(state.roots.desktopUi), + ...Object.values(state.roots.sessions).flatMap(walk), + ]; +} + +function findCompositionEntry( + state: MakaCompositionState, + entryId: string, +): MakaCompositionEntry | undefined { + return compositionEntries(state).find((entry) => entry.id === entryId); +} + +function compositionWithGeneration( + state: MakaCompositionState, + generation: number, +): MakaCompositionState { + return Object.freeze({ ...state, generation }); +} + +function compositionEntryRecords(state: MakaCompositionState): readonly CompositionEntryRecord[] { + const records: CompositionEntryRecord[] = []; + const visit = ( + entries: readonly MakaCompositionEntry[], + rootId: MakaPluginRootId, + ancestorDisabled: boolean, + ): void => { + for (const entry of entries) { + const disabled = ancestorDisabled || entry.disabled === true; + records.push(Object.freeze({ entry, rootId, disabled })); + visit(entry.children ?? [], rootId, disabled); + } + }; + visit(state.roots.profile, 'profile', false); + visit(state.roots.desktopUi, 'desktop-ui', false); + for (const [scopeId, entries] of Object.entries(state.roots.sessions)) { + visit(entries, `session:${scopeId}`, false); + } + return Object.freeze(records); +} + +function withoutEntries( + state: MakaCompositionState, + excluded: ReadonlySet, +): MakaCompositionState { + const filter = (entries: readonly MakaCompositionEntry[]): readonly MakaCompositionEntry[] => + Object.freeze( + entries.flatMap((entry) => + excluded.has(entry.id) + ? [] + : [Object.freeze({ ...entry, children: filter(entry.children ?? []) })], + ), + ); + return Object.freeze({ + schemaVersion: 1, + generation: state.generation, + roots: Object.freeze({ + profile: filter(state.roots.profile), + desktopUi: filter(state.roots.desktopUi), + sessions: Object.freeze( + Object.fromEntries( + Object.entries(state.roots.sessions).map(([scopeId, entries]) => [ + scopeId, + filter(entries), + ]), + ), + ), + }), + }); +} + +function operationFailures( + input: MakaCompositionApplyInput, + error: unknown, +): readonly MakaCompositionRecoveryFailure[] { + const diagnostic = boundedDiagnostic(error); + const ids = new Set(); + for (const operation of input.operations) { + if (operation.type === 'insert') ids.add(operation.entry.id); + else ids.add(operation.entryId); + } + return Object.freeze([...ids].map((entryId) => Object.freeze({ entryId, diagnostic }))); +} + +function scalarConfiguration(value: unknown): Readonly> { + if (value === undefined) return Object.freeze({}); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new HostPluginCompositionStoreError( + 'invalid_state', + 'Plugin Entry config must be a scalar record', + ); + } + const output: Record = {}; + for (const [key, item] of Object.entries(value)) { + if ( + typeof item !== 'string' && + typeof item !== 'boolean' && + !(typeof item === 'number' && Number.isFinite(item)) + ) { + throw new HostPluginCompositionStoreError( + 'invalid_state', + `Plugin Entry config value is invalid: ${key}`, + ); + } + output[key] = item; + } + return Object.freeze(output); +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + +function boundedDiagnostic(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + return message.slice(0, 4096) || 'Plugin Platform operation failed'; +} diff --git a/packages/runtime/src/__tests__/plugin-composition-loader.test.ts b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts index 53e44f61ab..2680f17721 100644 --- a/packages/runtime/src/__tests__/plugin-composition-loader.test.ts +++ b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts @@ -19,9 +19,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { Context, type Plugin } from '../plugin-kernel.js'; +import { Context, type Fiber, type Plugin } from '../plugin-kernel.js'; import { MakaCompositionLoader } from '../plugin-composition-loader.js'; import { + applyCompositionState, MakaPluginTransactionBuffer, type MakaCompositionEntry, type MakaPluginPackage, @@ -58,6 +59,46 @@ test('composition tree supports nested groups and repeated package instances', a await loader.close(); }); +test('package Entry descendants stay owned by the parent package Fiber', async () => { + const fibers = new Map(); + const capture = (ctx: Context) => { + fibers.set(ctx.maka!.entryId, ctx.fiber); + }; + const loader = new MakaCompositionLoader(); + await loader.install(pkg('parent-package', capture)); + await loader.install(pkg('child-package', capture)); + + await loader.create('profile', entry('parent-entry', 'parent-package')); + await loader.create('profile', entry('child-entry', 'child-package'), 'parent-entry'); + assert.equal(fibers.get('child-entry')?.parent, fibers.get('parent-entry')); + + await loader.create('profile', { id: 'scope-group' }); + await loader.move('child-entry', 'scope-group'); + assert.equal(fibers.get('child-entry')?.parent, loader.root.fiber); + await loader.move('child-entry', 'parent-entry'); + assert.equal(fibers.get('child-entry')?.parent, fibers.get('parent-entry')); + + await loader.recoverComposition({ + schemaVersion: 1, + generation: 7, + roots: { + profile: [ + { + ...entry('parent-entry', 'parent-package'), + children: [entry('child-entry', 'child-package')], + }, + ], + desktopUi: [], + sessions: {}, + }, + }); + assert.equal(fibers.get('child-entry')?.parent, fibers.get('parent-entry')); + + await loader.reload(pkg('parent-package', capture)); + assert.equal(fibers.get('child-entry')?.parent, fibers.get('parent-entry')); + await loader.close(); +}); + test('missing injected service enters pending and activates when provided', async () => { let started = 0; const plugin = Object.assign( @@ -109,7 +150,7 @@ test('config update uses the existing Fiber and preserves entry identity', async 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.equal(loader.compositionState().generation, 2); assert.deepEqual(values, [1, 2]); await loader.close(); }); @@ -290,11 +331,11 @@ test('retirement cleanup failure does not roll back a published removal generati }), ); await loader.create('profile', entry('retired-entry', 'retirement-failure')); - const generation = loader.snapshot().generation; + const generation = loader.compositionState().generation; await loader.remove('retired-entry'); - assert.equal(loader.snapshot().generation, generation + 1); + assert.equal(loader.compositionState().generation, generation + 1); assert.deepEqual(loader.inspectTree('profile'), []); await loader.close(); }); @@ -309,11 +350,11 @@ test('retirement cleanup failure does not roll back a published structural updat ); await loader.install(pkg('replacement-package', () => undefined)); await loader.create('profile', entry('updated-entry', 'retired-package')); - const generation = loader.snapshot().generation; + const generation = loader.compositionState().generation; await loader.update('updated-entry', { packageId: 'replacement-package' }); - assert.equal(loader.snapshot().generation, generation + 1); + assert.equal(loader.compositionState().generation, generation + 1); assert.equal(loader.inspect('updated-entry').packageId, 'replacement-package'); assert.equal(loader.inspect('updated-entry').status, 'active'); await loader.close(); @@ -347,19 +388,19 @@ test('contribution registrations are staged and owned by the entry Fiber', async await loader.close(); }); -test('snapshot replacement restores ordered roots and descendants', async () => { +test('state replacement restores ordered roots and descendants', async () => { const loader = new MakaCompositionLoader(); - await loader.install(pkg('snapshot', () => undefined)); - await loader.replaceSnapshot({ + await loader.install(pkg('state', () => undefined)); + await loader.replaceComposition({ 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')] }, + profile: [entry('profile-entry', 'state')], + desktopUi: [{ id: 'ui-group', children: [entry('ui-entry', 'state')] }], + sessions: { s1: [entry('session-entry', 'state')] }, }, }); - assert.equal(loader.snapshot().generation, 41); + assert.equal(loader.compositionState().generation, 41); assert.deepEqual( loader.inspectTree().map(({ id }) => id), ['profile-entry', 'ui-group', 'session-entry'], @@ -368,26 +409,26 @@ test('snapshot replacement restores ordered roots and descendants', async () => await loader.close(); }); -test('live snapshot and subtree replacement publish a fresh composition generation', async () => { +test('live state and subtree replacement publish a fresh composition generation', async () => { const loader = new MakaCompositionLoader(); await loader.create('profile', { id: 'before' }); - const staleGeneration = loader.snapshot().generation; + const staleGeneration = loader.compositionState().generation; - await loader.replaceSnapshot({ + await loader.replaceComposition({ schemaVersion: 1, generation: staleGeneration, roots: { profile: [{ id: 'after' }], desktopUi: [], sessions: {} }, }); - assert.equal(loader.snapshot().generation, staleGeneration + 1); + assert.equal(loader.compositionState().generation, staleGeneration + 1); await assert.rejects( () => loader.apply({ baseGeneration: staleGeneration, operations: [] }), /Composition generation changed/u, ); - const beforeSubtreeReplacement = loader.snapshot().generation; + const beforeSubtreeReplacement = loader.compositionState().generation; await loader.replaceSubtree('after', { id: 'after', children: [{ id: 'child' }] }); - assert.equal(loader.snapshot().generation, beforeSubtreeReplacement + 1); + assert.equal(loader.compositionState().generation, beforeSubtreeReplacement + 1); await loader.close(); }); @@ -425,18 +466,18 @@ test('replacement subtrees reject duplicate ids across different branches', asyn await loader.close(); }); -test('snapshot preserves session ids that overlap object prototype properties', async () => { +test('state 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); + const state = loader.compositionState(); + assert.equal(Object.hasOwn(state.roots.sessions, '__proto__'), true); assert.deepEqual( - snapshot.roots.sessions.__proto__?.map(({ id }) => id), + state.roots.sessions.__proto__?.map(({ id }) => id), ['special-session-entry'], ); - await loader.replaceSnapshot(snapshot); + await loader.replaceComposition(state); assert.deepEqual( loader.inspectTree('session:__proto__').map(({ id }) => id), ['special-session-entry'], @@ -444,26 +485,26 @@ test('snapshot preserves session ids that overlap object prototype properties', await loader.close(); }); -test('inspecting a missing root does not mutate the composition snapshot', async () => { +test('inspecting a missing root does not mutate the composition state', async () => { const loader = new MakaCompositionLoader(); - const before = loader.snapshot(); + const before = loader.compositionState(); assert.deepEqual(loader.inspectTree('session:missing'), []); - assert.deepEqual(loader.snapshot(), before); + assert.deepEqual(loader.compositionState(), before); await loader.close(); }); test('failed insert does not create an empty composition root', async () => { const loader = new MakaCompositionLoader(); - const before = loader.snapshot(); + const before = loader.compositionState(); await assert.rejects( loader.create('session:ghost', { id: 'orphan' }, 'missing-parent'), /Composition entry not found: missing-parent/u, ); - assert.deepEqual(loader.snapshot(), before); + assert.deepEqual(loader.compositionState(), before); await loader.close(); }); @@ -477,7 +518,7 @@ test('structural updates preserve descendants added after the parent was created 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), + loader.compositionState().roots.profile[0]?.children?.map(({ id }) => id), ['dynamic-child'], ); await loader.close(); @@ -492,12 +533,12 @@ test('failed rebind leaves parent and position unchanged', async () => { ); await loader.create('profile', { id: 'target-parent', intercept: { moveGuard: true } }); await loader.create('profile', entry('movable-entry', 'move-guard')); - const before = loader.snapshot(); + const before = loader.compositionState(); 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); + assert.deepEqual(loader.compositionState(), before); await loader.close(); }); @@ -562,7 +603,7 @@ test('callable config remains inspectable after publication', async () => { assert.equal(inspection.config, config); assert.equal(loader.inspect('callable-config-entry').config, config); - assert.equal(loader.snapshot().roots.profile[0]?.config, config); + assert.equal(loader.compositionState().roots.profile[0]?.config, config); await loader.close(); }); @@ -585,7 +626,7 @@ test('callable intercept changes trigger structural Context replacement', async await loader.update('callable-intercept-entry', { intercept: { fixture: second } }); assert.deepEqual(seen, [first, second]); - assert.equal(loader.snapshot().roots.profile[0]?.intercept?.fixture, second); + assert.equal(loader.compositionState().roots.profile[0]?.intercept?.fixture, second); await loader.close(); }); @@ -603,7 +644,7 @@ test('staging and commit failures do not retain newly created session roots', as }); }), ); - const before = loader.snapshot(); + const before = loader.compositionState(); await assert.rejects( loader.create('session:missing-package', entry('missing-package-entry', 'missing-package')), @@ -621,14 +662,14 @@ test('staging and commit failures do not retain newly created session roots', as /commit failed/u, ); - assert.deepEqual(loader.snapshot(), before); + assert.deepEqual(loader.compositionState(), 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 initial = loader.compositionState().generation; const changed = await loader.apply({ baseGeneration: initial, operations: [ @@ -652,7 +693,7 @@ test('composition apply batches EntryTree operations under one generation check' 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(); + const before = loader.compositionState(); await assert.rejects( () => @@ -666,7 +707,124 @@ test('failed composition batches restore the prior generation exactly', async () /Composition entry not found: missing-entry/u, ); - assert.deepEqual(loader.snapshot(), before); + assert.deepEqual(loader.compositionState(), before); + await loader.close(); +}); + +test('package reload replaces every matching mount without restarting unrelated Entries', async () => { + const events: string[] = []; + const host = + (label: string): Plugin => + (ctx: Context) => { + events.push(`start:${label}:${ctx.maka!.entryId}`); + ctx.effect(() => () => events.push(`stop:${label}:${ctx.maka!.entryId}`), label); + }; + const loader = new MakaCompositionLoader(); + await loader.install(pkg('reload-target', host('old'))); + await loader.install(pkg('reload-bystander', host('bystander'))); + await loader.create('profile', entry('reload-one', 'reload-target')); + await loader.create('session:one', entry('reload-two', 'reload-target')); + await loader.create('profile', entry('reload-unrelated', 'reload-bystander')); + const unrelatedGeneration = loader.inspect('reload-unrelated').generation; + const desiredGeneration = loader.compositionState().generation; + + await loader.reload(pkg('reload-target', host('new'))); + + assert.equal(loader.inspect('reload-unrelated').generation, unrelatedGeneration); + assert.equal(loader.compositionState().generation, desiredGeneration); + assert.deepEqual( + events.filter((event) => event.startsWith('start:new')), + ['start:new:reload-one', 'start:new:reload-two'], + ); + assert.equal(events.includes('stop:bystander:reload-unrelated'), false); + await loader.close(); +}); + +test('partial recovery preserves desired generation and isolates failed siblings', async () => { + const loader = new MakaCompositionLoader(); + await loader.install(pkg('recoverable', () => undefined)); + const failures = await loader.recoverComposition({ + schemaVersion: 1, + generation: 7, + roots: { + profile: [entry('recovered-entry', 'recoverable'), entry('missing-entry', 'missing-package')], + desktopUi: [], + sessions: {}, + }, + }); + + assert.deepEqual( + failures.map(({ entryId }) => entryId), + ['missing-entry'], + ); + assert.equal(loader.inspect('recovered-entry').status, 'active'); + assert.throws(() => loader.inspect('missing-entry'), /not found/u); + assert.equal(loader.compositionState().generation, 7); + await loader.close(); +}); + +test('desired-state reducer applies dependent operations without activating code', () => { + const initial = { + schemaVersion: 1, + generation: 3, + roots: { + profile: [{ id: 'parent', children: [{ id: 'child' }] }], + desktopUi: [], + sessions: {}, + }, + } as const; + + const next = applyCompositionState(initial, { + baseGeneration: 3, + operations: [ + { type: 'update', entryId: 'parent', patch: { disabled: true } }, + { type: 'update', entryId: 'child', patch: { disabled: true } }, + { type: 'move', entryId: 'child', position: 0 }, + ], + }); + + assert.equal(next.generation, 4); + assert.deepEqual(next.roots.profile, [ + { id: 'child', disabled: true, children: [] }, + { id: 'parent', disabled: true, children: [] }, + ]); +}); + +test('desired-state reducer stays equivalent to live Entry Tree batch semantics', async () => { + const loader = new MakaCompositionLoader(); + await loader.replaceComposition({ + schemaVersion: 1, + generation: 4, + roots: { + profile: [ + { id: 'equivalence-a', children: [{ id: 'equivalence-a1' }, { id: 'equivalence-a2' }] }, + { id: 'equivalence-b' }, + ], + desktopUi: [], + sessions: {}, + }, + }); + const before = loader.compositionState(); + const input = { + baseGeneration: before.generation, + operations: [ + { type: 'update', entryId: 'equivalence-a', patch: { disabled: true } }, + { + type: 'insert', + parentId: 'equivalence-a', + position: 1, + entry: { id: 'equivalence-a3' }, + }, + { type: 'move', entryId: 'equivalence-a2', parentId: 'equivalence-b' }, + { type: 'remove', entryId: 'equivalence-a1' }, + { type: 'update', entryId: 'equivalence-a3', patch: { disabled: true } }, + ], + } as const; + + const planned = applyCompositionState(before, input); + await loader.apply(input); + + assert.deepEqual(loader.compositionState(), planned); await loader.close(); }); diff --git a/packages/runtime/src/plugin-composition-loader.ts b/packages/runtime/src/plugin-composition-loader.ts index abb887437c..6cfd6a53e4 100644 --- a/packages/runtime/src/plugin-composition-loader.ts +++ b/packages/runtime/src/plugin-composition-loader.ts @@ -17,13 +17,13 @@ * under the License. */ -import { Context, type Fiber, type Inject, type Plugin } from './plugin-kernel.js'; +import { Context, type Fiber, FiberState, type Inject, type Plugin } from './plugin-kernel.js'; import { fiberStateName, type MakaCompositionEntry, type MakaCompositionEntryInspection, type MakaCompositionApplyInput, - type MakaCompositionSnapshot, + type MakaCompositionState, type MakaPluginMetadata, type MakaPluginPackage, type MakaPluginRootId, @@ -60,6 +60,11 @@ export interface MakaCompositionLoaderOptions { readonly transaction?: (context: Context) => MakaPluginTransaction | undefined; } +export interface MakaCompositionRecoveryFailure { + readonly entryId: string; + readonly diagnostic: string; +} + export class MakaCompositionLoader { readonly root: Context; readonly #packages = new Map(); @@ -89,6 +94,26 @@ export class MakaCompositionLoader { }); } + reload(pkg: MakaPluginPackage): Promise { + return this.#mutate(async () => { + validatePluginPackage(pkg); + const previous = this.#packages.get(pkg.packageId); + if (!previous) { + throw new MakaPluginRuntimeError( + 'package_not_found', + `Plugin package is not installed: ${pkg.packageId}`, + ); + } + this.#packages.set(pkg.packageId, freezePackage(pkg)); + try { + await this.#reloadPackage(pkg.packageId); + } catch (error) { + this.#packages.set(pkg.packageId, previous); + throw error; + } + }); + } + uninstall(packageId: string): Promise { return this.#mutate(async () => { if (!this.#packages.has(packageId)) { @@ -156,7 +181,7 @@ export class MakaCompositionLoader { 'invalid_entry', `Composition generation changed from ${input.baseGeneration} to ${this.#compositionGeneration}`, ); - const before = this.snapshot(); + const before = this.compositionState(); const inspections: MakaCompositionEntryInspection[] = []; let appliedOperations = 0; try { @@ -199,7 +224,7 @@ export class MakaCompositionLoader { // A candidate can fail before changing the live tree. Rebuilding in // that case would unnecessarily dispose the current Fiber and lose // its registered contributions. - if (appliedOperations > 0) await this.#replaceSnapshot(before, 'rollback'); + if (appliedOperations > 0) await this.#replaceComposition(before, 'rollback'); throw error; } if (input.operations.length > 0) this.#compositionGeneration += 1; @@ -289,7 +314,7 @@ export class MakaCompositionLoader { } } - snapshot(): MakaCompositionSnapshot { + compositionState(): MakaCompositionState { const encode = (rootId: MakaPluginRootId): readonly MakaCompositionEntry[] => Object.freeze((this.#roots.get(rootId)?.entries ?? []).map((entry) => serialize(entry))); const sessions = Object.fromEntries( @@ -310,22 +335,137 @@ export class MakaCompositionLoader { }); } - replaceSnapshot(snapshot: MakaCompositionSnapshot): Promise { - return this.#mutate(() => this.#replaceSnapshot(snapshot, 'publish')); + replaceComposition(state: MakaCompositionState): Promise { + return this.#mutate(() => this.#replaceComposition(state, 'publish')); + } + + /** Restores an externally uncommitted mutation without advancing its generation. */ + restoreComposition(state: MakaCompositionState): Promise { + return this.#mutate(() => this.#replaceComposition(state, 'rollback')); } - async #replaceSnapshot( - snapshot: MakaCompositionSnapshot, + /** + * Recovers as much of a durable desired tree as possible. A failed Entry + * does not prevent unrelated roots or siblings from becoming active. + */ + recoverComposition( + state: MakaCompositionState, + ): Promise { + return this.#mutate(async () => { + if (state.schemaVersion !== 1) { + throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition state'); + } + const failures: MakaCompositionRecoveryFailure[] = []; + const stagedRoots = new Map(); + const stagedIds = new Set(); + const specs = new Map([ + ['profile', state.roots.profile], + ['desktop-ui', state.roots.desktopUi], + ...Object.entries(state.roots.sessions).map( + ([id, entries]) => [`session:${id}` as MakaPluginRootId, entries] as const, + ), + ]); + const recoverEntry = async ( + spec: MakaCompositionEntry, + rootId: MakaPluginRootId, + parent: LiveEntry | undefined, + parentContext: Context, + ancestorDisabled: boolean, + ): Promise => { + for (const item of walk(spec)) { + if (stagedIds.has(item.id)) { + failures.push( + Object.freeze({ + entryId: spec.id, + diagnostic: `Composition entry already exists: ${item.id}`, + }), + ); + return undefined; + } + } + const shallow = freezeEntry({ ...spec, children: [] }); + let live: LiveEntry | undefined; + try { + validateCompositionEntry(shallow); + live = await this.#stage(shallow, rootId, parent, parentContext, ancestorDisabled); + await this.#commitSubtree(live); + } catch (error) { + if (live) await this.#dispose(live).catch(() => undefined); + failures.push( + Object.freeze({ entryId: spec.id, diagnostic: diagnostic(error).slice(0, 4096) }), + ); + return undefined; + } + stagedIds.add(spec.id); + const disabled = ancestorDisabled || spec.disabled === true; + for (const child of spec.children ?? []) { + const recovered = await recoverEntry( + child, + rootId, + live, + childMountContext(live), + disabled, + ); + if (recovered) live.children.push(recovered); + } + live.spec = freezeEntry({ ...live.spec, children: live.children.map(serialize) }); + return live; + }; + + 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) { + const recovered = await recoverEntry(spec, rootId, undefined, context, false); + if (recovered) root.entries.push(recovered); + } + } + } catch (error) { + await settleAll( + [...stagedRoots.values()].flatMap((root) => + [...root.entries].reverse().map((entry) => this.#dispose(entry)), + ), + 'Recovered composition cleanup failed', + ); + throw error; + } + + 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 = state.generation; + await this.#retire( + settleAll( + previous.flatMap((root) => + [...root.entries].reverse().map((entry) => this.#dispose(entry)), + ), + 'Previous composition cleanup failed', + ), + 'Previous composition cleanup failed after recovering desired state', + ); + return Object.freeze(failures); + }); + } + + async #replaceComposition( + state: MakaCompositionState, generationMode: 'publish' | 'rollback', ): Promise { - if (snapshot.schemaVersion !== 1) - throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition snapshot'); + if (state.schemaVersion !== 1) + throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition state'); 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( + ['profile', state.roots.profile], + ['desktop-ui', state.roots.desktopUi], + ...Object.entries(state.roots.sessions).map( ([id, entries]) => [`session:${id}` as MakaPluginRootId, entries] as const, ), ]); @@ -374,10 +514,10 @@ export class MakaCompositionLoader { } this.#compositionGeneration = generationMode === 'rollback' - ? snapshot.generation + ? state.generation : pristine - ? snapshot.generation - : Math.max(previousGeneration, snapshot.generation) + 1; + ? state.generation + : Math.max(previousGeneration, state.generation) + 1; await this.#retire( settleAll( previous.flatMap((root) => @@ -416,7 +556,9 @@ export class MakaCompositionLoader { current: LiveEntry, spec: MakaCompositionEntry, ): Promise { - const parentContext = current.parent?.context ?? this.#root(current.rootId).context; + const parentContext = current.parent + ? childMountContext(current.parent) + : this.#root(current.rootId).context; const candidate = await this.#stage( spec, current.rootId, @@ -446,12 +588,59 @@ export class MakaCompositionLoader { return this.#inspect(candidate); } + async #reloadPackage(packageId: string): Promise { + const affected = [...this.#entries.values()].filter( + (entry) => + entry.spec.packageId === packageId && + ![...ancestors(entry)].some((ancestor) => ancestor.spec.packageId === packageId), + ); + if (!affected.length) return; + const candidates: { readonly current: LiveEntry; readonly replacement: LiveEntry }[] = []; + try { + for (const current of affected) { + const replacement = await this.#stage( + serialize(current), + current.rootId, + current.parent, + current.parent ? childMountContext(current.parent) : this.#root(current.rootId).context, + current.parent ? isDisabled(current.parent) : false, + ); + candidates.push({ current, replacement }); + } + for (const { replacement } of candidates) await this.#commitSubtree(replacement); + } catch (error) { + return rethrowAfterCleanup( + error, + () => + settleAll( + candidates.map(({ replacement }) => this.#dispose(replacement)), + `Plugin package ${packageId} candidate cleanup failed`, + ), + `Plugin package ${packageId} reload and cleanup failed`, + ); + } + for (const { current, replacement } of candidates) { + const siblings = current.parent?.children ?? this.#root(current.rootId).entries; + const index = siblings.indexOf(current); + this.#unindex(current); + siblings[index] = replacement; + this.#index(replacement); + } + await this.#retire( + settleAll( + candidates.map(({ current }) => this.#dispose(current)), + `Plugin package ${packageId} previous generation cleanup failed`, + ), + `Plugin package ${packageId} cleanup failed after publishing its replacement`, + ); + } + 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 ? childMountContext(parent) : this.#root(entry.rootId).context, parent ? isDisabled(parent) : false, ); try { @@ -547,8 +736,11 @@ export class MakaCompositionLoader { } } try { - for (const child of spec.children ?? []) - live.children.push(await this.#stage(child, rootId, live, live.context, disabled)); + for (const child of spec.children ?? []) { + live.children.push( + await this.#stage(child, rootId, live, childMountContext(live), disabled), + ); + } } catch (error) { return rethrowAfterCleanup( error, @@ -632,7 +824,7 @@ export class MakaCompositionLoader { entry, rootId, parent, - parent?.context ?? root.context, + parent ? childMountContext(parent) : root.context, parent ? isDisabled(parent) : false, ); try { @@ -864,6 +1056,24 @@ function* walk(entry: MakaCompositionEntry): Generator { for (const child of entry.children ?? []) yield* walk(child); } +function* walkLive(entry: LiveEntry): Generator { + yield entry; + for (const child of entry.children) yield* walkLive(child); +} + +function* ancestors(entry: LiveEntry): Generator { + for (let current = entry.parent; current; current = current.parent) yield current; +} + +/** + * Package Entries introduce a Fiber ownership boundary. Their descendants + * must mount through that Fiber's Context; scope-only Entries keep using the + * Context view owned by their nearest package ancestor (or the root Fiber). + */ +function childMountContext(entry: LiveEntry): Context { + return entry.fiber?.context ?? entry.context; +} + function isWithin(entry: LiveEntry, root: LiveEntry): boolean { for (let current: LiveEntry | undefined = entry; current; current = current.parent) if (current === root) return true; diff --git a/packages/runtime/src/plugin-runtime.ts b/packages/runtime/src/plugin-runtime.ts index 06fbfe0408..287a3abe57 100644 --- a/packages/runtime/src/plugin-runtime.ts +++ b/packages/runtime/src/plugin-runtime.ts @@ -46,7 +46,7 @@ export interface MakaCompositionEntry { readonly children?: readonly MakaCompositionEntry[]; } -export interface MakaCompositionSnapshot { +export interface MakaCompositionState { readonly schemaVersion: 1; readonly generation: number; readonly roots: { @@ -82,6 +82,202 @@ export interface MakaCompositionApplyInput { readonly operations: readonly MakaCompositionOperation[]; } +/** + * Applies Entry Tree operations to the desired-state value without activating + * Plugin code. Runtime Host uses this reducer to durably commit desired state + * before asking the live Composition Loader to converge. + */ +export function applyCompositionState( + state: MakaCompositionState, + input: MakaCompositionApplyInput, +): MakaCompositionState { + if (state.schemaVersion !== 1) { + throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition state'); + } + if (input.baseGeneration !== undefined && input.baseGeneration !== state.generation) { + throw new MakaPluginRuntimeError( + 'invalid_entry', + `Composition generation changed from ${input.baseGeneration} to ${state.generation}`, + ); + } + if (input.operations.length === 0) return state; + if (state.generation >= Number.MAX_SAFE_INTEGER) { + throw new MakaPluginRuntimeError('invalid_entry', 'Composition generation is exhausted'); + } + + interface MutableLocation { + entry: MakaCompositionEntry; + parent?: MutableLocation; + readonly rootId: MakaPluginRootId; + siblings: MakaCompositionEntry[]; + } + + const profile = state.roots.profile.map(cloneCompositionEntry); + const desktopUi = state.roots.desktopUi.map(cloneCompositionEntry); + const sessions = Object.fromEntries( + Object.entries(state.roots.sessions).map(([scopeId, entries]) => [ + scopeId, + entries.map(cloneCompositionEntry), + ]), + ) as Record; + const locations = new Map(); + + const index = ( + entries: MakaCompositionEntry[], + rootId: MakaPluginRootId, + parent?: MutableLocation, + ): void => { + validatePluginRootId(rootId); + for (const entry of entries) { + validateCompositionEntry(entry); + if (locations.has(entry.id)) { + throw new MakaPluginRuntimeError( + 'entry_exists', + `Composition entry already exists: ${entry.id}`, + ); + } + const location: MutableLocation = { entry, parent, rootId, siblings: entries }; + locations.set(entry.id, location); + index(entry.children as MakaCompositionEntry[], rootId, location); + } + }; + index(profile, 'profile'); + index(desktopUi, 'desktop-ui'); + for (const [scopeId, entries] of Object.entries(sessions)) { + index(entries, `session:${scopeId}`); + } + + const requireLocation = (entryId: string): MutableLocation => { + const location = locations.get(entryId); + if (!location) { + throw new MakaPluginRuntimeError( + 'entry_not_found', + `Composition entry not found: ${entryId}`, + ); + } + return location; + }; + const rootEntries = (rootId: MakaPluginRootId): MakaCompositionEntry[] => { + validatePluginRootId(rootId); + if (rootId === 'profile') return profile; + if (rootId === 'desktop-ui') return desktopUi; + const scopeId = rootId.slice('session:'.length); + return (sessions[scopeId] ??= []); + }; + const unindex = (entry: MakaCompositionEntry): void => { + locations.delete(entry.id); + for (const child of entry.children ?? []) unindex(child); + }; + const indexInserted = ( + entry: MakaCompositionEntry, + rootId: MakaPluginRootId, + siblings: MakaCompositionEntry[], + parent?: MutableLocation, + ): void => { + if (locations.has(entry.id)) { + throw new MakaPluginRuntimeError( + 'entry_exists', + `Composition entry already exists: ${entry.id}`, + ); + } + const location: MutableLocation = { entry, parent, rootId, siblings }; + locations.set(entry.id, location); + for (const child of entry.children ?? []) { + indexInserted(child, rootId, entry.children as MakaCompositionEntry[], location); + } + }; + + for (const operation of input.operations) { + switch (operation.type) { + case 'insert': { + const parent = operation.parentId ? requireLocation(operation.parentId) : undefined; + const rootId = operation.rootId ?? parent?.rootId ?? 'profile'; + validatePluginRootId(rootId); + if (parent && parent.rootId !== rootId) { + throw new MakaPluginRuntimeError( + 'invalid_entry', + 'Composition entries cannot move between roots', + ); + } + const entry = cloneCompositionEntry(operation.entry); + validateCompositionEntry(entry); + const subtreeIds = new Set(); + for (const item of walkCompositionEntry(entry)) { + if (subtreeIds.has(item.id) || locations.has(item.id)) { + throw new MakaPluginRuntimeError( + 'entry_exists', + `Composition entry already exists: ${item.id}`, + ); + } + subtreeIds.add(item.id); + } + const siblings = parent + ? (parent.entry.children as MakaCompositionEntry[]) + : rootEntries(rootId); + siblings.splice(Math.min(operation.position ?? Infinity, siblings.length), 0, entry); + indexInserted(entry, rootId, siblings, parent); + break; + } + case 'update': { + const location = requireLocation(operation.entryId); + const next: MakaCompositionEntry = { + ...location.entry, + ...operation.patch, + id: location.entry.id, + children: location.entry.children, + }; + validateCompositionEntry(next); + const position = location.siblings.indexOf(location.entry); + location.siblings[position] = next; + location.entry = next; + break; + } + case 'move': { + const location = requireLocation(operation.entryId); + const parent = operation.parentId ? requireLocation(operation.parentId) : undefined; + if (parent && parent.rootId !== location.rootId) { + throw new MakaPluginRuntimeError( + 'invalid_entry', + 'Composition entries cannot move between roots', + ); + } + for (let ancestor = parent; ancestor; ancestor = ancestor.parent) { + if (ancestor === location) { + throw new MakaPluginRuntimeError( + 'dependency_cycle', + `Entry ${operation.entryId} cannot contain itself`, + ); + } + } + location.siblings.splice(location.siblings.indexOf(location.entry), 1); + const siblings = parent + ? (parent.entry.children as MakaCompositionEntry[]) + : rootEntries(location.rootId); + siblings.splice( + Math.min(operation.position ?? Infinity, siblings.length), + 0, + location.entry, + ); + location.parent = parent; + location.siblings = siblings; + break; + } + case 'remove': { + const location = requireLocation(operation.entryId); + location.siblings.splice(location.siblings.indexOf(location.entry), 1); + unindex(location.entry); + break; + } + } + } + + return freezeCompositionState({ + schemaVersion: 1, + generation: state.generation + 1, + roots: { profile, desktopUi, sessions }, + }); +} + export type MakaCompositionEntryStatus = | 'disabled' | 'pending' @@ -125,20 +321,6 @@ export interface MakaPluginMountInspection { 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; @@ -202,6 +384,40 @@ export function validatePluginPackage(pkg: MakaPluginPackage): void { `Plugin package ${pkg.packageId} has no host or client plugin`, ); } + if (!Array.isArray(pkg.contributions ?? []) || (pkg.contributions?.length ?? 0) > 1024) { + throw new MakaPluginRuntimeError( + 'invalid_package', + `Plugin package ${pkg.packageId} has invalid contributions`, + ); + } + const contributions = new Set(); + for (const contribution of pkg.contributions ?? []) { + if ( + !contribution || + typeof contribution !== 'object' || + typeof contribution.id !== 'string' || + contribution.id.length === 0 || + contribution.id.length > 128 || + /[\u0000-\u001f\u007f]/u.test(contribution.id) || + typeof contribution.kind !== 'string' || + contribution.kind.length === 0 || + contribution.kind.length > 128 || + /[\u0000-\u001f\u007f]/u.test(contribution.kind) + ) { + throw new MakaPluginRuntimeError( + 'invalid_package', + `Plugin package ${pkg.packageId} has an invalid contribution`, + ); + } + const identity = `${contribution.kind}\0${contribution.id}`; + if (contributions.has(identity)) { + throw new MakaPluginRuntimeError( + 'invalid_package', + `Plugin package ${pkg.packageId} repeats contribution ${contribution.kind}:${contribution.id}`, + ); + } + contributions.add(identity); + } } export function validateCompositionEntry(entry: MakaCompositionEntry): void { @@ -378,6 +594,56 @@ export function isCanonicalPluginId(value: unknown): value is string { export const isCanonicalExtensionId = isCanonicalPluginId; +function cloneCompositionEntry(entry: MakaCompositionEntry): MakaCompositionEntry { + return { + ...entry, + ...(entry.inject && !Array.isArray(entry.inject) + ? { inject: { ...entry.inject } } + : entry.inject + ? { inject: [...entry.inject] } + : {}), + ...(entry.isolate ? { isolate: { ...entry.isolate } } : {}), + ...(entry.intercept ? { intercept: { ...entry.intercept } } : {}), + children: (entry.children ?? []).map(cloneCompositionEntry), + }; +} + +function* walkCompositionEntry(entry: MakaCompositionEntry): Generator { + yield entry; + for (const child of entry.children ?? []) yield* walkCompositionEntry(child); +} + +function freezeCompositionState(state: MakaCompositionState): MakaCompositionState { + const freezeEntry = (entry: MakaCompositionEntry): MakaCompositionEntry => + 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)), + }); + return Object.freeze({ + schemaVersion: 1, + generation: state.generation, + roots: Object.freeze({ + profile: Object.freeze(state.roots.profile.map(freezeEntry)), + desktopUi: Object.freeze(state.roots.desktopUi.map(freezeEntry)), + sessions: Object.freeze( + Object.fromEntries( + Object.entries(state.roots.sessions).map(([scopeId, entries]) => [ + scopeId, + Object.freeze(entries.map(freezeEntry)), + ]), + ), + ), + }), + }); +} + 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) From 89271944b865a57b79ecc3a85c2a25e80fc94334 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:52:11 +0800 Subject: [PATCH 2/5] fix(runtime-host): recover package install boundaries --- .../src/__tests__/plugin-platform.test.ts | 173 ++++++++++++++++-- .../src/server/plugin-package-store.ts | 27 ++- 2 files changed, 182 insertions(+), 18 deletions(-) diff --git a/packages/runtime-host/src/__tests__/plugin-platform.test.ts b/packages/runtime-host/src/__tests__/plugin-platform.test.ts index 93cae14c74..2788ff4730 100644 --- a/packages/runtime-host/src/__tests__/plugin-platform.test.ts +++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts @@ -18,7 +18,7 @@ */ import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; @@ -793,21 +793,117 @@ test('Manifest dependencies gate activation and protect required packages', asyn } }); -test('package storage repairs an owner-death previous generation', async () => { - const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-recovery-')); +test('package storage recovers every base-generation install and rollback boundary', async () => { + const cases = [ + { + name: 'journal synced before publication', + target: 'old', + candidate: 'new', + }, + { + name: 'previous moved out of target', + previous: 'old', + candidate: 'new', + }, + { + name: 'candidate published before authority commit', + target: 'new', + previous: 'old', + }, + { + name: 'rollback rejected the candidate', + previous: 'old', + rejected: 'new', + }, + { + name: 'rollback restored the previous Package', + target: 'old', + rejected: 'new', + }, + { + name: 'rollback returned the candidate to staging', + target: 'old', + candidate: 'new', + }, + ] as const; + for (const state of cases) { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-recovery-')); + try { + const { store, transaction, target } = await writeInstallRecoveryState(root, state); + await store.recover(7); + assert.equal(await readPackageMarker(target), 'old', state.name); + await assert.rejects(() => readdir(transaction), isEnoent, state.name); + } finally { + await rm(root, { recursive: true, force: true }); + } + } +}); + +test('package storage removes a fresh install at every base-generation boundary', async () => { + const cases = [ + { name: 'prepared', candidate: 'new' }, + { name: 'published', target: 'new' }, + { name: 'rollback started', rejected: 'new' }, + { name: 'rollback completed', candidate: 'new' }, + ] as const; + for (const state of cases) { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-fresh-recovery-')); + try { + const { store, transaction, target } = await writeInstallRecoveryState(root, state); + await store.recover(7); + await assert.rejects(() => readdir(target), isEnoent, state.name); + await assert.rejects(() => readdir(transaction), isEnoent, state.name); + } finally { + await rm(root, { recursive: true, force: true }); + } + } +}); + +test('package storage retains a Package committed by the authority generation', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-committed-recovery-')); try { - const control = join(root, 'control'); - const platform = new HostPluginPlatform(control); - await platform.recover(); - await platform.installPackage(await writeFixturePackage(root, 'recover-package', 'recover')); - await platform.close(); - const packages = join(control, 'plugin-packages-v2'); - await rename(join(packages, 'recover-package'), join(packages, '.previous-owner-death')); + const { store, transaction, target } = await writeInstallRecoveryState(root, { + target: 'new', + previous: 'old', + }); + await store.recover(8); + assert.equal(await readPackageMarker(target), 'new'); + await assert.rejects(() => readdir(transaction), isEnoent); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); - const recovered = new HostPluginPlatform(control); - await recovered.recover(); - assert.equal((await recovered.packages.load('recover-package')).extensionId, 'recover-package'); - await recovered.close(); +test('package storage discards journal-less transaction remnants', async () => { + const cases = [ + { name: 'abandoned preparation', target: 'old', candidate: 'new' }, + { name: 'partially removed committed transaction', target: 'new', previous: 'old' }, + ] as const; + for (const state of cases) { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-journal-less-')); + try { + const { store, transaction, target } = await writeInstallRecoveryState(root, state, false); + await store.recover(state.target === 'new' ? 8 : 7); + assert.equal(await readPackageMarker(target), state.target, state.name); + await assert.rejects(() => readdir(transaction), isEnoent, state.name); + } finally { + await rm(root, { recursive: true, force: true }); + } + } +}); + +test('package storage still fences a corrupt install journal', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-corrupt-journal-')); + try { + const { store, transaction } = await writeInstallRecoveryState(root, { + target: 'old', + candidate: 'new', + }); + await writeFile(join(transaction, 'transaction.json'), '{invalid'); + await assert.rejects( + () => store.recover(7), + /Unable to read Plugin package install transaction/u, + ); } finally { await rm(root, { recursive: true, force: true }); } @@ -833,6 +929,55 @@ test('Plugin Platform close aggregates every resource failure', async () => { } }); +interface InstallRecoveryState { + readonly target?: string; + readonly candidate?: string; + readonly previous?: string; + readonly rejected?: string; +} + +async function writeInstallRecoveryState( + root: string, + state: InstallRecoveryState, + journal = true, +): Promise<{ + readonly store: PluginPackageStore; + readonly transaction: string; + readonly target: string; +}> { + const store = new PluginPackageStore(join(root, 'control')); + const transaction = join(store.root, '.install-owner-death'); + const target = join(store.root, 'recover-package'); + await mkdir(transaction, { recursive: true }); + if (journal) { + await writeFile( + join(transaction, 'transaction.json'), + `${JSON.stringify({ + schemaVersion: 1, + extensionId: 'recover-package', + baseGeneration: 7, + nextGeneration: 8, + })}\n`, + ); + } + for (const name of ['target', 'candidate', 'previous', 'rejected'] as const) { + const marker = state[name]; + if (marker === undefined) continue; + const directory = name === 'target' ? target : join(transaction, name); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, 'marker'), marker); + } + return { store, transaction, target }; +} + +async function readPackageMarker(root: string): Promise { + return await readFile(join(root, 'marker'), 'utf8'); +} + +function isEnoent(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; +} + async function writeFixturePackage( root: string, packageId: string, diff --git a/packages/runtime-host/src/server/plugin-package-store.ts b/packages/runtime-host/src/server/plugin-package-store.ts index 89b188ce07..9346cba360 100644 --- a/packages/runtime-host/src/server/plugin-package-store.ts +++ b/packages/runtime-host/src/server/plugin-package-store.ts @@ -257,17 +257,35 @@ export class PluginPackageStore { async #recoverInstall(transactionRoot: string, authorityGeneration: number): Promise { const transaction = await readTransaction(transactionRoot); + if (!transaction) { + // The journal is synced before the first canonical Package rename. A + // journal-less directory is therefore either an abandoned preparation + // or a partially removed, already-committed transaction. In both cases + // the transaction directory is only a remnant and is safe to discard. + await rm(transactionRoot, { recursive: true, force: true }); + return; + } const target = join(this.root, transaction.extensionId); const candidate = join(transactionRoot, 'candidate'); const previous = join(transactionRoot, 'previous'); + const rejected = join(transactionRoot, 'rejected'); const candidateExists = await exists(candidate); const targetExists = await exists(target); const previousExists = await exists(previous); + const rejectedExists = await exists(rejected); if (authorityGeneration === transaction.baseGeneration) { - if (!candidateExists && targetExists) { - await rm(target, { recursive: true, force: true }); + if (rejectedExists) { + // Rollback has already moved the candidate away from the canonical + // target. If target exists it is the restored previous Package; if it + // does not, finish restoring previous before dropping the rejected + // candidate with the transaction directory. + if (!targetExists && previousExists) await rename(previous, target); + } else { + if (!candidateExists && targetExists) { + await rm(target, { recursive: true, force: true }); + } + if (previousExists) await rename(previous, target); } - if (previousExists) await rename(previous, target); await syncDirectory(this.root); await rm(transactionRoot, { recursive: true, force: true }); return; @@ -360,11 +378,12 @@ async function writeTransaction( await syncDirectory(dirname(root)); } -async function readTransaction(root: string): Promise { +async function readTransaction(root: string): Promise { let value: unknown; try { value = JSON.parse(await readFile(join(root, 'transaction.json'), 'utf8')); } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; throw persistence('Unable to read Plugin package install transaction', error); } if (!value || typeof value !== 'object' || Array.isArray(value)) { From 6104fc71b424a3e68a1fb1527baa7370f913b4b3 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:25:57 +0800 Subject: [PATCH 3/5] fix(plugin): preserve foundation consistency --- .../src/__tests__/plugin-platform.test.ts | 66 +++++++++++++++++++ .../src/server/extension-package-manifest.ts | 4 +- .../src/server/plugin-platform.ts | 25 ++++++- .../plugin-composition-loader.test.ts | 29 ++++++++ .../runtime/src/plugin-composition-loader.ts | 11 +++- 5 files changed, 130 insertions(+), 5 deletions(-) diff --git a/packages/runtime-host/src/__tests__/plugin-platform.test.ts b/packages/runtime-host/src/__tests__/plugin-platform.test.ts index 2788ff4730..d5c3c61735 100644 --- a/packages/runtime-host/src/__tests__/plugin-platform.test.ts +++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts @@ -560,6 +560,42 @@ test('failed uninstall keeps Package layers and desired state unchanged', async } }); +test('package storage uninstall failure restores durable authority and Runtime state', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-uninstall-rollback-')); + try { + const control = join(root, 'control'); + const packages = new FailingUninstallPackageStore(control); + const platform = new HostPluginPlatform(control, { packages }); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'uninstall-rollback', 'installed', { + composition: [ + { + type: 'insert', + entry: { id: 'package-default', packageId: 'uninstall-rollback' }, + }, + ], + }), + ); + const before = platform.desiredComposition(); + packages.failUninstall = true; + + await assert.rejects( + () => platform.uninstallPackage('uninstall-rollback'), + /injected package uninstall failure/u, + ); + + const authority = await platform.store.read(); + assert.deepEqual(authority?.packageLayers, ['uninstall-rollback']); + assert.deepEqual(platform.desiredComposition().roots, before.roots); + assert.equal(platform.composition.inspect('package-default').status, 'active'); + assert.deepEqual(await platform.packages.identities(), ['uninstall-rollback']); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('composition authority commits before Runtime convergence and exposes divergence', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-divergence-')); try { @@ -752,6 +788,27 @@ test('Manifest configuration defaults are committed to desired and live Entries' } }); +test('Manifest v1 rejects unsupported secret configuration metadata', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-config-secret-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + const source = await writeFixturePackage(root, 'secret-package', 'secret', { + manifest: { + configuration: { + properties: { token: { type: 'string', secret: true } }, + }, + }, + }); + + await assert.rejects(() => platform.installPackage(source), /manifest fields are invalid/u); + assert.deepEqual(await platform.packages.identities(), []); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('Manifest dependencies gate activation and protect required packages', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-dependencies-')); try { @@ -1031,6 +1088,15 @@ class FailingCompositionStore extends HostPluginCompositionStore { } } +class FailingUninstallPackageStore extends PluginPackageStore { + failUninstall = false; + + override async uninstall(extensionId: string): Promise { + if (this.failUninstall) throw new Error('injected package uninstall failure'); + await super.uninstall(extensionId); + } +} + class UnknownCommitCompositionStore extends HostPluginCompositionStore { fail = false; diff --git a/packages/runtime-host/src/server/extension-package-manifest.ts b/packages/runtime-host/src/server/extension-package-manifest.ts index a4d3f7ba62..0001be11e2 100644 --- a/packages/runtime-host/src/server/extension-package-manifest.ts +++ b/packages/runtime-host/src/server/extension-package-manifest.ts @@ -37,7 +37,6 @@ export interface ExtensionConfigurationProperty { readonly description?: string; readonly default?: ExtensionConfigurationScalar; readonly enum?: readonly ExtensionConfigurationScalar[]; - readonly secret: boolean; } export interface ExtensionConfigurationSchema { @@ -208,7 +207,7 @@ function decodeConfigurationSchema(value: unknown): ExtensionConfigurationSchema for (const [key, value] of Object.entries(propertiesSource)) { if (!KEY_PATTERN.test(key)) throw invalid(`Extension configuration key is invalid: ${key}`); const property = record(value, `configuration.properties.${key}`); - exactOptional(property, ['type'], ['title', 'description', 'default', 'enum', 'secret']); + exactOptional(property, ['type'], ['title', 'description', 'default', 'enum']); if (property.type !== 'string' && property.type !== 'number' && property.type !== 'boolean') { throw invalid(`Extension configuration property type is invalid: ${key}`); } @@ -246,7 +245,6 @@ function decodeConfigurationSchema(value: unknown): ExtensionConfigurationSchema ? {} : { default: defaultValue as ExtensionConfigurationScalar }), ...(values ? { enum: values } : {}), - secret: property.secret === true, }); } const required = schema.required === undefined ? [] : schema.required; diff --git a/packages/runtime-host/src/server/plugin-platform.ts b/packages/runtime-host/src/server/plugin-platform.ts index 59446525ed..1c13b00a6e 100644 --- a/packages/runtime-host/src/server/plugin-platform.ts +++ b/packages/runtime-host/src/server/plugin-platform.ts @@ -259,6 +259,8 @@ export class HostPluginPlatform { async uninstallPackage(extensionId: string): Promise { this.#assertMutable(); await this.#serializeMutable(async () => { + const previousAuthority = this.#authority; + const previousDesired = this.#desired; let planned: MakaCompositionState | undefined; let packageLayers = this.#authority.packageLayers; if (this.#authority.packageLayers.includes(extensionId)) { @@ -304,6 +306,7 @@ export class HostPluginPlatform { { cause: error }, ); } + const rollbackErrors: unknown[] = []; if (pkg) { let restored: MakaPluginPackage | undefined; try { @@ -312,9 +315,29 @@ export class HostPluginPlatform { await this.#releaseGeneration(pkg); } catch (rollbackError) { if (restored) await this.packageLoader.release(restored).catch(() => undefined); - this.#poisoned = asError(rollbackError); + rollbackErrors.push(rollbackError); } } + if (planned) { + try { + await this.#replaceDesiredComposition( + compositionWithGeneration(previousDesired, this.#desired.generation + 1), + previousAuthority.packageLayers, + previousAuthority.overlays, + ); + } catch (rollbackError) { + rollbackErrors.push(rollbackError); + } + } + if (rollbackErrors.length > 0) { + this.#poisoned = asError(rollbackErrors[0]); + this.#draining = true; + throw new HostPluginPlatformError( + 'mutation_failed', + 'Plugin package uninstall and rollback both failed; Plugin Platform was fenced', + { cause: new AggregateError([error, ...rollbackErrors]) }, + ); + } throw error; } }); diff --git a/packages/runtime/src/__tests__/plugin-composition-loader.test.ts b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts index 2680f17721..41ab5d9ebd 100644 --- a/packages/runtime/src/__tests__/plugin-composition-loader.test.ts +++ b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts @@ -711,6 +711,35 @@ test('failed composition batches restore the prior generation exactly', async () await loader.close(); }); +test('failed composition batches preserve both the operation and rollback failures', async () => { + let activations = 0; + const loader = new MakaCompositionLoader(); + await loader.install( + pkg('rollback-failure', () => { + activations += 1; + if (activations > 1) throw new Error('rollback activation failed'); + }), + ); + await loader.create('profile', entry('stable-entry', 'rollback-failure')); + + await assert.rejects( + () => + loader.apply({ + operations: [ + { type: 'insert', entry: { id: 'temporary-entry' } }, + { type: 'update', entryId: 'missing-entry', patch: { disabled: true } }, + ], + }), + (error: unknown) => + error instanceof AggregateError && + error.errors.length === 2 && + error.errors.some((cause) => /missing-entry/u.test(String(cause))) && + error.errors.some((cause) => /rollback activation failed/u.test(String(cause))), + ); + + await loader.close(); +}); + test('package reload replaces every matching mount without restarting unrelated Entries', async () => { const events: string[] = []; const host = diff --git a/packages/runtime/src/plugin-composition-loader.ts b/packages/runtime/src/plugin-composition-loader.ts index 6cfd6a53e4..9ec17c2fa5 100644 --- a/packages/runtime/src/plugin-composition-loader.ts +++ b/packages/runtime/src/plugin-composition-loader.ts @@ -224,7 +224,16 @@ export class MakaCompositionLoader { // A candidate can fail before changing the live tree. Rebuilding in // that case would unnecessarily dispose the current Fiber and lose // its registered contributions. - if (appliedOperations > 0) await this.#replaceComposition(before, 'rollback'); + if (appliedOperations > 0) { + try { + await this.#replaceComposition(before, 'rollback'); + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + 'Plugin composition batch and rollback both failed', + ); + } + } throw error; } if (input.operations.length > 0) this.#compositionGeneration += 1; From a547cc2bcafa8edd758606f48b020e5a188cb8a9 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Thu, 27 Aug 2026 16:36:03 +0800 Subject: [PATCH 4/5] fix(plugin): harden prototype and query boundaries --- .../src/__tests__/plugin-platform.test.ts | 107 ++++++++++++++++++ .../src/protocol/plugin-platform.ts | 19 ++-- .../src/server/extension-package-manifest.ts | 2 +- .../src/server/plugin-platform-coordinator.ts | 3 +- .../plugin-composition-loader.test.ts | 15 +++ packages/runtime/src/plugin-runtime.ts | 10 +- 6 files changed, 146 insertions(+), 10 deletions(-) diff --git a/packages/runtime-host/src/__tests__/plugin-platform.test.ts b/packages/runtime-host/src/__tests__/plugin-platform.test.ts index d5c3c61735..37df17e142 100644 --- a/packages/runtime-host/src/__tests__/plugin-platform.test.ts +++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts @@ -28,6 +28,7 @@ import { decodeRequestFrame, decodeResponseFrame, } from '../protocol/index.js'; +import { PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES } from '../protocol/plugin-platform.js'; import { HostPluginCompositionStore, HostPluginCompositionStoreError, @@ -142,6 +143,53 @@ test('Plugin Platform coordinator keeps package and composition operations gener } }); +test('Plugin Platform query pages share the protocol byte budget across multiple items', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-query-budget-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + const coordinator = new HostPluginPlatformCoordinator(platform); + await platform.recover(); + for (let index = 0; index < 12; index += 1) { + await platform.apply({ + operations: [ + { + type: 'insert', + entry: { + id: `large-query-entry-${index}`, + config: { payload: `${index}:${'x'.repeat(60 * 1024)}` }, + }, + }, + ], + }); + } + + const queried = await coordinator.handlers['plugin.platform.query']( + { view: 'entries', limit: 64 }, + null as never, + ); + assert.equal(queried.ok, true); + if (!queried.ok || queried.result.view !== 'entries') throw new Error('Expected Entry page'); + assert.ok(queried.result.items.length > 1); + assert.ok(queried.result.items.length < 12); + assert.notEqual(queried.result.nextCursor, null); + assert.ok( + Buffer.byteLength(JSON.stringify(queried.result), 'utf8') <= + PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES, + ); + assert.doesNotThrow(() => + decodeResponseFrame({ + requestId: 'bounded-query', + operation: 'plugin.platform.query', + ok: true, + result: queried.result, + }), + ); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('package Composition layers override in install order and unwind on uninstall', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-layers-')); try { @@ -369,6 +417,28 @@ test('Plugin Platform protocol rejects open and malformed generic composition sh }, }), ); + + const prototypeKeys = decodePluginCompositionApplyInput( + JSON.parse( + '{"operations":[{"type":"insert","entry":{"id":"prototype-fields","config":{"__proto__":"configured","constructor":"constructor-value"},"isolate":{"constructor":"mapped"}}}]}', + ), + ); + const operation = prototypeKeys.operations[0]; + assert.equal(operation?.type, 'insert'); + if (operation?.type !== 'insert') throw new Error('Expected insert operation'); + const config = operation.entry.config as Readonly>; + const isolate = operation.entry.isolate as Readonly>; + assert.equal(Object.hasOwn(config, '__proto__'), true); + assert.equal(config['__proto__'], 'configured'); + assert.equal(Object.hasOwn(isolate, 'constructor'), true); + assert.equal(isolate['constructor'], 'mapped'); + assert.throws(() => + decodePluginCompositionApplyInput( + JSON.parse( + '{"operations":[{"type":"insert","entry":{"id":"invalid-isolate","isolate":{"__proto__":true}}}]}', + ), + ), + ); }); test('durable overlays may accumulate beyond one command frame without oversized responses', () => { @@ -809,6 +879,43 @@ test('Manifest v1 rejects unsupported secret configuration metadata', async () = } }); +test('Manifest configuration reads declared prototype-named keys as own values', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-config-prototype-')); + try { + const platform = new HostPluginPlatform(join(root, 'control')); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'prototype-config-package', 'prototype-config', { + manifest: { + configuration: { + properties: { constructor: { type: 'string' } }, + required: ['constructor'], + }, + }, + }), + ); + + await platform.apply({ + operations: [ + { + type: 'insert', + entry: { + id: 'prototype-config-entry', + packageId: 'prototype-config-package', + config: { constructor: 'configured' }, + }, + }, + ], + }); + const config = platform.desiredComposition().roots.profile[0]?.config; + assert.equal(Object.hasOwn(config ?? {}, 'constructor'), true); + assert.equal(config?.constructor, 'configured'); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('Manifest dependencies gate activation and protect required packages', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-dependencies-')); try { diff --git a/packages/runtime-host/src/protocol/plugin-platform.ts b/packages/runtime-host/src/protocol/plugin-platform.ts index a2cd7e1f82..2faa9cdcb1 100644 --- a/packages/runtime-host/src/protocol/plugin-platform.ts +++ b/packages/runtime-host/src/protocol/plugin-platform.ts @@ -53,6 +53,7 @@ const MUTATE_ERRORS = [ 'commit_outcome_unknown', ] as const; const MAX_FRAME_BYTES = 512 * 1024; +export const PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES = 480 * 1024; export interface PluginPackageProjection { readonly extensionId: string; @@ -307,7 +308,11 @@ function decodePluginPlatformQueryResult(value: unknown): PluginPlatformQueryRes ? { view, items: decodeInspections(output.items), nextCursor } : { view: 'failures', items: output.items.map(decodePlatformFailure), nextCursor }; } - requireEncodedByteLimit(decoded, 'Plugin Platform query result', MAX_FRAME_BYTES); + requireEncodedByteLimit( + decoded, + 'Plugin Platform query result', + PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES, + ); return decoded; } @@ -569,15 +574,15 @@ function decodeInject(value: unknown): readonly string[] | Readonly> { const record = requireRecord(value, 'Plugin Entry isolate'); - const output: Record = {}; + const output: Array = []; for (const [key, item] of Object.entries(record)) { requireId(key, 'Plugin Entry isolate key'); if (item !== true && (typeof item !== 'string' || !item)) { throw invalidProtocolFrame('Invalid Plugin Entry isolate value'); } - output[key] = item; + output.push([key, item]); } - return output; + return Object.fromEntries(output); } function decodeJsonRecord(value: unknown, label: string): Readonly> { @@ -595,7 +600,7 @@ function decodeScalarRecord( label: string, ): Readonly> { const record = requireRecord(value, `Plugin Entry ${label}`); - const output: Record = {}; + const output: Array = []; for (const [key, item] of Object.entries(record)) { requireId(key, `Plugin Entry ${label} key`); if ( @@ -603,10 +608,10 @@ function decodeScalarRecord( typeof item === 'boolean' || (typeof item === 'number' && Number.isFinite(item)) ) - output[key] = item; + output.push([key, item]); else throw invalidProtocolFrame(`Invalid Plugin Entry ${label} value`); } - return output; + return Object.fromEntries(output); } function requireBoolean(value: unknown): boolean { diff --git a/packages/runtime-host/src/server/extension-package-manifest.ts b/packages/runtime-host/src/server/extension-package-manifest.ts index 0001be11e2..89f6170a7e 100644 --- a/packages/runtime-host/src/server/extension-package-manifest.ts +++ b/packages/runtime-host/src/server/extension-package-manifest.ts @@ -157,7 +157,7 @@ export function validateExtensionConfiguration( if (unknown) throw invalid(`Extension configuration key is not declared: ${unknown}`); const result: Record = {}; for (const [key, property] of Object.entries(schema.properties)) { - const configured = input[key] ?? property.default; + const configured = Object.hasOwn(input, key) ? input[key] : property.default; if (configured === undefined) { if (schema.required.includes(key)) { throw invalid(`Extension configuration is missing required key: ${key}`); diff --git a/packages/runtime-host/src/server/plugin-platform-coordinator.ts b/packages/runtime-host/src/server/plugin-platform-coordinator.ts index 47514f5861..69460641b1 100644 --- a/packages/runtime-host/src/server/plugin-platform-coordinator.ts +++ b/packages/runtime-host/src/server/plugin-platform-coordinator.ts @@ -32,6 +32,7 @@ import type { PluginPlatformQueryInput, PluginPlatformQueryResult, } from '../protocol/index.js'; +import { PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES } from '../protocol/plugin-platform.js'; import { ExtensionBundleError } from './extension-bundle.js'; import { ExtensionPackageManifestError } from './extension-package-manifest.js'; import type { PluginPlatformOperationHandlerMap } from './operation-dispatcher.js'; @@ -209,7 +210,7 @@ function boundedPage( const candidate = [...items, values[index] as T]; if ( Buffer.byteLength(JSON.stringify({ view, items: candidate, nextCursor: index + 1 }), 'utf8') > - 480 * 1024 + PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES ) { break; } diff --git a/packages/runtime/src/__tests__/plugin-composition-loader.test.ts b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts index 41ab5d9ebd..3f04653180 100644 --- a/packages/runtime/src/__tests__/plugin-composition-loader.test.ts +++ b/packages/runtime/src/__tests__/plugin-composition-loader.test.ts @@ -468,6 +468,21 @@ test('replacement subtrees reject duplicate ids across different branches', asyn test('state preserves session ids that overlap object prototype properties', async () => { const loader = new MakaCompositionLoader(); + const reduced = applyCompositionState(loader.compositionState(), { + operations: [ + { + type: 'insert', + rootId: 'session:__proto__', + entry: { id: 'reduced-special-session-entry' }, + }, + ], + }); + assert.equal(Object.hasOwn(reduced.roots.sessions, '__proto__'), true); + assert.deepEqual( + reduced.roots.sessions.__proto__?.map(({ id }) => id), + ['reduced-special-session-entry'], + ); + await loader.create('session:__proto__', { id: 'special-session-entry' }); const state = loader.compositionState(); diff --git a/packages/runtime/src/plugin-runtime.ts b/packages/runtime/src/plugin-runtime.ts index 287a3abe57..8b8e106e6c 100644 --- a/packages/runtime/src/plugin-runtime.ts +++ b/packages/runtime/src/plugin-runtime.ts @@ -162,7 +162,15 @@ export function applyCompositionState( if (rootId === 'profile') return profile; if (rootId === 'desktop-ui') return desktopUi; const scopeId = rootId.slice('session:'.length); - return (sessions[scopeId] ??= []); + if (!Object.hasOwn(sessions, scopeId)) { + Object.defineProperty(sessions, scopeId, { + value: [], + writable: true, + enumerable: true, + configurable: true, + }); + } + return sessions[scopeId]!; }; const unindex = (entry: MakaCompositionEntry): void => { locations.delete(entry.id); From 39937b5d795335f5db503adbd696c302c9244186 Mon Sep 17 00:00:00 2001 From: xxhZs <84456268+xxhZs@users.noreply.github.com> Date: Sat, 29 Aug 2026 19:25:43 +0800 Subject: [PATCH 5/5] fix(plugin): make platform state and convergence explicit --- .../runtime-host-operator-command.test.ts | 23 +- packages/cli/src/cli-core.ts | 1 + packages/cli/src/runtime-host-cli.ts | 22 +- .../cli/src/runtime-host-plugin-command.ts | 7 +- .../src/__tests__/plugin-platform.test.ts | 412 ++++++-- .../src/protocol/operation-spec.ts | 1 + .../runtime-host/src/protocol/operations.ts | 6 - .../src/protocol/plugin-platform.ts | 196 +++- .../src/server/extension-bundle.ts | 8 +- .../src/server/extension-package-manifest.ts | 20 +- packages/runtime-host/src/server/index.ts | 13 - .../src/server/plugin-package-store.ts | 33 +- .../src/server/plugin-platform-coordinator.ts | 119 ++- .../src/server/plugin-platform.ts | 927 +++++++++++++----- 14 files changed, 1311 insertions(+), 477 deletions(-) diff --git a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts index 75dcaef8c0..dcc6732458 100644 --- a/packages/cli/src/__tests__/runtime-host-operator-command.test.ts +++ b/packages/cli/src/__tests__/runtime-host-operator-command.test.ts @@ -146,11 +146,21 @@ describe('Runtime Host operator commands', () => { }, ); assert.deepEqual( - parseRuntimeHostCommand(['plugin', 'inspect', '--scope', 'profile', '--limit', '16']), + parseRuntimeHostCommand([ + 'plugin', + 'inspect', + '--scope', + 'profile', + '--cursor', + 'opaque-cursor', + '--limit', + '16', + ]), { kind: 'runtime-host-plugin', action: 'inspect', rootId: 'profile', + cursor: 'opaque-cursor', limit: 16, }, ); @@ -307,6 +317,8 @@ describe('Runtime Host operator commands', () => { assert.equal(resolved.operationGrants.includes('access.credential.rotation.prepare'), false); assert.equal(resolved.operationGrants.includes('access.credential.rotation.revoke'), false); assert.equal(resolved.operationGrants.includes('host.upgrade.prepare'), false); + assert.equal(resolved.operationGrants.includes('plugin.platform.query'), false); + assert.equal(resolved.operationGrants.includes('plugin.package.install'), false); assert.equal(resolved.operationGrants.includes('turn.start'), true); assert.equal(resolved.operationGrants.includes('project.catalog.query'), true); } @@ -399,6 +411,13 @@ describe('Runtime Host operator commands', () => { 'peer.mesh.query', 'peer.mesh.reconcile', 'peer.mesh.remove', + 'plugin.composition.apply', + 'plugin.package.export', + 'plugin.package.install', + 'plugin.package.reload', + 'plugin.package.uninstall', + 'plugin.platform.query', + 'plugin.platform.reconcile', ], ); }); @@ -533,6 +552,7 @@ describe('Runtime Host operator commands', () => { targetPath: './plugin.maka-extension', }, { rootPath: '/srv/maka', action: 'apply' as const, subject: './operations.json' }, + { rootPath: '/srv/maka', action: 'reconcile' as const }, ]; for (const command of commands) { assert.equal(await runRuntimeHostPluginCli(command, overrides), 0); @@ -549,6 +569,7 @@ describe('Runtime Host operator commands', () => { 'plugin.package.reload', 'plugin.package.export', 'plugin.composition.apply', + 'plugin.platform.reconcile', ], ); assert.equal(closeCount, commands.length); diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts index 7d995b3a05..aa7a6e10e6 100644 --- a/packages/cli/src/cli-core.ts +++ b/packages/cli/src/cli-core.ts @@ -164,6 +164,7 @@ function helpText(cliCommand: string): string { ` ${cliCommand} runtime-host plugin install|uninstall|reload [--root ]`, ` ${cliCommand} runtime-host plugin export [--root ]`, ` ${cliCommand} runtime-host plugin apply [--root ]`, + ` ${cliCommand} runtime-host plugin reconcile [--root ]`, ` ${cliCommand} runtime-host profile list`, ` ${cliCommand} runtime-host profile set --id --name --tls-url --expected-root [--credential-env ]`, ` ${cliCommand} runtime-host profile set --id --name --ssh-destination --ssh-remote-port --expected-root [--ssh-port ] [--credential-env ]`, diff --git a/packages/cli/src/runtime-host-cli.ts b/packages/cli/src/runtime-host-cli.ts index bf01979cbe..a189932bb3 100644 --- a/packages/cli/src/runtime-host-cli.ts +++ b/packages/cli/src/runtime-host-cli.ts @@ -279,11 +279,12 @@ export type RuntimeHostCliCommand = | 'uninstall' | 'reload' | 'export' - | 'apply'; + | 'apply' + | 'reconcile'; subject?: string; targetPath?: string; rootId?: string; - cursor?: number; + cursor?: string; limit?: number; } | { @@ -1377,6 +1378,7 @@ function parsePluginCommand(argv: string[]): RuntimeHostCliCommand { 'reload', 'export', 'apply', + 'reconcile', ] as const; if (!actions.includes(action as (typeof actions)[number])) { return error( @@ -1387,7 +1389,7 @@ function parsePluginCommand(argv: string[]): RuntimeHostCliCommand { } let rootPath: string | undefined; let rootId: string | undefined; - let cursor: number | undefined; + let cursor: string | undefined; let limit: number | undefined; const positional: string[] = []; for (let index = 1; index < argv.length; index += 1) { @@ -1402,17 +1404,13 @@ function parsePluginCommand(argv: string[]): RuntimeHostCliCommand { if (typeof parsed !== 'string') return parsed; if (argument === '--root') rootPath = parsed; else if (argument === '--scope') rootId = parsed; + else if (argument === '--cursor') cursor = parsed; else { const numeric = Number(parsed); - if ( - !Number.isSafeInteger(numeric) || - numeric < 0 || - (argument === '--limit' && (numeric < 1 || numeric > 64)) - ) { - return error(`${argument} requires a non-negative integer`); + if (!Number.isSafeInteger(numeric) || numeric < 0 || numeric < 1 || numeric > 64) { + return error('--limit requires an integer between 1 and 64'); } - if (argument === '--cursor') cursor = numeric; - else limit = numeric; + limit = numeric; } index += 1; continue; @@ -1445,7 +1443,7 @@ function parsePluginCommand(argv: string[]): RuntimeHostCliCommand { ...(positional[0] ? { subject: positional[0] } : {}), ...(positional[1] ? { targetPath: positional[1] } : {}), ...(rootId ? { rootId } : {}), - ...(cursor === undefined ? {} : { cursor }), + ...(cursor ? { cursor } : {}), ...(limit === undefined ? {} : { limit }), }; } diff --git a/packages/cli/src/runtime-host-plugin-command.ts b/packages/cli/src/runtime-host-plugin-command.ts index fe2f6be937..ca87bd1c10 100644 --- a/packages/cli/src/runtime-host-plugin-command.ts +++ b/packages/cli/src/runtime-host-plugin-command.ts @@ -38,11 +38,12 @@ export interface RuntimeHostPluginCommand { | 'uninstall' | 'reload' | 'export' - | 'apply'; + | 'apply' + | 'reconcile'; readonly subject?: string; readonly targetPath?: string; readonly rootId?: string; - readonly cursor?: number; + readonly cursor?: string; readonly limit?: number; } @@ -110,6 +111,8 @@ async function execute( const decoded = JSON.parse(await readText(resolve(requireSubject(command)))) as unknown; return await connection.request('plugin.composition.apply', decoded as never); } + case 'reconcile': + return await connection.request('plugin.platform.reconcile', {}); } } diff --git a/packages/runtime-host/src/__tests__/plugin-platform.test.ts b/packages/runtime-host/src/__tests__/plugin-platform.test.ts index 37df17e142..f8681d41d5 100644 --- a/packages/runtime-host/src/__tests__/plugin-platform.test.ts +++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts @@ -27,6 +27,7 @@ import { decodePluginCompositionApplyInput, decodeRequestFrame, decodeResponseFrame, + operationAllowsRemoteOwner, } from '../protocol/index.js'; import { PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES } from '../protocol/plugin-platform.js'; import { @@ -37,7 +38,38 @@ import { import { HostPluginPlatformCoordinator } from '../server/plugin-platform-coordinator.js'; import { TrustedPluginPackageLoader } from '../server/plugin-package-loader.js'; import { PluginPackageStore } from '../server/plugin-package-store.js'; -import { HostPluginPlatform } from '../server/plugin-platform.js'; +import { HostPluginPlatform, type HostPluginPlatformOptions } from '../server/plugin-platform.js'; + +interface TestPlatformInternals { + readonly composition: MakaCompositionLoader; + readonly packages: PluginPackageStore; + readonly store: HostPluginCompositionStore; +} + +const testPlatformInternals = new WeakMap(); + +function createPlatform( + controlDirectory: string, + options: HostPluginPlatformOptions = {}, +): HostPluginPlatform { + const composition = options.composition ?? new MakaCompositionLoader(); + const packages = options.packages ?? new PluginPackageStore(controlDirectory); + const packageLoader = + options.packageLoader ?? new TrustedPluginPackageLoader(controlDirectory, packages); + const store = options.store ?? new HostPluginCompositionStore(controlDirectory); + const platform = new HostPluginPlatform(controlDirectory, { + composition, + packages, + packageLoader, + store, + }); + testPlatformInternals.set(platform, { composition, packages, store }); + return platform; +} + +function internals(platform: HostPluginPlatform): TestPlatformInternals { + return testPlatformInternals.get(platform)!; +} test('Plugin Platform installs, activates, persists, and recovers a generic package', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-platform-')); @@ -51,29 +83,36 @@ test('Plugin Platform installs, activates, persists, and recovers a generic pack }, ], }); - const platform = new HostPluginPlatform(join(root, 'control')); + const platform = createPlatform(join(root, 'control')); await platform.recover(); - assert.deepEqual(await platform.installPackage(source), { extensionId: 'fixture-package' }); - const published = platform.composition.package('fixture-package'); + assert.deepEqual(await platform.installPackage(source), { + extensionId: 'fixture-package', + authorityEpoch: 1, + durability: 'committed', + convergence: 'converged', + cleanup: 'complete', + failures: [], + }); + const published = internals(platform).composition.package('fixture-package'); assert.deepEqual(published.contributions, [{ id: 'first', kind: 'foundation-test' }]); const bundle = join(root, 'fixture-package.maka-extension'); - await platform.packages.export('fixture-package', bundle); - const imported = new HostPluginPlatform(join(root, 'import-control')); + await platform.exportPackage('fixture-package', bundle); + const imported = createPlatform(join(root, 'import-control')); await imported.recover(); - assert.deepEqual(await imported.installPackage(bundle), { extensionId: 'fixture-package' }); + assert.equal((await imported.installPackage(bundle)).convergence, 'converged'); assert.equal(imported.inspect('profile')[0]?.id, 'fixture-entry'); await imported.close(); await platform.close(); - const recovered = new HostPluginPlatform(join(root, 'control')); + const recovered = createPlatform(join(root, 'control')); await recovered.recover(); assert.equal(recovered.inspect('profile')[0]?.status, 'active'); assert.equal(recovered.desiredComposition().generation, 1); - assert.deepEqual(recovered.composition.package('fixture-package').contributions, [ + assert.deepEqual(internals(recovered).composition.package('fixture-package').contributions, [ { id: 'first', kind: 'foundation-test' }, ]); - assert.deepEqual(Object.keys((await recovered.store.read()) ?? {}).sort(), [ + assert.deepEqual(Object.keys((await internals(recovered).store.read()) ?? {}).sort(), [ 'generation', 'overlays', 'packageLayers', @@ -99,7 +138,7 @@ test('Plugin Platform coordinator keeps package and composition operations gener }, ], }); - const platform = new HostPluginPlatform(join(root, 'control')); + const platform = createPlatform(join(root, 'control')); const coordinator = new HostPluginPlatformCoordinator(platform); await platform.recover(); @@ -109,7 +148,14 @@ test('Plugin Platform coordinator keeps package and composition operations gener ); assert.deepEqual(installed, { ok: true, - result: { extensionId: 'protocol-package' }, + result: { + extensionId: 'protocol-package', + authorityEpoch: 1, + durability: 'committed', + convergence: 'converged', + cleanup: 'complete', + failures: [], + }, }); const queried = await coordinator.handlers['plugin.platform.query']( { view: 'packages' }, @@ -130,13 +176,11 @@ test('Plugin Platform coordinator keeps package and composition operations gener if (entries.ok && entries.result.view === 'entries') { assert.equal(entries.result.items[0]?.id, 'protocol-entry'); } - assert.deepEqual( - await coordinator.handlers['plugin.package.reload']( - { extensionId: 'protocol-package' }, - null as never, - ), - { ok: true, result: {} }, + const reloaded = await coordinator.handlers['plugin.package.reload']( + { extensionId: 'protocol-package' }, + null as never, ); + assert.equal(reloaded.ok && reloaded.result.convergence, 'converged'); await platform.close(); } finally { await rm(root, { recursive: true, force: true }); @@ -146,7 +190,7 @@ test('Plugin Platform coordinator keeps package and composition operations gener test('Plugin Platform query pages share the protocol byte budget across multiple items', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-query-budget-')); try { - const platform = new HostPluginPlatform(join(root, 'control')); + const platform = createPlatform(join(root, 'control')); const coordinator = new HostPluginPlatformCoordinator(platform); await platform.recover(); for (let index = 0; index < 12; index += 1) { @@ -190,10 +234,161 @@ test('Plugin Platform query pages share the protocol byte budget across multiple } }); +test('Plugin Platform lifecycle gates operations and recovery runs exactly once', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-lifecycle-')); + try { + const platform = createPlatform(join(root, 'control')); + const coordinator = new HostPluginPlatformCoordinator(platform); + const before = await coordinator.handlers['plugin.platform.query']( + { view: 'status' }, + null as never, + ); + assert.equal(before.ok, false); + if (!before.ok) assert.equal(before.error.code, 'host_not_ready'); + await platform.recover(); + await assert.rejects(() => platform.recover(), /cannot recover from phase ready/u); + assert.equal((await platform.status()).phase, 'ready'); + platform.beginDrain(); + await assert.rejects(() => platform.apply({ operations: [] }), /draining/u); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Plugin Platform cursors reject a changed query snapshot', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-stale-cursor-')); + try { + const platform = createPlatform(join(root, 'control')); + const coordinator = new HostPluginPlatformCoordinator(platform); + await platform.recover(); + await platform.apply({ + operations: [ + { type: 'insert', entry: { id: 'cursor-one' } }, + { type: 'insert', entry: { id: 'cursor-two' } }, + ], + }); + const first = await coordinator.handlers['plugin.platform.query']( + { view: 'entries', limit: 1 }, + null as never, + ); + if (!first.ok || first.result.view !== 'entries' || !first.result.nextCursor) { + throw new Error('Expected a paged Entry snapshot'); + } + await platform.apply({ operations: [{ type: 'insert', entry: { id: 'cursor-three' } }] }); + const stale = await coordinator.handlers['plugin.platform.query']( + { view: 'entries', limit: 1, cursor: first.result.nextCursor }, + null as never, + ); + assert.equal(stale.ok, false); + if (!stale.ok) assert.equal(stale.error.code, 'stale_cursor'); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Package composition declares and exposes structural dependencies', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-structural-dependencies-')); + try { + const platform = createPlatform(join(root, 'control')); + const coordinator = new HostPluginPlatformCoordinator(platform); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'structural-base', 'base', { + composition: [{ type: 'insert', entry: { id: 'structural-parent' } }], + }), + ); + const undeclared = await writeFixturePackage(root, 'undeclared-child', 'undeclared', { + composition: [ + { + type: 'insert', + parentId: 'structural-parent', + entry: { id: 'undeclared-entry' }, + }, + ], + }); + await assert.rejects( + () => platform.installPackage(undeclared), + /structural dependencies do not match/u, + ); + const childSource = await writeFixturePackage(root, 'structural-child', 'child', { + structuralDependencies: ['structural-base'], + composition: [ + { + type: 'insert', + parentId: 'structural-parent', + entry: { id: 'structural-child-entry' }, + }, + ], + }); + await platform.installPackage(childSource); + const packages = await coordinator.handlers['plugin.platform.query']( + { view: 'packages' }, + null as never, + ); + if (!packages.ok || packages.result.view !== 'packages') throw new Error('Expected packages'); + assert.match( + packages.result.items.find(({ extensionId }) => extensionId === 'structural-child') + ?.contentDigest ?? '', + /^sha256-[a-f0-9]{64}$/u, + ); + assert.deepEqual( + packages.result.items.find(({ extensionId }) => extensionId === 'structural-child') + ?.structuralDependencies, + ['structural-base'], + ); + assert.deepEqual( + packages.result.items.find(({ extensionId }) => extensionId === 'structural-base') + ?.requiredBy, + ['structural-child'], + ); + await assert.rejects( + () => platform.uninstallPackage('structural-base'), + /structurally required/u, + ); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('Package replacement releases a single-provider Service before activating its successor', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-service-reload-')); + try { + const platform = createPlatform(join(root, 'control')); + await platform.recover(); + await platform.installPackage( + await writeFixturePackage(root, 'service-package', 'first', { + provideService: 'replacementService', + composition: [ + { type: 'insert', entry: { id: 'service-entry', packageId: 'service-package' } }, + ], + }), + ); + const replacement = await writeFixturePackage(root, 'service-package', 'second', { + directorySuffix: 'replacement', + provideService: 'replacementService', + composition: [ + { type: 'insert', entry: { id: 'service-entry', packageId: 'service-package' } }, + ], + }); + const receipt = await platform.installPackage(replacement); + assert.equal(receipt.convergence, 'converged'); + assert.equal(platform.inspect('profile')[0]?.status, 'active'); + assert.deepEqual(internals(platform).composition.root.get('replacementService'), { + source: 'second', + }); + await platform.close(); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test('package Composition layers override in install order and unwind on uninstall', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-layers-')); try { - const platform = new HostPluginPlatform(join(root, 'control')); + const platform = createPlatform(join(root, 'control')); await platform.recover(); await platform.installPackage( await writeFixturePackage(root, 'layer-base', 'base', { @@ -215,12 +410,14 @@ test('package Composition layers override in install order and unwind on uninsta }), ); const overrideSource = await writeFixturePackage(root, 'layer-override', 'override', { + structuralDependencies: ['layer-base'], composition: [ { type: 'update', entryId: 'layer-entry', patch: { config: { theme: 'override' } } }, ], }); await platform.installPackage(overrideSource); const tailSource = await writeFixturePackage(root, 'layer-tail', 'tail', { + structuralDependencies: ['layer-base'], composition: [ { type: 'update', entryId: 'layer-entry', patch: { config: { theme: 'tail' } } }, ], @@ -244,7 +441,7 @@ test('package Composition layers override in install order and unwind on uninsta assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { theme: 'user' }); await platform.uninstallPackage('layer-override'); assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { theme: 'user' }); - assert.deepEqual((await platform.store.read())?.packageLayers, ['layer-base']); + assert.deepEqual((await internals(platform).store.read())?.packageLayers, ['layer-base']); await platform.close(); } finally { await rm(root, { recursive: true, force: true }); @@ -254,13 +451,13 @@ test('package Composition layers override in install order and unwind on uninsta test('invalid package Composition patch is rejected before package publication', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-invalid-patch-')); try { - const platform = new HostPluginPlatform(join(root, 'control')); + const platform = createPlatform(join(root, 'control')); await platform.recover(); const source = await writeFixturePackage(root, 'invalid-patch', 'invalid', { composition: [{}], }); await assert.rejects(() => platform.installPackage(source), /Composition patch is invalid/u); - assert.deepEqual(await platform.packages.identities(), []); + assert.deepEqual(await internals(platform).packages.identities(), []); assert.deepEqual(platform.desiredComposition().roots.profile, []); const semanticSource = await writeFixturePackage(root, 'invalid-layer', 'invalid', { @@ -272,7 +469,7 @@ test('invalid package Composition patch is rejected before package publication', ], }); await assert.rejects(() => platform.installPackage(semanticSource), /missing-package/u); - assert.deepEqual(await platform.packages.identities(), []); + assert.deepEqual(await internals(platform).packages.identities(), []); assert.deepEqual(platform.desiredComposition().roots.profile, []); await platform.close(); } finally { @@ -284,7 +481,7 @@ test('failed package replacement restores both stored bytes and live Runtime pac const root = await mkdtemp(join(tmpdir(), 'maka-plugin-rollback-')); try { const source = await writeFixturePackage(root, 'rollback-package', 'stable'); - const platform = new HostPluginPlatform(join(root, 'control')); + const platform = createPlatform(join(root, 'control')); await platform.recover(); await platform.installPackage(source); await platform.apply({ @@ -295,23 +492,29 @@ test('failed package replacement restores both stored bytes and live Runtime pac }, ], }); - const before = platform.composition.package('rollback-package').contributions; + const before = internals(platform).composition.package('rollback-package').contributions; const invalid = await writeFixturePackage(root, 'rollback-package', 'replacement', { runtimePackageId: 'wrong-package', directorySuffix: 'invalid', }); await assert.rejects(() => platform.installPackage(invalid), /does not match manifest/u); - assert.deepEqual(platform.composition.package('rollback-package').contributions, before); + assert.deepEqual( + internals(platform).composition.package('rollback-package').contributions, + before, + ); assert.equal( - (await platform.packages.load('rollback-package')).manifest.id, + (await internals(platform).packages.load('rollback-package')).manifest.id, 'rollback-package', ); await platform.close(); - const recovered = new HostPluginPlatform(join(root, 'control')); + const recovered = createPlatform(join(root, 'control')); await recovered.recover(); - assert.deepEqual(recovered.composition.package('rollback-package').contributions, before); + assert.deepEqual( + internals(recovered).composition.package('rollback-package').contributions, + before, + ); await recovered.close(); } finally { await rm(root, { recursive: true, force: true }); @@ -324,7 +527,7 @@ test('package replacement recovery follows the durable Composition generation', for (const mode of ['before', 'after'] as const) { const control = join(root, mode); const store = new AmbiguousCompositionStore(control); - const initial = new HostPluginPlatform(control, { store }); + const initial = createPlatform(control, { store }); await initial.recover(); await initial.installPackage( await writeFixturePackage(root, `generation-${mode}`, 'stable', { @@ -348,16 +551,16 @@ test('package replacement recovery follows the durable Composition generation', }); await assert.rejects(() => initial.installPackage(replacement), /commit outcome is unknown/u); assert.equal( - initial.composition.package(`generation-${mode}`).contributions?.[0]?.id, + internals(initial).composition.package(`generation-${mode}`).contributions?.[0]?.id, 'stable', 'Runtime convergence waits until the authority outcome is known', ); await initial.close(); - const recovered = new HostPluginPlatform(control); + const recovered = createPlatform(control); await recovered.recover(); assert.equal( - recovered.composition.package(`generation-${mode}`).contributions?.[0]?.id, + internals(recovered).composition.package(`generation-${mode}`).contributions?.[0]?.id, mode === 'after' ? 'replacement' : 'stable', ); await recovered.close(); @@ -368,6 +571,17 @@ test('package replacement recovery follows the durable Composition generation', }); test('Plugin Platform protocol rejects open and malformed generic composition shapes', () => { + for (const operation of [ + 'plugin.platform.query', + 'plugin.platform.reconcile', + 'plugin.package.install', + 'plugin.package.uninstall', + 'plugin.package.reload', + 'plugin.package.export', + 'plugin.composition.apply', + ] as const) { + assert.equal(operationAllowsRemoteOwner(operation), false); + } assert.equal( decodeRequestFrame({ requestId: 'plugin-reload', @@ -413,7 +627,7 @@ test('Plugin Platform protocol rejects open and malformed generic composition sh result: { view: 'entries', items: [], - nextCursor: 'invalid', + nextCursor: 1, }, }), ); @@ -455,7 +669,13 @@ test('durable overlays may accumulate beyond one command frame without oversized requestId: 'large-apply', operation: 'plugin.composition.apply', ok: true, - result: { generation: 700 }, + result: { + authorityEpoch: 700, + durability: 'committed', + convergence: 'converged', + cleanup: 'complete', + failures: [], + }, }), ); }); @@ -466,7 +686,7 @@ test('failed desired-state persistence leaves Runtime composition unchanged', as const control = join(root, 'control'); const store = new FailingCompositionStore(control); const source = await writeFixturePackage(root, 'persistent-package', 'stable'); - const platform = new HostPluginPlatform(control, { store }); + const platform = createPlatform(control, { store }); await platform.recover(); await platform.installPackage(source); await platform.apply({ @@ -477,7 +697,7 @@ test('failed desired-state persistence leaves Runtime composition unchanged', as }, ], }); - const before = platform.composition.compositionState(); + const before = internals(platform).composition.compositionState(); store.fail = true; await assert.rejects( @@ -488,7 +708,7 @@ test('failed desired-state persistence leaves Runtime composition unchanged', as }), /Runtime state was not changed/u, ); - assert.deepEqual(platform.composition.compositionState(), before); + assert.deepEqual(internals(platform).composition.compositionState(), before); assert.equal(platform.inspect('profile')[0]?.status, 'active'); await platform.close(); } finally { @@ -501,12 +721,12 @@ test('recovery loads installed packages that do not yet have an Entry', async () try { const control = join(root, 'control'); const source = await writeFixturePackage(root, 'unused-package', 'available'); - const initial = new HostPluginPlatform(control); + const initial = createPlatform(control); await initial.recover(); await initial.installPackage(source); await initial.close(); - const recovered = new HostPluginPlatform(control); + const recovered = createPlatform(control); await recovered.recover(); await recovered.apply({ operations: [{ type: 'insert', entry: { id: 'later-entry', packageId: 'unused-package' } }], @@ -522,7 +742,7 @@ test('immutable package generation is owned by package lifetime across repeated const root = await mkdtemp(join(tmpdir(), 'maka-plugin-generation-owner-')); try { const control = join(root, 'control'); - const platform = new HostPluginPlatform(control); + const platform = createPlatform(control); await platform.recover(); await platform.installPackage(await writeFixturePackage(root, 'shared-package', 'shared')); await platform.apply({ @@ -536,7 +756,7 @@ test('immutable package generation is owned by package lifetime across repeated await platform.apply({ operations: [{ type: 'remove', entryId: 'shared-one' }] }); assert.equal((await readdir(generations)).length, 1); - assert.equal(platform.composition.inspect('shared-two').status, 'active'); + assert.equal(internals(platform).composition.inspect('shared-two').status, 'active'); await platform.apply({ operations: [{ type: 'remove', entryId: 'shared-two' }] }); await platform.uninstallPackage('shared-package'); @@ -552,7 +772,7 @@ test('unknown desired-state commit outcome fences mutation without inventing a r try { const control = join(root, 'control'); const store = new UnknownCommitCompositionStore(control); - const platform = new HostPluginPlatform(control, { store }); + const platform = createPlatform(control, { store }); await platform.recover(); store.fail = true; @@ -560,7 +780,7 @@ test('unknown desired-state commit outcome fences mutation without inventing a r () => platform.apply({ operations: [{ type: 'insert', entry: { id: 'uncertain-entry' } }] }), /commit outcome is unknown/u, ); - assert.deepEqual(platform.composition.compositionState().roots.profile, []); + assert.deepEqual(internals(platform).composition.compositionState().roots.profile, []); await assert.rejects( () => platform.apply({ operations: [{ type: 'remove', entryId: 'uncertain-entry' }] }), /fenced/u, @@ -576,7 +796,7 @@ test('a queued mutation rechecks the fence after an unknown commit outcome', asy try { const control = join(root, 'control'); const store = new DeferredUnknownCompositionStore(control); - const platform = new HostPluginPlatform(control, { store }); + const platform = createPlatform(control, { store }); await platform.recover(); store.fail = true; @@ -602,7 +822,7 @@ test('failed uninstall keeps Package layers and desired state unchanged', async const root = await mkdtemp(join(tmpdir(), 'maka-plugin-uninstall-plan-')); try { const control = join(root, 'control'); - const platform = new HostPluginPlatform(control); + const platform = createPlatform(control); await platform.recover(); await platform.installPackage( await writeFixturePackage(root, 'uninstall-plan', 'installed', { @@ -617,11 +837,11 @@ test('failed uninstall keeps Package layers and desired state unchanged', async await platform.apply({ operations: [{ type: 'insert', entry: { id: 'user-entry', packageId: 'uninstall-plan' } }], }); - const authority = await platform.store.read(); + const authority = await internals(platform).store.read(); const desired = platform.desiredComposition(); await assert.rejects(() => platform.uninstallPackage('uninstall-plan'), /used by desired/u); - assert.deepEqual(await platform.store.read(), authority); + assert.deepEqual(await internals(platform).store.read(), authority); assert.deepEqual(platform.desiredComposition(), desired); assert.equal(platform.inspect('profile').length, 2); await platform.close(); @@ -630,12 +850,12 @@ test('failed uninstall keeps Package layers and desired state unchanged', async } }); -test('package storage uninstall failure restores durable authority and Runtime state', async () => { +test('package storage uninstall failure reports committed authority with pending cleanup', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-uninstall-rollback-')); try { const control = join(root, 'control'); const packages = new FailingUninstallPackageStore(control); - const platform = new HostPluginPlatform(control, { packages }); + const platform = createPlatform(control, { packages }); await platform.recover(); await platform.installPackage( await writeFixturePackage(root, 'uninstall-rollback', 'installed', { @@ -647,19 +867,18 @@ test('package storage uninstall failure restores durable authority and Runtime s ], }), ); - const before = platform.desiredComposition(); packages.failUninstall = true; - await assert.rejects( - () => platform.uninstallPackage('uninstall-rollback'), - /injected package uninstall failure/u, - ); + const receipt = await platform.uninstallPackage('uninstall-rollback'); + assert.equal(receipt.convergence, 'diverged'); + assert.equal(receipt.cleanup, 'pending'); + assert.match(receipt.failures[0]?.diagnostic ?? '', /injected package uninstall failure/u); - const authority = await platform.store.read(); - assert.deepEqual(authority?.packageLayers, ['uninstall-rollback']); - assert.deepEqual(platform.desiredComposition().roots, before.roots); - assert.equal(platform.composition.inspect('package-default').status, 'active'); - assert.deepEqual(await platform.packages.identities(), ['uninstall-rollback']); + const authority = await internals(platform).store.read(); + assert.deepEqual(authority?.packageLayers, []); + assert.deepEqual(platform.desiredComposition().roots.profile, []); + assert.deepEqual(internals(platform).composition.compositionState().roots.profile, []); + assert.deepEqual(await internals(platform).packages.identities(), ['uninstall-rollback']); await platform.close(); } finally { await rm(root, { recursive: true, force: true }); @@ -669,24 +888,22 @@ test('package storage uninstall failure restores durable authority and Runtime s test('composition authority commits before Runtime convergence and exposes divergence', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-divergence-')); try { - const platform = new HostPluginPlatform(join(root, 'control')); + const platform = createPlatform(join(root, 'control')); const coordinator = new HostPluginPlatformCoordinator(platform); await platform.recover(); await platform.installPackage( await writeFixturePackage(root, 'failing-package', 'failing', { throwOnApply: true }), ); - await assert.rejects( - () => - platform.apply({ - operations: [ - { type: 'insert', entry: { id: 'desired-failure', packageId: 'failing-package' } }, - ], - }), - /desired Plugin composition was committed/iu, - ); + const receipt = await platform.apply({ + operations: [ + { type: 'insert', entry: { id: 'desired-failure', packageId: 'failing-package' } }, + ], + }); + assert.equal(receipt.durability, 'committed'); + assert.equal(receipt.convergence, 'diverged'); assert.equal(platform.desiredComposition().roots.profile[0]?.id, 'desired-failure'); - assert.deepEqual(platform.composition.compositionState().roots.profile, []); + assert.deepEqual(internals(platform).composition.compositionState().roots.profile, []); const queried = await coordinator.handlers['plugin.platform.query']( { view: 'failures' }, null as never, @@ -705,7 +922,7 @@ test('recovery is fail-open for Host and isolates a broken desired Entry', async const root = await mkdtemp(join(tmpdir(), 'maka-plugin-partial-recovery-')); try { const control = join(root, 'control'); - const initial = new HostPluginPlatform(control); + const initial = createPlatform(control); await initial.recover(); await initial.installPackage(await writeFixturePackage(root, 'healthy-package', 'healthy')); await initial.close(); @@ -725,7 +942,7 @@ test('recovery is fail-open for Host and isolates a broken desired Entry', async ], }); - const recovered = new HostPluginPlatform(control); + const recovered = createPlatform(control); await recovered.recover(); assert.equal(recovered.inspect('profile')[0]?.id, 'healthy-entry'); assert.equal(recovered.desiredComposition().generation, 5); @@ -749,7 +966,7 @@ test('corrupt Plugin authority fails closed locally without failing Host recover const control = join(root, 'control'); await mkdir(control, { recursive: true }); await writeFile(join(control, 'plugin-composition-v2.json'), '{not-json'); - const platform = new HostPluginPlatform(control); + const platform = createPlatform(control); const coordinator = new HostPluginPlatformCoordinator(platform); await platform.recover(); @@ -757,8 +974,11 @@ test('corrupt Plugin authority fails closed locally without failing Host recover { view: 'status' }, null as never, ); - assert.equal(queried.ok, false); - if (!queried.ok) assert.equal(queried.error.code, 'persistence_failed'); + assert.equal(queried.ok, true); + if (queried.ok && queried.result.view === 'status') { + assert.equal(queried.result.phase, 'fenced'); + assert.equal(queried.result.convergence, 'unknown'); + } await platform.close(); } finally { await rm(root, { recursive: true, force: true }); @@ -772,8 +992,10 @@ test('a package that fails Runtime loading can still be uninstalled for repair', const source = await writeFixturePackage(root, 'broken-package', 'broken', { runtimePackageId: 'wrong-package', }); - await new PluginPackageStore(control).install(source); - const platform = new HostPluginPlatform(control); + const prepared = await new PluginPackageStore(control).prepareInstall(source); + await prepared.publish(0, 1); + await prepared.commit(); + const platform = createPlatform(control); await platform.recover(); assert.equal( platform.failures().some(({ extensionId }) => extensionId === 'broken-package'), @@ -781,7 +1003,7 @@ test('a package that fails Runtime loading can still be uninstalled for repair', ); await platform.uninstallPackage('broken-package'); - assert.deepEqual(await platform.packages.identities(), []); + assert.deepEqual(await internals(platform).packages.identities(), []); assert.equal( platform.failures().some(({ extensionId }) => extensionId === 'broken-package'), false, @@ -795,7 +1017,7 @@ test('a package that fails Runtime loading can still be uninstalled for repair', test('Manifest configuration is enforced before desired state is committed', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-config-contract-')); try { - const platform = new HostPluginPlatform(join(root, 'control')); + const platform = createPlatform(join(root, 'control')); await platform.recover(); await platform.installPackage( await writeFixturePackage(root, 'configured-package', 'configured', { @@ -821,7 +1043,7 @@ test('Manifest configuration is enforced before desired state is committed', asy /missing required key/u.test(error.cause.message), ); assert.deepEqual(platform.desiredComposition().roots.profile, []); - assert.deepEqual(platform.composition.compositionState().roots.profile, []); + assert.deepEqual(internals(platform).composition.compositionState().roots.profile, []); await platform.close(); } finally { await rm(root, { recursive: true, force: true }); @@ -831,7 +1053,7 @@ test('Manifest configuration is enforced before desired state is committed', asy test('Manifest configuration defaults are committed to desired and live Entries', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-config-defaults-')); try { - const platform = new HostPluginPlatform(join(root, 'control')); + const platform = createPlatform(join(root, 'control')); await platform.recover(); await platform.installPackage( await writeFixturePackage(root, 'defaulted-package', 'defaulted', { @@ -849,7 +1071,7 @@ test('Manifest configuration defaults are committed to desired and live Entries' ], }); assert.deepEqual(platform.desiredComposition().roots.profile[0]?.config, { enabled: true }); - assert.deepEqual(platform.composition.compositionState().roots.profile[0]?.config, { + assert.deepEqual(internals(platform).composition.compositionState().roots.profile[0]?.config, { enabled: true, }); await platform.close(); @@ -861,7 +1083,7 @@ test('Manifest configuration defaults are committed to desired and live Entries' test('Manifest v1 rejects unsupported secret configuration metadata', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-config-secret-')); try { - const platform = new HostPluginPlatform(join(root, 'control')); + const platform = createPlatform(join(root, 'control')); await platform.recover(); const source = await writeFixturePackage(root, 'secret-package', 'secret', { manifest: { @@ -872,7 +1094,7 @@ test('Manifest v1 rejects unsupported secret configuration metadata', async () = }); await assert.rejects(() => platform.installPackage(source), /manifest fields are invalid/u); - assert.deepEqual(await platform.packages.identities(), []); + assert.deepEqual(await internals(platform).packages.identities(), []); await platform.close(); } finally { await rm(root, { recursive: true, force: true }); @@ -882,7 +1104,7 @@ test('Manifest v1 rejects unsupported secret configuration metadata', async () = test('Manifest configuration reads declared prototype-named keys as own values', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-config-prototype-')); try { - const platform = new HostPluginPlatform(join(root, 'control')); + const platform = createPlatform(join(root, 'control')); await platform.recover(); await platform.installPackage( await writeFixturePackage(root, 'prototype-config-package', 'prototype-config', { @@ -919,7 +1141,7 @@ test('Manifest configuration reads declared prototype-named keys as own values', test('Manifest dependencies gate activation and protect required packages', async () => { const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-dependencies-')); try { - const platform = new HostPluginPlatform(join(root, 'control')); + const platform = createPlatform(join(root, 'control')); await platform.recover(); await platform.installPackage( await writeFixturePackage(root, 'dependent-package', 'dependent', { @@ -1080,7 +1302,7 @@ test('Plugin Platform close aggregates every resource failure', async () => { const packages = new PluginPackageStore(control); const composition = new FailingCloseCompositionLoader(); const packageLoader = new FailingClosePackageLoader(control, packages); - const platform = new HostPluginPlatform(control, { composition, packages, packageLoader }); + const platform = createPlatform(control, { composition, packages, packageLoader }); await platform.recover(); await assert.rejects( () => platform.close(), @@ -1150,6 +1372,8 @@ async function writeFixturePackage( readonly runtimePackageId?: string; readonly directorySuffix?: string; readonly throwOnApply?: boolean; + readonly provideService?: string; + readonly structuralDependencies?: readonly string[]; readonly manifest?: Readonly>; readonly composition?: readonly unknown[]; } = {}, @@ -1165,7 +1389,14 @@ async function writeFixturePackage( schemaVersion: 1, id: packageId, runtime: { entry: 'index.mjs' }, - ...(options.composition ? { composition: { patch: 'maka.composition.yml' } } : {}), + ...(options.composition + ? { + composition: { + patch: 'maka.composition.yml', + structuralDependencies: options.structuralDependencies ?? [], + }, + } + : {}), ...(options.manifest ?? {}), }), ); @@ -1179,6 +1410,7 @@ async function writeFixturePackage( contributions: Object.freeze([{ id: ${JSON.stringify(contributionId)}, kind: 'foundation-test' }]), host: Object.freeze({ apply(ctx) { ${options.throwOnApply ? "throw new Error('fixture activation failed');" : ''} + ${options.provideService ? `ctx.provide(${JSON.stringify(options.provideService)}, { source: ${JSON.stringify(contributionId)} });` : ''} ctx.effect(() => () => undefined, 'fixture'); } }), });\n`, diff --git a/packages/runtime-host/src/protocol/operation-spec.ts b/packages/runtime-host/src/protocol/operation-spec.ts index 6371f1dfa8..7b85f061a4 100644 --- a/packages/runtime-host/src/protocol/operation-spec.ts +++ b/packages/runtime-host/src/protocol/operation-spec.ts @@ -32,6 +32,7 @@ export type HostOperationErrorCode = | 'capability_unavailable' | 'invalid_request' | 'projection_incomplete' + | 'stale_cursor' | 'persistence_failed' | 'commit_outcome_unknown' | 'already_resolved' diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index 0936c017ec..c9223ebfe4 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -276,12 +276,6 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'plan.control', 'plan.query', 'plan.turn.start', - 'plugin.composition.apply', - 'plugin.package.export', - 'plugin.package.install', - 'plugin.package.reload', - 'plugin.package.uninstall', - 'plugin.platform.query', 'pricing.mutate', 'pricing.query', 'project.catalog.mutate', diff --git a/packages/runtime-host/src/protocol/plugin-platform.ts b/packages/runtime-host/src/protocol/plugin-platform.ts index 2faa9cdcb1..2a0620e1e7 100644 --- a/packages/runtime-host/src/protocol/plugin-platform.ts +++ b/packages/runtime-host/src/protocol/plugin-platform.ts @@ -45,6 +45,7 @@ const QUERY_ERRORS = [ 'invalid_request', 'persistence_failed', 'internal_failure', + 'stale_cursor', ] as const; const MUTATE_ERRORS = [ ...QUERY_ERRORS, @@ -55,42 +56,69 @@ const MUTATE_ERRORS = [ const MAX_FRAME_BYTES = 512 * 1024; export const PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES = 480 * 1024; +export type PluginPlatformPhase = + | 'new' + | 'recovering' + | 'ready' + | 'degraded' + | 'fenced' + | 'draining' + | 'closed'; + +export type PluginPlatformConvergence = 'unknown' | 'converged' | 'diverged'; + +export interface PluginMutationReceipt { + readonly authorityEpoch: number; + readonly durability: 'committed'; + readonly convergence: Exclude; + readonly cleanup: 'complete' | 'pending'; + readonly failures: readonly PluginPlatformFailureProjection[]; +} + export interface PluginPackageProjection { readonly extensionId: string; + readonly contentDigest: string; readonly displayName: string; readonly description?: string; readonly dependencies: readonly string[]; + readonly structuralDependencies: readonly string[]; + readonly requiredBy: readonly string[]; } export interface PluginPlatformQueryInput { readonly view: 'status' | 'packages' | 'entries' | 'failures'; readonly rootId?: MakaPluginRootId; - readonly cursor?: number; + readonly cursor?: string; readonly limit?: number; } export type PluginPlatformQueryResult = | { readonly view: 'status'; - readonly generation: number; - readonly packageCount: number; - readonly entryCount: number; + readonly phase: PluginPlatformPhase; + readonly authorityEpoch: number; + readonly convergence: PluginPlatformConvergence; + readonly installedPackageCount: number; + readonly layeredPackageCount: number; + readonly desiredEntryCount: number; + readonly liveEntryCount: number; readonly failureCount: number; + readonly fenceDiagnostic: string | null; } | { readonly view: 'packages'; readonly items: readonly PluginPackageProjection[]; - readonly nextCursor: number | null; + readonly nextCursor: string | null; } | { readonly view: 'entries'; readonly items: readonly MakaCompositionEntryInspection[]; - readonly nextCursor: number | null; + readonly nextCursor: string | null; } | { readonly view: 'failures'; readonly items: readonly PluginPlatformFailureProjection[]; - readonly nextCursor: number | null; + readonly nextCursor: string | null; }; export interface PluginPlatformFailureProjection { @@ -103,7 +131,7 @@ export interface PluginPackageInstallInput { readonly sourcePath: string; } -export interface PluginPackageInstallResult { +export interface PluginPackageInstallResult extends PluginMutationReceipt { readonly extensionId: string; } @@ -119,9 +147,8 @@ export interface PluginPackageExportResult { readonly targetPath: string; } -export interface PluginCompositionApplyResult { - readonly generation: number; -} +export type PluginPackageMutationResult = PluginMutationReceipt; +export type PluginCompositionApplyResult = PluginMutationReceipt; export const PLUGIN_PLATFORM_OPERATION_SPECS = { 'plugin.platform.query': defineOperation< @@ -151,31 +178,25 @@ export const PLUGIN_PLATFORM_OPERATION_SPECS = { }), 'plugin.package.uninstall': defineOperation< PluginPackageUninstallInput, - Record, + PluginPackageMutationResult, (typeof MUTATE_ERRORS)[number] >({ mode: 'command', availability: 'ready', errors: MUTATE_ERRORS, decodeInput: decodePluginPackageUninstallInput, - decodeOutput: (value) => { - requireExactRecord(value, 'Plugin package uninstall result', []); - return {}; - }, + decodeOutput: decodePluginMutationReceipt, }), 'plugin.package.reload': defineOperation< PluginPackageUninstallInput, - Record, + PluginPackageMutationResult, (typeof MUTATE_ERRORS)[number] >({ mode: 'command', availability: 'ready', errors: MUTATE_ERRORS, decodeInput: decodePluginPackageUninstallInput, - decodeOutput: (value) => { - requireExactRecord(value, 'Plugin package reload result', []); - return {}; - }, + decodeOutput: decodePluginMutationReceipt, }), 'plugin.package.export': defineHostPathOperation< PluginPackageExportInput, @@ -209,10 +230,21 @@ export const PLUGIN_PLATFORM_OPERATION_SPECS = { availability: 'ready', errors: MUTATE_ERRORS, decodeInput: decodePluginCompositionApplyInput, - decodeOutput: (value) => { - const output = requireExactRecord(value, 'Plugin composition apply result', ['generation']); - return { generation: requireCount(output.generation, 'Plugin composition generation') }; + decodeOutput: decodePluginMutationReceipt, + }), + 'plugin.platform.reconcile': defineOperation< + Record, + PluginMutationReceipt, + (typeof MUTATE_ERRORS)[number] + >({ + mode: 'command', + availability: 'ready', + errors: MUTATE_ERRORS, + decodeInput: (value) => { + requireExactRecord(value, 'Plugin Platform reconcile input', []); + return {}; }, + decodeOutput: decodePluginMutationReceipt, }), } as const; @@ -230,7 +262,7 @@ function decodePluginPlatformQueryInput(value: unknown): PluginPlatformQueryInpu const cursor = input.cursor === undefined ? undefined - : requireCount(input.cursor, 'Plugin Platform query cursor'); + : requireString(input.cursor, 'Plugin Platform query cursor', 4096); const limit = input.limit === undefined ? undefined @@ -272,17 +304,41 @@ function decodePluginPlatformQueryResult(value: unknown): PluginPlatformQueryRes if (view === 'status') { const output = requireExactRecord(record, 'Plugin Platform status result', [ 'view', - 'generation', - 'packageCount', - 'entryCount', + 'phase', + 'authorityEpoch', + 'convergence', + 'installedPackageCount', + 'layeredPackageCount', + 'desiredEntryCount', + 'liveEntryCount', 'failureCount', + 'fenceDiagnostic', ]); + if ( + !['new', 'recovering', 'ready', 'degraded', 'fenced', 'draining', 'closed'].includes( + output.phase as string, + ) || + !['unknown', 'converged', 'diverged'].includes(output.convergence as string) + ) { + throw invalidProtocolFrame('Invalid Plugin Platform status'); + } decoded = { view, - generation: requireCount(output.generation, 'Plugin composition generation'), - packageCount: requireCount(output.packageCount, 'Plugin package count'), - entryCount: requireCount(output.entryCount, 'Plugin Entry count'), + phase: output.phase as PluginPlatformPhase, + authorityEpoch: requireCount(output.authorityEpoch, 'Plugin authority epoch'), + convergence: output.convergence as PluginPlatformConvergence, + installedPackageCount: requireCount( + output.installedPackageCount, + 'Installed Plugin package count', + ), + layeredPackageCount: requireCount(output.layeredPackageCount, 'Layered Plugin package count'), + desiredEntryCount: requireCount(output.desiredEntryCount, 'Desired Plugin Entry count'), + liveEntryCount: requireCount(output.liveEntryCount, 'Live Plugin Entry count'), failureCount: requireCount(output.failureCount, 'Plugin failure count'), + fenceDiagnostic: + output.fenceDiagnostic === null + ? null + : requireString(output.fenceDiagnostic, 'Plugin fence diagnostic', 4096), }; } else { const output = requireExactRecord(record, 'Plugin Platform page result', [ @@ -300,7 +356,7 @@ function decodePluginPlatformQueryResult(value: unknown): PluginPlatformQueryRes const nextCursor = output.nextCursor === null ? null - : requireCount(output.nextCursor, 'Plugin Platform next cursor'); + : requireString(output.nextCursor, 'Plugin Platform next cursor', 4096); decoded = view === 'packages' ? { view, items: output.items.map(decodePackageProjection), nextCursor } @@ -341,12 +397,26 @@ function decodePackageProjection(value: unknown): PluginPackageProjection { const item = requireShapedRecord( value, 'Plugin package projection', - ['extensionId', 'displayName', 'dependencies'], + [ + 'extensionId', + 'contentDigest', + 'displayName', + 'dependencies', + 'structuralDependencies', + 'requiredBy', + ], ['description'], ); - if (!Array.isArray(item.dependencies)) throw invalidProtocolFrame('Invalid Plugin dependencies'); + if ( + !Array.isArray(item.dependencies) || + !Array.isArray(item.structuralDependencies) || + !Array.isArray(item.requiredBy) + ) { + throw invalidProtocolFrame('Invalid Plugin dependencies'); + } return { extensionId: requireId(item.extensionId, 'Plugin package identity'), + contentDigest: requireDigest(item.contentDigest, 'Plugin package content digest'), displayName: requireString(item.displayName, 'Plugin display name', 512), ...(item.description === undefined ? {} @@ -354,12 +424,60 @@ function decodePackageProjection(value: unknown): PluginPackageProjection { dependencies: item.dependencies.map((dependency) => requireId(dependency, 'Plugin dependency identity'), ), + structuralDependencies: item.structuralDependencies.map((dependency) => + requireId(dependency, 'Plugin structural dependency identity'), + ), + requiredBy: item.requiredBy.map((dependency) => + requireId(dependency, 'Plugin dependent identity'), + ), }; } function decodePluginPackageInstallResult(value: unknown): PluginPackageInstallResult { - const output = requireExactRecord(value, 'Plugin package install result', ['extensionId']); - return { extensionId: requireId(output.extensionId, 'Plugin package identity') }; + const output = requireExactRecord(value, 'Plugin package install result', [ + 'extensionId', + 'authorityEpoch', + 'durability', + 'convergence', + 'cleanup', + 'failures', + ]); + const receipt = decodePluginMutationReceipt({ + authorityEpoch: output.authorityEpoch, + durability: output.durability, + convergence: output.convergence, + cleanup: output.cleanup, + failures: output.failures, + }); + return { + extensionId: requireId(output.extensionId, 'Plugin package identity'), + ...receipt, + }; +} + +function decodePluginMutationReceipt(value: unknown): PluginMutationReceipt { + const output = requireExactRecord(value, 'Plugin mutation receipt', [ + 'authorityEpoch', + 'durability', + 'convergence', + 'cleanup', + 'failures', + ]); + if ( + output.durability !== 'committed' || + !['converged', 'diverged'].includes(output.convergence as string) || + !['complete', 'pending'].includes(output.cleanup as string) || + !Array.isArray(output.failures) + ) { + throw invalidProtocolFrame('Invalid Plugin mutation receipt'); + } + return { + authorityEpoch: requireCount(output.authorityEpoch, 'Plugin authority epoch'), + durability: 'committed', + convergence: output.convergence as PluginMutationReceipt['convergence'], + cleanup: output.cleanup as PluginMutationReceipt['cleanup'], + failures: output.failures.map(decodePlatformFailure), + }; } function decodePluginPackageUninstallInput(value: unknown): PluginPackageUninstallInput { @@ -618,3 +736,9 @@ function requireBoolean(value: unknown): boolean { if (typeof value !== 'boolean') throw invalidProtocolFrame('Invalid Plugin Entry disabled flag'); return value; } + +function requireDigest(value: unknown, label: string): string { + const digest = requireString(value, label, 80); + if (!/^sha256-[a-f0-9]{64}$/u.test(digest)) throw invalidProtocolFrame(`Invalid ${label}`); + return digest; +} diff --git a/packages/runtime-host/src/server/extension-bundle.ts b/packages/runtime-host/src/server/extension-bundle.ts index 40a665511e..6af475aa5e 100644 --- a/packages/runtime-host/src/server/extension-bundle.ts +++ b/packages/runtime-host/src/server/extension-bundle.ts @@ -50,7 +50,7 @@ export async function exportExtensionBundle(sourceRoot: string, targetPath: stri const files = await readDirectory(sourceRoot); const document: ExtensionBundleDocument = Object.freeze({ schemaVersion: 1, - digest: packageDigest(files), + digest: extensionPackageContentDigest(files), files: Object.freeze( files.map((file) => Object.freeze({ @@ -215,7 +215,7 @@ function decodeBundle(value: unknown): ExtensionBundleDocument { } return { path, content }; }); - if (typeof record.digest !== 'string' || packageDigest(files) !== record.digest) + if (typeof record.digest !== 'string' || extensionPackageContentDigest(files) !== record.digest) throw invalid('Extension bundle digest is invalid'); return Object.freeze({ schemaVersion: 1, @@ -232,7 +232,9 @@ function decodeBundle(value: unknown): ExtensionBundleDocument { }); } -function packageDigest(files: readonly { path: string; content: Buffer }[]): string { +export function extensionPackageContentDigest( + files: readonly { path: string; content: Buffer }[], +): string { const hash = createHash('sha256'); for (const file of files) { const path = Buffer.from(file.path, 'utf8'); diff --git a/packages/runtime-host/src/server/extension-package-manifest.ts b/packages/runtime-host/src/server/extension-package-manifest.ts index 89f6170a7e..e4898c50c3 100644 --- a/packages/runtime-host/src/server/extension-package-manifest.ts +++ b/packages/runtime-host/src/server/extension-package-manifest.ts @@ -61,6 +61,7 @@ export interface ExtensionPackageRuntime { export interface ExtensionPackageComposition { readonly patch: string; + readonly structuralDependencies: readonly string[]; } export class ExtensionPackageManifestError extends Error { @@ -123,8 +124,15 @@ export function decodeExtensionPackageManifest(value: unknown): ExtensionPackage function decodeComposition(value: unknown): ExtensionPackageComposition | undefined { if (value === undefined) return undefined; const composition = record(value, 'composition'); - exactOptional(composition, ['patch'], []); - return Object.freeze({ patch: packagePath(composition.patch, 'composition.patch') }); + exactOptional(composition, ['patch'], ['structuralDependencies']); + const structuralDependencies = decodeExtensionIds( + composition.structuralDependencies, + 'composition.structuralDependencies', + ); + return Object.freeze({ + patch: packagePath(composition.patch, 'composition.patch'), + structuralDependencies, + }); } function decodeRuntime(value: unknown): ExtensionPackageRuntime | undefined { @@ -195,6 +203,14 @@ function decodeDependencies(value: unknown): readonly ExtensionPackageDependency return Object.freeze(dependencies.sort((left, right) => left.id.localeCompare(right.id))); } +function decodeExtensionIds(value: unknown, label: string): readonly string[] { + if (value === undefined) return Object.freeze([]); + if (!Array.isArray(value) || value.length > 64) throw invalid(`${label} is invalid`); + const ids = value.map((item) => extensionId(item)); + if (new Set(ids).size !== ids.length) throw invalid(`${label} repeats an identity`); + return Object.freeze(ids.sort((left, right) => left.localeCompare(right))); +} + function decodeConfigurationSchema(value: unknown): ExtensionConfigurationSchema { if (value === undefined) return Object.freeze({ properties: Object.freeze({}), required: Object.freeze([]) }); diff --git a/packages/runtime-host/src/server/index.ts b/packages/runtime-host/src/server/index.ts index b5dae0e8d2..4e20075014 100644 --- a/packages/runtime-host/src/server/index.ts +++ b/packages/runtime-host/src/server/index.ts @@ -57,22 +57,9 @@ export { PluginCompositionPatchError, loadPluginCompositionPatch, } from './plugin-composition-patch.js'; -export { - HostPluginCompositionStore, - HostPluginCompositionStoreError, - type PersistedPluginComposition, -} from './plugin-composition-store.js'; -export { PluginPackageLoaderError, TrustedPluginPackageLoader } from './plugin-package-loader.js'; -export { - PluginPackageStore, - PluginPackageStoreError, - type InstalledPluginPackage, - type PreparedPluginPackageInstall, -} from './plugin-package-store.js'; export { HostPluginPlatform, HostPluginPlatformError, type HostPluginPlatformFailure, - type HostPluginPlatformOptions, } from './plugin-platform.js'; export { HostPluginPlatformCoordinator } from './plugin-platform-coordinator.js'; diff --git a/packages/runtime-host/src/server/plugin-package-store.ts b/packages/runtime-host/src/server/plugin-package-store.ts index 9346cba360..be2d9866a8 100644 --- a/packages/runtime-host/src/server/plugin-package-store.ts +++ b/packages/runtime-host/src/server/plugin-package-store.ts @@ -22,7 +22,11 @@ import type { Dirent } from 'node:fs'; import { mkdir, open, readFile, readdir, realpath, rename, rm, stat } from 'node:fs/promises'; import { dirname, join, posix } from 'node:path'; import { isCanonicalExtensionId } from '@maka/runtime/plugin-runtime'; -import { exportExtensionBundle, materializeExtensionPackage } from './extension-bundle.js'; +import { + exportExtensionBundle, + extensionPackageContentDigest, + materializeExtensionPackage, +} from './extension-bundle.js'; import { EXTENSION_PACKAGE_MANIFEST_FILE, type ExtensionPackageManifest, @@ -41,6 +45,7 @@ interface PackageFile { export interface InstalledPluginPackage { readonly extensionId: string; + readonly contentDigest: string; readonly root: string; readonly entry: string; readonly manifest: ExtensionPackageManifest; @@ -86,13 +91,6 @@ export class PluginPackageStore { this.root = join(controlDirectory, STORE_DIRECTORY); } - async install(sourcePath: string): Promise { - const prepared = await this.prepareInstall(sourcePath); - await prepared.publish(0, 1); - await prepared.commit(); - return await this.load(prepared.installed.extensionId); - } - /** Repairs or removes package-store transaction remnants after owner death. */ async recover(authorityGeneration = 0): Promise { let entries: Dirent[]; @@ -451,7 +449,11 @@ async function exists(path: string): Promise { async function decodePackage( root: string, files: readonly PackageFile[], -): Promise<{ readonly manifest: ExtensionPackageManifest; readonly entry: string }> { +): Promise<{ + readonly manifest: ExtensionPackageManifest; + readonly entry: string; + readonly contentDigest: string; +}> { if (!files.some((file) => file.path === EXTENSION_PACKAGE_MANIFEST_FILE)) { throw invalid(`Plugin package is missing ${EXTENSION_PACKAGE_MANIFEST_FILE}`); } @@ -464,7 +466,11 @@ async function decodePackage( if (manifest.composition && !files.some((file) => file.path === manifest.composition!.patch)) { throw invalid(`Plugin Composition patch does not exist: ${manifest.composition.patch}`); } - return Object.freeze({ manifest, entry: manifest.runtime.entry }); + return Object.freeze({ + manifest, + entry: manifest.runtime.entry, + contentDigest: extensionPackageContentDigest(files), + }); } async function readPackage(rootValue: string): Promise { @@ -552,10 +558,15 @@ async function syncDirectory(directory: string): Promise { function freezeInstalled( root: string, - decoded: { readonly manifest: ExtensionPackageManifest; readonly entry: string }, + decoded: { + readonly manifest: ExtensionPackageManifest; + readonly entry: string; + readonly contentDigest: string; + }, ): InstalledPluginPackage { return Object.freeze({ extensionId: decoded.manifest.id, + contentDigest: decoded.contentDigest, root, entry: join(root, ...decoded.entry.split('/')), manifest: decoded.manifest, diff --git a/packages/runtime-host/src/server/plugin-platform-coordinator.ts b/packages/runtime-host/src/server/plugin-platform-coordinator.ts index 69460641b1..ec1cc78071 100644 --- a/packages/runtime-host/src/server/plugin-platform-coordinator.ts +++ b/packages/runtime-host/src/server/plugin-platform-coordinator.ts @@ -17,6 +17,7 @@ * under the License. */ +import { createHash } from 'node:crypto'; import { MakaPluginRuntimeError, type MakaCompositionApplyInput, @@ -49,6 +50,7 @@ export class HostPluginPlatformCoordinator { 'plugin.package.reload': (input) => this.#reload(input), 'plugin.package.export': (input) => this.#export(input), 'plugin.composition.apply': (input) => this.#apply(input), + 'plugin.platform.reconcile': () => this.#reconcile(), }; constructor(readonly platform: HostPluginPlatform) {} @@ -58,18 +60,13 @@ export class HostPluginPlatformCoordinator { ): Promise> { try { return await this.platform.read(async () => { - const identities = await this.platform.packages.identities(); const failures = this.platform.failures(); if (input.view === 'status') { - const entryCount = countInspections(this.platform.inspect()); return { ok: true, result: { view: 'status', - generation: this.platform.desiredComposition().generation, - packageCount: identities.length, - entryCount, - failureCount: failures.length, + ...(await this.platform.status()), }, }; } @@ -80,16 +77,7 @@ export class HostPluginPlatformCoordinator { if (input.view === 'failures') { return { ok: true, result: boundedPage('failures', failures, input) }; } - const packages = []; - for (const extensionId of identities) { - const { manifest } = await this.platform.packages.load(extensionId); - packages.push({ - extensionId, - displayName: manifest.displayName, - ...(manifest.description ? { description: manifest.description } : {}), - dependencies: manifest.dependencies.map(({ id }) => id), - }); - } + const packages = await this.platform.packageProjections(); return { ok: true, result: boundedPage('packages', packages, input), @@ -114,8 +102,7 @@ export class HostPluginPlatformCoordinator { input: PluginPackageUninstallInput, ): Promise> { try { - await this.platform.uninstallPackage(input.extensionId); - return { ok: true, result: {} }; + return { ok: true, result: await this.platform.uninstallPackage(input.extensionId) }; } catch (error) { return failure(error); } @@ -125,8 +112,7 @@ export class HostPluginPlatformCoordinator { input: PluginPackageUninstallInput, ): Promise> { try { - await this.platform.reloadPackage(input.extensionId); - return { ok: true, result: {} }; + return { ok: true, result: await this.platform.reloadPackage(input.extensionId) }; } catch (error) { return failure(error); } @@ -136,9 +122,7 @@ export class HostPluginPlatformCoordinator { input: PluginPackageExportInput, ): Promise> { try { - await this.platform.read(() => - this.platform.packages.export(input.extensionId, input.targetPath), - ); + await this.platform.exportPackage(input.extensionId, input.targetPath); return { ok: true, result: { targetPath: input.targetPath } }; } catch (error) { return failure(error); @@ -149,22 +133,19 @@ export class HostPluginPlatformCoordinator { input: MakaCompositionApplyInput, ): Promise> { try { - await this.platform.apply(input); - return { - ok: true, - result: { generation: this.platform.desiredComposition().generation }, - }; + return { ok: true, result: await this.platform.apply(input) }; } catch (error) { return failure(error); } } -} -function countInspections(inspections: readonly MakaCompositionEntryInspection[]): number { - return inspections.reduce( - (total, inspection) => total + 1 + countInspections(inspection.children), - 0, - ); + async #reconcile(): Promise> { + try { + return { ok: true, result: await this.platform.reconcile() }; + } catch (error) { + return failure(error); + } + } } function flattenInspections( @@ -201,16 +182,24 @@ function boundedPage( values: readonly T[], input: PluginPlatformQueryInput, ): PluginPlatformQueryResult { - const cursor = input.cursor ?? 0; + const digest = pageDigest(view, input.rootId, values); + const cursor = + input.cursor === undefined ? 0 : decodeCursor(input.cursor, view, input.rootId, digest); const limit = input.limit ?? 32; if (cursor > values.length) - throw new MakaPluginRuntimeError('invalid_entry', 'Invalid query cursor'); + throw new HostPluginPlatformError('stale_cursor', 'Plugin Platform query cursor is stale'); const items: T[] = []; for (let index = cursor; index < values.length && items.length < limit; index += 1) { const candidate = [...items, values[index] as T]; if ( - Buffer.byteLength(JSON.stringify({ view, items: candidate, nextCursor: index + 1 }), 'utf8') > - PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES + Buffer.byteLength( + JSON.stringify({ + view, + items: candidate, + nextCursor: encodeCursor(view, input.rootId, digest, index + 1), + }), + 'utf8', + ) > PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES ) { break; } @@ -223,14 +212,66 @@ function boundedPage( return Object.freeze({ view, items: Object.freeze(items), - nextCursor: next < values.length ? next : null, + nextCursor: next < values.length ? encodeCursor(view, input.rootId, digest, next) : null, }) as PluginPlatformQueryResult; } +interface PageCursor { + readonly version: 1; + readonly view: 'packages' | 'entries' | 'failures'; + readonly rootId?: string; + readonly digest: string; + readonly offset: number; +} + +function pageDigest(view: string, rootId: string | undefined, values: readonly unknown[]): string { + return createHash('sha256') + .update(JSON.stringify({ view, rootId: rootId ?? null, values })) + .digest('base64url'); +} + +function encodeCursor( + view: PageCursor['view'], + rootId: string | undefined, + digest: string, + offset: number, +): string { + return Buffer.from( + JSON.stringify({ version: 1, view, ...(rootId ? { rootId } : {}), digest, offset }), + 'utf8', + ).toString('base64url'); +} + +function decodeCursor( + encoded: string, + view: PageCursor['view'], + rootId: string | undefined, + digest: string, +): number { + try { + const cursor = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as PageCursor; + if ( + cursor.version !== 1 || + cursor.view !== view || + cursor.rootId !== rootId || + cursor.digest !== digest || + !Number.isSafeInteger(cursor.offset) || + cursor.offset < 0 + ) { + throw new Error('mismatch'); + } + return cursor.offset; + } catch { + throw new HostPluginPlatformError('stale_cursor', 'Plugin Platform query cursor is stale'); + } +} + function failure( error: unknown, ): OperationOutcome { if (error instanceof HostPluginPlatformError) { + if (error.code === 'not_ready') return failed('host_not_ready', error.message); + if (error.code === 'stale_cursor') return failed('stale_cursor', error.message); if (error.code === 'closed') return failed('host_draining', error.message); if (error.code === 'persistence_failed') return failed('persistence_failed', error.message); if (error.code === 'recovery_failed') return failed('persistence_failed', error.message); diff --git a/packages/runtime-host/src/server/plugin-platform.ts b/packages/runtime-host/src/server/plugin-platform.ts index 1c13b00a6e..4ebc5575a5 100644 --- a/packages/runtime-host/src/server/plugin-platform.ts +++ b/packages/runtime-host/src/server/plugin-platform.ts @@ -42,6 +42,12 @@ import { } from './plugin-composition-store.js'; import { TrustedPluginPackageLoader } from './plugin-package-loader.js'; import { PluginPackageStore, PluginPackageStoreError } from './plugin-package-store.js'; +import type { + PluginMutationReceipt, + PluginPackageProjection, + PluginPlatformConvergence, + PluginPlatformPhase, +} from '../protocol/plugin-platform.js'; export class HostPluginPlatformError extends Error { readonly name = 'HostPluginPlatformError'; @@ -52,7 +58,9 @@ export class HostPluginPlatformError extends Error { | 'persistence_failed' | 'commit_outcome_unknown' | 'recovery_failed' - | 'mutation_failed', + | 'mutation_failed' + | 'not_ready' + | 'stale_cursor', message: string, options?: ErrorOptions, ) { @@ -79,48 +87,78 @@ interface CompositionEntryRecord { readonly disabled: boolean; } +interface DesiredProjection { + readonly desired: MakaCompositionState; + readonly failures: readonly HostPluginPlatformFailure[]; + readonly structuralDependencies: ReadonlyMap>; +} + +interface PackageOverride { + readonly extensionId: string; + readonly patch: MakaCompositionApplyInput | undefined; + readonly manifest: ExtensionPackageManifest; +} + /** * Runtime Host's sole authority for trusted Plugin packages and Entry composition. * Package layers and user overlays are durable; the desired Entry Tree is derived from them. */ export class HostPluginPlatform { - readonly composition: MakaCompositionLoader; - readonly packages: PluginPackageStore; - readonly packageLoader: TrustedPluginPackageLoader; - readonly store: HostPluginCompositionStore; + readonly #composition: MakaCompositionLoader; + readonly #packages: PluginPackageStore; + readonly #packageLoader: TrustedPluginPackageLoader; + readonly #store: HostPluginCompositionStore; #authority: PersistedPluginComposition = emptyCompositionAuthority(); #desired: MakaCompositionState = emptyCompositionState(); #mutation: Promise = Promise.resolve(); - #closed = false; - #draining = false; - #poisoned?: Error; - #diverged = false; + #phase: PluginPlatformPhase = 'new'; + #recoveryStarted = false; + #recoveryComplete = false; + #drainRequested = false; + #convergence: PluginPlatformConvergence = 'unknown'; + #fence?: Error; + #reconcileTimer?: ReturnType; + #reconcileDelayMs = 250; #failures: readonly HostPluginPlatformFailure[] = Object.freeze([]); + #structuralDependencies: ReadonlyMap> = new Map(); constructor( readonly controlDirectory: string, options: HostPluginPlatformOptions = {}, ) { - this.composition = options.composition ?? new MakaCompositionLoader(); - this.packages = options.packages ?? new PluginPackageStore(controlDirectory); - this.packageLoader = - options.packageLoader ?? new TrustedPluginPackageLoader(controlDirectory, this.packages); - this.store = options.store ?? new HostPluginCompositionStore(controlDirectory); + this.#composition = options.composition ?? new MakaCompositionLoader(); + this.#packages = options.packages ?? new PluginPackageStore(controlDirectory); + this.#packageLoader = + options.packageLoader ?? new TrustedPluginPackageLoader(controlDirectory, this.#packages); + this.#store = options.store ?? new HostPluginCompositionStore(controlDirectory); } async recover(): Promise { - if (this.#closed) throw new HostPluginPlatformError('closed', 'Plugin Platform is closed'); + if ( + this.#recoveryStarted || + (this.#phase !== 'new' && !(this.#phase === 'draining' && this.#drainRequested)) + ) { + throw new HostPluginPlatformError( + 'recovery_failed', + `Plugin Platform cannot recover from phase ${this.#phase}`, + ); + } + this.#recoveryStarted = true; + if (!this.#drainRequested) this.#phase = 'recovering'; await this.#serialize(async () => { try { - const storedAuthority = (await this.store.read()) ?? emptyCompositionAuthority(); - await this.packages.recover(storedAuthority.generation); - await this.packageLoader.collectGarbage(); + const storedAuthority = (await this.#store.read()) ?? emptyCompositionAuthority(); + await this.#packages.recover(storedAuthority.generation); + await this.#packageLoader.collectGarbage(); const packageFailures: HostPluginPlatformFailure[] = []; - for (const extensionId of await this.packages.identities()) { + for (const extensionId of await this.#packages.identities()) { + let loaded: MakaPluginPackage | undefined; try { - await this.composition.install(await this.packageLoader.load(extensionId)); + loaded = await this.#packageLoader.load(extensionId); + await this.#composition.install(loaded); } catch (error) { + if (loaded) await this.#packageLoader.release(loaded).catch(() => undefined); packageFailures.push( Object.freeze({ extensionId, @@ -129,33 +167,43 @@ export class HostPluginPlatform { ); } } - const desired = await this.#normalizeCompositionConfigurations( - await this.#composePersistedAuthority(storedAuthority), - ); - const entryFailures = await this.#recoverDesiredRuntime(desired); + const projection = await this.#deriveDesired(storedAuthority, 'recovery'); + const entryFailures = await this.#recoverDesiredRuntime(projection.desired); this.#failures = Object.freeze([ ...packageFailures, + ...projection.failures, ...entryFailures.map((failure) => Object.freeze({ entryId: failure.entryId, diagnostic: failure.diagnostic }), ), ]); - this.#diverged = entryFailures.length > 0; this.#authority = storedAuthority; - this.#desired = desired; + this.#desired = projection.desired; + this.#structuralDependencies = projection.structuralDependencies; + this.#convergence = this.#failures.length > 0 ? 'diverged' : 'converged'; + this.#recoveryComplete = true; + this.#phase = this.#drainRequested + ? 'draining' + : this.#failures.length > 0 + ? 'degraded' + : 'ready'; + if (this.#failures.length > 0) this.#scheduleReconcile(); } catch (error) { - this.#poisoned = asError(error); - // Plugin recovery is fail-open for the Host. Mutations and Plugin - // queries remain fenced until the persisted authority is repaired. + this.#fence = asError(error); + this.#recoveryComplete = true; + this.#phase = 'fenced'; + this.#convergence = 'unknown'; } }); } - async installPackage(sourcePath: string): Promise<{ readonly extensionId: string }> { + async installPackage( + sourcePath: string, + ): Promise { this.#assertMutable(); return await this.#serializeMutable(async () => { let prepared; try { - prepared = await this.packages.prepareInstall(sourcePath); + prepared = await this.#packages.prepareInstall(sourcePath); } catch (error) { if (error instanceof PluginPackageStoreError && error.code === 'commit_outcome_unknown') { throw this.#fenceUnknownPackageOutcome(error, 'preparation'); @@ -163,16 +211,15 @@ export class HostPluginPlatform { throw error; } let loaded: MakaPluginPackage | undefined; - let previous: MakaPluginPackage | undefined; let authorityCommitted = false; - let runtimeAdopted = false; + let packageCommitted = false; + let runtimeAdoptionStarted = false; try { const compositionPatch = await loadPluginCompositionPatch(prepared.installed); - loaded = await this.packageLoader.loadInstalled(prepared.installed); - const alreadyInstalled = this.composition + loaded = await this.#packageLoader.loadInstalled(prepared.installed); + const alreadyInstalled = this.#composition .installedPackages() .some(({ packageId }) => packageId === prepared.installed.extensionId); - if (alreadyInstalled) previous = this.composition.package(prepared.installed.extensionId); const layerPlan = await this.#planPackageLayer( prepared.installed.extensionId, compositionPatch, @@ -185,90 +232,110 @@ export class HostPluginPlatform { this.#authority.overlays, ); authorityCommitted = true; + this.#structuralDependencies = layerPlan.structuralDependencies; await prepared.commit(); + packageCommitted = true; this.#clearPackageFailure(prepared.installed.extensionId); - if (alreadyInstalled) await this.composition.reload(loaded); - else await this.composition.install(loaded); - runtimeAdopted = true; - const failures = await this.composition.recoverComposition(layerPlan.planned); + runtimeAdoptionStarted = true; + const failures = await this.#adoptRuntimePackage( + prepared.installed.extensionId, + loaded, + layerPlan.planned, + alreadyInstalled, + ); await this.#publishEntryFailures(failures); - if (failures.length > 0) { - throw new Error(failures.map(({ diagnostic }) => diagnostic).join('; ')); - } - if (previous) await this.#releaseGeneration(previous); - return Object.freeze({ extensionId: prepared.installed.extensionId }); + this.#settleConvergence(failures.length === 0); + return Object.freeze({ + extensionId: prepared.installed.extensionId, + ...this.#receipt('complete'), + }); } catch (error) { if (authorityCommitted) { - this.#diverged = true; - if (loaded && !runtimeAdopted) { - await this.packageLoader.release(loaded).catch(() => undefined); + if (loaded && !runtimeAdoptionStarted) { + await this.#packageLoader.release(loaded).catch(() => undefined); } - if (previous && runtimeAdopted) await this.#releaseGeneration(previous); - throw new HostPluginPlatformError( - 'mutation_failed', - 'Plugin package authority was committed but Runtime convergence failed', - { cause: error }, - ); + this.#recordPackageFailure(prepared.installed.extensionId, error); + this.#settleConvergence(false); + return Object.freeze({ + extensionId: prepared.installed.extensionId, + ...this.#receipt(packageCommitted ? 'complete' : 'pending'), + }); } if (error instanceof HostPluginPlatformError && error.code === 'commit_outcome_unknown') { - if (loaded) await this.packageLoader.release(loaded).catch(() => undefined); + if (loaded) await this.#packageLoader.release(loaded).catch(() => undefined); throw error; } try { await prepared.rollback(); } catch (rollbackError) { - if (loaded) await this.packageLoader.release(loaded).catch(() => undefined); + if (loaded) await this.#packageLoader.release(loaded).catch(() => undefined); if ( rollbackError instanceof PluginPackageStoreError && rollbackError.code === 'commit_outcome_unknown' ) { throw this.#fenceUnknownPackageOutcome(rollbackError, 'rollback'); } - this.#poisoned = asError(rollbackError); - this.#draining = true; + this.#fence = asError(rollbackError); + this.#phase = 'fenced'; throw new HostPluginPlatformError( 'persistence_failed', 'Plugin package installation and stored-package rollback both failed', { cause: new AggregateError([error, rollbackError]) }, ); } - if (loaded) await this.packageLoader.release(loaded).catch(() => undefined); + if (loaded) await this.#packageLoader.release(loaded).catch(() => undefined); throw error; } }); } - async reloadPackage(extensionId: string): Promise { + async reloadPackage(extensionId: string): Promise { this.#assertMutable(); - await this.#serializeMutable(async () => { - const previous = this.composition.package(extensionId); - const loaded = await this.packageLoader.load(extensionId); + return await this.#serializeMutable(async () => { + const loaded = await this.#packageLoader.load(extensionId); + let adoptionStarted = false; try { await this.#validateDesired(this.desiredComposition()); - await this.composition.reload(loaded); + adoptionStarted = true; + const failures = await this.#adoptRuntimePackage(extensionId, loaded, this.#desired, true); + await this.#publishEntryFailures(failures); + this.#clearPackageFailure(extensionId); + this.#settleConvergence(failures.length === 0); } catch (error) { - await this.packageLoader.release(loaded).catch(() => undefined); - throw error; + if (!adoptionStarted) await this.#packageLoader.release(loaded).catch(() => undefined); + this.#recordPackageFailure(extensionId, error); + this.#settleConvergence(false); } - await this.#releaseGeneration(previous); - this.#clearPackageFailure(extensionId); - if (this.#diverged) await this.#convergeDesired(); + return this.#receipt('complete'); }); } - async uninstallPackage(extensionId: string): Promise { + async uninstallPackage(extensionId: string): Promise { this.#assertMutable(); - await this.#serializeMutable(async () => { - const previousAuthority = this.#authority; - const previousDesired = this.#desired; - let planned: MakaCompositionState | undefined; - let packageLayers = this.#authority.packageLayers; - if (this.#authority.packageLayers.includes(extensionId)) { - packageLayers = this.#authority.packageLayers.filter((item) => item !== extensionId); - planned = await this.#composeLayers(packageLayers, this.#authority.overlays); + return await this.#serializeMutable(async () => { + const structuralDependent = this.#structuralDependent(extensionId); + if (structuralDependent) { + throw new MakaPluginRuntimeError( + 'package_in_use', + `Plugin package is structurally required by ${structuralDependent}`, + ); } - const candidate = planned ?? this.#desired; - const desiredUser = compositionEntries(candidate).find( + const manifestDependent = await this.#manifestDependentPackage(extensionId); + if (manifestDependent) { + throw new MakaPluginRuntimeError( + 'package_in_use', + `Plugin package is required by package ${manifestDependent}`, + ); + } + const packageLayers = this.#authority.packageLayers.filter((item) => item !== extensionId); + const candidateAuthority = compositionAuthority( + this.#authority.generation + + (packageLayers.length === this.#authority.packageLayers.length ? 0 : 1), + packageLayers, + this.#authority.overlays, + ); + const projection = await this.#deriveDesired(candidateAuthority, 'strict'); + const desiredUser = compositionEntries(projection.desired).find( (entry) => entry.packageId === extensionId, ); if (desiredUser) { @@ -277,105 +344,85 @@ export class HostPluginPlatform { `Plugin package is used by desired entry ${desiredUser.id}`, ); } - const dependent = await this.#desiredPackageDependent(extensionId, candidate); + const dependent = await this.#desiredPackageDependent(extensionId, projection.desired); if (dependent) { throw new MakaPluginRuntimeError( 'package_in_use', `Plugin package is required by desired entry ${dependent.id}`, ); } - if (planned) { - await this.#replaceDesiredComposition(planned, packageLayers, this.#authority.overlays); + if (packageLayers.length !== this.#authority.packageLayers.length) { + await this.#commitDesiredAuthority( + projection.desired, + packageLayers, + this.#authority.overlays, + ); + this.#structuralDependencies = projection.structuralDependencies; } - const installedInRuntime = this.composition + const installedInRuntime = this.#composition .installedPackages() .some(({ packageId }) => packageId === extensionId); - const pkg = installedInRuntime ? this.composition.package(extensionId) : undefined; - if (pkg) await this.composition.uninstall(extensionId); - try { - await this.packages.uninstall(extensionId); - this.#clearPackageFailure(extensionId); - if (pkg) await this.#releaseGeneration(pkg); - } catch (error) { - if (error instanceof PluginPackageStoreError && error.code === 'commit_outcome_unknown') { - this.#poisoned = error; - this.#draining = true; - throw new HostPluginPlatformError( - 'commit_outcome_unknown', - 'Plugin package uninstall outcome is unknown; Plugin Platform was fenced', - { cause: error }, - ); - } - const rollbackErrors: unknown[] = []; - if (pkg) { - let restored: MakaPluginPackage | undefined; - try { - restored = await this.packageLoader.load(extensionId); - await this.composition.install(restored); - await this.#releaseGeneration(pkg); - } catch (rollbackError) { - if (restored) await this.packageLoader.release(restored).catch(() => undefined); - rollbackErrors.push(rollbackError); - } - } - if (planned) { - try { - await this.#replaceDesiredComposition( - compositionWithGeneration(previousDesired, this.#desired.generation + 1), - previousAuthority.packageLayers, - previousAuthority.overlays, - ); - } catch (rollbackError) { - rollbackErrors.push(rollbackError); - } - } - if (rollbackErrors.length > 0) { - this.#poisoned = asError(rollbackErrors[0]); - this.#draining = true; - throw new HostPluginPlatformError( - 'mutation_failed', - 'Plugin package uninstall and rollback both failed; Plugin Platform was fenced', - { cause: new AggregateError([error, ...rollbackErrors]) }, - ); - } - throw error; + const pkg = installedInRuntime ? this.#composition.package(extensionId) : undefined; + const postCommitErrors: unknown[] = []; + const failures = await this.#recoverDesiredRuntime(projection.desired).catch((error) => { + postCommitErrors.push(error); + return Object.freeze([]) as readonly MakaCompositionRecoveryFailure[]; + }); + await this.#publishEntryFailures(failures); + if (failures.length > 0) postCommitErrors.push(new Error('Runtime convergence failed')); + if (pkg) { + await this.#composition + .uninstall(extensionId) + .catch((error) => postCommitErrors.push(error)); + } + await this.#packages.uninstall(extensionId).catch((error) => postCommitErrors.push(error)); + if (pkg) await this.#releaseGeneration(pkg); + if (postCommitErrors.length > 0) { + this.#recordPackageFailure(extensionId, new AggregateError(postCommitErrors)); + this.#settleConvergence(false); + return this.#receipt('pending'); } + this.#clearPackageFailure(extensionId); + this.#settleConvergence(true); + return this.#receipt('complete'); }); } - async apply( - input: MakaCompositionApplyInput, - ): Promise { + async apply(input: MakaCompositionApplyInput): Promise { this.#assertMutable(); return await this.#serializeMutable(async () => { - const desired = this.#desired; let normalizedInput: MakaCompositionApplyInput; - let planned: MakaCompositionState; + let projection: DesiredProjection; try { - normalizedInput = await this.#normalizeApplyInput(desired, input); - planned = applyCompositionState(desired, normalizedInput); - await this.#validateDesired(planned); + normalizedInput = await this.#normalizeApplyInput(this.#desired, input); + const nextAuthority = compositionAuthority( + this.#authority.generation + (normalizedInput.operations.length > 0 ? 1 : 0), + this.#authority.packageLayers, + Object.freeze([...this.#authority.overlays, ...normalizedInput.operations]), + ); + projection = await this.#deriveDesired(nextAuthority, 'strict'); } catch (error) { throw new HostPluginPlatformError('mutation_failed', 'Plugin composition mutation failed', { cause: error, }); } const next = compositionAuthority( - planned.generation, + projection.desired.generation, this.#authority.packageLayers, Object.freeze([...this.#authority.overlays, ...normalizedInput.operations]), ); try { - await this.store.replace(next); + await this.#store.replace(next); this.#authority = next; - this.#desired = planned; + this.#desired = projection.desired; + this.#structuralDependencies = projection.structuralDependencies; } catch (error) { if ( error instanceof HostPluginCompositionStoreError && error.code === 'commit_outcome_unknown' ) { - this.#poisoned = error; - this.#draining = true; + this.#fence = error; + this.#phase = 'fenced'; throw new HostPluginPlatformError( 'commit_outcome_unknown', 'Plugin composition commit outcome is unknown; Plugin Platform was fenced', @@ -389,60 +436,131 @@ export class HostPluginPlatform { ); } - let convergenceFailures: readonly MakaCompositionRecoveryFailure[] | undefined; try { - if (this.#diverged) { - const failures = await this.composition.recoverComposition(planned); - await this.#publishEntryFailures(failures); - if (failures.length > 0) { - convergenceFailures = failures; - throw new Error(failures.map(({ diagnostic }) => diagnostic).join('; ')); - } - return this.composition.inspectTree(); - } - const inspections = await this.composition.apply(normalizedInput); - this.#failures = Object.freeze( - this.#failures.filter((failure) => failure.entryId === undefined), - ); - return inspections; + const failures = await this.#recoverDesiredRuntime(projection.desired); + await this.#publishEntryFailures(failures); + this.#settleConvergence(failures.length === 0); + return this.#receipt('complete'); } catch (error) { - this.#diverged = true; - if (!convergenceFailures) { - await this.#publishEntryFailures(operationFailures(normalizedInput, error)); - } - throw new HostPluginPlatformError( - 'mutation_failed', - 'Desired Plugin composition was committed but Runtime convergence failed', - { cause: error }, - ); + await this.#publishEntryFailures(operationFailures(normalizedInput, error)); + this.#settleConvergence(false); + return this.#receipt('complete'); } }); } desiredComposition(): MakaCompositionState { + this.#assertReadable(); return this.#desired; } failures(): readonly HostPluginPlatformFailure[] { + this.#assertReadable(); return this.#failures; } inspect(rootId?: MakaPluginRootId): readonly MakaCompositionEntryInspection[] { - return this.composition.inspectTree(rootId); + this.#assertReadable(); + return this.#composition.inspectTree(rootId); + } + + async status(): Promise<{ + readonly phase: PluginPlatformPhase; + readonly authorityEpoch: number; + readonly convergence: PluginPlatformConvergence; + readonly installedPackageCount: number; + readonly layeredPackageCount: number; + readonly desiredEntryCount: number; + readonly liveEntryCount: number; + readonly failureCount: number; + readonly fenceDiagnostic: string | null; + }> { + this.#assertReadable(); + return Object.freeze({ + phase: this.#phase, + authorityEpoch: this.#authority.generation, + convergence: this.#convergence, + installedPackageCount: (await this.#packages.identities()).length, + layeredPackageCount: this.#authority.packageLayers.length, + desiredEntryCount: compositionEntries(this.#desired).length, + liveEntryCount: countInspections(this.#composition.inspectTree()), + failureCount: this.#failures.length, + fenceDiagnostic: this.#fence ? boundedDiagnostic(this.#fence) : null, + }); + } + + async packageProjections(): Promise { + this.#assertReadable(); + const requiredBy = new Map>(); + for (const [actor, dependencies] of this.#structuralDependencies) { + if (actor.startsWith('@')) continue; + for (const dependency of dependencies) { + const users = requiredBy.get(dependency) ?? new Set(); + users.add(actor); + requiredBy.set(dependency, users); + } + } + const installedPackages = await Promise.all( + (await this.#packages.identities()).map((extensionId) => this.#packages.load(extensionId)), + ); + for (const installed of installedPackages) { + for (const dependency of installed.manifest.dependencies) { + const users = requiredBy.get(dependency.id) ?? new Set(); + users.add(installed.extensionId); + requiredBy.set(dependency.id, users); + } + } + const projections: PluginPackageProjection[] = []; + for (const installed of installedPackages) { + const extensionId = installed.extensionId; + projections.push( + Object.freeze({ + extensionId, + contentDigest: installed.contentDigest, + displayName: installed.manifest.displayName, + ...(installed.manifest.description + ? { description: installed.manifest.description } + : {}), + dependencies: Object.freeze(installed.manifest.dependencies.map(({ id }) => id)), + structuralDependencies: Object.freeze( + [...(this.#structuralDependencies.get(extensionId) ?? [])].sort(), + ), + requiredBy: Object.freeze([...(requiredBy.get(extensionId) ?? [])].sort()), + }), + ); + } + return Object.freeze(projections); + } + + async exportPackage(extensionId: string, targetPath: string): Promise { + await this.read(() => this.#packages.export(extensionId, targetPath)); + } + + async reconcile(): Promise { + this.#assertMutable(); + return await this.#serializeMutable(() => this.#reconcileNow()); } read(operation: () => T | Promise): Promise { - this.#assertOpen(); - return this.#serialize(async () => await operation()); + this.#assertReadable(); + return this.#serialize(async () => { + this.#assertReadable(); + return await operation(); + }); } beginDrain(): void { - this.#draining = true; + if (this.#phase === 'closed') return; + this.#drainRequested = true; + this.#phase = 'draining'; } async close(): Promise { - if (this.#closed) return; - this.#closed = true; + if (this.#phase === 'closed') return; + this.#drainRequested = true; + this.#phase = 'draining'; + if (this.#reconcileTimer) clearTimeout(this.#reconcileTimer); + this.#reconcileTimer = undefined; const errors: unknown[] = []; try { await this.#mutation; @@ -450,15 +568,16 @@ export class HostPluginPlatform { errors.push(error); } try { - await this.composition.close(); + await this.#composition.close(); } catch (error) { errors.push(error); } try { - await this.packageLoader.close(); + await this.#packageLoader.close(); } catch (error) { errors.push(error); } + this.#phase = 'closed'; if (errors.length > 0) { throw new AggregateError(errors, 'Unable to close every Plugin Platform resource'); } @@ -471,81 +590,148 @@ export class HostPluginPlatform { ): Promise<{ readonly planned: MakaCompositionState; readonly packageLayers: readonly string[]; + readonly structuralDependencies: ReadonlyMap>; }> { const previousIndex = this.#authority.packageLayers.indexOf(extensionId); const packageLayers = this.#authority.packageLayers.filter((item) => item !== extensionId); const nextIndex = previousIndex < 0 ? packageLayers.length : previousIndex; packageLayers.splice(nextIndex, 0, extensionId); - const planned = await this.#composeLayers(packageLayers, this.#authority.overlays, { + const candidate = compositionAuthority( + this.#authority.generation + 1, + packageLayers, + this.#authority.overlays, + ); + const projection = await this.#deriveDesired(candidate, 'strict', { extensionId, patch, manifest, }); - return { planned, packageLayers }; - } - - async #composeLayers( - packageLayers: readonly string[], - overlays: readonly MakaCompositionOperation[], - override?: { - readonly extensionId: string; - readonly patch: MakaCompositionApplyInput | undefined; - readonly manifest: ExtensionPackageManifest; - }, - ): Promise { - let working = emptyCompositionState(); - for (const extensionId of packageLayers) { - const patch = - override?.extensionId === extensionId - ? override.patch - : await loadPluginCompositionPatch(await this.packages.load(extensionId)); - if (!patch) continue; - const normalized = await this.#normalizeApplyInput(working, patch, override?.manifest); - working = applyCompositionState(working, normalized); - } - if (overlays.length > 0) { - const normalized = await this.#normalizeApplyInput( - working, - { operations: overlays }, - override?.manifest, - ); - working = applyCompositionState(working, normalized); - } - await this.#validateDesired(working, override?.manifest); - return compositionWithGeneration(working, this.#desired.generation + 1); + return { + planned: projection.desired, + packageLayers, + structuralDependencies: projection.structuralDependencies, + }; } - /** Rebuilds the desired Entry Tree without trusting a stored materialized projection. */ - async #composePersistedAuthority( + async #deriveDesired( authority: PersistedPluginComposition, - ): Promise { + policy: 'strict' | 'recovery', + override?: PackageOverride, + ): Promise { let working = emptyCompositionState(); + let owners = new Map(); + let dependencies = new Map>(); + const failures: HostPluginPlatformFailure[] = []; + + const applyLayer = async ( + actor: string, + input: MakaCompositionApplyInput, + manifestOverride?: ExtensionPackageManifest, + ): Promise => { + let candidate = working; + const candidateOwners = new Map(owners); + const candidateDependencies = cloneDependencyGraph(dependencies); + const applyOperation = async (operation: MakaCompositionOperation): Promise => { + recordStructuralDependencies( + candidate, + candidateOwners, + candidateDependencies, + actor, + operation, + ); + const previous = candidate; + candidate = applyCompositionState(candidate, { operations: [operation] }); + updateEntryOwners(previous, candidateOwners, actor, operation); + }; + if (policy === 'strict') { + const normalized = await this.#normalizeApplyInput(candidate, input, manifestOverride); + for (const operation of normalized.operations) await applyOperation(operation); + } else { + for (const operation of input.operations) { + try { + const normalized = await this.#normalizeApplyInput( + candidate, + { operations: [operation] }, + manifestOverride, + ); + await applyOperation(normalized.operations[0]!); + } catch (error) { + try { + await applyOperation(operation); + } catch { + // A structurally invalid operation cannot contribute to the + // recoverable desired tree, but its diagnostic is retained. + } + const identity = overlayFailureIdentity([operation]); + failures.push( + Object.freeze({ + ...(actor.startsWith('@') ? { entryId: identity } : { extensionId: actor }), + diagnostic: boundedDiagnostic(error), + }), + ); + } + } + } + if (!actor.startsWith('@') && manifestOverride) { + const inferred = [...(candidateDependencies.get(actor) ?? [])].sort(); + const declared = [...(manifestOverride.composition?.structuralDependencies ?? [])].sort(); + if ( + inferred.length !== declared.length || + inferred.some((dependency, index) => dependency !== declared[index]) + ) { + throw new MakaPluginRuntimeError( + 'invalid_package', + `Plugin package ${actor} structural dependencies do not match its composition patch`, + ); + } + } + working = candidate; + owners = candidateOwners; + dependencies = candidateDependencies; + }; + for (const extensionId of authority.packageLayers) { - const patch = await loadPluginCompositionPatch(await this.packages.load(extensionId)); - if (patch) working = applyCompositionState(working, patch); + try { + const installed = + override?.extensionId === extensionId + ? undefined + : await this.#packages.load(extensionId); + const patch = + override?.extensionId === extensionId + ? override.patch + : await loadPluginCompositionPatch(installed!); + const manifest = + override?.extensionId === extensionId ? override.manifest : installed!.manifest; + if (patch) { + await applyLayer(extensionId, patch, manifest); + } + } catch (error) { + if (policy === 'strict') throw error; + failures.push(Object.freeze({ extensionId, diagnostic: boundedDiagnostic(error) })); + } } if (authority.overlays.length > 0) { - working = applyCompositionState(working, { operations: authority.overlays }); + try { + await applyLayer('@user-overlay', { operations: authority.overlays }); + } catch (error) { + if (policy === 'strict') throw error; + failures.push( + Object.freeze({ + entryId: overlayFailureIdentity(authority.overlays), + diagnostic: boundedDiagnostic(error), + }), + ); + } } - return compositionWithGeneration(working, authority.generation); - } - async #replaceDesiredComposition( - planned: MakaCompositionState, - packageLayers: readonly string[], - overlays: readonly MakaCompositionOperation[], - ): Promise { - await this.#commitDesiredAuthority(planned, packageLayers, overlays); - const failures = await this.composition.recoverComposition(planned); - await this.#publishEntryFailures(failures); - this.#diverged = failures.length > 0; - if (failures.length > 0) { - throw new HostPluginPlatformError( - 'mutation_failed', - 'Desired Plugin composition was committed but Runtime convergence failed', - { cause: new Error(failures.map(({ diagnostic }) => diagnostic).join('; ')) }, - ); - } + let desired = compositionWithGeneration(working, authority.generation); + if (policy === 'strict') await this.#validateDesired(desired, override?.manifest); + else desired = await this.#normalizeCompositionConfigurations(desired); + return Object.freeze({ + desired, + failures: Object.freeze(failures), + structuralDependencies: freezeDependencyGraph(dependencies), + }); } async #commitDesiredAuthority( @@ -555,7 +741,7 @@ export class HostPluginPlatform { ): Promise { const next = compositionAuthority(planned.generation, packageLayers, overlays); try { - await this.store.replace(next); + await this.#store.replace(next); this.#authority = next; this.#desired = planned; } catch (error) { @@ -563,8 +749,8 @@ export class HostPluginPlatform { error instanceof HostPluginCompositionStoreError && error.code === 'commit_outcome_unknown' ) { - this.#poisoned = error; - this.#draining = true; + this.#fence = error; + this.#phase = 'fenced'; throw new HostPluginPlatformError( 'commit_outcome_unknown', 'Plugin composition commit outcome is unknown; Plugin Platform was fenced', @@ -782,13 +968,7 @@ export class HostPluginPlatform { ): Promise { return manifestOverride?.id === extensionId ? manifestOverride - : (await this.packages.load(extensionId)).manifest; - } - - async #convergeDesired(): Promise { - const desired = this.desiredComposition(); - const failures = await this.#recoverDesiredRuntime(desired); - await this.#publishEntryFailures(failures); + : (await this.#packages.load(extensionId)).manifest; } async #recoverDesiredRuntime( @@ -798,7 +978,7 @@ export class HostPluginPlatform { (await this.#desiredValidationFailures(desired)).map((failure) => [failure.entryId, failure]), ); for (;;) { - const recovered = await this.composition.recoverComposition( + const recovered = await this.#composition.recoverComposition( withoutEntries(desired, new Set(failures.keys())), ); for (const failure of recovered) failures.set(failure.entryId, failure); @@ -819,7 +999,7 @@ export class HostPluginPlatform { changed = false; for (const record of records) { if (record.disabled || !record.entry.packageId || failures.has(record.entry.id)) continue; - const manifest = (await this.packages.load(record.entry.packageId)).manifest; + const manifest = (await this.#packages.load(record.entry.packageId)).manifest; for (const dependency of manifest.dependencies) { const candidates = records.filter( (candidate) => @@ -852,7 +1032,7 @@ export class HostPluginPlatform { if (packageId === extensionId) return true; if (visited.has(packageId)) return false; visited.add(packageId); - const manifest = (await this.packages.load(packageId)).manifest; + const manifest = (await this.#packages.load(packageId)).manifest; for (const dependency of manifest.dependencies) { if (await dependsOn(dependency.id, visited)) return true; } @@ -871,6 +1051,15 @@ export class HostPluginPlatform { return undefined; } + async #manifestDependentPackage(extensionId: string): Promise { + for (const candidateId of this.#authority.packageLayers) { + if (candidateId === extensionId) continue; + const manifest = (await this.#packages.load(candidateId)).manifest; + if (manifest.dependencies.some(({ id }) => id === extensionId)) return candidateId; + } + return undefined; + } + async #publishEntryFailures(failures: readonly MakaCompositionRecoveryFailure[]): Promise { const packageFailures = this.#failures.filter((failure) => failure.entryId === undefined); this.#failures = Object.freeze([ @@ -879,14 +1068,37 @@ export class HostPluginPlatform { Object.freeze({ entryId: failure.entryId, diagnostic: failure.diagnostic }), ), ]); - this.#diverged = failures.length > 0; + } + + async #adoptRuntimePackage( + extensionId: string, + loaded: MakaPluginPackage, + desired: MakaCompositionState, + replacing: boolean, + ): Promise { + let installed = false; + try { + if (replacing) { + const previous = this.#composition.package(extensionId); + const detached = withoutPackageEntries(desired, extensionId); + await this.#recoverDesiredRuntime(detached); + await this.#composition.uninstall(extensionId); + await this.#releaseGeneration(previous); + } + await this.#composition.install(loaded); + installed = true; + return await this.#recoverDesiredRuntime(desired); + } catch (error) { + if (!installed) await this.#packageLoader.release(loaded).catch(() => undefined); + throw error; + } } async #releaseGeneration(pkg: MakaPluginPackage): Promise { try { - await this.packageLoader.release(pkg); + await this.#packageLoader.release(pkg); } catch (error) { - this.composition.root.logger.warn('Unable to remove retired Plugin generation', error); + this.#composition.root.logger.warn('Unable to remove retired Plugin generation', error); } } @@ -896,26 +1108,33 @@ export class HostPluginPlatform { ); } - #assertOpen(): void { - if (this.#closed) throw new HostPluginPlatformError('closed', 'Plugin Platform is closed'); - if (this.#poisoned) { - throw new HostPluginPlatformError('recovery_failed', 'Plugin Platform is fenced', { - cause: this.#poisoned, - }); + #assertReadable(): void { + if (!this.#recoveryComplete) { + throw new HostPluginPlatformError('not_ready', 'Plugin Platform is not recovered'); + } + if (this.#phase === 'closed') { + throw new HostPluginPlatformError('closed', 'Plugin Platform is closed'); } } #assertMutable(): void { - this.#assertOpen(); - if (this.#draining) throw new HostPluginPlatformError('closed', 'Plugin Platform is draining'); + this.#assertReadable(); + if (this.#phase === 'fenced') { + throw new HostPluginPlatformError('recovery_failed', 'Plugin Platform is fenced', { + cause: this.#fence, + }); + } + if (this.#phase === 'draining') { + throw new HostPluginPlatformError('closed', 'Plugin Platform is draining'); + } } #fenceUnknownPackageOutcome( error: PluginPackageStoreError, operation: string, ): HostPluginPlatformError { - this.#poisoned = error; - this.#draining = true; + this.#fence = error; + this.#phase = 'fenced'; return new HostPluginPlatformError( 'commit_outcome_unknown', `Plugin package ${operation} outcome is unknown; Plugin Platform was fenced`, @@ -938,6 +1157,94 @@ export class HostPluginPlatform { ); return result; } + + #recordPackageFailure(extensionId: string, error: unknown): void { + this.#clearPackageFailure(extensionId); + this.#failures = Object.freeze([ + ...this.#failures, + Object.freeze({ extensionId, diagnostic: boundedDiagnostic(error) }), + ]); + } + + #settleConvergence(converged: boolean): void { + this.#convergence = converged ? 'converged' : 'diverged'; + if (!this.#drainRequested && this.#phase !== 'fenced' && this.#phase !== 'closed') { + this.#phase = converged ? 'ready' : 'degraded'; + } + if (converged) { + this.#reconcileDelayMs = 250; + if (this.#reconcileTimer) clearTimeout(this.#reconcileTimer); + this.#reconcileTimer = undefined; + } else { + this.#scheduleReconcile(); + } + } + + async #reconcileNow(): Promise { + await this.#packages.recover(this.#authority.generation); + await this.#packageLoader.collectGarbage(); + const projection = await this.#deriveDesired(this.#authority, 'recovery'); + const packageFailures: HostPluginPlatformFailure[] = []; + const retryPackages = new Set( + this.#failures.flatMap(({ extensionId }) => (extensionId ? [extensionId] : [])), + ); + const storedPackages = new Set(await this.#packages.identities()); + for (const extensionId of storedPackages) { + const installed = this.#composition + .installedPackages() + .some(({ packageId }) => packageId === extensionId); + if (installed && !retryPackages.has(extensionId)) continue; + try { + const loaded = await this.#packageLoader.load(extensionId); + await this.#adoptRuntimePackage(extensionId, loaded, projection.desired, installed); + } catch (error) { + packageFailures.push(Object.freeze({ extensionId, diagnostic: boundedDiagnostic(error) })); + } + } + const runtimeFailures = await this.#recoverDesiredRuntime(projection.desired); + this.#desired = projection.desired; + this.#structuralDependencies = projection.structuralDependencies; + this.#failures = Object.freeze([ + ...projection.failures, + ...packageFailures, + ...runtimeFailures.map(({ entryId, diagnostic }) => Object.freeze({ entryId, diagnostic })), + ]); + this.#settleConvergence(this.#failures.length === 0); + return this.#receipt('complete'); + } + + #scheduleReconcile(): void { + if (this.#reconcileTimer || this.#drainRequested || this.#phase !== 'degraded') return; + const delay = this.#reconcileDelayMs; + this.#reconcileDelayMs = Math.min(this.#reconcileDelayMs * 2, 30_000); + this.#reconcileTimer = setTimeout(() => { + this.#reconcileTimer = undefined; + void this.#serialize(async () => { + if (this.#phase !== 'degraded') return; + await this.#reconcileNow(); + }).catch((error) => { + this.#composition.root.logger.warn('Unable to reconcile Plugin Platform', error); + }); + }, delay); + this.#reconcileTimer.unref?.(); + } + + #receipt(cleanup: 'complete' | 'pending'): PluginMutationReceipt { + return Object.freeze({ + authorityEpoch: this.#authority.generation, + durability: 'committed', + convergence: this.#convergence === 'converged' ? 'converged' : 'diverged', + cleanup, + failures: Object.freeze(this.#failures.map((failure) => Object.freeze({ ...failure }))), + }); + } + + #structuralDependent(extensionId: string): string | undefined { + for (const [actor, dependencies] of this.#structuralDependencies) { + if (dependencies.has(extensionId)) return actor; + } + return undefined; + } } function emptyCompositionAuthority(): PersistedPluginComposition { @@ -1049,6 +1356,97 @@ function withoutEntries( }); } +function withoutPackageEntries( + state: MakaCompositionState, + extensionId: string, +): MakaCompositionState { + return withoutEntries( + state, + new Set( + compositionEntries(state) + .filter(({ packageId }) => packageId === extensionId) + .map(({ id }) => id), + ), + ); +} + +function countInspections(inspections: readonly MakaCompositionEntryInspection[]): number { + return inspections.reduce( + (total, inspection) => total + 1 + countInspections(inspection.children), + 0, + ); +} + +function cloneDependencyGraph( + graph: ReadonlyMap>, +): Map> { + return new Map([...graph].map(([actor, dependencies]) => [actor, new Set(dependencies)])); +} + +function freezeDependencyGraph( + graph: ReadonlyMap>, +): ReadonlyMap> { + return new Map( + [...graph].map(([actor, dependencies]) => [ + actor, + Object.freeze(new Set([...dependencies].sort())) as ReadonlySet, + ]), + ); +} + +function recordStructuralDependencies( + state: MakaCompositionState, + owners: ReadonlyMap, + dependencies: Map>, + actor: string, + operation: MakaCompositionOperation, +): void { + const referencedIds: string[] = []; + if (operation.type === 'insert') { + if (operation.parentId) referencedIds.push(operation.parentId); + } else { + referencedIds.push(operation.entryId); + if (operation.type === 'move' && operation.parentId) referencedIds.push(operation.parentId); + } + for (const entryId of referencedIds) { + if (!findCompositionEntry(state, entryId)) continue; + const owner = owners.get(entryId); + if (!owner || owner === actor) continue; + const actorDependencies = dependencies.get(actor) ?? new Set(); + actorDependencies.add(owner); + dependencies.set(actor, actorDependencies); + } +} + +function updateEntryOwners( + previous: MakaCompositionState, + owners: Map, + actor: string, + operation: MakaCompositionOperation, +): void { + if (operation.type === 'insert') { + for (const entry of walkCompositionEntries(operation.entry)) owners.set(entry.id, actor); + return; + } + if (operation.type === 'remove') { + const removed = findCompositionEntry(previous, operation.entryId); + if (removed) { + for (const entry of walkCompositionEntries(removed)) owners.delete(entry.id); + } + } +} + +function walkCompositionEntries(entry: MakaCompositionEntry): readonly MakaCompositionEntry[] { + return [entry, ...(entry.children ?? []).flatMap(walkCompositionEntries)]; +} + +function overlayFailureIdentity(operations: readonly MakaCompositionOperation[]): string { + const operation = operations[0]; + if (!operation) return 'user-overlay'; + if (operation.type === 'insert') return operation.entry.id; + return operation.entryId; +} + function operationFailures( input: MakaCompositionApplyInput, error: unknown, @@ -1092,6 +1490,11 @@ function asError(error: unknown): Error { } function boundedDiagnostic(error: unknown): string { - const message = error instanceof Error ? error.message : String(error); + const message = + error instanceof AggregateError + ? error.errors.map((item) => boundedDiagnostic(item)).join('; ') + : error instanceof Error + ? error.message + : String(error); return message.slice(0, 4096) || 'Plugin Platform operation failed'; }