diff --git a/packages/plugin-sdk/src/plugin-host/core.test.ts b/packages/plugin-sdk/src/plugin-host/core.test.ts index 671492a13..ba0fb777b 100644 --- a/packages/plugin-sdk/src/plugin-host/core.test.ts +++ b/packages/plugin-sdk/src/plugin-host/core.test.ts @@ -1,7 +1,7 @@ import { join } from 'node:path' import { createContext, defineEventa, defineInvokeHandler } from '@moeru/eventa' -import { moduleStatus } from '@proj-airi/plugin-protocol/types' +import { moduleCompatibilityResult, moduleStatus } from '@proj-airi/plugin-protocol/types' import { describe, expect, it, vi } from 'vitest' import { FileSystemLoader, PluginHost } from '.' @@ -408,4 +408,64 @@ describe('for PluginHost', () => { const reloaded = await host.reload(session.id) expect(reloaded.phase).toBe('ready') }) + + it('should emit downgraded compatibility result when fallback versions overlap', async () => { + const host = new PluginHost({ + runtime: 'electron', + transport: { kind: 'in-memory' }, + protocolVersion: 'v2', + apiVersion: 'v2', + supportedProtocolVersions: ['v1'], + supportedApiVersions: ['v1'], + }) + reportPluginCapability(host, { + key: providersCapability, + state: 'ready', + metadata: { source: 'test' }, + }) + + const session = await host.load(testManifest, { cwd: '' }) + const compatibilityEvents: Array<{ body?: Record }> = [] + session.channels.host.on(moduleCompatibilityResult, (payload) => { + compatibilityEvents.push(payload as unknown as { body?: Record }) + }) + + const initialized = await host.init(session.id, { + compatibility: { + supportedProtocolVersions: ['v1'], + supportedApiVersions: ['v1'], + }, + }) + + expect(initialized.phase).toBe('ready') + expect(compatibilityEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ + protocolVersion: 'v1', + apiVersion: 'v1', + mode: 'downgraded', + }), + }), + ])) + }) + + it('should reject initialization when compatibility has no overlap', async () => { + const host = new PluginHost({ + runtime: 'electron', + transport: { kind: 'in-memory' }, + protocolVersion: 'v2', + apiVersion: 'v2', + }) + + const session = await host.load(testManifest, { cwd: '' }) + + await expect(host.init(session.id, { + compatibility: { + supportedProtocolVersions: ['v9'], + supportedApiVersions: ['v9'], + }, + })).rejects.toThrow('Negotiation rejected:') + + expect(host.getSession(session.id)?.phase).toBe('failed') + }) }) diff --git a/packages/plugin-sdk/src/plugin-host/core.ts b/packages/plugin-sdk/src/plugin-host/core.ts index 9b22e1ca3..b78cf7e98 100644 --- a/packages/plugin-sdk/src/plugin-host/core.ts +++ b/packages/plugin-sdk/src/plugin-host/core.ts @@ -349,6 +349,49 @@ function createModuleIdentity(name: string, index: number): ModuleIdentity { } } +// TODO: Maybe support more complex version formats. +function resolveSupportedVersions(preferredVersion: string, supportedVersions?: string[]) { + const list = [preferredVersion, ...(supportedVersions ?? [])] + return [...new Set(list)] +} + +function resolveNegotiatedVersion(preferredVersion: string, hostSupportedVersions: string[], peerSupportedVersions?: string[]) { + if (!peerSupportedVersions?.length) { + if (hostSupportedVersions.includes(preferredVersion)) { + return { + acceptedVersion: preferredVersion, + exact: true, + } + } + + return { + exact: false, + reason: `Host does not support preferred version "${preferredVersion}".`, + } + } + + if (peerSupportedVersions.includes(preferredVersion) && hostSupportedVersions.includes(preferredVersion)) { + return { + acceptedVersion: preferredVersion, + exact: true, + } + } + + for (const version of hostSupportedVersions) { + if (peerSupportedVersions.includes(version)) { + return { + acceptedVersion: version, + exact: false, + } + } + } + + return { + exact: false, + reason: `No overlapping supported versions. host=[${hostSupportedVersions.join(', ')}]; peer=[${peerSupportedVersions.join(', ')}].`, + } +} + export type PluginRuntime = 'electron' | 'node' | 'web' export type ModulePhase = ProtocolModulePhase @@ -406,6 +449,8 @@ export interface PluginHostOptions { transport?: PluginTransport protocolVersion?: string apiVersion?: string + supportedProtocolVersions?: string[] + supportedApiVersions?: string[] } export interface PluginStartOptions { @@ -458,6 +503,8 @@ export class PluginHost { private readonly transport: PluginTransport private readonly protocolVersion: string private readonly apiVersion: string + private readonly supportedProtocolVersions: string[] + private readonly supportedApiVersions: string[] private readonly capabilities = new Map() private readonly capabilityWaiters = new Map void>>() private providersListResolver: () => Promise> | Array<{ name: string }> = () => [] @@ -469,6 +516,8 @@ export class PluginHost { this.transport = options.transport ?? { kind: 'in-memory' } this.protocolVersion = options.protocolVersion ?? 'v1' this.apiVersion = options.apiVersion ?? 'v1' + this.supportedProtocolVersions = resolveSupportedVersions(this.protocolVersion, options.supportedProtocolVersions) + this.supportedApiVersions = resolveSupportedVersions(this.apiVersion, options.supportedApiVersions) this.markCapabilityReady(protocolListProvidersEventName, { source: 'plugin-host' }) } @@ -597,10 +646,37 @@ export class PluginHost { } session.channels.host.emit(moduleCompatibilityRequest, compatibilityRequest) + const protocolNegotiation = resolveNegotiatedVersion( + compatibilityRequest.protocolVersion, + this.supportedProtocolVersions, + compatibilityRequest.supportedProtocolVersions, + ) + const apiNegotiation = resolveNegotiatedVersion( + compatibilityRequest.apiVersion, + this.supportedApiVersions, + compatibilityRequest.supportedApiVersions, + ) + + const rejectionReasons = [ + ...protocolNegotiation.acceptedVersion ? [] : [`protocol: ${protocolNegotiation.reason}`], + ...apiNegotiation.acceptedVersion ? [] : [`api: ${apiNegotiation.reason}`], + ] + + if (rejectionReasons.length > 0) { + const reason = `Negotiation rejected: ${rejectionReasons.join('; ')}` + session.channels.host.emit(moduleCompatibilityResult, { + protocolVersion: compatibilityRequest.protocolVersion, + apiVersion: compatibilityRequest.apiVersion, + mode: 'rejected', + reason, + }) + throw new Error(reason) + } + session.channels.host.emit(moduleCompatibilityResult, { - protocolVersion: compatibilityRequest.protocolVersion, - apiVersion: compatibilityRequest.apiVersion, - mode: 'exact', + protocolVersion: protocolNegotiation.acceptedVersion!, + apiVersion: apiNegotiation.acceptedVersion!, + mode: protocolNegotiation.exact && apiNegotiation.exact ? 'exact' : 'downgraded', }) // Step 4: broadcast currently known modules for dependency discovery/bootstrap.