You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by ku...@apache.org on 2026/09/01 03:02:29 UTC
(maka) branch main updated: feat(runtime): add Plugin Platform foundation (#3729)
This is an automated email from the ASF dual-hosted git repository.
likun666661 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 8fa1e8947d feat(runtime): add Plugin Platform foundation (#3729)
8fa1e8947d is described below
commit 8fa1e8947de3b92e89053594f8e91f643f84b596
Author: Xinhao Xu <84...@users.noreply.github.com>
AuthorDate: Tue Sep 1 11:02:24 2026 +0800
feat(runtime): add Plugin Platform foundation (#3729)
* feat(runtime): add plugin platform foundation
* fix(runtime-host): recover package install boundaries
* fix(plugin): preserve foundation consistency
* fix(plugin): harden prototype and query boundaries
* fix(plugin): make platform state and convergence explicit
* fix(plugin): harden recovery and reconciliation
---
package-lock.json | 1 +
.../runtime-host-operator-command.test.ts | 93 ++
packages/cli/src/cli-core.ts | 17 +
packages/cli/src/runtime-host-cli.ts | 105 +-
packages/cli/src/runtime-host-plugin-command.ts | 141 ++
packages/runtime-host/package.json | 1 +
.../src/__tests__/plugin-platform.test.ts | 1651 ++++++++++++++++++++
packages/runtime-host/src/protocol/index.ts | 4 +-
.../runtime-host/src/protocol/operation-spec.ts | 1 +
packages/runtime-host/src/protocol/operations.ts | 3 +
.../runtime-host/src/protocol/plugin-platform.ts | 744 +++++++++
.../src/server/execution-composition.ts | 19 +
.../runtime-host/src/server/extension-bundle.ts | 279 ++++
.../src/server/extension-package-manifest.ts | 338 ++++
packages/runtime-host/src/server/index.ts | 29 +
.../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 | 604 +++++++
.../src/server/plugin-platform-coordinator.ts | 329 ++++
.../runtime-host/src/server/plugin-platform.ts | 1507 ++++++++++++++++++
.../__tests__/plugin-composition-loader.test.ts | 280 +++-
packages/runtime/src/plugin-composition-loader.ts | 263 +++-
packages/runtime/src/plugin-runtime.ts | 304 +++-
25 files changed, 7031 insertions(+), 78 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index bb1e604af8..15ccc3ad4e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14357,6 +14357,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 0486e79a63..04b5991f16 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';
@@ -157,6 +158,34 @@ describe('Runtime Host operator commands', () => {
prefer: false,
},
);
+ assert.deepEqual(
+ parseRuntimeHostCommand([
+ 'plugin',
+ 'inspect',
+ '--scope',
+ 'profile',
+ '--cursor',
+ 'opaque-cursor',
+ '--limit',
+ '16',
+ ]),
+ {
+ kind: 'runtime-host-plugin',
+ action: 'inspect',
+ rootId: 'profile',
+ cursor: 'opaque-cursor',
+ 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',
@@ -301,6 +330,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);
}
@@ -432,6 +463,13 @@ describe('Runtime Host operator commands', () => {
'peer.mesh.remove',
'peer.mesh.rename',
'peer.mesh.transit.set',
+ 'plugin.composition.apply',
+ 'plugin.package.export',
+ 'plugin.package.install',
+ 'plugin.package.reload',
+ 'plugin.package.uninstall',
+ 'plugin.platform.query',
+ 'plugin.platform.reconcile',
],
);
});
@@ -533,6 +571,61 @@ 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' },
+ { rootPath: '/srv/maka', action: 'reconcile' as const },
+ ];
+ 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',
+ 'plugin.platform.reconcile',
+ ],
+ );
+ assert.equal(closeCount, commands.length);
+ });
});
function presetOptions(
diff --git a/packages/cli/src/cli-core.ts b/packages/cli/src/cli-core.ts
index b1b068fb54..e99c46dbea 100644
--- a/packages/cli/src/cli-core.ts
+++ b/packages/cli/src/cli-core.ts
@@ -160,6 +160,11 @@ function helpText(cliCommand: string): string {
` ${cliCommand} runtime-host access revoke --credential <id>`,
` ${cliCommand} runtime-host project list [--root <path>]`,
` ${cliCommand} runtime-host project add <path> [--prefer] [--root <path>]`,
+ ` ${cliCommand} runtime-host plugin status|list|inspect|failures [--root <path>]`,
+ ` ${cliCommand} runtime-host plugin install|uninstall|reload <target> [--root <path>]`,
+ ` ${cliCommand} runtime-host plugin export <extension-id> <bundle-path> [--root <path>]`,
+ ` ${cliCommand} runtime-host plugin apply <operations.json> [--root <path>]`,
+ ` ${cliCommand} runtime-host plugin reconcile [--root <path>]`,
` ${cliCommand} runtime-host profile list`,
` ${cliCommand} runtime-host profile set --id <id> --name <name> --tls-url <wss-url> --expected-root <root-id> [--credential-env <name>]`,
` ${cliCommand} runtime-host profile set --id <id> --name <name> --ssh-destination <us...@host> --ssh-remote-port <port> --expected-root <root-id> [--ssh-port <port>] [--credential-env <name>]`,
@@ -708,6 +713,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 c52df12063..5f7c52f7ac 100644
--- a/packages/cli/src/runtime-host-cli.ts
+++ b/packages/cli/src/runtime-host-cli.ts
@@ -301,6 +301,26 @@ export type RuntimeHostCliCommand =
path: string;
prefer: boolean;
}
+ | {
+ kind: 'runtime-host-plugin';
+ rootPath?: string;
+ action:
+ | 'status'
+ | 'list'
+ | 'inspect'
+ | 'failures'
+ | 'install'
+ | 'uninstall'
+ | 'reload'
+ | 'export'
+ | 'apply'
+ | 'reconcile';
+ subject?: string;
+ targetPath?: string;
+ rootId?: string;
+ cursor?: string;
+ limit?: number;
+ }
| {
kind: 'runtime-host-capability-provider-serve';
url: string;
@@ -360,6 +380,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));
}
@@ -367,7 +388,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',
);
}
@@ -1545,6 +1566,88 @@ 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',
+ 'reconcile',
+ ] 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: string | 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 if (argument === '--cursor') cursor = parsed;
+ else {
+ const numeric = Number(parsed);
+ if (!Number.isSafeInteger(numeric) || numeric < 0 || numeric < 1 || numeric > 64) {
+ return error('--limit requires an integer between 1 and 64');
+ }
+ 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 ? { 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..ca87bd1c10
--- /dev/null
+++ b/packages/cli/src/runtime-host-plugin-command.ts
@@ -0,0 +1,141 @@
+/*
+ * 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'
+ | 'reconcile';
+ readonly subject?: string;
+ readonly targetPath?: string;
+ readonly rootId?: string;
+ readonly cursor?: string;
+ readonly limit?: number;
+}
+
+interface RuntimeHostPluginCommandDeps {
+ readonly connect: (rootPath: string) => Promise<RuntimeHostConnection>;
+ readonly readText: (path: string) => Promise<string>;
+ readonly write: (value: string) => void;
+}
+
+export async function runRuntimeHostPluginCli(
+ command: RuntimeHostPluginCommand,
+ overrides: Partial<RuntimeHostPluginCommandDeps> = {},
+): Promise<number> {
+ 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<string>,
+): Promise<unknown> {
+ 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);
+ }
+ case 'reconcile':
+ return await connection.request('plugin.platform.reconcile', {});
+ }
+}
+
+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<RuntimeHostConnection> {
+ 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 e6c8afb72a..58aa06632c 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..99e4646f7b
--- /dev/null
+++ b/packages/runtime-host/src/__tests__/plugin-platform.test.ts
@@ -0,0 +1,1651 @@
+/*
+ * 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, readFile, readdir, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { test } from 'node:test';
+import { waitFor } from '@maka/core/test-only/async-primitives';
+import { MakaCompositionLoader } from '@maka/runtime/plugin-composition-loader';
+import {
+ decodePluginCompositionApplyInput,
+ decodeRequestFrame,
+ decodeResponseFrame,
+ operationAllowsRemoteOwner,
+} from '../protocol/index.js';
+import { PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES } from '../protocol/plugin-platform.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, type HostPluginPlatformOptions } from '../server/plugin-platform.js';
+
+interface TestPlatformInternals {
+ readonly composition: MakaCompositionLoader;
+ readonly packages: PluginPackageStore;
+ readonly store: HostPluginCompositionStore;
+}
+
+const testPlatformInternals = new WeakMap<HostPluginPlatform, TestPlatformInternals>();
+
+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-'));
+ try {
+ const source = await writeFixturePackage(root, 'fixture-package', 'first', {
+ composition: [
+ {
+ type: 'insert',
+ rootId: 'profile',
+ entry: { id: 'fixture-entry', packageId: 'fixture-package' },
+ },
+ ],
+ });
+ const platform = createPlatform(join(root, 'control'));
+ await platform.recover();
+
+ 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.exportPackage('fixture-package', bundle);
+ const imported = createPlatform(join(root, 'import-control'));
+ await imported.recover();
+ 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 = createPlatform(join(root, 'control'));
+ await recovered.recover();
+ assert.equal(recovered.inspect('profile')[0]?.status, 'active');
+ assert.equal(recovered.desiredComposition().generation, 1);
+ assert.deepEqual(internals(recovered).composition.package('fixture-package').contributions, [
+ { id: 'first', kind: 'foundation-test' },
+ ]);
+ assert.deepEqual(Object.keys((await internals(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 = createPlatform(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',
+ authorityEpoch: 1,
+ durability: 'committed',
+ convergence: 'converged',
+ cleanup: 'complete',
+ failures: [],
+ },
+ });
+ 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');
+ }
+ 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 });
+ }
+});
+
+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 = createPlatform(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('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 = createPlatform(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', {
+ 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' } } },
+ ],
+ });
+ 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 internals(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 = 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 internals(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 internals(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 = createPlatform(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 = 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(
+ internals(platform).composition.package('rollback-package').contributions,
+ before,
+ );
+ assert.equal(
+ (await internals(platform).packages.load('rollback-package')).manifest.id,
+ 'rollback-package',
+ );
+ await platform.close();
+
+ const recovered = createPlatform(join(root, 'control'));
+ await recovered.recover();
+ assert.deepEqual(
+ internals(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 = createPlatform(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(
+ internals(initial).composition.package(`generation-${mode}`).contributions?.[0]?.id,
+ 'stable',
+ 'Runtime convergence waits until the authority outcome is known',
+ );
+ await initial.close();
+
+ const recovered = createPlatform(control);
+ await recovered.recover();
+ assert.equal(
+ internals(recovered).composition.package(`generation-${mode}`).contributions?.[0]?.id,
+ mode === 'after' ? 'replacement' : 'stable',
+ );
+ await recovered.close();
+ }
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test('package replacement fences and preserves recovery evidence when candidate publication fails', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-plugin-package-publication-'));
+ try {
+ const control = join(root, 'control');
+ const packages = new FailingCandidatePublishPackageStore(control);
+ const platform = createPlatform(control, { packages });
+ await platform.recover();
+ await platform.installPackage(await writeFixturePackage(root, 'publication-package', 'stable'));
+ packages.failNextCandidatePublication = true;
+
+ await assert.rejects(
+ async () =>
+ platform.installPackage(
+ await writeFixturePackage(root, 'publication-package', 'replacement', {
+ directorySuffix: 'replacement',
+ }),
+ ),
+ /publication outcome is unknown/u,
+ );
+ assert.equal((await platform.status()).phase, 'fenced');
+ assert.equal(
+ (await readdir(packages.root)).some((entry) => entry.startsWith('.install-')),
+ true,
+ 'the install journal must survive for generation-based recovery',
+ );
+ await platform.close();
+
+ const recovered = createPlatform(control);
+ await recovered.recover();
+ assert.equal((await recovered.status()).phase, 'ready');
+ assert.equal(
+ internals(recovered).composition.package('publication-package').contributions?.[0]?.id,
+ 'stable',
+ );
+ assert.equal(
+ (await readdir(internals(recovered).packages.root)).some((entry) =>
+ entry.startsWith('.install-'),
+ ),
+ false,
+ );
+ await recovered.close();
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test('automatic reconciliation retries after a transient exceptional failure', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-plugin-reconcile-retry-'));
+ try {
+ const control = join(root, 'control');
+ const packages = new TransientRecoveryPackageStore(control);
+ const source = await writeFixturePackage(root, 'reconcile-package', 'loaded');
+ const prepared = await packages.prepareInstall(source);
+ await prepared.publish(0, 1);
+ await prepared.commit();
+ const packageLoader = new FailOncePackageLoader(control, packages);
+ const platform = createPlatform(control, { packages, packageLoader });
+
+ await platform.recover();
+ assert.equal((await platform.status()).phase, 'degraded');
+ await waitFor(async () => (await platform.status()).phase === 'ready', {
+ timeoutMs: 3_000,
+ pollMs: 20,
+ message: 'automatic Plugin reconciliation did not retry after an exception',
+ });
+ assert.equal(packages.recoverCalls >= 3, true);
+ assert.equal(packageLoader.loadCalls >= 2, true);
+ await platform.close();
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test('Plugin Platform recovery removes orphaned bundle import roots', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-plugin-bundle-import-recovery-'));
+ try {
+ const control = join(root, 'control');
+ const imports = join(control, 'bundle-imports-v1');
+ const orphan = join(imports, 'owner-died');
+ await mkdir(orphan, { recursive: true });
+ await writeFile(join(orphan, 'partial-package'), 'orphan');
+
+ const platform = createPlatform(control);
+ await platform.recover();
+ await assert.rejects(() => readdir(imports), isEnoent);
+ await platform.close();
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+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',
+ 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: 1,
+ },
+ }),
+ );
+
+ 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<Record<string, unknown>>;
+ const isolate = operation.entry.isolate as Readonly<Record<string, unknown>>;
+ 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', () => {
+ 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: {
+ authorityEpoch: 700,
+ durability: 'committed',
+ convergence: 'converged',
+ cleanup: 'complete',
+ failures: [],
+ },
+ }),
+ );
+});
+
+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 = createPlatform(control, { store });
+ await platform.recover();
+ await platform.installPackage(source);
+ await platform.apply({
+ operations: [
+ {
+ type: 'insert',
+ entry: { id: 'persistent-entry', packageId: 'persistent-package' },
+ },
+ ],
+ });
+ const before = internals(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(internals(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 = createPlatform(control);
+ await initial.recover();
+ await initial.installPackage(source);
+ await initial.close();
+
+ const recovered = createPlatform(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 = createPlatform(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(internals(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 = createPlatform(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(internals(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 = createPlatform(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 = createPlatform(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 internals(platform).store.read();
+ const desired = platform.desiredComposition();
+
+ await assert.rejects(() => platform.uninstallPackage('uninstall-plan'), /used by desired/u);
+ assert.deepEqual(await internals(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('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 = createPlatform(control, { packages });
+ await platform.recover();
+ await platform.installPackage(
+ await writeFixturePackage(root, 'uninstall-rollback', 'installed', {
+ composition: [
+ {
+ type: 'insert',
+ entry: { id: 'package-default', packageId: 'uninstall-rollback' },
+ },
+ ],
+ }),
+ );
+ packages.failUninstall = true;
+
+ 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 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 });
+ }
+});
+
+test('composition authority commits before Runtime convergence and exposes divergence', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-plugin-divergence-'));
+ try {
+ 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 }),
+ );
+
+ 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(internals(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 = createPlatform(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 = createPlatform(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 = createPlatform(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, 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 });
+ }
+});
+
+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',
+ });
+ 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'),
+ true,
+ );
+
+ await platform.uninstallPackage('broken-package');
+ assert.deepEqual(await internals(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 = createPlatform(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(internals(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 = createPlatform(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(internals(platform).composition.compositionState().roots.profile[0]?.config, {
+ enabled: true,
+ });
+ await platform.close();
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+test('Manifest v1 rejects unsupported secret configuration metadata', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'maka-plugin-config-secret-'));
+ try {
+ const platform = createPlatform(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 internals(platform).packages.identities(), []);
+ await platform.close();
+ } finally {
+ await rm(root, { recursive: true, force: true });
+ }
+});
+
+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 = createPlatform(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 {
+ const platform = createPlatform(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 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 { 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 });
+ }
+});
+
+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 });
+ }
+});
+
+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 = createPlatform(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 });
+ }
+});
+
+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<string> {
+ 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,
+ contributionId: string,
+ options: {
+ readonly runtimePackageId?: string;
+ readonly directorySuffix?: string;
+ readonly throwOnApply?: boolean;
+ readonly provideService?: string;
+ readonly structuralDependencies?: readonly string[];
+ readonly manifest?: Readonly<Record<string, unknown>>;
+ readonly composition?: readonly unknown[];
+ } = {},
+): Promise<string> {
+ 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',
+ structuralDependencies: options.structuralDependencies ?? [],
+ },
+ }
+ : {}),
+ ...(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');" : ''}
+ ${options.provideService ? `ctx.provide(${JSON.stringify(options.provideService)}, { source: ${JSON.stringify(contributionId)} });` : ''}
+ ctx.effect(() => () => undefined, 'fixture');
+ } }),
+ });\n`,
+ );
+ return source;
+}
+
+class FailingCompositionStore extends HostPluginCompositionStore {
+ fail = false;
+
+ override async replace(state: PersistedPluginComposition): Promise<void> {
+ if (this.fail) throw new Error('injected persistence failure');
+ await super.replace(state);
+ }
+}
+
+class FailingUninstallPackageStore extends PluginPackageStore {
+ failUninstall = false;
+
+ override async uninstall(extensionId: string): Promise<void> {
+ if (this.failUninstall) throw new Error('injected package uninstall failure');
+ await super.uninstall(extensionId);
+ }
+}
+
+class FailingCandidatePublishPackageStore extends PluginPackageStore {
+ failNextCandidatePublication = false;
+
+ protected override async publishCandidate(staging: string, target: string): Promise<void> {
+ if (this.failNextCandidatePublication) {
+ this.failNextCandidatePublication = false;
+ throw new Error('injected candidate publication failure');
+ }
+ await super.publishCandidate(staging, target);
+ }
+}
+
+class TransientRecoveryPackageStore extends PluginPackageStore {
+ recoverCalls = 0;
+
+ override async recover(authorityGeneration = 0): Promise<void> {
+ this.recoverCalls += 1;
+ if (this.recoverCalls === 2) throw new Error('injected transient recovery failure');
+ await super.recover(authorityGeneration);
+ }
+}
+
+class FailOncePackageLoader extends TrustedPluginPackageLoader {
+ loadCalls = 0;
+
+ override async load(extensionId: string) {
+ this.loadCalls += 1;
+ if (this.loadCalls === 1) throw new Error('injected initial package load failure');
+ return await super.load(extensionId);
+ }
+}
+
+class UnknownCommitCompositionStore extends HostPluginCompositionStore {
+ fail = false;
+
+ override async replace(state: PersistedPluginComposition): Promise<void> {
+ 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<void> {
+ 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<void>;
+ readonly #signalEntered: () => void;
+ readonly #gate: Promise<void>;
+ readonly #release: () => void;
+
+ constructor(controlDirectory: string) {
+ super(controlDirectory);
+ let signalEntered!: () => void;
+ let release!: () => void;
+ this.entered = new Promise<void>((resolve) => {
+ signalEntered = resolve;
+ });
+ this.#gate = new Promise<void>((resolve) => {
+ release = resolve;
+ });
+ this.#signalEntered = signalEntered;
+ this.#release = release;
+ }
+
+ release(): void {
+ this.#release();
+ }
+
+ override async replace(state: PersistedPluginComposition): Promise<void> {
+ 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<void> {
+ this.closeAttempted = true;
+ throw new Error('injected composition close failure');
+ }
+}
+
+class FailingClosePackageLoader extends TrustedPluginPackageLoader {
+ closeAttempted = false;
+
+ override async close(): Promise<void> {
+ 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 34e8139bb0..2d3d3237b5 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -95,7 +95,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 = 84 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 85 as const;
+// 85: Plugin package and Entry composition operations become Host-owned protocol
+// surfaces. Older peers cannot safely exchange these strict operation shapes.
// 84: Message content carries Host-bound directory references. Older peers
// reject this field and cannot preserve its identity through admission/replay.
// 83: WorkHub Coordination actions add linked replacement proposals,
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 fb497a1cd3..1f6f900805 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,
@@ -162,6 +163,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';
@@ -219,6 +221,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;
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..2a0620e1e7
--- /dev/null
+++ b/packages/runtime-host/src/protocol/plugin-platform.ts
@@ -0,0 +1,744 @@
+/*
+ * 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',
+ 'stale_cursor',
+] as const;
+const MUTATE_ERRORS = [
+ ...QUERY_ERRORS,
+ 'not_found',
+ 'operation_conflict',
+ 'commit_outcome_unknown',
+] as const;
+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<PluginPlatformConvergence, 'unknown'>;
+ 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?: string;
+ readonly limit?: number;
+}
+
+export type PluginPlatformQueryResult =
+ | {
+ readonly view: 'status';
+ 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: string | null;
+ }
+ | {
+ readonly view: 'entries';
+ readonly items: readonly MakaCompositionEntryInspection[];
+ readonly nextCursor: string | null;
+ }
+ | {
+ readonly view: 'failures';
+ readonly items: readonly PluginPlatformFailureProjection[];
+ readonly nextCursor: string | null;
+ };
+
+export interface PluginPlatformFailureProjection {
+ readonly entryId?: string;
+ readonly extensionId?: string;
+ readonly diagnostic: string;
+}
+
+export interface PluginPackageInstallInput {
+ readonly sourcePath: string;
+}
+
+export interface PluginPackageInstallResult extends PluginMutationReceipt {
+ readonly extensionId: string;
+}
+
+export interface PluginPackageUninstallInput {
+ readonly extensionId: string;
+}
+
+export interface PluginPackageExportInput extends PluginPackageUninstallInput {
+ readonly targetPath: string;
+}
+
+export interface PluginPackageExportResult {
+ readonly targetPath: string;
+}
+
+export type PluginPackageMutationResult = PluginMutationReceipt;
+export type PluginCompositionApplyResult = PluginMutationReceipt;
+
+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,
+ PluginPackageMutationResult,
+ (typeof MUTATE_ERRORS)[number]
+ >({
+ mode: 'command',
+ availability: 'ready',
+ errors: MUTATE_ERRORS,
+ decodeInput: decodePluginPackageUninstallInput,
+ decodeOutput: decodePluginMutationReceipt,
+ }),
+ 'plugin.package.reload': defineOperation<
+ PluginPackageUninstallInput,
+ PluginPackageMutationResult,
+ (typeof MUTATE_ERRORS)[number]
+ >({
+ mode: 'command',
+ availability: 'ready',
+ errors: MUTATE_ERRORS,
+ decodeInput: decodePluginPackageUninstallInput,
+ decodeOutput: decodePluginMutationReceipt,
+ }),
+ '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: decodePluginMutationReceipt,
+ }),
+ 'plugin.platform.reconcile': defineOperation<
+ Record<string, never>,
+ 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;
+
+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
+ : requireString(input.cursor, 'Plugin Platform query cursor', 4096);
+ 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',
+ '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,
+ 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', [
+ '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
+ : requireString(output.nextCursor, 'Plugin Platform next cursor', 4096);
+ 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',
+ PLUGIN_PLATFORM_QUERY_RESULT_MAX_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',
+ 'contentDigest',
+ 'displayName',
+ 'dependencies',
+ 'structuralDependencies',
+ 'requiredBy',
+ ],
+ ['description'],
+ );
+ 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
+ ? {}
+ : { description: requireString(item.description, 'Plugin description', 4096) }),
+ 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',
+ '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 {
+ 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<Omit<MakaCompositionEntry, 'id' | 'children'>> {
+ 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<Record<string, unknown>> {
+ if (Array.isArray(value)) return value.map((item) => requireId(item, 'Plugin injection'));
+ return decodeJsonRecord(value, 'inject');
+}
+
+function decodeIsolate(value: unknown): Readonly<Record<string, true | string>> {
+ const record = requireRecord(value, 'Plugin Entry isolate');
+ const output: Array<readonly [string, true | string]> = [];
+ 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.push([key, item]);
+ }
+ return Object.fromEntries(output);
+}
+
+function decodeJsonRecord(value: unknown, label: string): Readonly<Record<string, unknown>> {
+ 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<Record<string, string | number | boolean>> {
+ const record = requireRecord(value, `Plugin Entry ${label}`);
+ const output: Array<readonly [string, string | number | boolean]> = [];
+ 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.push([key, item]);
+ else throw invalidProtocolFrame(`Invalid Plugin Entry ${label} value`);
+ }
+ return Object.fromEntries(output);
+}
+
+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/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts
index 59f967ad17..4407a1b9a3 100644
--- a/packages/runtime-host/src/server/execution-composition.ts
+++ b/packages/runtime-host/src/server/execution-composition.ts
@@ -158,6 +158,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';
@@ -201,6 +203,7 @@ import {
export interface ExecutionRuntimeHostComposition extends RuntimeHostComposition {
readonly workspaceExecution: RuntimeHostWorkspaceExecutionComposition;
+ readonly plugins: HostPluginPlatform;
}
const CONTEXT_OFFLOAD_READER_LIMITS: ContextOffloadLimits = Object.freeze({
@@ -268,7 +271,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);
@@ -1609,6 +1615,13 @@ export async function createExecutionRuntimeHostComposition(
);
let recoverySessions: Awaited<ReturnType<typeof stores.sessionStore.listForRecovery>> = [];
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],
@@ -1866,6 +1879,7 @@ export async function createExecutionRuntimeHostComposition(
handlers,
moduleIds: Object.freeze(domainModules.map(({ id }) => id)),
workspaceExecution: requireWorkspaceExecution(workspaceExecution),
+ plugins: pluginPlatform,
continuity: continuityCoordinator,
clientCapabilities,
hostChanges,
@@ -1878,6 +1892,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..6fe513e9fe
--- /dev/null
+++ b/packages/runtime-host/src/server/extension-bundle.ts
@@ -0,0 +1,279 @@
+/*
+ * 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;
+const BUNDLE_IMPORTS_DIRECTORY = 'bundle-imports-v1';
+
+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<void> {
+ if (!isAbsolute(targetPath)) throw invalid('Extension bundle targetPath must be absolute');
+ const files = await readDirectory(sourceRoot);
+ const document: ExtensionBundleDocument = Object.freeze({
+ schemaVersion: 1,
+ digest: extensionPackageContentDigest(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<ReturnType<typeof open>> | 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<void> }> {
+ 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_DIRECTORY);
+ 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);
+ }
+}
+
+/** Removes bundle materializations left behind when the previous owner died. */
+export async function recoverExtensionBundleImports(controlDirectory: string): Promise<void> {
+ try {
+ await rm(join(controlDirectory, BUNDLE_IMPORTS_DIRECTORY), {
+ recursive: true,
+ force: true,
+ });
+ } catch (error) {
+ throw invalid('Unable to recover Extension bundle imports', error);
+ }
+}
+
+async function readDirectory(
+ rootValue: string,
+): Promise<readonly { path: string; content: Buffer }[]> {
+ 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<void> {
+ 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<string, unknown>;
+ 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<string>();
+ 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<string, unknown>;
+ 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' || extensionPackageContentDigest(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'),
+ }),
+ ),
+ ),
+ });
+}
+
+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');
+ 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..e4898c50c3
--- /dev/null
+++ b/packages/runtime-host/src/server/extension-package-manifest.ts
@@ -0,0 +1,338 @@
+/*
+ * 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[];
+}
+
+export interface ExtensionConfigurationSchema {
+ readonly properties: Readonly<Record<string, ExtensionConfigurationProperty>>;
+ 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;
+ readonly structuralDependencies: readonly string[];
+}
+
+export class ExtensionPackageManifestError extends Error {
+ readonly name = 'ExtensionPackageManifestError';
+ constructor(message: string, options?: ErrorOptions) {
+ super(message, options);
+ }
+}
+
+export async function loadExtensionPackageManifest(
+ root: string,
+): Promise<ExtensionPackageManifest | undefined> {
+ 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'], ['structuralDependencies']);
+ const structuralDependencies = decodeExtensionIds(
+ composition.structuralDependencies,
+ 'composition.structuralDependencies',
+ );
+ return Object.freeze({
+ patch: packagePath(composition.patch, 'composition.patch'),
+ structuralDependencies,
+ });
+}
+
+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<Record<string, ExtensionConfigurationScalar>> {
+ 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<string, ExtensionConfigurationScalar> = {};
+ for (const [key, property] of Object.entries(schema.properties)) {
+ 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}`);
+ }
+ 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<string>();
+ 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 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([]) });
+ 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<string, ExtensionConfigurationProperty> = {};
+ 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']);
+ 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 } : {}),
+ });
+ }
+ 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<string, unknown>,
+ 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<string, unknown> {
+ if (!value || typeof value !== 'object' || Array.isArray(value))
+ throw invalid(`${label} must be an object`);
+ return value as Record<string, unknown>;
+}
+
+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 a1ed9f5283..cd0bc031ef 100644
--- a/packages/runtime-host/src/server/index.ts
+++ b/packages/runtime-host/src/server/index.ts
@@ -35,3 +35,32 @@ 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 {
+ HostPluginPlatform,
+ HostPluginPlatformError,
+ type HostPluginPlatformFailure,
+} 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 d16495822c..c3d464e9b1 100644
--- a/packages/runtime-host/src/server/operation-dispatcher.ts
+++ b/packages/runtime-host/src/server/operation-dispatcher.ts
@@ -153,6 +153,7 @@ export type WebSearchOperationKey = Extract<OperationKey, `web-search.${string}`
export type NetworkProxyOperationKey = Extract<OperationKey, `network-proxy.${string}`>;
export type ConfigurationOperationKey = Extract<OperationKey, `configuration.${string}`>;
export type WorkHubCoordinationOperationKey = Extract<OperationKey, `workhub.${string}`>;
+export type PluginPlatformOperationKey = Extract<OperationKey, `plugin.${string}`>;
export type DomainOperationHandlerMap = Pick<OperationHandlerMap, DomainOperationKey>;
export type TurnOperationHandlerMap = Pick<OperationHandlerMap, TurnOperationKey>;
export type ContextOperationHandlerMap = Pick<OperationHandlerMap, ContextOperationKey>;
@@ -223,6 +224,10 @@ export type WorkHubCoordinationOperationHandlerMap = Pick<
OperationHandlerMap,
WorkHubCoordinationOperationKey
>;
+export type PluginPlatformOperationHandlerMap = Pick<
+ OperationHandlerMap,
+ PluginPlatformOperationKey
+>;
export type AccessAuthorityOperationHandlerMap = Pick<
OperationHandlerMap,
keyof typeof ACCESS_AUTHORITY_OPERATION_SPECS | keyof typeof SESSION_COLLABORATION_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<MakaCompositionApplyInput | undefined> {
+ 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<PersistedPluginComposition | undefined> {
+ 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<void> {
+ 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<ReturnType<typeof open>> | 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<string, unknown> {
+ if (!value || typeof value !== 'object' || Array.isArray(value))
+ throw invalid(`${label} must be an object`);
+ return value as Record<string, unknown>;
+}
+
+function exact(value: Record<string, unknown>, 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<string>();
+
+ constructor(
+ controlDirectory: string,
+ readonly store: PluginPackageStore,
+ ) {
+ this.#generations = join(controlDirectory, GENERATION_DIRECTORY);
+ }
+
+ async load(extensionId: string): Promise<MakaPluginPackage> {
+ let installed;
+ try {
+ installed = await this.store.load(extensionId);
+ } catch (error) {
+ throw translate(error);
+ }
+ return await this.loadInstalled(installed);
+ }
+
+ async loadInstalled(installed: InstalledPluginPackage): Promise<MakaPluginPackage> {
+ 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<string, unknown>;
+ 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<void> {
+ this.#owned.clear();
+ await rm(this.#generations, { recursive: true, force: true });
+ }
+
+ async release(pkg: MakaPluginPackage): Promise<void> {
+ 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<void> {
+ 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..65e5b9af4f
--- /dev/null
+++ b/packages/runtime-host/src/server/plugin-package-store.ts
@@ -0,0 +1,604 @@
+/*
+ * 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,
+ extensionPackageContentDigest,
+ 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 contentDigest: string;
+ readonly root: string;
+ readonly entry: string;
+ readonly manifest: ExtensionPackageManifest;
+}
+
+export interface PreparedPluginPackageInstall {
+ readonly installed: InstalledPluginPackage;
+ publish(baseGeneration: number, nextGeneration: number): Promise<void>;
+ commit(): Promise<void>;
+ rollback(): Promise<void>;
+}
+
+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);
+ }
+
+ protected async publishCandidate(staging: string, target: string): Promise<void> {
+ await rename(staging, target);
+ }
+
+ /** Repairs or removes package-store transaction remnants after owner death. */
+ async recover(authorityGeneration = 0): Promise<void> {
+ 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<readonly string[]> {
+ 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<PreparedPluginPackageInstall> {
+ 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 this.publishCandidate(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<void> {
+ 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 (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);
+ }
+ 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<readonly InstalledPluginPackage[]> {
+ 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<InstalledPluginPackage> {
+ 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<void> {
+ const installed = await this.load(extensionId);
+ await exportExtensionBundle(installed.root, targetPath);
+ }
+
+ async uninstall(extensionId: string): Promise<void> {
+ 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<void> {
+ 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<PackageInstallTransaction | undefined> {
+ 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)) {
+ throw persistence('Plugin package install transaction is invalid');
+ }
+ const record = value as Record<string, unknown>;
+ 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<void> {
+ 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<boolean> {
+ 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;
+ readonly contentDigest: 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,
+ contentDigest: extensionPackageContentDigest(files),
+ });
+}
+
+async function readPackage(rootValue: string): Promise<readonly PackageFile[]> {
+ 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<void> {
+ 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<void> {
+ 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<void> {
+ const directories = new Set<string>([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<void> {
+ 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;
+ readonly contentDigest: string;
+ },
+): InstalledPluginPackage {
+ return Object.freeze({
+ extensionId: decoded.manifest.id,
+ contentDigest: decoded.contentDigest,
+ 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..ec1cc78071
--- /dev/null
+++ b/packages/runtime-host/src/server/plugin-platform-coordinator.ts
@@ -0,0 +1,329 @@
+/*
+ * 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 } from 'node:crypto';
+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 { 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';
+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),
+ 'plugin.platform.reconcile': () => this.#reconcile(),
+ };
+
+ constructor(readonly platform: HostPluginPlatform) {}
+
+ async #query(
+ input: PluginPlatformQueryInput,
+ ): Promise<OperationOutcome<'plugin.platform.query'>> {
+ try {
+ return await this.platform.read(async () => {
+ const failures = this.platform.failures();
+ if (input.view === 'status') {
+ return {
+ ok: true,
+ result: {
+ view: 'status',
+ ...(await this.platform.status()),
+ },
+ };
+ }
+ 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 = await this.platform.packageProjections();
+ return {
+ ok: true,
+ result: boundedPage('packages', packages, input),
+ };
+ });
+ } catch (error) {
+ return failure(error);
+ }
+ }
+
+ async #install(
+ input: PluginPackageInstallInput,
+ ): Promise<OperationOutcome<'plugin.package.install'>> {
+ try {
+ return { ok: true, result: await this.platform.installPackage(input.sourcePath) };
+ } catch (error) {
+ return failure(error);
+ }
+ }
+
+ async #uninstall(
+ input: PluginPackageUninstallInput,
+ ): Promise<OperationOutcome<'plugin.package.uninstall'>> {
+ try {
+ return { ok: true, result: await this.platform.uninstallPackage(input.extensionId) };
+ } catch (error) {
+ return failure(error);
+ }
+ }
+
+ async #reload(
+ input: PluginPackageUninstallInput,
+ ): Promise<OperationOutcome<'plugin.package.reload'>> {
+ try {
+ return { ok: true, result: await this.platform.reloadPackage(input.extensionId) };
+ } catch (error) {
+ return failure(error);
+ }
+ }
+
+ async #export(
+ input: PluginPackageExportInput,
+ ): Promise<OperationOutcome<'plugin.package.export'>> {
+ try {
+ await this.platform.exportPackage(input.extensionId, input.targetPath);
+ return { ok: true, result: { targetPath: input.targetPath } };
+ } catch (error) {
+ return failure(error);
+ }
+ }
+
+ async #apply(
+ input: MakaCompositionApplyInput,
+ ): Promise<OperationOutcome<'plugin.composition.apply'>> {
+ try {
+ return { ok: true, result: await this.platform.apply(input) };
+ } catch (error) {
+ return failure(error);
+ }
+ }
+
+ async #reconcile(): Promise<OperationOutcome<'plugin.platform.reconcile'>> {
+ try {
+ return { ok: true, result: await this.platform.reconcile() };
+ } catch (error) {
+ return failure(error);
+ }
+ }
+}
+
+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<PluginPlatformQueryResult, { readonly view: 'packages' }>;
+function boundedPage(
+ view: 'entries',
+ values: readonly MakaCompositionEntryInspection[],
+ input: PluginPlatformQueryInput,
+): Extract<PluginPlatformQueryResult, { readonly view: 'entries' }>;
+function boundedPage(
+ view: 'failures',
+ values: readonly PluginPlatformFailureProjection[],
+ input: PluginPlatformQueryInput,
+): Extract<PluginPlatformQueryResult, { readonly view: 'failures' }>;
+function boundedPage<T>(
+ view: 'packages' | 'entries' | 'failures',
+ values: readonly T[],
+ input: PluginPlatformQueryInput,
+): PluginPlatformQueryResult {
+ 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 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: encodeCursor(view, input.rootId, digest, index + 1),
+ }),
+ 'utf8',
+ ) > PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES
+ ) {
+ 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 ? 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<K extends keyof PluginPlatformOperationHandlerMap>(
+ error: unknown,
+): OperationOutcome<K> {
+ 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);
+ 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<K extends keyof PluginPlatformOperationHandlerMap>(
+ code: string,
+ message: string,
+): OperationOutcome<K> {
+ return { ok: false, error: { code, message } } as OperationOutcome<K>;
+}
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..7ffec1121a
--- /dev/null
+++ b/packages/runtime-host/src/server/plugin-platform.ts
@@ -0,0 +1,1507 @@
+/*
+ * 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 { recoverExtensionBundleImports } from './extension-bundle.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';
+import type {
+ PluginMutationReceipt,
+ PluginPackageProjection,
+ PluginPlatformConvergence,
+ PluginPlatformPhase,
+} from '../protocol/plugin-platform.js';
+
+export class HostPluginPlatformError extends Error {
+ readonly name = 'HostPluginPlatformError';
+
+ constructor(
+ readonly code:
+ | 'closed'
+ | 'persistence_failed'
+ | 'commit_outcome_unknown'
+ | 'recovery_failed'
+ | 'mutation_failed'
+ | 'not_ready'
+ | 'stale_cursor',
+ 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;
+}
+
+interface DesiredProjection {
+ readonly desired: MakaCompositionState;
+ readonly failures: readonly HostPluginPlatformFailure[];
+ readonly structuralDependencies: ReadonlyMap<string, ReadonlySet<string>>;
+}
+
+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;
+
+ #authority: PersistedPluginComposition = emptyCompositionAuthority();
+ #desired: MakaCompositionState = emptyCompositionState();
+ #mutation: Promise<void> = Promise.resolve();
+ #phase: PluginPlatformPhase = 'new';
+ #recoveryStarted = false;
+ #recoveryComplete = false;
+ #drainRequested = false;
+ #convergence: PluginPlatformConvergence = 'unknown';
+ #fence?: Error;
+ #reconcileTimer?: ReturnType<typeof setTimeout>;
+ #reconcileDelayMs = 250;
+ #failures: readonly HostPluginPlatformFailure[] = Object.freeze([]);
+ #structuralDependencies: ReadonlyMap<string, ReadonlySet<string>> = 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);
+ }
+
+ async recover(): Promise<void> {
+ 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 recoverExtensionBundleImports(this.controlDirectory);
+ await this.#packages.recover(storedAuthority.generation);
+ await this.#packageLoader.collectGarbage();
+ const packageFailures: HostPluginPlatformFailure[] = [];
+ for (const extensionId of await this.#packages.identities()) {
+ let loaded: MakaPluginPackage | undefined;
+ try {
+ 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,
+ diagnostic: boundedDiagnostic(error),
+ }),
+ );
+ }
+ }
+ 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.#authority = storedAuthority;
+ 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.#fence = asError(error);
+ this.#recoveryComplete = true;
+ this.#phase = 'fenced';
+ this.#convergence = 'unknown';
+ }
+ });
+ }
+
+ async installPackage(
+ sourcePath: string,
+ ): Promise<PluginMutationReceipt & { 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 authorityCommitted = 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
+ .installedPackages()
+ .some(({ packageId }) => packageId === 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;
+ this.#structuralDependencies = layerPlan.structuralDependencies;
+ await prepared.commit();
+ packageCommitted = true;
+ this.#clearPackageFailure(prepared.installed.extensionId);
+ runtimeAdoptionStarted = true;
+ const failures = await this.#adoptRuntimePackage(
+ prepared.installed.extensionId,
+ loaded,
+ layerPlan.planned,
+ alreadyInstalled,
+ );
+ await this.#publishEntryFailures(failures);
+ this.#settleConvergence(failures.length === 0);
+ return Object.freeze({
+ extensionId: prepared.installed.extensionId,
+ ...this.#receipt('complete'),
+ });
+ } catch (error) {
+ if (authorityCommitted) {
+ if (loaded && !runtimeAdoptionStarted) {
+ await this.#packageLoader.release(loaded).catch(() => undefined);
+ }
+ this.#recordPackageFailure(prepared.installed.extensionId, error);
+ this.#settleConvergence(false);
+ return Object.freeze({
+ extensionId: prepared.installed.extensionId,
+ ...this.#receipt(packageCommitted ? 'complete' : 'pending'),
+ });
+ }
+ if (error instanceof PluginPackageStoreError && error.code === 'commit_outcome_unknown') {
+ if (loaded) await this.#packageLoader.release(loaded).catch(() => undefined);
+ throw this.#fenceUnknownPackageOutcome(error, 'publication');
+ }
+ 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.#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);
+ throw error;
+ }
+ });
+ }
+
+ async reloadPackage(extensionId: string): Promise<PluginMutationReceipt> {
+ this.#assertMutable();
+ return await this.#serializeMutable(async () => {
+ const loaded = await this.#packageLoader.load(extensionId);
+ let adoptionStarted = false;
+ try {
+ await this.#validateDesired(this.desiredComposition());
+ 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) {
+ if (!adoptionStarted) await this.#packageLoader.release(loaded).catch(() => undefined);
+ this.#recordPackageFailure(extensionId, error);
+ this.#settleConvergence(false);
+ }
+ return this.#receipt('complete');
+ });
+ }
+
+ async uninstallPackage(extensionId: string): Promise<PluginMutationReceipt> {
+ this.#assertMutable();
+ 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 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) {
+ throw new MakaPluginRuntimeError(
+ 'package_in_use',
+ `Plugin package is used by desired entry ${desiredUser.id}`,
+ );
+ }
+ 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 (packageLayers.length !== this.#authority.packageLayers.length) {
+ await this.#commitDesiredAuthority(
+ projection.desired,
+ packageLayers,
+ this.#authority.overlays,
+ );
+ this.#structuralDependencies = projection.structuralDependencies;
+ }
+ const installedInRuntime = this.#composition
+ .installedPackages()
+ .some(({ packageId }) => packageId === extensionId);
+ 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<PluginMutationReceipt> {
+ this.#assertMutable();
+ return await this.#serializeMutable(async () => {
+ let normalizedInput: MakaCompositionApplyInput;
+ let projection: DesiredProjection;
+ try {
+ 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(
+ projection.desired.generation,
+ this.#authority.packageLayers,
+ Object.freeze([...this.#authority.overlays, ...normalizedInput.operations]),
+ );
+ try {
+ await this.#store.replace(next);
+ this.#authority = next;
+ this.#desired = projection.desired;
+ this.#structuralDependencies = projection.structuralDependencies;
+ } catch (error) {
+ if (
+ error instanceof HostPluginCompositionStoreError &&
+ error.code === 'commit_outcome_unknown'
+ ) {
+ this.#fence = error;
+ this.#phase = 'fenced';
+ 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 },
+ );
+ }
+
+ try {
+ const failures = await this.#recoverDesiredRuntime(projection.desired);
+ await this.#publishEntryFailures(failures);
+ this.#settleConvergence(failures.length === 0);
+ return this.#receipt('complete');
+ } catch (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[] {
+ 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<readonly PluginPackageProjection[]> {
+ this.#assertReadable();
+ const requiredBy = new Map<string, Set<string>>();
+ for (const [actor, dependencies] of this.#structuralDependencies) {
+ if (actor.startsWith('@')) continue;
+ for (const dependency of dependencies) {
+ const users = requiredBy.get(dependency) ?? new Set<string>();
+ 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<string>();
+ 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<void> {
+ await this.read(() => this.#packages.export(extensionId, targetPath));
+ }
+
+ async reconcile(): Promise<PluginMutationReceipt> {
+ this.#assertMutable();
+ return await this.#serializeMutable(() => this.#reconcileNow());
+ }
+
+ read<T>(operation: () => T | Promise<T>): Promise<T> {
+ this.#assertReadable();
+ return this.#serialize(async () => {
+ this.#assertReadable();
+ return await operation();
+ });
+ }
+
+ beginDrain(): void {
+ if (this.#phase === 'closed') return;
+ this.#drainRequested = true;
+ this.#phase = 'draining';
+ }
+
+ async close(): Promise<void> {
+ 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;
+ } 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);
+ }
+ this.#phase = 'closed';
+ 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[];
+ readonly structuralDependencies: ReadonlyMap<string, ReadonlySet<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 candidate = compositionAuthority(
+ this.#authority.generation + 1,
+ packageLayers,
+ this.#authority.overlays,
+ );
+ const projection = await this.#deriveDesired(candidate, 'strict', {
+ extensionId,
+ patch,
+ manifest,
+ });
+ return {
+ planned: projection.desired,
+ packageLayers,
+ structuralDependencies: projection.structuralDependencies,
+ };
+ }
+
+ async #deriveDesired(
+ authority: PersistedPluginComposition,
+ policy: 'strict' | 'recovery',
+ override?: PackageOverride,
+ ): Promise<DesiredProjection> {
+ let working = emptyCompositionState();
+ let owners = new Map<string, string>();
+ let dependencies = new Map<string, Set<string>>();
+ const failures: HostPluginPlatformFailure[] = [];
+
+ const applyLayer = async (
+ actor: string,
+ input: MakaCompositionApplyInput,
+ manifestOverride?: ExtensionPackageManifest,
+ ): Promise<void> => {
+ let candidate = working;
+ const candidateOwners = new Map(owners);
+ const candidateDependencies = cloneDependencyGraph(dependencies);
+ const applyOperation = async (operation: MakaCompositionOperation): Promise<void> => {
+ 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) {
+ 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) {
+ 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),
+ }),
+ );
+ }
+ }
+
+ 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(
+ planned: MakaCompositionState,
+ packageLayers: readonly string[],
+ overlays: readonly MakaCompositionOperation[],
+ ): Promise<void> {
+ 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.#fence = error;
+ this.#phase = 'fenced';
+ 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<void> {
+ 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<MakaCompositionApplyInput> {
+ 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<MakaCompositionState> {
+ const normalize = async (entry: MakaCompositionEntry): Promise<MakaCompositionEntry> => {
+ 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<MakaCompositionEntry> {
+ 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<readonly MakaCompositionRecoveryFailure[]> {
+ 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<void> {
+ if (!entry.packageId) return;
+ const manifests = new Map<string, ExtensionPackageManifest>();
+ const visiting = new Set<string>();
+ const visited = new Set<string>();
+ const visit = async (extensionId: string): Promise<void> => {
+ 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<void> {
+ 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<ExtensionPackageManifest> {
+ return manifestOverride?.id === extensionId
+ ? manifestOverride
+ : (await this.#packages.load(extensionId)).manifest;
+ }
+
+ async #recoverDesiredRuntime(
+ desired: MakaCompositionState,
+ ): Promise<readonly MakaCompositionRecoveryFailure[]> {
+ 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<readonly MakaCompositionRecoveryFailure[]> {
+ 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<MakaCompositionEntry | undefined> {
+ const dependsOn = async (packageId: string, visited: Set<string>): Promise<boolean> => {
+ 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 #manifestDependentPackage(extensionId: string): Promise<string | undefined> {
+ 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<void> {
+ 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 }),
+ ),
+ ]);
+ }
+
+ async #adoptRuntimePackage(
+ extensionId: string,
+ loaded: MakaPluginPackage,
+ desired: MakaCompositionState,
+ replacing: boolean,
+ ): Promise<readonly MakaCompositionRecoveryFailure[]> {
+ 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<void> {
+ 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),
+ );
+ }
+
+ #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.#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.#fence = error;
+ this.#phase = 'fenced';
+ return new HostPluginPlatformError(
+ 'commit_outcome_unknown',
+ `Plugin package ${operation} outcome is unknown; Plugin Platform was fenced`,
+ { cause: error },
+ );
+ }
+
+ #serializeMutable<T>(operation: () => Promise<T>): Promise<T> {
+ return this.#serialize(async () => {
+ this.#assertMutable();
+ return await operation();
+ });
+ }
+
+ #serialize<T>(operation: () => Promise<T>): Promise<T> {
+ const result = this.#mutation.then(operation, operation);
+ this.#mutation = result.then(
+ () => undefined,
+ () => undefined,
+ );
+ 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<PluginMutationReceipt> {
+ 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);
+ this.#scheduleReconcile();
+ });
+ }, 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 {
+ 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<string>,
+): 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 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<string, ReadonlySet<string>>,
+): Map<string, Set<string>> {
+ return new Map([...graph].map(([actor, dependencies]) => [actor, new Set(dependencies)]));
+}
+
+function freezeDependencyGraph(
+ graph: ReadonlyMap<string, ReadonlySet<string>>,
+): ReadonlyMap<string, ReadonlySet<string>> {
+ return new Map(
+ [...graph].map(([actor, dependencies]) => [
+ actor,
+ Object.freeze(new Set([...dependencies].sort())) as ReadonlySet<string>,
+ ]),
+ );
+}
+
+function recordStructuralDependencies(
+ state: MakaCompositionState,
+ owners: ReadonlyMap<string, string>,
+ dependencies: Map<string, Set<string>>,
+ 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<string>();
+ actorDependencies.add(owner);
+ dependencies.set(actor, actorDependencies);
+ }
+}
+
+function updateEntryOwners(
+ previous: MakaCompositionState,
+ owners: Map<string, string>,
+ 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,
+): readonly MakaCompositionRecoveryFailure[] {
+ const diagnostic = boundedDiagnostic(error);
+ const ids = new Set<string>();
+ 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<Record<string, string | number | boolean>> {
+ 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<string, string | number | boolean> = {};
+ 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 AggregateError
+ ? error.errors.map((item) => boundedDiagnostic(item)).join('; ')
+ : 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..3f04653180 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<string, Fiber>();
+ 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,33 @@ 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();
+ 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 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 +500,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 +533,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 +548,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 +618,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 +641,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 +659,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 +677,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 +708,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 +722,153 @@ 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('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 =
+ (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..9ec17c2fa5 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<string, MakaPluginPackage>();
@@ -89,6 +94,26 @@ export class MakaCompositionLoader {
});
}
+ reload(pkg: MakaPluginPackage): Promise<void> {
+ 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<void> {
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,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.#replaceSnapshot(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;
@@ -289,7 +323,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 +344,137 @@ export class MakaCompositionLoader {
});
}
- replaceSnapshot(snapshot: MakaCompositionSnapshot): Promise<void> {
- return this.#mutate(() => this.#replaceSnapshot(snapshot, 'publish'));
+ replaceComposition(state: MakaCompositionState): Promise<void> {
+ return this.#mutate(() => this.#replaceComposition(state, 'publish'));
+ }
+
+ /** Restores an externally uncommitted mutation without advancing its generation. */
+ restoreComposition(state: MakaCompositionState): Promise<void> {
+ 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<readonly MakaCompositionRecoveryFailure[]> {
+ return this.#mutate(async () => {
+ if (state.schemaVersion !== 1) {
+ throw new MakaPluginRuntimeError('invalid_entry', 'Unsupported composition state');
+ }
+ const failures: MakaCompositionRecoveryFailure[] = [];
+ const stagedRoots = new Map<MakaPluginRootId, LiveRoot>();
+ const stagedIds = new Set<string>();
+ const specs = new Map<MakaPluginRootId, readonly MakaCompositionEntry[]>([
+ ['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<LiveEntry | undefined> => {
+ 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<void> {
- 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<MakaPluginRootId, readonly MakaCompositionEntry[]>([
- ['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 +523,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 +565,9 @@ export class MakaCompositionLoader {
current: LiveEntry,
spec: MakaCompositionEntry,
): Promise<MakaCompositionEntryInspection> {
- 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 +597,59 @@ export class MakaCompositionLoader {
return this.#inspect(candidate);
}
+ async #reloadPackage(packageId: string): Promise<void> {
+ 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<void> {
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 +745,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 +833,7 @@ export class MakaCompositionLoader {
entry,
rootId,
parent,
- parent?.context ?? root.context,
+ parent ? childMountContext(parent) : root.context,
parent ? isDisabled(parent) : false,
);
try {
@@ -864,6 +1065,24 @@ function* walk(entry: MakaCompositionEntry): Generator<MakaCompositionEntry> {
for (const child of entry.children ?? []) yield* walk(child);
}
+function* walkLive(entry: LiveEntry): Generator<LiveEntry> {
+ yield entry;
+ for (const child of entry.children) yield* walkLive(child);
+}
+
+function* ancestors(entry: LiveEntry): Generator<LiveEntry> {
+ 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..8b8e106e6c 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,210 @@ 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<string, MakaCompositionEntry[]>;
+ const locations = new Map<string, MutableLocation>();
+
+ 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);
+ 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);
+ 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<string>();
+ 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 +329,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 +392,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<string>();
+ 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 +602,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<MakaCompositionEntry> {
+ 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)
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org