feat(plugin-sdk): kits api, binding api, and better comments
This commit is contained in:
@@ -46,5 +46,8 @@
|
||||
"nanoid": "catalog:",
|
||||
"valibot": "^1.3.1",
|
||||
"xstate": "^5.30.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"es-toolkit": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<any, any>) {
|
||||
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<any, any>) {
|
||||
channels.data = context
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<unknown, { raw?: any }>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof pluginLifecycleMachine>
|
||||
/** 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<typeof createPluginContext>
|
||||
}
|
||||
/** Bound plugin SDK APIs exposed to plugin code. */
|
||||
apis: ReturnType<typeof createApis>
|
||||
/** 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<C extends HostDataRecord = HostDataRecord> = AnnounceBindingInput<C>
|
||||
type BoundUpdateBindingInput<C extends HostDataRecord = HostDataRecord> = UpdateBindingInput<C>
|
||||
|
||||
function omitModuleId<C extends HostDataRecord>(input: BoundUpdateBindingInput<C>) {
|
||||
return {
|
||||
state: input.state,
|
||||
config: input.config,
|
||||
}
|
||||
}
|
||||
|
||||
function cloneHostDataValue<T extends HostDataValue>(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<T extends HostDataRecord>(record: T): T {
|
||||
return cloneHostDataValue(record)
|
||||
}
|
||||
|
||||
function cloneKitCapabilities(capabilities: KitCapabilityDescriptor[]): KitCapabilityDescriptor[] {
|
||||
return capabilities.map(capability => ({
|
||||
key: capability.key,
|
||||
actions: [...capability.actions],
|
||||
}))
|
||||
}
|
||||
|
||||
function cloneKitDescriptor<TKit extends KitDescriptor>(kit: TKit): TKit {
|
||||
return {
|
||||
...kit,
|
||||
runtimes: [...kit.runtimes],
|
||||
capabilities: cloneKitCapabilities(kit.capabilities),
|
||||
}
|
||||
}
|
||||
|
||||
function cloneBindingRecord<C extends HostDataRecord>(module: BindingRecord<C>): BindingRecord<C> {
|
||||
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<string, ModulePermissionGrant>()
|
||||
@@ -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<C extends HostDataRecord = HostDataRecord>(
|
||||
sessionId: string,
|
||||
input: BoundAnnounceBindingInput<C>,
|
||||
): BindingRecord<C> {
|
||||
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<C>)
|
||||
}
|
||||
|
||||
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<C extends HostDataRecord = HostDataRecord>(
|
||||
sessionId: string,
|
||||
moduleId: string,
|
||||
patch: UpdateBindingInput<C> | Omit<UpdateBindingInput<C>, '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<PluginHostSession> {
|
||||
// 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
|
||||
|
||||
@@ -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<any, any> {
|
||||
switch (transport.kind) {
|
||||
case 'in-memory':
|
||||
|
||||
@@ -38,6 +38,19 @@ async function coercePluginFromModule(moduleValue: unknown): Promise<Plugin> {
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<C extends HostDataRecord = HostDataRecord> {
|
||||
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<C extends HostDataRecord = HostDataRecord> {
|
||||
state?: BindingState
|
||||
config?: Partial<C>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<BindingState, readonly BindingState[]> = {
|
||||
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<C extends HostDataRecord = HostDataRecord> {
|
||||
private readonly bindings = new Map<string, BindingRecord<C>>()
|
||||
|
||||
/**
|
||||
* 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<C>) {
|
||||
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<C> = {
|
||||
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<C>) {
|
||||
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<C> = {},
|
||||
) {
|
||||
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<C> = {
|
||||
...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
|
||||
}
|
||||
}
|
||||
@@ -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<string, CapabilityDescriptor>()
|
||||
private readonly capabilityWaiters = new Map<string, Set<(descriptor: CapabilityDescriptor) => void>>()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<TKit extends KitDescriptor = KitDescriptor> {
|
||||
private readonly kits = new Map<string, TKit>()
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -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<string, PermissionSnapshot>()
|
||||
|
||||
|
||||
@@ -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<T> = () => Promise<T> | T
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<TSession extends { id: string }> {
|
||||
private readonly sessions = new Map<string, TSession>()
|
||||
private sessionCounter = 0
|
||||
|
||||
@@ -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> | boolean
|
||||
execute: (input: unknown) => Promise<unknown> | 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<string, ToolRegistryRecord>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<any, any> {
|
||||
switch (transport.kind) {
|
||||
case 'in-memory':
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<C extends HostDataRecord = HostDataRecord> {
|
||||
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<typeof bindingRecordSchema>
|
||||
@@ -1 +1,4 @@
|
||||
export * from './bindings'
|
||||
export * from './kits'
|
||||
export * from './tools'
|
||||
export * from './types'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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<typeof kitCapabilitySchema>
|
||||
/**
|
||||
* 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<typeof kitDescriptorSchema>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<HostDataValue> {}
|
||||
|
||||
/**
|
||||
* 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<C = Record<string, unknown>> = ProtocolModuleConfigEnvelope<C>
|
||||
|
||||
/**
|
||||
* 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<ModulePermissionGrant>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ModuleCompatibilityRequest, 'protocolVersion' | 'apiVersion'>
|
||||
/** Capability keys that must become ready before the session can proceed. */
|
||||
requiredCapabilities?: string[]
|
||||
/** Wait timeout applied to each required capability. @default 15000 */
|
||||
capabilityWaitTimeoutMs?: number
|
||||
}
|
||||
|
||||
+42
@@ -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<void> {
|
||||
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(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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[] }
|
||||
|
||||
@@ -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<C extends HostDataRecord = HostDataRecord> {
|
||||
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<C extends HostDataRecord = HostDataRecord> extends BindingUpdatePatch<C> {
|
||||
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<C extends HostDataRecord = HostDataRecord> {
|
||||
list: () => Promise<BindingRecord<C>[]> | BindingRecord<C>[]
|
||||
announce: (input: AnnounceBindingInput<C>) => Promise<BindingRecord<C>> | BindingRecord<C>
|
||||
activate: (input: ActivateBindingInput) => Promise<BindingRecord<C>> | BindingRecord<C>
|
||||
update: (input: UpdateBindingInput<C>) => Promise<BindingRecord<C>> | BindingRecord<C>
|
||||
withdraw: (input: WithdrawBindingInput) => Promise<BindingRecord<C>> | BindingRecord<C>
|
||||
}
|
||||
|
||||
function createMissingBindingError(method: string) {
|
||||
return new Error(`Plugin binding API binding missing for \`${method}\`.`)
|
||||
}
|
||||
|
||||
function requireBinding<TBinding>(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<C extends HostDataRecord = HostDataRecord>(
|
||||
_ctx: EventContext<any, any>,
|
||||
bindings?: BindingClientBindings<C>,
|
||||
) {
|
||||
return {
|
||||
async list() {
|
||||
return await requireBinding(bindings, 'bindings.list').list()
|
||||
},
|
||||
async announce(input: AnnounceBindingInput<C>) {
|
||||
return await requireBinding(bindings, 'bindings.announce').announce(input)
|
||||
},
|
||||
async activate(input: ActivateBindingInput) {
|
||||
return await requireBinding(bindings, 'bindings.activate').activate(input)
|
||||
},
|
||||
async update(input: UpdateBindingInput<C>) {
|
||||
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<typeof createBindings>
|
||||
@@ -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<any, any>) {
|
||||
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<any, any>, 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<typeof createApis>
|
||||
export * from './bindings'
|
||||
export * from './kits'
|
||||
export * from './resources'
|
||||
export * from './tools'
|
||||
|
||||
@@ -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<TKit extends KitDescriptor = KitDescriptor> {
|
||||
list: () => Promise<TKit[]> | TKit[]
|
||||
getCapabilities: (kitId: string) => Promise<KitCapabilityDescriptor[]> | KitCapabilityDescriptor[]
|
||||
}
|
||||
|
||||
function createMissingBindingError(method: string) {
|
||||
return new Error(`Plugin kit API binding missing for \`${method}\`.`)
|
||||
}
|
||||
|
||||
function requireBinding<TBinding>(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<TKit extends KitDescriptor = KitDescriptor>(
|
||||
_ctx: EventContext<any, any>,
|
||||
bindings?: KitClientBindings<TKit>,
|
||||
) {
|
||||
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<typeof createKits>
|
||||
@@ -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<any, any>) {
|
||||
return {
|
||||
providers: createProviders(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
export { createProviders } from './providers'
|
||||
|
||||
@@ -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<any, any>) {
|
||||
return {
|
||||
async listProviders() {
|
||||
|
||||
@@ -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> | boolean
|
||||
execute: (input: unknown) => Promise<unknown> | 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> | void
|
||||
}
|
||||
|
||||
function createMissingBindingError(method: string) {
|
||||
return new Error(`Plugin tool API binding missing for \`${method}\`.`)
|
||||
}
|
||||
|
||||
function requireBinding<TBinding>(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<any, any>, 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<typeof createTools>
|
||||
@@ -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<CapabilityDescriptor, { key: string, timeoutMs?: number }>(
|
||||
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<CapabilityDescriptor[]>(
|
||||
protocolCapabilitySnapshotEventName,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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> | Plugin): {
|
||||
name: string
|
||||
version: string
|
||||
|
||||
@@ -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<void | undefined | false>
|
||||
/**
|
||||
* 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<void | undefined>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user