diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index bb305dcf2..15633df7b 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -46,5 +46,8 @@ "nanoid": "catalog:", "valibot": "^1.3.1", "xstate": "^5.30.0" + }, + "devDependencies": { + "es-toolkit": "catalog:" } } diff --git a/packages/plugin-sdk/src/channels/index.ts b/packages/plugin-sdk/src/channels/index.ts index efdcccdfe..d67f4bb33 100644 --- a/packages/plugin-sdk/src/channels/index.ts +++ b/packages/plugin-sdk/src/channels/index.ts @@ -2,6 +2,19 @@ import type { EventContext } from '@moeru/eventa' import { createContext } from '@moeru/eventa' +/** + * Holds the active plugin-sdk channel contexts for the current process. + * + * Use when: + * - Bootstrapping local or remote plugin transports + * - Reading the current control-plane or data-plane Eventa context + * + * Expects: + * - Callers replace the fallback contexts with a concrete transport during startup + * + * Returns: + * - Mutable host and data channel references shared by the SDK runtime + */ export const channels = { /** * Channel for talking to Plugin Host. @@ -21,10 +34,34 @@ export const channels = { data: createContext(), } +/** + * Replaces the active control-plane channel used to talk to Plugin Host. + * + * Use when: + * - A runtime has created its concrete host transport context + * + * Expects: + * - `context` is compatible with the current plugin transport implementation + * + * Returns: + * - Nothing. Future reads from {@link channels}.host use the provided context. + */ export function setActiveHostChannel(context: EventContext) { channels.host = context } +/** + * Replaces the active data-plane channel used for plugin-to-plugin or stage messaging. + * + * Use when: + * - A runtime has created its concrete data transport context + * + * Expects: + * - `context` is compatible with the current plugin transport implementation + * + * Returns: + * - Nothing. Future reads from {@link channels}.data use the provided context. + */ export function setActiveDataChannel(context: EventContext) { channels.data = context } diff --git a/packages/plugin-sdk/src/channels/local/event-target/index.ts b/packages/plugin-sdk/src/channels/local/event-target/index.ts index bce68f23b..f2e153e20 100644 --- a/packages/plugin-sdk/src/channels/local/event-target/index.ts +++ b/packages/plugin-sdk/src/channels/local/event-target/index.ts @@ -1,10 +1,34 @@ import { createContext } from '@moeru/eventa/adapters/event-target' +/** + * Creates a control-plane Eventa context backed by a local `EventTarget`. + * + * Use when: + * - A browser-like runtime wants an in-process host channel transport + * + * Expects: + * - `eventTarget` dispatches and listens for the Eventa adapter event format + * + * Returns: + * - An Eventa context that can be assigned to the active host channel + */ export function createEventTargetHostChannel(eventTarget: EventTarget) { // TODO: implement actual event target based host channel return createContext(eventTarget) } +/** + * Creates a data-plane Eventa context backed by a local `EventTarget`. + * + * Use when: + * - A browser-like runtime wants an in-process shared data channel transport + * + * Expects: + * - `eventTarget` dispatches and listens for the Eventa adapter event format + * + * Returns: + * - An Eventa context that can be assigned to the active data channel + */ export function createEventTargetDataChannel(eventTarget: EventTarget) { // TODO: implement actual event target based data channel return createContext(eventTarget) diff --git a/packages/plugin-sdk/src/channels/remote/websocket/index.ts b/packages/plugin-sdk/src/channels/remote/websocket/index.ts index 8ff6981d1..c8f8b3ae7 100644 --- a/packages/plugin-sdk/src/channels/remote/websocket/index.ts +++ b/packages/plugin-sdk/src/channels/remote/websocket/index.ts @@ -1,10 +1,34 @@ import { createContext } from '@moeru/eventa/adapters/websocket/native' +/** + * Creates a control-plane Eventa context backed by a native `WebSocket`. + * + * Use when: + * - A remote plugin talks to the host over a WebSocket transport + * + * Expects: + * - `webSocket` is already connected and managed by the caller + * + * Returns: + * - An Eventa context that can be assigned to the active host channel + */ export function createWebSocketHostChannel(webSocket: WebSocket) { // TODO: make sure to setup proper event handling on the webSocket return createContext(webSocket) } +/** + * Creates a data-plane Eventa context backed by a native `WebSocket`. + * + * Use when: + * - A remote plugin needs a WebSocket-backed shared data channel + * + * Expects: + * - `webSocket` is already connected and managed by the caller + * + * Returns: + * - An Eventa context that can be assigned to the active data channel + */ export function createWebSocketDataChannel(webSocket: WebSocket) { // TODO: make sure to setup proper event handling on the webSocket return createContext(webSocket) diff --git a/packages/plugin-sdk/src/channels/shared.ts b/packages/plugin-sdk/src/channels/shared.ts index 8be168c72..8543ce2e5 100644 --- a/packages/plugin-sdk/src/channels/shared.ts +++ b/packages/plugin-sdk/src/channels/shared.ts @@ -1,3 +1,16 @@ import type { EventContext } from '@moeru/eventa' +/** + * Describes the control-plane Eventa context used between a plugin and its host. + * + * Use when: + * - Typing `ContextInit.channels.host` + * - Passing a host-backed Eventa context through plugin bootstrap code + * + * Expects: + * - The context transports plugin-host lifecycle and RPC traffic + * + * Returns: + * - An Eventa context whose raw transport payload may be exposed through `raw` + */ export type ChannelHost = EventContext diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 8bf377210..573a4ab59 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -1 +1,16 @@ console.warn('@proj-airi/plugin-sdk is currently working in progress. APIs may change without warning.') + +export * from './plugin' +/** + * Re-exports the plugin bootstrap contracts from the package root. + * + * Use when: + * - Consumers want the high-level plugin authoring types from `@proj-airi/plugin-sdk` + * + * Expects: + * - Downstream code imports from the package root instead of the internal path + * + * Returns: + * - The `ContextInit` and `Plugin` types from `./plugin/shared` + */ +export type { ContextInit, Plugin } from './plugin/shared' diff --git a/packages/plugin-sdk/src/plugin-host/core.test.ts b/packages/plugin-sdk/src/plugin-host/core.test.ts index 988645900..ccd524e6e 100644 --- a/packages/plugin-sdk/src/plugin-host/core.test.ts +++ b/packages/plugin-sdk/src/plugin-host/core.test.ts @@ -185,6 +185,9 @@ describe('for FileSystemPluginHost', () => { describe('for PluginHost', () => { const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' + const kitRegistryResourceKey = 'proj-airi:plugin-sdk:resources:kits' + const toolRegistryResourceKey = 'proj-airi:plugin-sdk:resources:tools' + const widgetKitBindingsResourceKey = 'proj-airi:plugin-sdk:resources:kits:kit.widget:bindings' const testManifest = { apiVersion: 'v1' as const, kind: 'manifest.plugin.airi.moeru.ai' as const, @@ -205,6 +208,52 @@ describe('for PluginHost', () => { electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'), }, } + const dynamicApiManifest = { + ...testManifest, + permissions: { + ...testManifest.permissions, + apis: [ + ...(testManifest.permissions.apis ?? []), + { key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:kits:get-capabilities', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:bindings:list', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:bindings:announce', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:bindings:activate', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:bindings:update', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:bindings:withdraw', actions: ['invoke'] }, + { key: 'proj-airi:plugin-sdk:apis:client:tools:register', actions: ['invoke'] }, + ], + resources: [ + ...(testManifest.permissions.resources ?? []), + { key: kitRegistryResourceKey, actions: ['read'] }, + { key: toolRegistryResourceKey, actions: ['write'] }, + { key: 'proj-airi:plugin-sdk:resources:bindings', actions: ['read'] }, + { key: widgetKitBindingsResourceKey, actions: ['read', 'write'] }, + ], + } satisfies ModulePermissionDeclaration, + } + const deniedKitReadManifest = { + ...testManifest, + permissions: { + ...testManifest.permissions, + apis: [ + ...(testManifest.permissions.apis ?? []), + { key: 'proj-airi:plugin-sdk:apis:client:kits:list', actions: ['invoke'] }, + ], + } satisfies ModulePermissionDeclaration, + } + + function registerWidgetKit(host: PluginHost) { + return host.registerKit({ + kitId: 'kit.widget', + version: '1.0.0', + capabilities: [ + { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, + { key: 'kit.widget.channel', actions: ['publish', 'subscribe'] }, + ], + runtimes: ['electron', 'web'], + }) + } it('should run plugin lifecycle to ready in-memory', async () => { const host = new PluginHost({ @@ -259,6 +308,380 @@ describe('for PluginHost', () => { expect(latest?.phase).toBe('failed') }) + it('should expose runtime-compatible kits through bound plugin apis', async () => { + const host = new PluginHost({ + runtime: 'electron', + transport: { kind: 'in-memory' }, + }) + const widgetKit = registerWidgetKit(host) + host.registerKit({ + kitId: 'kit.node-only', + version: '1.0.0', + capabilities: [{ key: 'kit.node-only.module', actions: ['announce'] }], + runtimes: ['node'], + }) + reportPluginCapability(host, { + key: providersCapability, + state: 'ready', + metadata: { source: 'test' }, + }) + + const session = await host.start(dynamicApiManifest, { cwd: '' }) + const kits = await session.apis.kits.list() + const capabilities = await session.apis.kits.getCapabilities('kit.widget') + + expect(kits).toEqual([widgetKit]) + expect(capabilities).toEqual(widgetKit.capabilities) + }) + + it('should expose plugin tool client bindings on the plugin session api surface', async () => { + const host = new PluginHost({ + runtime: 'electron', + transport: { kind: 'in-memory' }, + }) + reportPluginCapability(host, { + key: providersCapability, + state: 'ready', + metadata: { source: 'test' }, + }) + + const session = await host.start(dynamicApiManifest, { cwd: '' }) + + expect(session.apis.tools).toBeDefined() + await expect(session.apis.tools.register({ + tool: { + id: 'play_chess', + title: 'Play Chess', + description: 'Open chess.', + activation: { + keywords: ['chess'], + patterns: ['play.*chess'], + }, + parameters: { + type: 'object', + properties: {}, + }, + }, + execute: async () => ({ ok: true }), + })).resolves.toBeUndefined() + }) + + it('should register available plugin tools and expose serialized xsai schemas', async () => { + const host = new PluginHost({ + runtime: 'electron', + transport: { kind: 'in-memory' }, + }) + reportPluginCapability(host, { + key: providersCapability, + state: 'ready', + metadata: { source: 'test' }, + }) + + const session = await host.start(dynamicApiManifest, { cwd: '' }) + + await session.apis.tools.register({ + tool: { + id: 'play_chess', + title: 'Play Chess', + description: 'Open chess.', + activation: { + keywords: ['chess'], + patterns: ['play.*chess'], + }, + parameters: { + type: 'object', + properties: { + opening: { + type: 'string', + }, + }, + }, + }, + availability: () => true, + execute: async input => ({ ok: true, input }), + }) + + await session.apis.tools.register({ + tool: { + id: 'end_play_chess', + title: 'End Play Chess', + description: 'End chess.', + activation: { + keywords: ['end chess'], + patterns: ['end.*chess'], + }, + parameters: { + type: 'object', + properties: {}, + }, + }, + availability: () => false, + execute: async () => ({ ok: true, ended: true }), + }) + + await expect(host.listAvailableToolDescriptors()).resolves.toEqual([ + { + id: 'play_chess', + title: 'Play Chess', + description: 'Open chess.', + activation: { + keywords: ['chess'], + patterns: ['play.*chess'], + }, + }, + ]) + await expect(host.listSerializedXsaiTools()).resolves.toEqual([ + { + ownerPluginId: session.identity.plugin.id, + name: 'play_chess', + description: 'Open chess.', + parameters: { + type: 'object', + properties: { + opening: { + type: 'string', + }, + }, + }, + }, + ]) + await expect(host.invokeTool(session.identity.plugin.id, 'play_chess', { opening: 'sicilian' })).resolves.toEqual({ + ok: true, + input: { opening: 'sicilian' }, + }) + await expect(host.invokeTool(session.identity.plugin.id, 'missing_tool', {})).rejects.toThrow( + `Plugin tool not found: ${session.identity.plugin.id}:missing_tool`, + ) + }) + + it('should allow plugin to announce update activate and withdraw dynamic bindings through bound apis', async () => { + const host = new PluginHost({ + runtime: 'electron', + transport: { kind: 'in-memory' }, + }) + registerWidgetKit(host) + reportPluginCapability(host, { + key: providersCapability, + state: 'ready', + metadata: { source: 'test' }, + }) + + const session = await host.start(dynamicApiManifest, { cwd: '' }) + expect('degrade' in session.apis.bindings).toBe(false) + expect(await session.apis.bindings.list()).toEqual([]) + + const announced = await session.apis.bindings.announce({ + moduleId: 'module-a', + kitId: 'kit.widget', + kitModuleType: 'window', + config: { route: '/widgets' }, + }) + const listedAfterAnnounce = await session.apis.bindings.list() + + expect(announced.moduleId).toBe('module-a') + expect(host.listBindings().some(item => item.moduleId === 'module-a')).toBe(true) + expect(listedAfterAnnounce).toEqual([ + expect.objectContaining({ + moduleId: 'module-a', + state: 'announced', + config: { route: '/widgets' }, + }), + ]) + + const activated = await session.apis.bindings.activate({ moduleId: 'module-a' }) + const listedAfterActivate = await session.apis.bindings.list() + const updated = await session.apis.bindings.update({ + moduleId: 'module-a', + config: { + route: '/widgets/main', + width: 420, + }, + }) + const listedAfterUpdate = await session.apis.bindings.list() + const withdrawn = await session.apis.bindings.withdraw({ moduleId: 'module-a' }) + const listedAfterWithdraw = await session.apis.bindings.list() + + expect(activated.state).toBe('active') + expect(listedAfterActivate).toEqual([ + expect.objectContaining({ + moduleId: 'module-a', + state: 'active', + }), + ]) + expect(updated.config).toEqual({ + route: '/widgets/main', + width: 420, + }) + expect(listedAfterUpdate).toEqual([ + expect.objectContaining({ + moduleId: 'module-a', + state: 'active', + config: { + route: '/widgets/main', + width: 420, + }, + }), + ]) + expect(withdrawn.state).toBe('withdrawn') + expect(listedAfterWithdraw).toEqual([ + expect.objectContaining({ + moduleId: 'module-a', + state: 'withdrawn', + config: { + route: '/widgets/main', + width: 420, + }, + }), + ]) + }) + + it('should let a test plugin consume injected kit and binding apis during init', async () => { + const host = new PluginHost({ + runtime: 'electron', + transport: { kind: 'in-memory' }, + }) + + registerWidgetKit(host) + + const session = await host.start({ + ...dynamicApiManifest, + name: 'test-plugin-injected-host-apis', + entrypoints: { + electron: join(import.meta.dirname, 'testdata', 'test-injected-host-apis-plugin.ts'), + }, + }, { cwd: '' }) + + expect(session.phase).toBe('ready') + expect(host.listBindings()).toEqual([ + expect.objectContaining({ + moduleId: 'test-injected-host-apis-module', + ownerSessionId: session.id, + ownerPluginId: session.identity.plugin.id, + kitId: 'kit.widget', + kitModuleType: 'window', + state: 'active', + config: { + route: '/widgets/injected-host-apis', + observedKitIds: ['kit.widget'], + observedCapabilityKeys: ['kit.widget.channel', 'kit.widget.module'], + }, + }), + ]) + }) + + it('should reuse dynamic binding ids after stop cleanup and reload', async () => { + const host = new PluginHost({ + runtime: 'electron', + transport: { kind: 'in-memory' }, + }) + registerWidgetKit(host) + reportPluginCapability(host, { + key: providersCapability, + state: 'ready', + metadata: { source: 'test' }, + }) + + const session = await host.start(dynamicApiManifest, { cwd: '' }) + await session.apis.bindings.announce({ + moduleId: 'module-reuse', + kitId: 'kit.widget', + kitModuleType: 'window', + config: { route: '/widgets/reuse' }, + }) + + const reloaded = await host.reload(session.id, { cwd: '' }) + + expect(host.getBinding('module-reuse')).toBeUndefined() + expect(host.listBindings().some(item => item.moduleId === 'module-reuse')).toBe(false) + + const reused = await reloaded.apis.bindings.announce({ + moduleId: 'module-reuse', + kitId: 'kit.widget', + kitModuleType: 'window', + config: { route: '/widgets/reuse-2' }, + }) + + expect(reused.ownerSessionId).toBe(reloaded.id) + expect(reused.config).toEqual({ route: '/widgets/reuse-2' }) + }) + + it('should isolate plugin-facing kit and module snapshots from plugin-side mutation', async () => { + const host = new PluginHost({ + runtime: 'electron', + transport: { kind: 'in-memory' }, + }) + registerWidgetKit(host) + reportPluginCapability(host, { + key: providersCapability, + state: 'ready', + metadata: { source: 'test' }, + }) + + const session = await host.start(dynamicApiManifest, { cwd: '' }) + const listedKits = await session.apis.kits.list() + listedKits[0].kitId = 'kit.mutated' + listedKits[0].capabilities[0].actions.push('tampered') + listedKits[0].runtimes.push('node') + + const listedCapabilities = await session.apis.kits.getCapabilities('kit.widget') + listedCapabilities[0].actions.push('shadow-write') + + const announced = await session.apis.bindings.announce({ + moduleId: 'module-snapshot', + kitId: 'kit.widget', + kitModuleType: 'window', + config: { route: '/widgets/snapshot' }, + }) + announced.config.route = '/widgets/tampered' + + const listedModules = await session.apis.bindings.list() + listedModules[0].config.route = '/widgets/list-tampered' + + expect(await session.apis.kits.list()).toEqual([ + expect.objectContaining({ + kitId: 'kit.widget', + capabilities: [ + expect.objectContaining({ + key: 'kit.widget.module', + actions: ['announce', 'activate', 'update', 'withdraw'], + }), + expect.objectContaining({ + key: 'kit.widget.channel', + actions: ['publish', 'subscribe'], + }), + ], + runtimes: ['electron', 'web'], + }), + ]) + expect(await session.apis.kits.getCapabilities('kit.widget')).toEqual([ + { key: 'kit.widget.module', actions: ['announce', 'activate', 'update', 'withdraw'] }, + { key: 'kit.widget.channel', actions: ['publish', 'subscribe'] }, + ]) + expect(await session.apis.bindings.list()).toEqual([ + expect.objectContaining({ + moduleId: 'module-snapshot', + config: { route: '/widgets/snapshot' }, + }), + ]) + }) + + it('should deny new kit apis when resource read permission is missing', async () => { + const host = new PluginHost({ + runtime: 'electron', + transport: { kind: 'in-memory' }, + }) + registerWidgetKit(host) + reportPluginCapability(host, { + key: providersCapability, + state: 'ready', + metadata: { source: 'test' }, + }) + + const session = await host.start(deniedKitReadManifest, { cwd: '' }) + + await expect(session.apis.kits.list()).rejects.toThrow('Permission denied: resources.read "proj-airi:plugin-sdk:resources:kits"') + }) + it('should reject non in-memory transport for MVP', async () => { const host = new PluginHost({ runtime: 'electron', diff --git a/packages/plugin-sdk/src/plugin-host/core.ts b/packages/plugin-sdk/src/plugin-host/core.ts index d044eee9c..d5f360b18 100644 --- a/packages/plugin-sdk/src/plugin-host/core.ts +++ b/packages/plugin-sdk/src/plugin-host/core.ts @@ -1,8 +1,13 @@ import type { ActorRefFrom } from 'xstate' import type { createApis } from '../plugin/apis/client' +import type { AnnounceBindingInput, UpdateBindingInput } from '../plugin/apis/client/bindings' +import type { RegisterToolInput } from '../plugin/apis/client/tools' import type { Plugin } from '../plugin/shared' +import type { BindingRecord, KitCapabilityDescriptor, KitDescriptor } from './shared' import type { + HostDataRecord, + HostDataValue, ManifestV1, ModuleCompatibilityRequest, ModuleConfigEnvelope, @@ -41,6 +46,25 @@ import { import { createActor, createMachine } from 'xstate' import { createApis as createBoundApis } from '../plugin/apis/client' +import { + getKitBindingResourceKey, + pluginBindingApiActivateEventName, + pluginBindingApiAnnounceEventName, + pluginBindingApiListEventName, + pluginBindingApiUpdateEventName, + pluginBindingApiWithdrawEventName, + pluginBindingRegistryResourceKey, +} from '../plugin/apis/client/bindings' +import { + pluginKitApiGetCapabilitiesEventName, + pluginKitApiListEventName, + pluginKitRegistryResourceKey, +} from '../plugin/apis/client/kits' +import { + pluginToolApiRegisterEventName, + pluginToolRegistryResourceKey, + +} from '../plugin/apis/client/tools' import { protocolCapabilitySnapshot, protocolCapabilitySnapshotEventName, @@ -54,10 +78,13 @@ import { import { createPluginContext } from './runtimes/node' import { FileSystemLoader } from './runtimes/node/loaders' import { + BindingsRegistryService, DependencyService, + KitRegistryService, PermissionService, PluginSessionService, ResourceService, + ToolRegistryService, } from './runtimes/shared' /** @@ -495,44 +522,153 @@ class PermissionDeniedError extends Error { } } +/** + * Describes the host-owned state tracked for one plugin session. + * + * Use when: + * - Reading session snapshots from `PluginHost` + * - Passing session state through host tests or orchestration code + * + * Expects: + * - `id` and `identity` stay stable for the lifetime of the session + * + * Returns: + * - The full session snapshot including transport, phase, bound APIs, and granted permissions + */ export interface PluginHostSession { + /** Manifest used to load the plugin. */ manifest: ManifestV1 + /** Loaded plugin hooks for the active session. */ plugin: Plugin + /** Unique host-generated session id. */ id: string + /** Monotonic index assigned when the session was created. */ index: number + /** Working directory used to resolve relative entrypoints. */ cwd: string + /** Protocol identity emitted on plugin lifecycle events. */ identity: ModuleIdentity + /** Current host lifecycle phase for the session. */ phase: PluginSessionPhase + /** XState actor that drives the session lifecycle transitions. */ lifecycle: ActorRefFrom + /** Transport used by the session Eventa context. */ transport: PluginTransport + /** Runtime used to load and run the plugin. */ runtime: PluginRuntime + /** Host-owned Eventa channels injected into the plugin context. */ channels: { + /** Control-plane Eventa context used for lifecycle and RPC traffic. */ host: ReturnType } + /** Bound plugin SDK APIs exposed to plugin code. */ apis: ReturnType + /** Requested and granted permissions for the session. */ permissions: { + /** Permissions requested by the manifest and runtime declarations. */ requested: ModulePermissionDeclaration + /** Permissions actually granted by the host. */ granted: ModulePermissionGrant + /** Permission snapshot revision number. */ revision: number } } /** - * In-memory Plugin Host MVP. + * Filters the binding list returned by `PluginHost.listBindings(...)`. * - * Procedure placement: - * - `load(...)` covers step 0 and step 1 preparation: - * - create channel gateway/context - * - prepare per-plugin isolated runtime resources - * - load plugin module from manifest entrypoint - * - `init(...)` covers protocol/lifecycle step 2 onwards: - * - authentication - * - compatibility negotiation - * - registry sync + announce/prepare/configure/ready flow + * Use when: + * - Narrowing the host binding snapshot by owner session or kit * - * The design intentionally keeps `load` and `init` separate so callers can: - * - inspect/patch session state before booting, - * - batch-load many plugins first, then initialize deterministically. + * Expects: + * - Omitted fields mean "do not filter by this dimension" + * + * Returns: + * - Optional filter criteria for the in-memory binding registry + */ +export interface PluginHostBindingListOptions { + /** Limit results to bindings owned by one plugin session. */ + ownerSessionId?: string + /** Limit results to bindings declared against one kit. */ + kitId?: string +} + +type BoundAnnounceBindingInput = AnnounceBindingInput +type BoundUpdateBindingInput = UpdateBindingInput + +function omitModuleId(input: BoundUpdateBindingInput) { + return { + state: input.state, + config: input.config, + } +} + +function cloneHostDataValue(value: T): T { + if (Array.isArray(value)) { + return value.map(item => cloneHostDataValue(item)) as T + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneHostDataValue(item as HostDataValue)]), + ) as T + } + + return value +} + +function cloneHostDataRecord(record: T): T { + return cloneHostDataValue(record) +} + +function cloneKitCapabilities(capabilities: KitCapabilityDescriptor[]): KitCapabilityDescriptor[] { + return capabilities.map(capability => ({ + key: capability.key, + actions: [...capability.actions], + })) +} + +function cloneKitDescriptor(kit: TKit): TKit { + return { + ...kit, + runtimes: [...kit.runtimes], + capabilities: cloneKitCapabilities(kit.capabilities), + } +} + +function cloneBindingRecord(module: BindingRecord): BindingRecord { + return { + ...module, + config: cloneHostDataRecord(module.config), + } +} + +/** + * Orchestrates plugin loading, session lifecycle, bindings, tools, resources, and permissions. + * + * Use when: + * - Running plugins inside the in-memory host implementation + * - Tests or applications need one place to load, initialize, start, stop, and query plugin sessions + * + * Expects: + * - Plugins are loaded from manifest entrypoints through {@link FileSystemLoader} + * - Each session gets its own Eventa context, permission scope, and lifecycle actor + * + * Returns: + * - A host instance that exposes session management plus access to kits, bindings, tools, and capabilities + * + * Call stack: + * + * caller + * -> {@link PluginHost.load} + * -> {@link FileSystemLoader.resolveEntrypointFor} + * -> {@link FileSystemLoader.loadPluginFor} + * -> {@link PluginHost.init} + * -> permission resolution + protocol negotiation + * -> binding of {@link createApis} into plugin context + * -> {@link PluginHost.start} + * -> {@link PluginHost.load} + * -> {@link PluginHost.init} */ export class PluginHost { private readonly loader: FileSystemLoader @@ -544,6 +680,9 @@ export class PluginHost { private readonly supportedProtocolVersions: string[] private readonly supportedApiVersions: string[] private readonly dependencies = new DependencyService() + private readonly kits = new KitRegistryService() + private readonly modules = new BindingsRegistryService() + private readonly tools = new ToolRegistryService() private readonly permissions = new PermissionService() private readonly permissionResolver?: PluginHostOptions['permissionResolver'] private readonly persistedPermissionGrants = new Map() @@ -600,6 +739,37 @@ export class PluginHost { throw error } + private getSessionOrThrow(sessionId: string) { + const session = this.sessionService.get(sessionId) + if (!session) { + throw new Error(`Unknown plugin session: ${sessionId}`) + } + + return session + } + + private getModuleOrThrow(moduleId: string) { + const module = this.modules.get(moduleId) + if (!module) { + throw new Error(`Module \`${moduleId}\` was not found.`) + } + + return module + } + + private assertKitAvailableForSession(session: PluginHostSession, kitId: string) { + const kit = this.kits.get(kitId) + if (!kit) { + throw new Error(`Kit \`${kitId}\` is not registered.`) + } + + if (!kit.runtimes.includes(session.runtime)) { + throw new Error(`Kit \`${kitId}\` is not available for runtime \`${session.runtime}\`.`) + } + + return kit + } + listSessions() { return this.sessionService.list() } @@ -608,6 +778,207 @@ export class PluginHost { return this.sessionService.get(sessionId) } + registerKit(kit: KitDescriptor) { + return this.kits.register(kit) + } + + unregisterKit(kitId: string) { + return this.kits.remove(kitId) + } + + getKit(kitId: string) { + const kit = this.kits.get(kitId) + if (!kit) { + return undefined + } + + return cloneKitDescriptor(kit) + } + + listKits(runtime?: PluginRuntime) { + const kits = runtime + ? this.kits.listByRuntime(runtime) + : this.kits.list() + + return kits.map(kit => cloneKitDescriptor(kit)) + } + + getKitCapabilities(kitId: string): KitCapabilityDescriptor[] { + const capabilities = this.kits.get(kitId)?.capabilities + if (!capabilities) { + return [] + } + + return cloneKitCapabilities(capabilities) + } + + getBinding(moduleId: string) { + const module = this.modules.get(moduleId) + if (!module) { + return undefined + } + + return cloneBindingRecord(module) + } + + listBindings(options: PluginHostBindingListOptions = {}) { + return this.modules.list().filter((module) => { + if (options.ownerSessionId && module.ownerSessionId !== options.ownerSessionId) { + return false + } + + if (options.kitId && module.kitId !== options.kitId) { + return false + } + + return true + }).map(module => cloneBindingRecord(module)) + } + + async listAvailableToolDescriptors() { + return await this.tools.listAvailableDescriptors() + } + + async listSerializedXsaiTools() { + return await this.tools.listSerializedXsaiTools() + } + + async invokeTool(ownerPluginId: string, toolId: string, input: unknown) { + return await this.tools.invoke(ownerPluginId, toolId, input) + } + + announceBinding( + sessionId: string, + input: BoundAnnounceBindingInput, + ): BindingRecord { + const session = this.getSessionOrThrow(sessionId) + const kit = this.assertKitAvailableForSession(session, input.kitId) + + this.assertPermission(session, { + area: 'apis', + action: 'invoke', + key: pluginBindingApiAnnounceEventName, + }) + this.assertPermission(session, { + area: 'resources', + action: 'write', + key: getKitBindingResourceKey(kit.kitId), + reason: `Module announce requires write access to kit \`${kit.kitId}\`.`, + }) + + return cloneBindingRecord(this.modules.bind({ + ...input, + ownerSessionId: session.id, + ownerPluginId: session.identity.plugin.id, + runtime: session.runtime, + }) as BindingRecord) + } + + activateBinding(sessionId: string, moduleId: string) { + const session = this.getSessionOrThrow(sessionId) + const module = this.getModuleOrThrow(moduleId) + + this.assertPermission(session, { + area: 'apis', + action: 'invoke', + key: pluginBindingApiActivateEventName, + }) + this.assertPermission(session, { + area: 'resources', + action: 'write', + key: getKitBindingResourceKey(module.kitId), + reason: `Module activation requires write access to kit \`${module.kitId}\`.`, + }) + + return cloneBindingRecord(this.modules.activate(session.id, session.identity.plugin.id, moduleId)) + } + + updateBinding( + sessionId: string, + moduleId: string, + patch: UpdateBindingInput | Omit, 'moduleId'>, + ) { + const session = this.getSessionOrThrow(sessionId) + const module = this.getModuleOrThrow(moduleId) + + this.assertPermission(session, { + area: 'apis', + action: 'invoke', + key: pluginBindingApiUpdateEventName, + }) + this.assertPermission(session, { + area: 'resources', + action: 'write', + key: getKitBindingResourceKey(module.kitId), + reason: `Module update requires write access to kit \`${module.kitId}\`.`, + }) + + const normalizedPatch = 'moduleId' in patch ? omitModuleId(patch) : patch + return cloneBindingRecord(this.modules.update(session.id, session.identity.plugin.id, moduleId, normalizedPatch)) + } + + degradeBinding(sessionId: string, moduleId: string) { + const session = this.getSessionOrThrow(sessionId) + const module = this.getModuleOrThrow(moduleId) + this.assertPermission(session, { + area: 'resources', + action: 'write', + key: getKitBindingResourceKey(module.kitId), + reason: `Module degradation requires write access to kit \`${module.kitId}\`.`, + }) + + return cloneBindingRecord(this.modules.degrade(session.id, session.identity.plugin.id, moduleId)) + } + + withdrawBinding(sessionId: string, moduleId: string) { + const session = this.getSessionOrThrow(sessionId) + const module = this.getModuleOrThrow(moduleId) + + this.assertPermission(session, { + area: 'apis', + action: 'invoke', + key: pluginBindingApiWithdrawEventName, + }) + this.assertPermission(session, { + area: 'resources', + action: 'write', + key: getKitBindingResourceKey(module.kitId), + reason: `Module withdrawal requires write access to kit \`${module.kitId}\`.`, + }) + + return cloneBindingRecord(this.modules.withdraw(session.id, session.identity.plugin.id, moduleId)) + } + + registerTool(sessionId: string, input: RegisterToolInput) { + const session = this.getSessionOrThrow(sessionId) + + this.assertPermission(session, { + area: 'apis', + action: 'invoke', + key: pluginToolApiRegisterEventName, + }) + this.assertPermission(session, { + area: 'resources', + action: 'write', + key: pluginToolRegistryResourceKey, + }) + + this.tools.register({ + ownerSessionId: session.id, + ownerPluginId: session.identity.plugin.id, + tool: { + ...input.tool, + activation: { + keywords: [...input.tool.activation.keywords], + patterns: [...input.tool.activation.patterns], + }, + parameters: cloneHostDataRecord(input.tool.parameters), + }, + availability: input.availability, + execute: input.execute, + }) + } + async load(manifest: ManifestV1, options: PluginLoadOptions = {}): Promise { // Step 0 (channel gateway preparation): resolve runtime and transport for this plugin. const runtime = options.runtime ?? this.runtime @@ -641,7 +1012,65 @@ export class PluginHost { }, ) - const session: PluginHostSession = { + let session!: PluginHostSession + const apis = createBoundApis(hostChannel, { + kits: { + list: () => { + this.assertPermission(session, { + area: 'apis', + action: 'invoke', + key: pluginKitApiListEventName, + }) + this.assertPermission(session, { + area: 'resources', + action: 'read', + key: pluginKitRegistryResourceKey, + }) + + return this.listKits(session.runtime) + }, + getCapabilities: (kitId) => { + this.assertPermission(session, { + area: 'apis', + action: 'invoke', + key: pluginKitApiGetCapabilitiesEventName, + }) + this.assertPermission(session, { + area: 'resources', + action: 'read', + key: pluginKitRegistryResourceKey, + }) + this.assertKitAvailableForSession(session, kitId) + + return this.getKitCapabilities(kitId) + }, + }, + bindings: { + list: () => { + this.assertPermission(session, { + area: 'apis', + action: 'invoke', + key: pluginBindingApiListEventName, + }) + this.assertPermission(session, { + area: 'resources', + action: 'read', + key: pluginBindingRegistryResourceKey, + }) + + return this.listBindings({ ownerSessionId: session.id }) + }, + announce: input => this.announceBinding(session.id, input), + activate: input => this.activateBinding(session.id, input.moduleId), + update: input => this.updateBinding(session.id, input.moduleId, input), + withdraw: input => this.withdrawBinding(session.id, input.moduleId), + }, + tools: { + register: input => this.registerTool(session.id, input), + }, + }) + + session = { manifest, plugin: {}, id, @@ -655,7 +1084,7 @@ export class PluginHost { channels: { host: hostChannel, }, - apis: createBoundApis(hostChannel), + apis, permissions: { requested: permissionSnapshot.requested, granted: permissionSnapshot.granted, @@ -1173,6 +1602,11 @@ export class PluginHost { } } + for (const module of this.modules.listByOwner(session.id)) { + this.modules.withdraw(session.id, session.identity.plugin.id, module.moduleId) + this.modules.unbind(session.id, session.identity.plugin.id, module.moduleId) + } + session.lifecycle.stop() this.sessionService.remove(session.id) return session diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/node/index.ts b/packages/plugin-sdk/src/plugin-host/runtimes/node/index.ts index f75c4551a..0ce1bc6a2 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/node/index.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/node/index.ts @@ -9,6 +9,18 @@ export * from '../../shared' export * from '../../transports' export * from './loaders' +/** + * Creates the Eventa context used by node-side plugin host sessions. + * + * Use when: + * - Bootstrapping a node runtime plugin session + * + * Expects: + * - `transport` describes a transport supported by the node runtime + * + * Returns: + * - A node-compatible Eventa context, or throws if the transport is not implemented + */ export function createPluginContext(transport: PluginTransport): EventContext { switch (transport.kind) { case 'in-memory': diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/fs.ts b/packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/fs.ts index 7e9af7f81..e06c06714 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/fs.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/node/loaders/fs.ts @@ -38,6 +38,19 @@ async function coercePluginFromModule(moduleValue: unknown): Promise { throw new Error('Failed to resolve plugin module. The entrypoint must export either definePlugin(...) or Plugin hooks.') } +/** + * Loads plugin entrypoints from the local filesystem for the current runtime. + * + * Use when: + * - The host needs to resolve a manifest entrypoint path + * - The host needs to import either a lazy `definePlugin(...)` export or a concrete plugin module + * + * Expects: + * - Entry points are valid importable module paths for the active runtime + * + * Returns: + * - Filesystem-backed helpers for resolving and loading plugin entrypoints + */ export class FileSystemLoader { /** * Resolve a manifest entrypoint for the requested runtime. diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/bindings.test.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/bindings.test.ts new file mode 100644 index 000000000..8f9293c63 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/bindings.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' + +import { BindingsRegistryService } from './bindings' + +describe('bindingsRegistryService', () => { + it('rejects ownership violations when updating a module from another session', () => { + const service = new BindingsRegistryService() + + service.bind({ + moduleId: 'm1', + ownerSessionId: 'session-a', + ownerPluginId: 'plugin-a', + kitId: 'kit.widget', + kitModuleType: 'panel', + config: {}, + runtime: 'electron', + }) + + expect(() => service.update('session-b', 'plugin-a', 'm1', { config: { size: 'l' } })).toThrowError(/ownership/i) + }) + + it('tracks lifecycle transitions with revision bumps and preserved ownership', () => { + const service = new BindingsRegistryService() + + const announced = service.bind({ + moduleId: 'm2', + ownerSessionId: 'session-a', + ownerPluginId: 'plugin-a', + kitId: 'kit.widget', + kitModuleType: 'panel', + config: { mountPoint: 'widgets' }, + runtime: 'web', + }) + + const activated = service.activate('session-a', 'plugin-a', 'm2') + const updated = service.update('session-a', 'plugin-a', 'm2', { config: { mountPoint: 'widgets', width: 320 } }) + const withdrawn = service.withdraw('session-a', 'plugin-a', 'm2') + + expect(announced.state).toBe('announced') + expect(activated.state).toBe('active') + expect(updated.revision).toBeGreaterThan(activated.revision) + expect(updated.config).toEqual({ mountPoint: 'widgets', width: 320 }) + expect(withdrawn.state).toBe('withdrawn') + expect(service.listByOwner('session-a')).toHaveLength(1) + }) + + it('rejects invalid lifecycle transitions after withdrawal', () => { + const service = new BindingsRegistryService() + + service.bind({ + moduleId: 'm3', + ownerSessionId: 'session-a', + ownerPluginId: 'plugin-a', + kitId: 'kit.widget', + kitModuleType: 'panel', + config: {}, + runtime: 'electron', + }) + + service.withdraw('session-a', 'plugin-a', 'm3') + + expect(() => service.activate('session-a', 'plugin-a', 'm3')).toThrowError(/invalid binding lifecycle transition/i) + }) + + it('rejects duplicate module ids from a different owner session', () => { + const service = new BindingsRegistryService() + + service.bind({ + moduleId: 'm4', + ownerSessionId: 'session-a', + ownerPluginId: 'plugin-a', + kitId: 'kit.widget', + kitModuleType: 'panel', + config: {}, + runtime: 'electron', + }) + + expect(() => + service.bind({ + moduleId: 'm4', + ownerSessionId: 'session-b', + ownerPluginId: 'plugin-b', + kitId: 'kit.widget', + kitModuleType: 'panel', + config: {}, + runtime: 'electron', + }), + ).toThrowError(/module id collision/i) + }) + + it('returns the existing record for an idempotent duplicate bind from the same owner', () => { + const service = new BindingsRegistryService() + + const original = service.bind({ + moduleId: 'm5', + ownerSessionId: 'session-a', + ownerPluginId: 'plugin-a', + kitId: 'kit.widget', + kitModuleType: 'panel', + config: { mountPoint: 'widgets' }, + runtime: 'electron', + }) + + const duplicate = service.bind({ + moduleId: 'm5', + ownerSessionId: 'session-a', + ownerPluginId: 'plugin-a', + kitId: 'kit.widget', + kitModuleType: 'dialog', + config: { mountPoint: 'mutated', width: 480 }, + runtime: 'web', + }) + + expect(duplicate).toBe(original) + expect(duplicate.kitModuleType).toBe('panel') + expect(duplicate.runtime).toBe('electron') + expect(duplicate.config).toEqual({ mountPoint: 'widgets' }) + }) + + it('rejects module reuse with the same session but a different owner plugin', () => { + const service = new BindingsRegistryService() + + service.bind({ + moduleId: 'm6', + ownerSessionId: 'session-a', + ownerPluginId: 'plugin-a', + kitId: 'kit.widget', + kitModuleType: 'panel', + config: {}, + runtime: 'electron', + }) + + expect(() => + service.bind({ + moduleId: 'm6', + ownerSessionId: 'session-a', + ownerPluginId: 'plugin-b', + kitId: 'kit.widget', + kitModuleType: 'panel', + config: {}, + runtime: 'electron', + }), + ).toThrowError(/module id collision/i) + }) + + it('removes a withdrawn binding with unbind for teardown flows', () => { + const service = new BindingsRegistryService() + + service.bind({ + moduleId: 'm7', + ownerSessionId: 'session-a', + ownerPluginId: 'plugin-a', + kitId: 'kit.widget', + kitModuleType: 'panel', + config: {}, + runtime: 'electron', + }) + + service.withdraw('session-a', 'plugin-a', 'm7') + + expect(service.unbind('session-a', 'plugin-a', 'm7')).toEqual( + expect.objectContaining({ + moduleId: 'm7', + state: 'withdrawn', + }), + ) + expect(service.has('m7')).toBe(false) + }) +}) diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/bindings.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/bindings.ts new file mode 100644 index 000000000..adf0980b4 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/bindings.ts @@ -0,0 +1,473 @@ +import type { BindingRecord, BindingState } from '../../../shared/bindings' +import type { HostDataRecord, PluginRuntime } from '../../../shared/types' + +/** + * Declares the host-owned data needed to create one binding record. + * + * Use when: + * - A plugin session contributes a concrete runtime instance through a kit + * - Higher-level kit helpers need to persist their low-level binding into the host registry + * + * Expects: + * - `moduleId` is stable within the owning plugin session + * - `kitId` points at a host-registered kit that defines the binding family + * - `kitModuleType` is a kit-defined subtype key, not a host-wide enum + * - `config` is transport-safe and already normalized by the caller + * + * Returns: + * - A serializable payload that {@link BindingsRegistryService.bind} stores as canonical binding state + */ +export interface BindingInput { + moduleId: string + ownerSessionId: string + ownerPluginId: string + kitId: string + kitModuleType: string + runtime: PluginRuntime + config: C +} + +/** + * Describes an incremental change to a binding record. + * + * Use when: + * - A kit-specific API needs to change binding lifecycle state + * - A plugin updates binding configuration after initial registration + * + * Expects: + * - `state` follows the host lifecycle rules for the current record + * - `config` only contains fields that should be shallow-merged into the current config + * + * Returns: + * - A partial mutation applied by {@link BindingsRegistryService.update} or {@link BindingsRegistryService.transition} + */ +export interface BindingUpdatePatch { + state?: BindingState + config?: Partial +} + +/** + * Identifies the plugin session that owns a binding record. + * + * Use when: + * - Enforcing that only the original plugin session mutates or removes a binding + * - Comparing current callers against stored binding ownership + * + * Expects: + * - `ownerSessionId` is the ephemeral runtime session id + * - `ownerPluginId` is the stable plugin identity across sessions + * + * Returns: + * - A compact identity tuple used in collision and ownership checks + */ +export interface BindingOwnerIdentity { + ownerSessionId: string + ownerPluginId: string +} + +const allowedBindingTransitions: Record = { + announced: ['active', 'degraded', 'withdrawn'], + active: ['degraded', 'withdrawn', 'active'], + degraded: ['active', 'withdrawn', 'degraded'], + withdrawn: ['withdrawn'], +} + +function createOwnershipError( + moduleId: string, + expected: BindingOwnerIdentity, + actual: BindingOwnerIdentity, +) { + return new Error( + `Ownership violation for module \`${moduleId}\`: owned by \`${expected.ownerSessionId}/${expected.ownerPluginId}\`, not \`${actual.ownerSessionId}/${actual.ownerPluginId}\`.`, + ) +} + +function createModuleCollisionError( + moduleId: string, + expected: BindingOwnerIdentity, + actual: BindingOwnerIdentity, +) { + return new Error( + `Module id collision for \`${moduleId}\`: owned by \`${expected.ownerSessionId}/${expected.ownerPluginId}\`, not \`${actual.ownerSessionId}/${actual.ownerPluginId}\`.`, + ) +} + +function createInvalidTransitionError(moduleId: string, from: BindingState, to: BindingState) { + return new Error(`Invalid binding lifecycle transition for \`${moduleId}\`: \`${from}\` -> \`${to}\`.`) +} + +/** + * Stores the host's canonical binding records for dynamically contributed kit instances. + * + * Use when: + * - A plugin contributes runtime instances through higher-level kit APIs + * - The host needs an ownership-aware registry that tracks lifecycle and config for those instances + * - Adapters, permission checks, and debug tooling need one authoritative binding table + * + * Expects: + * - Callers treat a binding as the low-level host record behind a higher-level contribution API + * - Kits define the meaning of `kitModuleType` and validate kit-specific config outside this service + * - Binding ids stay unique per owner and are reused intentionally, not accidentally + * + * Returns: + * - Stable {@link BindingRecord} snapshots representing bound runtime contributions + * + * A binding is the concrete link between a plugin-owned runtime instance and a host-registered kit. + * The host keeps kits generic: a kit only describes capabilities, supported runtimes, and allowed + * operations. That is not enough to render UI, route lifecycle, or enforce ownership for a specific + * plugin contribution. The missing piece is a binding record saying: + * + * - plugin session `X` owns runtime instance `moduleId` + * - that instance is attached to kit `kitId` + * - within that kit it behaves as subtype `kitModuleType` + * - here is its current generic config payload and lifecycle state + * + * Why kits require bindings: + * + * - Multiple plugins can use the same kit at the same time, so the host needs per-instance records. + * - Permission checks are kit-scoped, but ownership and lifecycle are instance-scoped. + * - Surface adapters need something concrete to mount, update, degrade, or withdraw. + * + * Higher-level kit APIs should normally sit on top of this service instead of exposing it directly. + * For example, a future `createWidgetApi(ctx)` could call `bind(...)`, `activate(...)`, and `update(...)` + * internally while presenting a simpler `register('widget-1')` API to plugin authors. In that model: + * + * - kits define the contract + * - bindings persist concrete instances of that contract + * - adapters consume binding records to produce UI/runtime behavior + * + * This is similar to VS Code's contribution system in spirit, but not in exact mechanics. VS Code uses + * declarative contribution points in extension manifests and the host interprets them at load time. AIRI's + * binding registry is more runtime-oriented: + * + * - contributions can appear after startup + * - they can transition through host-managed lifecycle states + * - they are session-owned and can be withdrawn or rebound on reload + * + * Examples: + * + * 1. One plugin binds once: + * - before: `{}` + * - `bind({ moduleId: 'widget-main', kitId: 'kit.widget', kitModuleType: 'window', ... })` + * - after: `{ 'widget-main' => { state: 'announced', revision: 1, ... } }` + * + * 2. The same plugin binds the same id again: + * - before: `{ 'widget-main' => { state: 'announced', revision: 1, runtime: 'electron' } }` + * - `bind(...)` with the same owner but different config + * - after: unchanged record is returned. This preserves idempotency and prevents silent mutation during rebind. + * + * 3. Another plugin tries to bind the same id: + * - before: `{ 'widget-main' => owned by session-a/plugin-a }` + * - `bind(...)` from session-b/plugin-b + * - after: throws collision error, registry remains unchanged + * + * 4. One plugin binds many times under one kit: + * - `widget-main`, `widget-sidebar`, `widget-dialog` + * - after: three binding records, all under `kit.widget`, each with independent lifecycle and config + * + * 5. One plugin binds across multiple kits: + * - `chat-sidebar` under `kit.chat` + * - `widget-main` under `kit.widget` + * - after: one registry, multiple kit families, each record still resolved by the same ownership rules + */ +export class BindingsRegistryService { + private readonly bindings = new Map>() + + /** + * Creates or reuses one binding record for a plugin-owned runtime instance. + * + * Use when: + * - A plugin or kit helper needs to declare that a concrete instance now exists + * - Rebinding the same id from the same owner should behave idempotently + * + * Expects: + * - `input` already identifies the intended owner and kit family + * - Rebinding the same id from a different owner is a collision + * + * Returns: + * - The newly stored binding record, or the existing record for idempotent same-owner rebinding + */ + bind(input: BindingInput) { + const current = this.bindings.get(input.moduleId) + if (current) { + if ( + current.ownerSessionId !== input.ownerSessionId + || current.ownerPluginId !== input.ownerPluginId + ) { + throw createModuleCollisionError( + input.moduleId, + { + ownerSessionId: current.ownerSessionId, + ownerPluginId: current.ownerPluginId, + }, + { + ownerSessionId: input.ownerSessionId, + ownerPluginId: input.ownerPluginId, + }, + ) + } + + return current + } + + const record: BindingRecord = { + moduleId: input.moduleId, + ownerSessionId: input.ownerSessionId, + ownerPluginId: input.ownerPluginId, + kitId: input.kitId, + kitModuleType: input.kitModuleType, + state: 'announced', + runtime: input.runtime, + revision: 1, + updatedAt: Date.now(), + config: input.config, + } + + this.bindings.set(record.moduleId, record) + return record + } + + /** + * Looks up one binding record by its runtime instance id. + * + * Use when: + * - Higher-level host flows need to inspect the current canonical binding state + * + * Expects: + * - `moduleId` is a binding id previously created by {@link bind} + * + * Returns: + * - The stored binding record, or `undefined` if it does not exist + */ + get(moduleId: string) { + return this.bindings.get(moduleId) + } + + /** + * Checks whether the registry currently contains a binding id. + * + * Use when: + * - Callers need a quick existence check before a larger operation + * + * Expects: + * - `moduleId` is the binding id to inspect + * + * Returns: + * - `true` when the binding exists in the registry + */ + has(moduleId: string) { + return this.bindings.has(moduleId) + } + + /** + * Lists every binding record currently tracked by the host. + * + * Use when: + * - Debug tools, snapshots, or adapters need the full binding table + * + * Expects: + * - Callers treat the returned array as a read-only snapshot + * + * Returns: + * - All stored binding records in insertion order + */ + list() { + return [...this.bindings.values()] + } + + /** + * Lists bindings owned by one plugin session. + * + * Use when: + * - Stopping or reloading a session + * - Inspecting one plugin's currently active contributions + * + * Expects: + * - `ownerSessionId` is the session-scoped owner id stored in each binding + * + * Returns: + * - All binding records whose owner session matches the input + */ + listByOwner(ownerSessionId: string) { + return this.list().filter(binding => binding.ownerSessionId === ownerSessionId) + } + + /** + * Lists bindings attached to one kit family. + * + * Use when: + * - A kit adapter needs to enumerate all currently bound instances + * - Debug tooling needs to inspect one kit's contribution footprint + * + * Expects: + * - `kitId` matches the `kitId` stored on each binding record + * + * Returns: + * - All binding records attached to the requested kit + */ + listByKit(kitId: string) { + return this.list().filter(binding => binding.kitId === kitId) + } + + /** + * Applies a shallow config and/or state update to an existing binding. + * + * Use when: + * - A kit helper wants to mutate config after the initial bind + * - A caller wants transition semantics and config merge in one operation + * + * Expects: + * - The caller owns the binding + * - Any requested `state` is valid from the current lifecycle state + * + * Returns: + * - The updated binding record with incremented revision and timestamp + */ + update(ownerSessionId: string, ownerPluginId: string, moduleId: string, patch: BindingUpdatePatch) { + return this.transition({ ownerSessionId, ownerPluginId }, moduleId, patch.state, patch) + } + + /** + * Transitions a bound instance into the `active` lifecycle state. + * + * Use when: + * - The host or a higher-level kit API has completed setup for a bound instance + * + * Expects: + * - The binding exists and is owned by the caller + * + * Returns: + * - The updated active binding record + */ + activate(ownerSessionId: string, ownerPluginId: string, moduleId: string) { + return this.transition({ ownerSessionId, ownerPluginId }, moduleId, 'active') + } + + /** + * Transitions a bound instance into the `degraded` lifecycle state. + * + * Use when: + * - A previously healthy binding loses a dependency or adapter guarantee + * + * Expects: + * - The binding exists and is owned by the caller + * + * Returns: + * - The updated degraded binding record + */ + degrade(ownerSessionId: string, ownerPluginId: string, moduleId: string) { + return this.transition({ ownerSessionId, ownerPluginId }, moduleId, 'degraded') + } + + /** + * Transitions a bound instance into the `withdrawn` lifecycle state. + * + * Use when: + * - A plugin wants the host to stop treating a binding as live before eventual removal + * + * Expects: + * - The binding exists and is owned by the caller + * + * Returns: + * - The updated withdrawn binding record + */ + withdraw(ownerSessionId: string, ownerPluginId: string, moduleId: string) { + return this.transition({ ownerSessionId, ownerPluginId }, moduleId, 'withdrawn') + } + + /** + * Performs the shared ownership checks and lifecycle transition logic for one binding. + * + * Use when: + * - A caller needs a custom lifecycle transition beyond the convenience helpers + * + * Expects: + * - `owner` matches the stored binding owner + * - `state`, when provided, is legal from the current lifecycle state + * + * Returns: + * - The next canonical binding record written back into the registry + */ + transition( + owner: BindingOwnerIdentity, + moduleId: string, + state?: BindingState, + patch: BindingUpdatePatch = {}, + ) { + const current = this.bindings.get(moduleId) + if (!current) { + throw new Error(`Module \`${moduleId}\` was not found.`) + } + + if ( + current.ownerSessionId !== owner.ownerSessionId + || current.ownerPluginId !== owner.ownerPluginId + ) { + throw createOwnershipError( + moduleId, + { + ownerSessionId: current.ownerSessionId, + ownerPluginId: current.ownerPluginId, + }, + owner, + ) + } + + const nextState = state ?? current.state + if (!allowedBindingTransitions[current.state].includes(nextState)) { + throw createInvalidTransitionError(moduleId, current.state, nextState) + } + + const next: BindingRecord = { + ...current, + state: nextState, + revision: current.revision + 1, + updatedAt: Date.now(), + config: patch.config ? ({ ...current.config, ...patch.config } as C) : current.config, + } + + this.bindings.set(moduleId, next) + return next + } + + /** + * Physically removes a withdrawn-or-obsolete binding record from the registry. + * + * Use when: + * - Stopping or reloading a plugin session after lifecycle cleanup + * - The host wants to forget a binding entirely, not merely mark it withdrawn + * + * Expects: + * - The caller owns the binding being removed + * - Callers normally withdraw first, then unbind during teardown + * + * Returns: + * - The removed binding record, or `undefined` when nothing existed + */ + unbind(ownerSessionId: string, ownerPluginId: string, moduleId: string) { + const current = this.bindings.get(moduleId) + if (!current) { + return undefined + } + + if ( + current.ownerSessionId !== ownerSessionId + || current.ownerPluginId !== ownerPluginId + ) { + throw createOwnershipError( + moduleId, + { + ownerSessionId: current.ownerSessionId, + ownerPluginId: current.ownerPluginId, + }, + { + ownerSessionId, + ownerPluginId, + }, + ) + } + + this.bindings.delete(moduleId) + return current + } +} diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.ts index d5a2c3921..2a2cbf1c0 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/dependencies.ts @@ -1,5 +1,18 @@ import type { CapabilityDescriptor } from '../../../../plugin/apis/protocol' +/** + * Tracks capability lifecycle state and waits for readiness across plugin sessions. + * + * Use when: + * - The host needs to announce, ready, degrade, or withdraw named capabilities + * - Plugins need to wait for another capability before continuing startup + * + * Expects: + * - Capability keys are stable across lifecycle transitions + * + * Returns: + * - An in-memory dependency registry with snapshot and wait primitives + */ export class DependencyService { private readonly capabilities = new Map() private readonly capabilityWaiters = new Map void>>() diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/index.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/index.ts index e856ffcba..a36b45669 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/index.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/index.ts @@ -1,4 +1,7 @@ +export * from './bindings' export * from './dependencies' +export * from './kits' export * from './permissions' export * from './resources' export * from './sessions' +export * from './tools' diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/kits.test.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/kits.test.ts new file mode 100644 index 000000000..fe571a0e3 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/kits.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' + +import { KitRegistryService } from './kits' + +describe('kitRegistryService', () => { + it('registers kits and resolves compatible kits by runtime', () => { + const service = new KitRegistryService() + + const widgetKit = service.register({ + kitId: 'kit.widget', + version: '1.0.0', + capabilities: [ + { key: 'kit.widget.module', actions: ['announce', 'activate'] }, + ], + runtimes: ['electron', 'web'], + }) + service.register({ + kitId: 'kit.system', + version: '1.0.0', + capabilities: [{ key: 'kit.system.channel', actions: ['publish'] }], + runtimes: ['node'], + }) + + expect(widgetKit.kitId).toBe('kit.widget') + expect(service.get('kit.widget')).toBe(widgetKit) + expect(service.list()).toHaveLength(2) + expect(service.listByRuntime('web')).toEqual([widgetKit]) + }) + + it('rejects conflicting duplicate kit registration', () => { + const service = new KitRegistryService() + + service.register({ + kitId: 'kit.widget', + version: '1.0.0', + capabilities: [{ key: 'kit.widget.module', actions: ['announce'] }], + runtimes: ['electron'], + }) + + expect(() => + service.register({ + kitId: 'kit.widget', + version: '1.0.1', + capabilities: [{ key: 'kit.widget.module', actions: ['announce', 'activate'] }], + runtimes: ['electron', 'web'], + }), + ).toThrowError(/duplicate kit registration/i) + }) + + it('accepts semantically equivalent duplicate kit registration with reordered arrays', () => { + const service = new KitRegistryService() + + const original = service.register({ + kitId: 'kit.widget', + version: '1.0.0', + capabilities: [ + { key: 'kit.widget.module', actions: ['announce', 'activate'] }, + { key: 'kit.widget.panel', actions: ['withdraw'] }, + ], + runtimes: ['electron', 'web'], + }) + + const duplicate = service.register({ + kitId: 'kit.widget', + version: '1.0.0', + capabilities: [ + { key: 'kit.widget.panel', actions: ['withdraw'] }, + { key: 'kit.widget.module', actions: ['activate', 'announce'] }, + ], + runtimes: ['web', 'electron'], + }) + + expect(duplicate).toBe(original) + }) +}) diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/kits.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/kits.ts new file mode 100644 index 000000000..88641c54e --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/kits.ts @@ -0,0 +1,81 @@ +import type { KitDescriptor } from '../../../shared/kits' +import type { PluginRuntime } from '../../../shared/types' + +function normalizeKitDescriptor(kit: KitDescriptor) { + return { + kitId: kit.kitId, + version: kit.version, + runtimes: [...new Set(kit.runtimes)].sort(), + capabilities: kit.capabilities + .map(capability => ({ + key: capability.key, + actions: [...new Set(capability.actions)].sort(), + })) + .sort((left, right) => left.key.localeCompare(right.key)), + } +} + +function isSemanticallyEqualKitDescriptor(left: KitDescriptor, right: KitDescriptor) { + return JSON.stringify(normalizeKitDescriptor(left)) === JSON.stringify(normalizeKitDescriptor(right)) +} + +function createKitCollisionError(kitId: string) { + return new Error(`Duplicate kit registration for \`${kitId}\` conflicts with an existing descriptor.`) +} + +/** + * Stores host-registered kit descriptors and exposes runtime-filtered lookups. + * + * Use when: + * - The host needs to register, read, and remove kit contracts + * - Plugin-facing kit APIs need runtime-compatible descriptor snapshots + * + * Expects: + * - `kitId` is unique unless the descriptor is semantically identical + * + * Returns: + * - An in-memory kit registry with duplicate collision detection + */ +export class KitRegistryService { + private readonly kits = new Map() + + register(kit: TKit) { + const current = this.kits.get(kit.kitId) + if (!current) { + this.kits.set(kit.kitId, kit) + return kit + } + + if (!isSemanticallyEqualKitDescriptor(current, kit)) { + throw createKitCollisionError(kit.kitId) + } + + return current + } + + get(kitId: string) { + return this.kits.get(kitId) + } + + has(kitId: string) { + return this.kits.has(kitId) + } + + remove(kitId: string) { + const kit = this.kits.get(kitId) + if (!kit) { + return undefined + } + + this.kits.delete(kitId) + return kit + } + + list() { + return [...this.kits.values()] + } + + listByRuntime(runtime: PluginRuntime) { + return this.list().filter(kit => kit.runtimes.includes(runtime)) + } +} diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.ts index 8b325c269..01d5dfb28 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/permissions.ts @@ -252,6 +252,20 @@ function mergePermissionDeclarations( } } +/** + * Tracks requested and granted permissions for plugin sessions. + * + * Use when: + * - The host needs to initialize permission state for a session + * - Runtime-declared permissions must be merged with persisted or host-granted scopes + * - Callers need to check whether one action is allowed for one scope + * + * Expects: + * - Permission declarations use the protocol key and action model + * + * Returns: + * - An in-memory permission store with initialize, merge, and query helpers + */ export class PermissionService { private readonly store = new Map() diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/resources.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/resources.ts index 67199b918..d3ebe1285 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/resources.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/resources.ts @@ -1,3 +1,15 @@ +/** + * Resolves one resource value lazily on demand. + * + * Use when: + * - A resource should be computed or fetched only when requested + * + * Expects: + * - The resolver returns the same logical resource shape each time + * + * Returns: + * - The resolved resource value, synchronously or asynchronously + */ export type ResourceResolver = () => Promise | T /** diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.ts index d0e82fb6d..447733b58 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/sessions.ts @@ -14,6 +14,19 @@ function createModuleIdentity(name: string, index: number): ModuleIdentity { } } +/** + * Stores plugin sessions and generates deterministic session identities. + * + * Use when: + * - The host needs to track loaded plugin sessions by id + * - New plugin sessions need a generated session id and module identity + * + * Expects: + * - `TSession` has a stable `id` field used as the registry key + * + * Returns: + * - An in-memory session registry with identity generation helpers + */ export class PluginSessionService { private readonly sessions = new Map() private sessionCounter = 0 diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/tools.ts b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/tools.ts new file mode 100644 index 000000000..ebd883de5 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/runtimes/shared/services/tools.ts @@ -0,0 +1,99 @@ +import type { + PluginToolDefinitionRecord, + RegisteredPluginToolDescriptor, + SerializedXsaiToolDefinition, +} from '../../../shared' + +/** + * Stores one plugin tool registration inside the in-memory host runtime. + * + * Use when: + * - Tracking tool ownership and availability per plugin session + * + * Expects: + * - `ownerPluginId` and `tool.id` together are unique + * + * Returns: + * - A host-managed record used for listing and invocation + */ +export interface ToolRegistryRecord { + ownerSessionId: string + ownerPluginId: string + tool: PluginToolDefinitionRecord + availability?: () => Promise | boolean + execute: (input: unknown) => Promise | unknown +} + +/** + * In-memory registry for plugin-contributed tools. + * + * Use when: + * - The host needs to list plugin tools for UI and xsai consumers + * - The host needs to dispatch a tool invocation back to its owning plugin + * + * Expects: + * - Callers filter by ownership through `ownerPluginId` + * + * Returns: + * - Serialisable metadata views and invoke routing + */ +export class ToolRegistryService { + private readonly tools = new Map() + + register(record: ToolRegistryRecord) { + const key = `${record.ownerPluginId}:${record.tool.id}` + this.tools.set(key, record) + return record + } + + async listAvailableDescriptors() { + const items: RegisteredPluginToolDescriptor[] = [] + + for (const record of this.tools.values()) { + if (await record.availability?.() === false) { + continue + } + + items.push({ + id: record.tool.id, + title: record.tool.title, + description: record.tool.description, + activation: { + keywords: [...record.tool.activation.keywords], + patterns: [...record.tool.activation.patterns], + }, + }) + } + + return items + } + + async listSerializedXsaiTools() { + const items: SerializedXsaiToolDefinition[] = [] + + for (const record of this.tools.values()) { + if (await record.availability?.() === false) { + continue + } + + items.push({ + ownerPluginId: record.ownerPluginId, + name: record.tool.id, + description: record.tool.description, + parameters: structuredClone(record.tool.parameters), + }) + } + + return items + } + + async invoke(ownerPluginId: string, toolId: string, input: unknown) { + const key = `${ownerPluginId}:${toolId}` + const record = this.tools.get(key) + if (!record) { + throw new Error(`Plugin tool not found: ${key}`) + } + + return await record.execute(input) + } +} diff --git a/packages/plugin-sdk/src/plugin-host/runtimes/web/index.ts b/packages/plugin-sdk/src/plugin-host/runtimes/web/index.ts index 0f199969f..eeca3b62f 100644 --- a/packages/plugin-sdk/src/plugin-host/runtimes/web/index.ts +++ b/packages/plugin-sdk/src/plugin-host/runtimes/web/index.ts @@ -8,6 +8,18 @@ export * from '../../core' export * from '../../shared' export * from '../../transports' +/** + * Creates the Eventa context used by web-side plugin host sessions. + * + * Use when: + * - Bootstrapping a web runtime plugin session + * + * Expects: + * - `transport` describes a transport supported by the web runtime + * + * Returns: + * - A web-compatible Eventa context, or throws if the transport is not implemented + */ export function createPluginContext(transport: PluginTransport): EventContext { switch (transport.kind) { case 'in-memory': diff --git a/packages/plugin-sdk/src/plugin-host/shared/bindings.test.ts b/packages/plugin-sdk/src/plugin-host/shared/bindings.test.ts new file mode 100644 index 000000000..9de0f4469 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/shared/bindings.test.ts @@ -0,0 +1,84 @@ +import { parse } from 'valibot' +import { describe, expect, it } from 'vitest' + +import { bindingRecordSchema } from './bindings' + +describe('bindingRecordSchema', () => { + it('accepts generic host-level module record without business coupling', () => { + const parsed = parse(bindingRecordSchema, { + moduleId: 'board-main', + ownerSessionId: 'plugin-session-1', + ownerPluginId: 'demo-plugin', + kitId: 'kit.widget', + kitModuleType: 'panel', + state: 'announced', + runtime: 'electron', + revision: 1, + updatedAt: Date.now(), + config: { mountPoint: 'widgets' }, + }) + + expect(parsed.kitModuleType).toBe('panel') + expect(parsed.state).toBe('announced') + }) + + it('rejects an unsupported module state', () => { + expect(() => + parse(bindingRecordSchema, { + moduleId: 'board-main', + ownerSessionId: 'plugin-session-1', + ownerPluginId: 'demo-plugin', + kitId: 'kit.widget', + kitModuleType: 'panel', + state: 'booting', + runtime: 'electron', + revision: 1, + updatedAt: 1712500000000, + config: { mountPoint: 'widgets' }, + }), + ).toThrowError() + }) + + it('rejects a negative revision', () => { + expect(() => + parse(bindingRecordSchema, { + moduleId: 'board-main', + ownerSessionId: 'plugin-session-1', + ownerPluginId: 'demo-plugin', + kitId: 'kit.widget', + kitModuleType: 'panel', + state: 'announced', + runtime: 'electron', + revision: -1, + updatedAt: 1712500000000, + config: { mountPoint: 'widgets' }, + }), + ).toThrowError() + }) + + it('rejects transport-unsafe module config values', () => { + class ConfigShape { + public mountPoint = 'widgets' + } + + expect(() => + parse(bindingRecordSchema, { + moduleId: 'board-main', + ownerSessionId: 'plugin-session-1', + ownerPluginId: 'demo-plugin', + kitId: 'kit.widget', + kitModuleType: 'panel', + state: 'announced', + runtime: 'electron', + revision: 1, + updatedAt: 1712500000000, + config: { + mountPoint: new ConfigShape(), + callback: () => undefined, + symbol: Symbol('nope'), + big: 1n, + }, + }), + ).toThrowError() + }) +}) diff --git a/packages/plugin-sdk/src/plugin-host/shared/bindings.ts b/packages/plugin-sdk/src/plugin-host/shared/bindings.ts new file mode 100644 index 000000000..42ca0cc48 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/shared/bindings.ts @@ -0,0 +1,100 @@ +import type { InferOutput } from 'valibot' + +import type { HostDataRecord } from './types' + +import { object, picklist, string } from 'valibot' + +import { hostDataRecordSchema, nonNegativeIntegerSchema, pluginRuntimeValues } from './types' + +/** + * Lists the valid lifecycle states for one host-managed binding record. + * + * Use when: + * - Validating binding state values + * - Narrowing `BindingState` to the canonical lifecycle literals + * + * Expects: + * - State transitions follow the host binding lifecycle rules + * + * Returns: + * - The canonical ordered list of binding lifecycle values + */ +export const bindingStateValues = ['announced', 'active', 'degraded', 'withdrawn'] as const + +/** + * Validates the serializable shape of one binding record. + * + * Use when: + * - Parsing or validating host-owned binding registry snapshots + * + * Expects: + * - `config` is JSON-like host data and timestamps are non-negative integers + * + * Returns: + * - A Valibot schema for one binding record + */ +export const bindingRecordSchema = object({ + moduleId: string(), + ownerSessionId: string(), + ownerPluginId: string(), + kitId: string(), + kitModuleType: string(), + state: picklist(bindingStateValues), + runtime: picklist(pluginRuntimeValues), + revision: nonNegativeIntegerSchema, + updatedAt: nonNegativeIntegerSchema, + config: hostDataRecordSchema, +}) + +/** + * Describes one valid binding lifecycle state. + * + * Use when: + * - Typing host-owned binding records + * + * Expects: + * - Values come from {@link bindingStateValues} + * + * Returns: + * - The union of valid binding state literals + */ +export type BindingState = typeof bindingStateValues[number] +/** + * Describes one host-managed binding record. + * + * Use when: + * - Reading binding registry state from the host + * - Returning binding snapshots through plugin APIs + * + * Expects: + * - `moduleId` is unique within the registry + * - `kitId` and `kitModuleType` identify the higher-level contract being bound + * + * Returns: + * - A serializable binding snapshot including lifecycle metadata and config + */ +export interface BindingRecord { + moduleId: string + ownerSessionId: string + ownerPluginId: string + kitId: string + kitModuleType: string + state: BindingState + runtime: (typeof pluginRuntimeValues)[number] + revision: number + updatedAt: number + config: C +} +/** + * Describes the validated output shape of {@link bindingRecordSchema}. + * + * Use when: + * - You need the exact schema-derived output type instead of the generic interface + * + * Expects: + * - Values have already passed through {@link bindingRecordSchema} + * + * Returns: + * - The inferred Valibot output type for one binding record + */ +export type BindingRecordOutput = InferOutput diff --git a/packages/plugin-sdk/src/plugin-host/shared/index.ts b/packages/plugin-sdk/src/plugin-host/shared/index.ts index c9f6f047d..388d81e38 100644 --- a/packages/plugin-sdk/src/plugin-host/shared/index.ts +++ b/packages/plugin-sdk/src/plugin-host/shared/index.ts @@ -1 +1,4 @@ +export * from './bindings' +export * from './kits' +export * from './tools' export * from './types' diff --git a/packages/plugin-sdk/src/plugin-host/shared/kits.test.ts b/packages/plugin-sdk/src/plugin-host/shared/kits.test.ts new file mode 100644 index 000000000..cb78db356 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/shared/kits.test.ts @@ -0,0 +1,34 @@ +import { parse } from 'valibot' +import { describe, expect, it } from 'vitest' + +import { kitDescriptorSchema } from './kits' + +describe('kitDescriptorSchema', () => { + it('accepts a generic host-level kit descriptor without business coupling', () => { + const parsed = parse(kitDescriptorSchema, { + kitId: 'kit.widget', + version: '1.0.0', + capabilities: [ + { + key: 'kit.widget.module', + actions: ['announce', 'activate', 'update', 'withdraw'], + }, + ], + runtimes: ['electron', 'web'], + }) + + expect(parsed.kitId).toBe('kit.widget') + expect(parsed.runtimes).toContain('electron') + }) + + it('rejects an unsupported runtime', () => { + expect(() => + parse(kitDescriptorSchema, { + kitId: 'kit.widget', + version: '1.0.0', + capabilities: [], + runtimes: ['browser'], + }), + ).toThrowError() + }) +}) diff --git a/packages/plugin-sdk/src/plugin-host/shared/kits.ts b/packages/plugin-sdk/src/plugin-host/shared/kits.ts new file mode 100644 index 000000000..c0c623624 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/shared/kits.ts @@ -0,0 +1,92 @@ +import type { InferOutput } from 'valibot' + +import { array, description, object, pipe, string } from 'valibot' + +import { pluginRuntimeSchema } from './types' + +/** + * Validates one declared capability inside a kit descriptor. + * + * Use when: + * - Parsing or validating host-owned kit descriptors + * + * Expects: + * - `key` is stable and `actions` lists the allowed capability actions + * + * Returns: + * - A Valibot schema for one kit capability descriptor + */ +export const kitCapabilitySchema = object({ + key: pipe( + string(), + description('Stable capability key exposed by this kit.'), + ), + actions: pipe( + array(pipe( + string(), + description('Capability action supported by this kit capability entry.'), + )), + description('Allowed actions for this capability key.'), + ), +}) + +/** + * Validates one host-owned kit descriptor. + * + * Use when: + * - Parsing or validating kit registry snapshots + * + * Expects: + * - `capabilities` and `runtimes` describe where and how the kit can be used + * + * Returns: + * - A Valibot schema for one kit descriptor + */ +export const kitDescriptorSchema = object({ + kitId: pipe( + string(), + description('Stable identifier for the host-registered kit.'), + ), + version: pipe( + string(), + description('Semantic version of the kit contract.'), + ), + capabilities: pipe( + array(kitCapabilitySchema), + description('Capabilities exposed by this kit descriptor.'), + ), + runtimes: pipe( + array(pipe( + pluginRuntimeSchema, + description('Runtime supported by this kit descriptor.'), + )), + description('Runtimes where this kit can be used.'), + ), +}) + +/** + * Describes one capability declared by a host kit. + * + * Use when: + * - Reading kit metadata from the registry or plugin APIs + * + * Expects: + * - Values have already been validated by {@link kitCapabilitySchema} + * + * Returns: + * - The inferred kit capability descriptor type + */ +export type KitCapabilityDescriptor = InferOutput +/** + * Describes one host-registered kit contract. + * + * Use when: + * - Reading kit metadata from the registry or plugin APIs + * + * Expects: + * - Values have already been validated by {@link kitDescriptorSchema} + * + * Returns: + * - The inferred kit descriptor type + */ +export type KitDescriptor = InferOutput diff --git a/packages/plugin-sdk/src/plugin-host/shared/tools.ts b/packages/plugin-sdk/src/plugin-host/shared/tools.ts new file mode 100644 index 000000000..ad1e49cf9 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/shared/tools.ts @@ -0,0 +1,67 @@ +import type { HostDataRecord } from './types' + +/** + * Describes the user-facing metadata for a plugin-contributed tool. + * + * Use when: + * - Listing plugin tools in renderer or devtools surfaces + * - Exposing activation hints without the execution handler + * + * Expects: + * - `id` is stable and unique within the owning plugin + * + * Returns: + * - A serializable descriptor suitable for host and renderer registries + */ +export interface RegisteredPluginToolDescriptor { + id: string + title: string + description: string + activation: { + keywords: string[] + patterns: string[] + } +} + +/** + * Describes the JSON-schema side of an xsai-compatible tool. + * + * Use when: + * - Serializing plugin tools across Electron boundaries + * - Reconstructing proxy `rawTool(...)` instances in the renderer + * + * Expects: + * - `parameters` is a provider-safe JSON Schema object + * + * Returns: + * - A serializable tool contract without executable callbacks + */ +export interface SerializedXsaiToolDefinition { + ownerPluginId: string + name: string + description: string + parameters: HostDataRecord +} + +/** + * Captures the single source-of-truth definition submitted by a plugin. + * + * Use when: + * - Registering tools from plugin runtimes into the host + * + * Expects: + * - `parameters` already contains a serialized input schema + * + * Returns: + * - A host-owned record that can be derived into UI metadata and xsai schemas + */ +export interface PluginToolDefinitionRecord { + id: string + title: string + description: string + activation: { + keywords: string[] + patterns: string[] + } + parameters: HostDataRecord +} diff --git a/packages/plugin-sdk/src/plugin-host/shared/types.ts b/packages/plugin-sdk/src/plugin-host/shared/types.ts index b24264e9f..7384acc89 100644 --- a/packages/plugin-sdk/src/plugin-host/shared/types.ts +++ b/packages/plugin-sdk/src/plugin-host/shared/types.ts @@ -10,23 +10,209 @@ import type { import type { PluginTransport } from '../transports' +import { isPlainObject } from 'es-toolkit' import { array, boolean, + check, + finite, + lazy, literal, + minValue, number, object, optional, picklist, + pipe, record, + safeInteger, string, union, } from 'valibot' -export type PluginRuntime = 'electron' | 'node' | 'web' +/** + * Lists the supported plugin runtimes recognized by the host. + * + * Use when: + * - Validating manifest entrypoints or host runtime configuration + * - Narrowing `PluginRuntime` to the canonical literals + * + * Expects: + * - Runtime-specific code branches use one of these exact values + * + * Returns: + * - The canonical runtime literals used throughout plugin-sdk + */ +export const pluginRuntimeValues = ['electron', 'node', 'web'] as const +/** + * Describes one supported plugin runtime. + * + * Use when: + * - Typing host runtime configuration and manifest runtime selection + * + * Expects: + * - Values come from {@link pluginRuntimeValues} + * + * Returns: + * - The union of valid runtime literals + */ +export type PluginRuntime = typeof pluginRuntimeValues[number] +/** + * Validates one runtime literal from {@link pluginRuntimeValues}. + * + * Use when: + * - Parsing runtime values from host options or descriptors + * + * Expects: + * - Inputs are runtime strings such as `electron`, `node`, or `web` + * + * Returns: + * - A Valibot schema for one plugin runtime literal + */ +export const pluginRuntimeSchema = picklist(pluginRuntimeValues) +/** + * Describes a JSON-like array accepted by plugin-host shared data schemas. + * + * Use when: + * - Typing serializable arrays inside binding config, resource payloads, or tool schemas + * + * Expects: + * - Every element is a {@link HostDataValue} + * + * Returns: + * - A recursive array interface for host-safe data + */ +export interface HostDataArray extends Array {} + +/** + * Describes a JSON-like object accepted by plugin-host shared data schemas. + * + * Use when: + * - Typing serializable records inside binding config, resource payloads, or tool schemas + * + * Expects: + * - Every property value is a {@link HostDataValue} + * + * Returns: + * - A recursive record interface for host-safe data + */ +export interface HostDataRecord { + [key: string]: HostDataValue +} + +/** + * Describes the recursive JSON-like value model accepted by the host. + * + * Use when: + * - Typing payloads that must stay serializable across plugin boundaries + * + * Expects: + * - Values are limited to primitives, arrays, or plain-object records + * + * Returns: + * - The recursive union used across shared host data structures + */ +export type HostDataValue + = | null + | string + | number + | boolean + | HostDataArray + | HostDataRecord + +/** + * Creates the recursive Valibot schema used for one {@link HostDataValue}. + * + * Use when: + * - You need a fresh recursive schema instance for nested host data validation + * + * Expects: + * - Values are plain JSON-like data and not class instances + * + * Returns: + * - A Valibot schema covering the full `HostDataValue` recursion + */ +export function createHostDataValueSchema() { + return union([ + literal(null), + string(), + boolean(), + pipe(number(), finite()), + array(lazy(createHostDataValueSchema)), + pipe(record(string(), lazy(createHostDataValueSchema)), check(isPlainObject)), + ]) +} + +/** + * Validates one recursive host-safe value. + * + * Use when: + * - Parsing individual payload values shared across the host boundary + * + * Expects: + * - Inputs conform to the {@link HostDataValue} model + * + * Returns: + * - A Valibot schema instance for one host-safe value + */ +export const hostDataValueSchema = createHostDataValueSchema() + +/** + * Validates one plain-object host-safe record. + * + * Use when: + * - Parsing config objects, metadata records, and JSON-schema-like payloads + * + * Expects: + * - Inputs are plain objects with {@link HostDataValue} values + * + * Returns: + * - A Valibot schema for one host-safe record + */ +export const hostDataRecordSchema = pipe(record(string(), lazy(createHostDataValueSchema)), check(isPlainObject)) + +/** + * Validates one non-negative safe integer used for timestamps and revisions. + * + * Use when: + * - Parsing revision counters and host-generated timestamps + * + * Expects: + * - Inputs are safe integers greater than or equal to zero + * + * Returns: + * - A Valibot schema for non-negative safe integers + */ +export const nonNegativeIntegerSchema = pipe(number(), safeInteger(), minValue(0)) + +/** + * Re-exports the protocol module phase literals used by the host. + * + * Use when: + * - Typing module lifecycle phases shared with `@proj-airi/plugin-protocol` + * + * Expects: + * - Values follow the protocol package lifecycle model + * + * Returns: + * - The protocol-defined module phase union + */ export type ModulePhase = ProtocolModulePhase +/** + * Describes all phases a plugin session can occupy inside `PluginHost`. + * + * Use when: + * - Typing `PluginHostSession.phase` + * - Checking host lifecycle transitions + * + * Expects: + * - Protocol phases are extended with host-only bootstrap and shutdown phases + * + * Returns: + * - The full plugin-session lifecycle union + */ export type PluginSessionPhase = | 'loading' | 'loaded' @@ -36,29 +222,135 @@ export type PluginSessionPhase | ModulePhase | 'stopped' +/** + * Re-exports the protocol plugin identity model used by the host. + * + * Use when: + * - Typing per-plugin identity values stored on sessions and events + * + * Expects: + * - Values originate from the protocol identity generator or host session service + * + * Returns: + * - The protocol-defined plugin identity type + */ export type PluginIdentity = ProtocolPluginIdentity +/** + * Re-exports the protocol module identity model used by the host. + * + * Use when: + * - Typing plugin session identities and protocol event payloads + * + * Expects: + * - Values originate from the protocol identity generator or host session service + * + * Returns: + * - The protocol-defined module identity type + */ export type ModuleIdentity = ProtocolModuleIdentity +/** + * Re-exports the protocol configuration envelope used for plugin configuration state. + * + * Use when: + * - Typing configuration payloads stored or emitted by the host + * + * Expects: + * - `C` describes the full configuration object carried in the envelope + * + * Returns: + * - The protocol-defined configuration envelope type + */ export type ModuleConfigEnvelope> = ProtocolModuleConfigEnvelope +/** + * Re-exports the protocol compatibility request payload type. + * + * Use when: + * - Typing compatibility negotiation messages in the host + * + * Expects: + * - Values conform to the protocol event payload + * + * Returns: + * - The protocol-defined compatibility request type + */ export type ModuleCompatibilityRequest = ProtocolEvents['module:compatibility:request'] +/** + * Re-exports the protocol compatibility result payload type. + * + * Use when: + * - Typing compatibility negotiation responses in the host + * + * Expects: + * - Values conform to the protocol event payload + * + * Returns: + * - The protocol-defined compatibility result type + */ export type ModuleCompatibilityResult = ProtocolEvents['module:compatibility:result'] +/** + * Re-exports the protocol permission declaration model used by manifests and runtime permission flow. + * + * Use when: + * - Typing requested permissions in plugin manifests and host sessions + * + * Expects: + * - Values conform to the protocol permission declaration model + * + * Returns: + * - The protocol-defined permission declaration type + */ export type ModulePermissionDeclaration = ProtocolModulePermissionDeclaration +/** + * Re-exports the protocol permission grant model used by host policy resolution. + * + * Use when: + * - Typing granted or persisted permissions in the host + * + * Expects: + * - Values conform to the protocol permission grant model + * + * Returns: + * - The protocol-defined permission grant type + */ export type ModulePermissionGrant = ProtocolModulePermissionGrant +/** + * Describes a version-1 plugin manifest consumed by `PluginHost`. + * + * Use when: + * - Loading a plugin from disk or another runtime + * - Typing manifest values in tests and host options + * + * Expects: + * - `kind` and `apiVersion` match the current manifest format + * + * Returns: + * - The structured plugin manifest contract understood by the host + */ export interface ManifestV1 { + /** Manifest schema version expected by the current host implementation. */ apiVersion: 'v1' + /** Manifest kind discriminator used to identify AIRI plugin manifests. */ kind: 'manifest.plugin.airi.moeru.ai' + /** Stable plugin name used for identity generation and display. */ name: string + /** Requested permissions that the host will evaluate and grant. */ permissions: ModulePermissionDeclaration + /** Runtime-specific module entrypoints that the host can resolve and import. */ entrypoints: { + /** Fallback entrypoint used when no runtime-specific path is provided. */ default?: string + /** Electron-specific entrypoint path. */ electron?: string + /** Node-specific entrypoint path. */ node?: string + /** Web-specific entrypoint path. */ web?: string } } @@ -72,6 +364,18 @@ const localizableSchema = union([ }), ]) +/** + * Validates a version-1 plugin manifest. + * + * Use when: + * - Parsing plugin manifests before loading them into the host + * + * Expects: + * - Inputs follow the `ManifestV1` shape including permission declarations and entrypoints + * + * Returns: + * - A Valibot schema for the AIRI plugin manifest format + */ export const manifestV1Schema = object({ apiVersion: literal('v1'), kind: literal('manifest.plugin.airi.moeru.ai'), @@ -121,18 +425,51 @@ export const manifestV1Schema = object({ }), }) +/** + * Configures how the host resolves and loads a plugin entrypoint. + * + * Use when: + * - Calling `PluginHost.load(...)` or loader helpers directly + * + * Expects: + * - Omitted fields fall back to host defaults + * + * Returns: + * - Runtime and working-directory overrides for one load operation + */ export interface PluginLoadOptions { + /** Working directory used to resolve relative manifest entrypoints. */ cwd?: string + /** Runtime used when selecting a manifest entrypoint. */ runtime?: PluginRuntime } +/** + * Configures one `PluginHost` instance. + * + * Use when: + * - Constructing a host with specific runtime, transport, or permission behavior + * + * Expects: + * - Omitted fields fall back to the host defaults documented below + * + * Returns: + * - The host bootstrap options consumed by {@link import('../core').PluginHost} + */ export interface PluginHostOptions { + /** Runtime used when callers do not override it per load/start call. @default 'electron' */ runtime?: PluginRuntime + /** Transport used when callers do not override it per load/start call. @default { kind: 'in-memory' } */ transport?: PluginTransport + /** Protocol version advertised during compatibility negotiation. @default 'v1' */ protocolVersion?: string + /** Plugin SDK API version advertised during compatibility negotiation. @default 'v1' */ apiVersion?: string + /** Additional protocol versions the host is willing to negotiate. @default [] */ supportedProtocolVersions?: string[] + /** Additional API versions the host is willing to negotiate. @default [] */ supportedApiVersions?: string[] + /** Callback that decides the granted permission set for one plugin session. */ permissionResolver?: (payload: { identity: ModuleIdentity manifest: ManifestV1 @@ -141,11 +478,29 @@ export interface PluginHostOptions { }) => ModulePermissionGrant | Promise } +/** + * Configures one `PluginHost.start(...)` or `PluginHost.init(...)` call. + * + * Use when: + * - Starting a session with runtime, compatibility, or capability-wait overrides + * + * Expects: + * - Omitted fields fall back to host defaults or method-local defaults + * + * Returns: + * - Per-start overrides for initialization behavior + */ export interface PluginStartOptions { + /** Working directory used to resolve relative manifest entrypoints. */ cwd?: string + /** Runtime override used for this specific start operation. */ runtime?: PluginRuntime + /** Whether initialization should stop in configuration-needed instead of auto-readying. */ requireConfiguration?: boolean + /** Compatibility ranges sent during protocol negotiation. */ compatibility?: Omit + /** Capability keys that must become ready before the session can proceed. */ requiredCapabilities?: string[] + /** Wait timeout applied to each required capability. @default 15000 */ capabilityWaitTimeoutMs?: number } diff --git a/packages/plugin-sdk/src/plugin-host/testdata/test-injected-host-apis-plugin.ts b/packages/plugin-sdk/src/plugin-host/testdata/test-injected-host-apis-plugin.ts new file mode 100644 index 000000000..779fb9175 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/testdata/test-injected-host-apis-plugin.ts @@ -0,0 +1,42 @@ +import type { ContextInit } from '../../plugin/shared' + +/** + * Exercises host-injected plugin APIs during initialization. + * + * Use when: + * - Verifying that a plugin can consume injected kit and binding APIs + * - Testing end-to-end plugin host bindings from a real plugin entrypoint + * + * Expects: + * - The host exposes `kit.widget` to the plugin runtime + * - The manifest grants the plugin read and write permissions for the relevant resources + * + * Returns: + * - Resolves after persisting the observed host state into a dynamic module config + */ +export async function init({ apis }: ContextInit): Promise { + const kits = await apis.kits.list() + const widgetCapabilities = await apis.kits.getCapabilities('kit.widget') + + await apis.bindings.announce({ + moduleId: 'test-injected-host-apis-module', + kitId: 'kit.widget', + kitModuleType: 'window', + config: { + route: '/widgets/injected-host-apis', + }, + }) + + await apis.bindings.activate({ + moduleId: 'test-injected-host-apis-module', + }) + + await apis.bindings.update({ + moduleId: 'test-injected-host-apis-module', + config: { + route: '/widgets/injected-host-apis', + observedKitIds: kits.map(kit => kit.kitId), + observedCapabilityKeys: widgetCapabilities.map(capability => capability.key).sort(), + }, + }) +} diff --git a/packages/plugin-sdk/src/plugin-host/transports/index.ts b/packages/plugin-sdk/src/plugin-host/transports/index.ts index 33981d1cc..6e131f689 100644 --- a/packages/plugin-sdk/src/plugin-host/transports/index.ts +++ b/packages/plugin-sdk/src/plugin-host/transports/index.ts @@ -1,3 +1,16 @@ +/** + * Describes the transport selected for one plugin host session. + * + * Use when: + * - Creating a plugin context for a specific runtime + * - Configuring how a plugin communicates with the host + * + * Expects: + * - `kind` matches the runtime-specific adapter chosen by the caller + * + * Returns: + * - A discriminated union describing the active transport and its required handles + */ export type PluginTransport = | { kind: 'in-memory' } | { kind: 'websocket', url: string, protocols?: string[] } diff --git a/packages/plugin-sdk/src/plugin/apis/client/bindings/index.ts b/packages/plugin-sdk/src/plugin/apis/client/bindings/index.ts new file mode 100644 index 000000000..b3d25701b --- /dev/null +++ b/packages/plugin-sdk/src/plugin/apis/client/bindings/index.ts @@ -0,0 +1,250 @@ +import type { EventContext } from '@moeru/eventa' + +import type { BindingUpdatePatch } from '../../../../plugin-host/runtimes/shared' +import type { BindingRecord } from '../../../../plugin-host/shared' +import type { HostDataRecord } from '../../../../plugin-host/shared/types' + +/** + * Identifies the bound API call used to list host-managed bindings. + * + * Use when: + * - Declaring permissions for `apis.bindings.list()` + * + * Expects: + * - Host and plugin agree on this event name + * + * Returns: + * - The permission/event key string for listing bindings + */ +export const pluginBindingApiListEventName = 'proj-airi:plugin-sdk:apis:client:bindings:list' +/** + * Identifies the bound API call used to create a new binding record. + * + * Use when: + * - Declaring permissions for `apis.bindings.announce()` + * + * Expects: + * - Host and plugin agree on this event name + * + * Returns: + * - The permission/event key string for announcing bindings + */ +export const pluginBindingApiAnnounceEventName = 'proj-airi:plugin-sdk:apis:client:bindings:announce' +/** + * Identifies the bound API call used to activate an existing binding. + * + * Use when: + * - Declaring permissions for `apis.bindings.activate()` + * + * Expects: + * - Host and plugin agree on this event name + * + * Returns: + * - The permission/event key string for activating bindings + */ +export const pluginBindingApiActivateEventName = 'proj-airi:plugin-sdk:apis:client:bindings:activate' +/** + * Identifies the bound API call used to update an existing binding. + * + * Use when: + * - Declaring permissions for `apis.bindings.update()` + * + * Expects: + * - Host and plugin agree on this event name + * + * Returns: + * - The permission/event key string for updating bindings + */ +export const pluginBindingApiUpdateEventName = 'proj-airi:plugin-sdk:apis:client:bindings:update' +/** + * Identifies the bound API call used to withdraw an existing binding. + * + * Use when: + * - Declaring permissions for `apis.bindings.withdraw()` + * + * Expects: + * - Host and plugin agree on this event name + * + * Returns: + * - The permission/event key string for withdrawing bindings + */ +export const pluginBindingApiWithdrawEventName = 'proj-airi:plugin-sdk:apis:client:bindings:withdraw' +/** + * Identifies the shared resource namespace that exposes the binding registry. + * + * Use when: + * - Declaring read permissions for `apis.bindings.list()` + * + * Expects: + * - The host stores binding records under this resource key + * + * Returns: + * - The resource key string for the global bindings registry + */ +export const pluginBindingRegistryResourceKey = 'proj-airi:plugin-sdk:resources:bindings' + +/** + * Builds the kit-scoped resource key used for binding write access. + * + * Use when: + * - Declaring per-kit binding permissions + * + * Expects: + * - `kitId` matches the host-registered kit identifier + * + * Returns: + * - The resource key string for bindings owned by the given kit + */ +export function getKitBindingResourceKey(kitId: string) { + return `proj-airi:plugin-sdk:resources:kits:${kitId}:bindings` +} + +/** + * Describes the payload required to declare a new binding instance. + * + * Use when: + * - Calling `apis.bindings.announce(...)` + * + * Expects: + * - `moduleId` is unique within the host registry + * - `kitId` and `kitModuleType` identify the higher-level kit contract being bound + * + * Returns: + * - A serializable binding declaration payload + */ +export interface AnnounceBindingInput { + moduleId: string + kitId: string + kitModuleType: string + config: C +} + +/** + * Identifies which binding should transition to the active state. + * + * Use when: + * - Calling `apis.bindings.activate(...)` + * + * Expects: + * - `moduleId` points at an existing host-managed binding + * + * Returns: + * - A minimal activation request payload + */ +export interface ActivateBindingInput { + moduleId: string +} + +/** + * Describes a partial update to one binding record. + * + * Use when: + * - Calling `apis.bindings.update(...)` + * + * Expects: + * - `moduleId` identifies the existing binding being changed + * - Any provided patch fields are valid for the target binding + * + * Returns: + * - A serializable binding update payload + */ +export interface UpdateBindingInput extends BindingUpdatePatch { + moduleId: string +} + +/** + * Identifies which binding should transition to the withdrawn state. + * + * Use when: + * - Calling `apis.bindings.withdraw(...)` + * + * Expects: + * - `moduleId` points at an existing host-managed binding + * + * Returns: + * - A minimal withdrawal request payload + */ +export interface WithdrawBindingInput { + moduleId: string +} + +/** + * Defines the host-side callbacks needed by the low-level bindings client. + * + * Use when: + * - Wiring `session.apis.bindings` to host-owned registry logic + * + * Expects: + * - Each callback returns the cloned binding record state observed by plugin code + * + * Returns: + * - The callback contract consumed by {@link createBindings} + */ +export interface BindingClientBindings { + list: () => Promise[]> | BindingRecord[] + announce: (input: AnnounceBindingInput) => Promise> | BindingRecord + activate: (input: ActivateBindingInput) => Promise> | BindingRecord + update: (input: UpdateBindingInput) => Promise> | BindingRecord + withdraw: (input: WithdrawBindingInput) => Promise> | BindingRecord +} + +function createMissingBindingError(method: string) { + return new Error(`Plugin binding API binding missing for \`${method}\`.`) +} + +function requireBinding(binding: TBinding | undefined, method: string): TBinding { + if (!binding) { + throw createMissingBindingError(method) + } + + return binding +} + +/** + * Creates the low-level bindings client exposed on `session.apis`. + * + * Use when: + * - Building the plugin SDK API object for a specific session + * + * Expects: + * - `bindings` comes from a host that manages binding records + * + * Returns: + * - A minimal `bindings.*` client that forwards to the bound host callbacks + */ +export function createBindings( + _ctx: EventContext, + bindings?: BindingClientBindings, +) { + return { + async list() { + return await requireBinding(bindings, 'bindings.list').list() + }, + async announce(input: AnnounceBindingInput) { + return await requireBinding(bindings, 'bindings.announce').announce(input) + }, + async activate(input: ActivateBindingInput) { + return await requireBinding(bindings, 'bindings.activate').activate(input) + }, + async update(input: UpdateBindingInput) { + return await requireBinding(bindings, 'bindings.update').update(input) + }, + async withdraw(input: WithdrawBindingInput) { + return await requireBinding(bindings, 'bindings.withdraw').withdraw(input) + }, + } +} + +/** + * Describes the concrete client object returned by {@link createBindings}. + * + * Use when: + * - Typing `apis.bindings` + * + * Expects: + * - The caller uses the same method set as the runtime-created bindings client + * + * Returns: + * - The inferred bindings client surface + */ +export type BindingClient = ReturnType diff --git a/packages/plugin-sdk/src/plugin/apis/client/index.ts b/packages/plugin-sdk/src/plugin/apis/client/index.ts index 49867731f..ea4a1d8e8 100644 --- a/packages/plugin-sdk/src/plugin/apis/client/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/client/index.ts @@ -1,12 +1,68 @@ import type { EventContext } from '@moeru/eventa' -import { createProviders } from './resources' +import type { BindingClientBindings } from './bindings' +import type { KitClientBindings } from './kits' +import type { ToolClientBindings } from './tools' -export function createApis(ctx: EventContext) { +import { createBindings } from './bindings' +import { createKits } from './kits' +import { createResources } from './resources' +import { createTools } from './tools' + +/** + * Collects the host-provided callbacks that back the plugin client API surface. + * + * Use when: + * - Binding `session.apis` to host-owned implementations + * + * Expects: + * - Each optional binding group is supplied when that API family should be available + * + * Returns: + * - A map of callback groups consumed by {@link createApis} + */ +export interface PluginApiBindings { + kits?: KitClientBindings + bindings?: BindingClientBindings + tools?: ToolClientBindings +} + +/** + * Creates the low-level plugin API surface exposed to plugin code. + * + * Use when: + * - Building `ContextInit.apis` for a plugin session + * + * Expects: + * - `ctx` is the Eventa context for the current plugin session + * - `bindings` contains the host-backed callbacks for each enabled API group + * + * Returns: + * - The composed plugin client APIs for resources, kits, bindings, and tools + */ +export function createApis(ctx: EventContext, bindings: PluginApiBindings = {}) { return { - providers: createProviders(ctx), + ...createResources(ctx), + kits: createKits(ctx, bindings.kits), + bindings: createBindings(ctx, bindings.bindings), + tools: createTools(ctx, bindings.tools), } } +/** + * Describes the concrete API object returned by {@link createApis}. + * + * Use when: + * - Typing `ContextInit.apis` + * + * Expects: + * - The caller uses the same shape as the runtime-created API object + * + * Returns: + * - The inferred plugin API client surface + */ export type PluginApis = ReturnType +export * from './bindings' +export * from './kits' export * from './resources' +export * from './tools' diff --git a/packages/plugin-sdk/src/plugin/apis/client/kits/index.ts b/packages/plugin-sdk/src/plugin/apis/client/kits/index.ts new file mode 100644 index 000000000..d420b44a7 --- /dev/null +++ b/packages/plugin-sdk/src/plugin/apis/client/kits/index.ts @@ -0,0 +1,113 @@ +import type { EventContext } from '@moeru/eventa' + +import type { KitCapabilityDescriptor, KitDescriptor } from '../../../../plugin-host/shared' + +/** + * Identifies the bound API call used to list runtime-compatible kits. + * + * Use when: + * - Declaring permissions for `apis.kits.list()` + * + * Expects: + * - Host and plugin agree on this event name + * + * Returns: + * - The permission/event key string for listing kits + */ +export const pluginKitApiListEventName = 'proj-airi:plugin-sdk:apis:client:kits:list' +/** + * Identifies the bound API call used to read one kit's capability descriptors. + * + * Use when: + * - Declaring permissions for `apis.kits.getCapabilities()` + * + * Expects: + * - Host and plugin agree on this event name + * + * Returns: + * - The permission/event key string for reading kit capabilities + */ +export const pluginKitApiGetCapabilitiesEventName = 'proj-airi:plugin-sdk:apis:client:kits:get-capabilities' +/** + * Identifies the shared resource namespace that exposes host kit descriptors. + * + * Use when: + * - Declaring read permissions for kit discovery calls + * + * Expects: + * - The host stores kit descriptors under this resource key + * + * Returns: + * - The resource key string for the kit registry + */ +export const pluginKitRegistryResourceKey = 'proj-airi:plugin-sdk:resources:kits' + +/** + * Defines the host-side callbacks needed by the low-level kit client. + * + * Use when: + * - Wiring `session.apis.kits` to host-owned kit registry logic + * + * Expects: + * - `list` returns runtime-filtered kit descriptors + * - `getCapabilities` returns only the capabilities for the requested kit + * + * Returns: + * - The callback contract consumed by {@link createKits} + */ +export interface KitClientBindings { + list: () => Promise | TKit[] + getCapabilities: (kitId: string) => Promise | KitCapabilityDescriptor[] +} + +function createMissingBindingError(method: string) { + return new Error(`Plugin kit API binding missing for \`${method}\`.`) +} + +function requireBinding(binding: TBinding | undefined, method: string): TBinding { + if (!binding) { + throw createMissingBindingError(method) + } + + return binding +} + +/** + * Creates the low-level kit client exposed on `session.apis`. + * + * Use when: + * - Building the plugin SDK API object for a specific session + * + * Expects: + * - `bindings` comes from a host that manages kit descriptors + * + * Returns: + * - A minimal `kits.*` client that forwards to the bound host callbacks + */ +export function createKits( + _ctx: EventContext, + bindings?: KitClientBindings, +) { + return { + async list() { + return await requireBinding(bindings, 'kits.list').list() + }, + async getCapabilities(kitId: string) { + return await requireBinding(bindings, 'kits.getCapabilities').getCapabilities(kitId) + }, + } +} + +/** + * Describes the concrete client object returned by {@link createKits}. + * + * Use when: + * - Typing `apis.kits` + * + * Expects: + * - The caller uses the same method set as the runtime-created kits client + * + * Returns: + * - The inferred kits client surface + */ +export type KitClient = ReturnType diff --git a/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts b/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts index 22a649944..21f45924d 100644 --- a/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts @@ -1 +1,23 @@ +import type { EventContext } from '@moeru/eventa' + +import { createProviders } from './providers' + +/** + * Creates the low-level resource API groups exposed on `session.apis`. + * + * Use when: + * - Building the plugin SDK API object for a specific session + * + * Expects: + * - `ctx` is the Eventa context for the current plugin session + * + * Returns: + * - The resource client groups currently supported by the SDK + */ +export function createResources(ctx: EventContext) { + return { + providers: createProviders(ctx), + } +} + export { createProviders } from './providers' diff --git a/packages/plugin-sdk/src/plugin/apis/client/resources/providers/index.ts b/packages/plugin-sdk/src/plugin/apis/client/resources/providers/index.ts index d8e041233..78a55a207 100644 --- a/packages/plugin-sdk/src/plugin/apis/client/resources/providers/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/client/resources/providers/index.ts @@ -5,6 +5,18 @@ import { defineInvoke } from '@moeru/eventa' import { protocolCapabilityWait } from '../../../protocol/capabilities' import { protocolListProviders, protocolListProvidersEventName } from '../../../protocol/resources/providers' +/** + * Creates the provider resource client used by plugins to query available providers. + * + * Use when: + * - A plugin needs to read the current provider list from the host + * + * Expects: + * - The host exposes the providers capability and corresponding list RPC + * + * Returns: + * - A client with `listProviders()` that waits for capability readiness before invoking + */ export function createProviders(ctx: EventContext) { return { async listProviders() { diff --git a/packages/plugin-sdk/src/plugin/apis/client/tools/index.ts b/packages/plugin-sdk/src/plugin/apis/client/tools/index.ts new file mode 100644 index 000000000..d6a836f79 --- /dev/null +++ b/packages/plugin-sdk/src/plugin/apis/client/tools/index.ts @@ -0,0 +1,111 @@ +import type { EventContext } from '@moeru/eventa' + +import type { PluginToolDefinitionRecord } from '../../../../plugin-host/shared' + +/** + * Identifies the bound API call used to register plugin tools. + * + * Use when: + * - Declaring permissions for `apis.tools.register()` + * + * Expects: + * - Host and plugin agree on this event name + * + * Returns: + * - The permission/event key string for tool registration + */ +export const pluginToolApiRegisterEventName = 'proj-airi:plugin-sdk:apis:client:tools:register' +/** + * Identifies the shared resource namespace that stores plugin tool records. + * + * Use when: + * - Declaring write permissions for tool registration + * + * Expects: + * - The host stores tool definitions under this resource key + * + * Returns: + * - The resource key string for the tool registry + */ +export const pluginToolRegistryResourceKey = 'proj-airi:plugin-sdk:resources:tools' + +/** + * Carries a low-level plugin tool registration request into the host. + * + * Use when: + * - A plugin has already normalized its tool metadata and JSON Schema + * + * Expects: + * - `tool` is serializable and validated by the caller + * - `execute` accepts JSON-compatible input from the host + * + * Returns: + * - A registration payload consumed by the bound host implementation + */ +export interface RegisterToolInput { + tool: PluginToolDefinitionRecord + availability?: () => Promise | boolean + execute: (input: unknown) => Promise | unknown +} + +/** + * Defines the host-side callbacks needed by the low-level plugin tool client. + * + * Use when: + * - Wiring plugin session APIs to host-owned registries + * + * Expects: + * - `register` stores or forwards the tool definition in the host + * + * Returns: + * - The bound client methods used by {@link createTools} + */ +export interface ToolClientBindings { + register: (input: RegisterToolInput) => Promise | void +} + +function createMissingBindingError(method: string) { + return new Error(`Plugin tool API binding missing for \`${method}\`.`) +} + +function requireBinding(binding: TBinding | undefined, method: string): TBinding { + if (!binding) { + throw createMissingBindingError(method) + } + + return binding +} + +/** + * Creates the low-level plugin tool client surface exposed on `session.apis`. + * + * Use when: + * - Building the plugin SDK API object for a specific session + * + * Expects: + * - `bindings` comes from a host that knows how to store tool registrations + * + * Returns: + * - A minimal `tools.register(...)` client + */ +export function createTools(_ctx: EventContext, bindings?: ToolClientBindings) { + return { + async register(input: RegisterToolInput) { + return await requireBinding(bindings, 'tools.register').register(input) + }, + } +} + +/** + * Describes the concrete client object returned by {@link createTools}. + * + * Use when: + * - Typing `apis.tools` + * + * Expects: + * - The caller uses the same method set as the runtime-created tools client + * + * Returns: + * - The inferred tools client surface + */ +export type ToolClient = ReturnType diff --git a/packages/plugin-sdk/src/plugin/apis/protocol/capabilities/index.ts b/packages/plugin-sdk/src/plugin/apis/protocol/capabilities/index.ts index cae3ae550..82a1ad825 100644 --- a/packages/plugin-sdk/src/plugin/apis/protocol/capabilities/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/protocol/capabilities/index.ts @@ -1,5 +1,18 @@ import { defineInvokeEventa } from '@moeru/eventa' +/** + * Describes one capability snapshot exposed by the host dependency registry. + * + * Use when: + * - Reading capability readiness from `protocolCapabilityWait` + * - Inspecting the full capability snapshot from `protocolCapabilitySnapshot` + * + * Expects: + * - `key` is stable across the capability lifecycle + * + * Returns: + * - A serializable view of capability state and optional metadata + */ export interface CapabilityDescriptor { key: string state: 'announced' | 'ready' | 'degraded' | 'withdrawn' @@ -7,12 +20,60 @@ export interface CapabilityDescriptor { updatedAt: number } +/** + * Identifies the control-plane RPC used to wait for one capability. + * + * Use when: + * - Declaring permissions or invoking the wait RPC directly + * + * Expects: + * - Host and plugin agree on this event name + * + * Returns: + * - The permission/event key string for capability waiting + */ export const protocolCapabilityWaitEventName = 'proj-airi:plugin-sdk:apis:protocol:capabilities:wait' +/** + * Defines the control-plane RPC that blocks until a capability becomes ready. + * + * Use when: + * - A plugin needs another host capability before continuing initialization + * + * Expects: + * - The host implements the matching invoke handler + * + * Returns: + * - A typed Eventa invoke descriptor for waiting on one capability + */ export const protocolCapabilityWait = defineInvokeEventa( protocolCapabilityWaitEventName, ) +/** + * Identifies the control-plane RPC used to snapshot all capabilities. + * + * Use when: + * - Declaring permissions or invoking the snapshot RPC directly + * + * Expects: + * - Host and plugin agree on this event name + * + * Returns: + * - The permission/event key string for capability snapshots + */ export const protocolCapabilitySnapshotEventName = 'proj-airi:plugin-sdk:apis:protocol:capabilities:snapshot' +/** + * Defines the control-plane RPC that returns the current capability snapshot. + * + * Use when: + * - A plugin or host tool wants the current state of all capabilities + * + * Expects: + * - The host implements the matching invoke handler + * + * Returns: + * - A typed Eventa invoke descriptor for reading all capability descriptors + */ export const protocolCapabilitySnapshot = defineInvokeEventa( protocolCapabilitySnapshotEventName, ) diff --git a/packages/plugin-sdk/src/plugin/apis/protocol/resources/providers/index.ts b/packages/plugin-sdk/src/plugin/apis/protocol/resources/providers/index.ts index 6fa60eef4..71d9a2011 100644 --- a/packages/plugin-sdk/src/plugin/apis/protocol/resources/providers/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/protocol/resources/providers/index.ts @@ -1,8 +1,44 @@ import { defineInvokeEventa } from '@moeru/eventa' +/** + * Identifies the control-plane RPC used to list available providers. + * + * Use when: + * - Declaring permissions for provider discovery + * + * Expects: + * - Host and plugin agree on this event name + * + * Returns: + * - The permission/event key string for provider listing + */ export const protocolListProvidersEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' +/** + * Defines the control-plane RPC that returns the current provider list. + * + * Use when: + * - A plugin needs to inspect which providers are currently available + * + * Expects: + * - The host implements the matching invoke handler + * + * Returns: + * - A typed Eventa invoke descriptor for listing provider names + */ export const protocolListProviders = defineInvokeEventa<{ name: string }[]>(protocolListProvidersEventName) +/** + * Groups provider-related protocol RPCs into one namespaced object. + * + * Use when: + * - Passing provider protocol helpers around as a single object + * + * Expects: + * - Consumers call `listProviders` for provider discovery + * + * Returns: + * - The provider protocol helper object + */ export const protocolProviders = { listProviders: protocolListProviders, } diff --git a/packages/plugin-sdk/src/plugin/define.ts b/packages/plugin-sdk/src/plugin/define.ts index 96b91cbfd..e5612fac3 100644 --- a/packages/plugin-sdk/src/plugin/define.ts +++ b/packages/plugin-sdk/src/plugin/define.ts @@ -1,5 +1,17 @@ import type { Plugin } from './shared' +/** + * Declares a lazily constructed plugin definition with stable metadata. + * + * Use when: + * - A plugin entrypoint wants to expose metadata and deferred setup together + * + * Expects: + * - `setup` returns a {@link Plugin} object when the host loads the entrypoint + * + * Returns: + * - A serializable plugin definition that loaders can recognize and execute + */ export function definePlugin(name: string, version: string, setup: () => Promise | Plugin): { name: string version: string diff --git a/packages/plugin-sdk/src/plugin/shared.ts b/packages/plugin-sdk/src/plugin/shared.ts index 3959149b2..075a87390 100644 --- a/packages/plugin-sdk/src/plugin/shared.ts +++ b/packages/plugin-sdk/src/plugin/shared.ts @@ -1,6 +1,20 @@ import type { ChannelHost } from '../channels/shared' import type { PluginApis } from './apis/client' +/** + * Describes the host-provided context injected into plugin hooks. + * + * Use when: + * - Implementing `Plugin.init` + * - Implementing `Plugin.setupModules` + * + * Expects: + * - `channels.host` is the control-plane Eventa context for the session + * - `apis` contains the host-bound plugin API surface for that session + * + * Returns: + * - A stable bootstrap object shared across plugin lifecycle hooks + */ export interface ContextInit { channels: { host: ChannelHost @@ -8,13 +22,43 @@ export interface ContextInit { apis: PluginApis } +/** + * Defines the hook surface implemented by a plugin module. + * + * Use when: + * - Exporting plugin behavior from a runtime entrypoint + * + * Expects: + * - Hooks are optional, but at least one meaningful hook should be provided by a real plugin + * + * Returns: + * - A plugin lifecycle object consumed by the plugin host loader + */ export interface Plugin { /** + * Performs plugin initialization against the injected host context. * + * Use when: + * - The plugin needs to announce state, wait for capabilities, or register resources during boot + * + * Expects: + * - The host has already created the plugin session and bound `initContext` + * + * Returns: + * - `false` to abort startup, or nothing to continue initialization */ init?: (initContext: ContextInit) => Promise /** + * Declares additional modules or bindings after basic initialization. * + * Use when: + * - The plugin wants to expose dynamic bindings after its initial boot logic + * + * Expects: + * - The host has already created the plugin session and bound `initContext` + * + * Returns: + * - Nothing. The host observes any side effects performed through `initContext.apis`. */ setupModules?: (initContext: ContextInit) => Promise }