blob: 889fd266e00208c3e0c0f9d8f096f8aeae49df3b [file]
/*
* 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 { JsonArrayPageBudget } from './json-array-page-budget.js';
import { createHash } from 'node:crypto';
import {
MakaPluginRuntimeError,
type MakaCompositionApplyInput,
type MakaCompositionEntryInspection,
} from '@maka/runtime/plugin-runtime';
import type { PluginToolInspection } from '@maka/runtime/plugin-tool-service';
import type { PluginCommandInspection } from '@maka/runtime/plugin-command-service';
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 === 'tools') {
return {
ok: true,
result: boundedPage('tools', this.platform.inspectTools(input.rootId), input),
};
}
if (input.view === 'commands') {
return {
ok: true,
result: boundedPage('commands', this.platform.inspectCommands(input.rootId), 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: 'tools',
values: readonly PluginToolInspection[],
input: PluginPlatformQueryInput,
): Extract<PluginPlatformQueryResult, { readonly view: 'tools' }>;
function boundedPage(
view: 'commands',
values: readonly PluginCommandInspection[],
input: PluginPlatformQueryInput,
): Extract<PluginPlatformQueryResult, { readonly view: 'commands' }>;
function boundedPage(
view: 'failures',
values: readonly PluginPlatformFailureProjection[],
input: PluginPlatformQueryInput,
): Extract<PluginPlatformQueryResult, { readonly view: 'failures' }>;
function boundedPage<T>(
view: 'packages' | 'entries' | 'tools' | 'commands' | '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[] = [];
const budget = new JsonArrayPageBudget(PLUGIN_PLATFORM_QUERY_RESULT_MAX_BYTES, {
view,
items: [],
nextCursor: null,
});
for (let index = cursor; index < values.length && items.length < limit; index += 1) {
// Preserve the existing non-null cursor reservation, including the last item.
if (!budget.tryAppend(values[index], encodeCursor(view, input.rootId, digest, index + 1))) {
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' | 'tools' | 'commands' | '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>;
}