feat(plugin-sdk,stage-tamagotchi): basic file system loader, protocol
This commit is contained in:
@@ -57,6 +57,7 @@
|
|||||||
"@proj-airi/font-cjkfonts-allseto": "workspace:^",
|
"@proj-airi/font-cjkfonts-allseto": "workspace:^",
|
||||||
"@proj-airi/font-xiaolai": "workspace:^",
|
"@proj-airi/font-xiaolai": "workspace:^",
|
||||||
"@proj-airi/i18n": "workspace:^",
|
"@proj-airi/i18n": "workspace:^",
|
||||||
|
"@proj-airi/plugin-sdk": "workspace:^",
|
||||||
"@proj-airi/server-runtime": "workspace:^",
|
"@proj-airi/server-runtime": "workspace:^",
|
||||||
"@proj-airi/server-sdk": "workspace:*",
|
"@proj-airi/server-sdk": "workspace:*",
|
||||||
"@proj-airi/stage-layouts": "workspace:^",
|
"@proj-airi/stage-layouts": "workspace:^",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { openDebugger, setupDebugger } from './app/debugger'
|
|||||||
import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle'
|
import { emitAppBeforeQuit, emitAppReady, emitAppWindowAllClosed } from './libs/bootkit/lifecycle'
|
||||||
import { setElectronMainDirname } from './libs/electron/location'
|
import { setElectronMainDirname } from './libs/electron/location'
|
||||||
import { setupServerChannelHandlers } from './services/airi/channel-server'
|
import { setupServerChannelHandlers } from './services/airi/channel-server'
|
||||||
|
import { setupPluginHost } from './services/airi/plugins'
|
||||||
import { setupAutoUpdater } from './services/electron/auto-updater'
|
import { setupAutoUpdater } from './services/electron/auto-updater'
|
||||||
import { setupTray } from './tray'
|
import { setupTray } from './tray'
|
||||||
import { setupAboutWindowReusable } from './windows/about'
|
import { setupAboutWindowReusable } from './windows/about'
|
||||||
@@ -73,6 +74,7 @@ app.whenReady().then(async () => {
|
|||||||
injeca.setLogger(createLoggLogger(useLogg('injeca').useGlobalConfig()))
|
injeca.setLogger(createLoggLogger(useLogg('injeca').useGlobalConfig()))
|
||||||
|
|
||||||
const serverChannel = injeca.provide('modules:channel-server', () => setupServerChannelHandlers())
|
const serverChannel = injeca.provide('modules:channel-server', () => setupServerChannelHandlers())
|
||||||
|
const pluginHost = injeca.provide('modules:plugin-host', () => setupPluginHost())
|
||||||
const autoUpdater = injeca.provide('services:auto-updater', () => setupAutoUpdater())
|
const autoUpdater = injeca.provide('services:auto-updater', () => setupAutoUpdater())
|
||||||
const widgetsManager = injeca.provide('windows:widgets', () => setupWidgetsWindowManager())
|
const widgetsManager = injeca.provide('windows:widgets', () => setupWidgetsWindowManager())
|
||||||
const noticeWindow = injeca.provide('windows:notice', () => setupNoticeWindowManager())
|
const noticeWindow = injeca.provide('windows:notice', () => setupNoticeWindowManager())
|
||||||
@@ -111,7 +113,7 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
injeca.invoke({
|
injeca.invoke({
|
||||||
dependsOn: { mainWindow, tray, serverChannel },
|
dependsOn: { mainWindow, tray, serverChannel, pluginHost },
|
||||||
callback: noop,
|
callback: noop,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { createContext } from '@moeru/eventa'
|
||||||
|
|
||||||
|
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { basename, join, resolve } from 'node:path'
|
||||||
|
|
||||||
|
import { defineInvoke } from '@moeru/eventa'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { electronPluginList, electronPluginLoadEnabled, electronPluginSetEnabled } from '../../../../shared/eventa'
|
||||||
|
import { setupPluginHost } from './index'
|
||||||
|
|
||||||
|
const appMock = vi.hoisted(() => ({
|
||||||
|
getPath: vi.fn(),
|
||||||
|
}))
|
||||||
|
const contextState = vi.hoisted(() => ({
|
||||||
|
lastContext: undefined as ReturnType<typeof createContext<any, any>> | undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
app: appMock,
|
||||||
|
ipcMain: {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@moeru/eventa/adapters/electron/main', async () => {
|
||||||
|
const eventa = await import('@moeru/eventa')
|
||||||
|
return {
|
||||||
|
createContext: () => {
|
||||||
|
const context = eventa.createContext()
|
||||||
|
contextState.lastContext = context
|
||||||
|
return { context, dispose: () => {} }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const testDataRoot = resolve(
|
||||||
|
import.meta.dirname,
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'packages',
|
||||||
|
'plugin-sdk',
|
||||||
|
'src',
|
||||||
|
'plugin-host',
|
||||||
|
'testdata',
|
||||||
|
)
|
||||||
|
|
||||||
|
async function writeManifest(params: { dir: string, name: string, entrypoint: string }) {
|
||||||
|
const manifest = {
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'manifest.plugin.airi.moeru.ai',
|
||||||
|
name: params.name,
|
||||||
|
entrypoints: {
|
||||||
|
electron: params.entrypoint,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = join(params.dir, `${params.name}.json`)
|
||||||
|
await writeFile(path, JSON.stringify(manifest, null, 2))
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyEntrypoint(params: { dir: string, path: string }) {
|
||||||
|
const file = basename(params.path)
|
||||||
|
const destination = join(params.dir, file)
|
||||||
|
const contents = await readFile(params.path, 'utf-8')
|
||||||
|
await writeFile(destination, contents)
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('setupPluginHost', () => {
|
||||||
|
let userDataDir: string
|
||||||
|
let pluginsDir: string
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
userDataDir = await mkdtemp(join(tmpdir(), 'airi-plugins-'))
|
||||||
|
pluginsDir = join(userDataDir, 'plugins', 'v1')
|
||||||
|
await mkdir(pluginsDir, { recursive: true })
|
||||||
|
appMock.getPath.mockReturnValue(userDataDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(userDataDir, { recursive: true, force: true })
|
||||||
|
contextState.lastContext = undefined
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('lists manifests from the plugins directory', async () => {
|
||||||
|
const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts')
|
||||||
|
const errorEntrypoint = join(testDataRoot, 'test-error-plugin.ts')
|
||||||
|
|
||||||
|
const normalFile = await copyEntrypoint({ dir: pluginsDir, path: normalEntrypoint })
|
||||||
|
const errorFile = await copyEntrypoint({ dir: pluginsDir, path: errorEntrypoint })
|
||||||
|
const normalPath = await writeManifest({ dir: pluginsDir, name: 'test-normal', entrypoint: normalFile })
|
||||||
|
const errorPath = await writeManifest({ dir: pluginsDir, name: 'test-error', entrypoint: errorFile })
|
||||||
|
|
||||||
|
await setupPluginHost()
|
||||||
|
|
||||||
|
expect(contextState.lastContext).toBeDefined()
|
||||||
|
const invokeList = defineInvoke(contextState.lastContext!, electronPluginList)
|
||||||
|
const snapshot = await invokeList()
|
||||||
|
|
||||||
|
expect(snapshot.root).toBe(pluginsDir)
|
||||||
|
expect(snapshot.plugins).toHaveLength(2)
|
||||||
|
expect(snapshot.plugins).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'test-normal', path: normalPath, enabled: false, loaded: false, isNew: true }),
|
||||||
|
expect.objectContaining({ name: 'test-error', path: errorPath, enabled: false, loaded: false, isNew: true }),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads enabled plugins and keeps failed plugins unloaded', async () => {
|
||||||
|
const normalEntrypoint = join(testDataRoot, 'test-normal-plugin.ts')
|
||||||
|
const errorEntrypoint = join(testDataRoot, 'test-error-plugin.ts')
|
||||||
|
|
||||||
|
const normalFile = await copyEntrypoint({ dir: pluginsDir, path: normalEntrypoint })
|
||||||
|
const errorFile = await copyEntrypoint({ dir: pluginsDir, path: errorEntrypoint })
|
||||||
|
await writeManifest({ dir: pluginsDir, name: 'test-normal', entrypoint: normalFile })
|
||||||
|
await writeManifest({ dir: pluginsDir, name: 'test-error', entrypoint: errorFile })
|
||||||
|
|
||||||
|
await setupPluginHost()
|
||||||
|
|
||||||
|
expect(contextState.lastContext).toBeDefined()
|
||||||
|
const invokeSetEnabled = defineInvoke(contextState.lastContext!, electronPluginSetEnabled)
|
||||||
|
const invokeLoadEnabled = defineInvoke(contextState.lastContext!, electronPluginLoadEnabled)
|
||||||
|
|
||||||
|
await invokeSetEnabled({ name: 'test-normal', enabled: true })
|
||||||
|
await invokeSetEnabled({ name: 'test-error', enabled: true })
|
||||||
|
|
||||||
|
const snapshot = await invokeLoadEnabled()
|
||||||
|
|
||||||
|
const normal = snapshot.plugins.find(plugin => plugin.name === 'test-normal')
|
||||||
|
const error = snapshot.plugins.find(plugin => plugin.name === 'test-error')
|
||||||
|
|
||||||
|
expect(normal).toEqual(expect.objectContaining({ enabled: true, loaded: true }))
|
||||||
|
expect(error).toEqual(expect.objectContaining({ enabled: true, loaded: false }))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import type { ManifestV1 } from '@proj-airi/plugin-sdk/plugin-host'
|
||||||
|
|
||||||
|
import type { PluginManifestSummary, PluginRegistrySnapshot } from '../../../../shared/eventa'
|
||||||
|
|
||||||
|
import { mkdir, readdir, readFile } from 'node:fs/promises'
|
||||||
|
import { dirname, extname, join } from 'node:path'
|
||||||
|
|
||||||
|
import { useLogg } from '@guiiai/logg'
|
||||||
|
import { defineInvoke, defineInvokeHandler } from '@moeru/eventa'
|
||||||
|
import { createContext } from '@moeru/eventa/adapters/electron/main'
|
||||||
|
import { manifestV1Schema, PluginHost } from '@proj-airi/plugin-sdk/plugin-host'
|
||||||
|
import { app, ipcMain } from 'electron'
|
||||||
|
import { array, object, record, safeParse, string } from 'valibot'
|
||||||
|
|
||||||
|
import {
|
||||||
|
electronPluginList,
|
||||||
|
electronPluginLoadEnabled,
|
||||||
|
electronPluginSetEnabled,
|
||||||
|
electronPluginUpdateCapability,
|
||||||
|
pluginProtocolListProviders,
|
||||||
|
pluginProtocolListProvidersEventName,
|
||||||
|
} from '../../../../shared/eventa'
|
||||||
|
import { onAppReady } from '../../../libs/bootkit/lifecycle'
|
||||||
|
import { createConfig } from '../../../libs/electron/persistence'
|
||||||
|
|
||||||
|
interface PluginHostService {
|
||||||
|
host: PluginHost
|
||||||
|
manifests: ManifestV1[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CapabilityAwarePluginHost extends PluginHost {
|
||||||
|
setProvidersListResolver: (resolver: () => Promise<Array<{ name: string }>> | Array<{ name: string }>) => void
|
||||||
|
announceCapability: (key: string, metadata?: Record<string, unknown>) => {
|
||||||
|
key: string
|
||||||
|
state: 'announced' | 'ready'
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
markCapabilityReady: (key: string, metadata?: Record<string, unknown>) => {
|
||||||
|
key: string
|
||||||
|
state: 'announced' | 'ready'
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PluginConfig {
|
||||||
|
enabled: string[]
|
||||||
|
known: Record<string, { path: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ManifestEntry {
|
||||||
|
manifest: ManifestV1
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const pluginConfigSchema = object({
|
||||||
|
enabled: array(string()),
|
||||||
|
known: record(string(), object({
|
||||||
|
path: string(),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
function isManifestV1(value: unknown): value is ManifestV1 {
|
||||||
|
return safeParse(manifestV1Schema, value).success
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadManifestsFrom(dir: string, log: ReturnType<typeof useLogg>): Promise<ManifestEntry[]> {
|
||||||
|
await mkdir(dir, { recursive: true })
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true })
|
||||||
|
const manifests: ManifestEntry[] = []
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile())
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (extname(entry.name) !== '.json')
|
||||||
|
continue
|
||||||
|
|
||||||
|
const path = join(dir, entry.name)
|
||||||
|
try {
|
||||||
|
const raw = await readFile(path, 'utf-8')
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
if (!isManifestV1(parsed)) {
|
||||||
|
log.warn('invalid plugin manifest schema', { path })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
manifests.push({ manifest: parsed, path })
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.withError(error).withFields({ path }).error('failed to read plugin manifest')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return manifests
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPluginSummary(entry: ManifestEntry, config: PluginConfig, loaded: Set<string>): PluginManifestSummary {
|
||||||
|
const name = entry.manifest.name
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
entrypoints: entry.manifest.entrypoints,
|
||||||
|
path: entry.path,
|
||||||
|
enabled: config.enabled.includes(name),
|
||||||
|
loaded: loaded.has(name),
|
||||||
|
isNew: !config.known[name],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the Electron plugin host and wires IPC handlers.
|
||||||
|
* Call once during app startup; it loads manifests, returns the host instance,
|
||||||
|
* and registers Eventa handlers for listing, enabling, and loading plugins.
|
||||||
|
*
|
||||||
|
* Loads plugin manifests from the app config directory under `plugins/v1`.
|
||||||
|
*
|
||||||
|
* - Windows: %APPDATA%\${appId}\plugins\v1
|
||||||
|
* - Linux: $XDG_CONFIG_HOME/${appId}/plugins/v1 or ~/.config/${appId}/plugins/v1
|
||||||
|
* - macOS: ~/Library/Application Support/${appId}/plugins/v1
|
||||||
|
*
|
||||||
|
* Persists enablement/known state to `plugins-v1.json` alongside config data.
|
||||||
|
*
|
||||||
|
* - Windows: %APPDATA%\${appId}\plugins-v1.json
|
||||||
|
* - Linux: $XDG_CONFIG_HOME/${appId}/plugins-v1.json or ~/.config/${appId}/plugins-v1.json
|
||||||
|
* - macOS: ~/Library/Application Support/${appId}/plugins-v1.json
|
||||||
|
*/
|
||||||
|
export async function setupPluginHost(): Promise<PluginHostService> {
|
||||||
|
const log = useLogg('main/plugin-host').useGlobalConfig()
|
||||||
|
const pluginsRoot = join(app.getPath('userData'), 'plugins', 'v1')
|
||||||
|
|
||||||
|
const pluginConfig = createConfig('plugins', 'v1.json', pluginConfigSchema, {
|
||||||
|
default: {
|
||||||
|
enabled: [],
|
||||||
|
known: {},
|
||||||
|
},
|
||||||
|
autoHeal: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
pluginConfig.setup()
|
||||||
|
|
||||||
|
const host = new PluginHost({ runtime: 'electron' })
|
||||||
|
// NOTICE: stage-tamagotchi currently typechecks against package exports while plugin-sdk changes
|
||||||
|
// are source-local in this workspace. Cast keeps the bridge typed until package dist is regenerated.
|
||||||
|
const capabilityHost = host as CapabilityAwarePluginHost
|
||||||
|
let entries = await loadManifestsFrom(pluginsRoot, log)
|
||||||
|
let manifests = entries.map(entry => entry.manifest)
|
||||||
|
const loaded = new Set<string>()
|
||||||
|
|
||||||
|
const refreshManifests = async () => {
|
||||||
|
entries = await loadManifestsFrom(pluginsRoot, log)
|
||||||
|
manifests = entries.map(entry => entry.manifest)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getConfig = (): PluginConfig => {
|
||||||
|
return pluginConfig.get() ?? { enabled: [], known: {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
const toSnapshot = (): PluginRegistrySnapshot => {
|
||||||
|
const config = getConfig()
|
||||||
|
return {
|
||||||
|
root: pluginsRoot,
|
||||||
|
plugins: entries.map(entry => createPluginSummary(entry, config, loaded)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadEnabled = async () => {
|
||||||
|
const config = getConfig()
|
||||||
|
for (const entry of entries) {
|
||||||
|
const name = entry.manifest.name
|
||||||
|
if (!config.enabled.includes(name))
|
||||||
|
continue
|
||||||
|
if (loaded.has(name))
|
||||||
|
continue
|
||||||
|
|
||||||
|
try {
|
||||||
|
await host.start(entry.manifest, { cwd: dirname(entry.path) })
|
||||||
|
loaded.add(name)
|
||||||
|
log.log('plugin loaded', { plugin: name })
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
log.withError(error).withFields({ plugin: name }).error('plugin failed to start')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { context } = createContext(ipcMain)
|
||||||
|
const invokePluginProtocolListProviders = defineInvoke(context, pluginProtocolListProviders)
|
||||||
|
|
||||||
|
defineInvokeHandler(context, electronPluginList, async () => {
|
||||||
|
// IPC: fetch current plugin list by refreshing manifests and returning a snapshot.
|
||||||
|
await refreshManifests()
|
||||||
|
return toSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineInvokeHandler(context, electronPluginSetEnabled, async (payload) => {
|
||||||
|
// IPC: toggle a plugin's enabled state, persist config, and return updated snapshot.
|
||||||
|
await refreshManifests()
|
||||||
|
const config = getConfig()
|
||||||
|
const enabled = new Set(config.enabled)
|
||||||
|
if (payload?.enabled)
|
||||||
|
enabled.add(payload.name)
|
||||||
|
else
|
||||||
|
enabled.delete(payload.name)
|
||||||
|
|
||||||
|
const entry = entries.find(candidate => candidate.manifest.name === payload.name)
|
||||||
|
const manifestPath = entry?.path ?? payload.path ?? ''
|
||||||
|
const nextConfig: PluginConfig = {
|
||||||
|
enabled: [...enabled],
|
||||||
|
known: {
|
||||||
|
...config.known,
|
||||||
|
[payload.name]: { path: manifestPath },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pluginConfig.update(nextConfig)
|
||||||
|
|
||||||
|
return toSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineInvokeHandler(context, electronPluginLoadEnabled, async () => {
|
||||||
|
// IPC: load all enabled plugins and return the latest snapshot.
|
||||||
|
await refreshManifests()
|
||||||
|
await loadEnabled()
|
||||||
|
return toSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineInvokeHandler(context, electronPluginUpdateCapability, async (payload) => {
|
||||||
|
if (payload.key === pluginProtocolListProvidersEventName && payload.state === 'ready') {
|
||||||
|
capabilityHost.setProvidersListResolver(async () => await invokePluginProtocolListProviders())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.state === 'announced') {
|
||||||
|
return capabilityHost.announceCapability(payload.key, payload.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
return capabilityHost.markCapabilityReady(payload.key, payload.metadata)
|
||||||
|
})
|
||||||
|
|
||||||
|
onAppReady(async () => {
|
||||||
|
await refreshManifests()
|
||||||
|
await loadEnabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
return { host, manifests }
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { useContextBridgeStore } from '@proj-airi/stage-ui/stores/mods/api/conte
|
|||||||
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
import { useAiriCardStore } from '@proj-airi/stage-ui/stores/modules/airi-card'
|
||||||
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
|
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
|
||||||
import { usePerfTracerBridgeStore } from '@proj-airi/stage-ui/stores/perf-tracer-bridge'
|
import { usePerfTracerBridgeStore } from '@proj-airi/stage-ui/stores/perf-tracer-bridge'
|
||||||
|
import { listProvidersForPluginHost, shouldPublishPluginHostCapabilities } from '@proj-airi/stage-ui/stores/plugin-host-capabilities'
|
||||||
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
|
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
|
||||||
import { useTheme } from '@proj-airi/ui'
|
import { useTheme } from '@proj-airi/ui'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
@@ -21,7 +22,14 @@ import { toast, Toaster } from 'vue-sonner'
|
|||||||
|
|
||||||
import ResizeHandler from './components/ResizeHandler.vue'
|
import ResizeHandler from './components/ResizeHandler.vue'
|
||||||
|
|
||||||
import { electronOpenSettings, electronStartTrackMousePosition, electronStartWebSocketServer } from '../shared/eventa'
|
import {
|
||||||
|
electronOpenSettings,
|
||||||
|
electronPluginUpdateCapability,
|
||||||
|
electronStartTrackMousePosition,
|
||||||
|
electronStartWebSocketServer,
|
||||||
|
pluginProtocolListProviders,
|
||||||
|
pluginProtocolListProvidersEventName,
|
||||||
|
} from '../shared/eventa'
|
||||||
import { useElectronEventaContext, useElectronEventaInvoke } from './composables/electron-vueuse'
|
import { useElectronEventaContext, useElectronEventaInvoke } from './composables/electron-vueuse'
|
||||||
|
|
||||||
const { isDark: dark } = useTheme()
|
const { isDark: dark } = useTheme()
|
||||||
@@ -67,8 +75,22 @@ onMounted(async () => {
|
|||||||
|
|
||||||
const context = useElectronEventaContext()
|
const context = useElectronEventaContext()
|
||||||
const startTrackingCursorPoint = defineInvoke(context.value, electronStartTrackMousePosition)
|
const startTrackingCursorPoint = defineInvoke(context.value, electronStartTrackMousePosition)
|
||||||
|
const reportPluginCapability = defineInvoke(context.value, electronPluginUpdateCapability)
|
||||||
await startTrackingCursorPoint()
|
await startTrackingCursorPoint()
|
||||||
|
|
||||||
|
// Expose stage provider definitions to plugin host APIs.
|
||||||
|
defineInvokeHandler(context.value, pluginProtocolListProviders, async () => listProvidersForPluginHost())
|
||||||
|
|
||||||
|
if (shouldPublishPluginHostCapabilities()) {
|
||||||
|
await reportPluginCapability({
|
||||||
|
key: pluginProtocolListProvidersEventName,
|
||||||
|
state: 'ready',
|
||||||
|
metadata: {
|
||||||
|
source: 'stage-ui',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Listen for open-settings IPC message from main process
|
// Listen for open-settings IPC message from main process
|
||||||
defineInvokeHandler(context.value, electronOpenSettings, () => router.push('/settings'))
|
defineInvokeHandler(context.value, electronOpenSettings, () => router.push('/settings'))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ export const electronOpenSettingsDevtools = defineInvokeEventa('eventa:invoke:el
|
|||||||
export const electronOpenDevtoolsWindow = defineInvokeEventa<void, { route?: string }>('eventa:invoke:electron:windows:devtools:open')
|
export const electronOpenDevtoolsWindow = defineInvokeEventa<void, { route?: string }>('eventa:invoke:electron:windows:devtools:open')
|
||||||
export const electronStartWebSocketServer = defineInvokeEventa<void, { websocketSecureEnabled: boolean }>('eventa:invoke:electron:start-websocket-server')
|
export const electronStartWebSocketServer = defineInvokeEventa<void, { websocketSecureEnabled: boolean }>('eventa:invoke:electron:start-websocket-server')
|
||||||
export const electronRestartWebSocketServer = defineInvokeEventa<void, { websocketSecureEnabled: boolean }>('eventa:invoke:electron:restart-websocket-server')
|
export const electronRestartWebSocketServer = defineInvokeEventa<void, { websocketSecureEnabled: boolean }>('eventa:invoke:electron:restart-websocket-server')
|
||||||
|
export const electronPluginList = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:list')
|
||||||
|
export const electronPluginSetEnabled = defineInvokeEventa<PluginRegistrySnapshot, { name: string, enabled: boolean, path?: string }>('eventa:invoke:electron:plugins:set-enabled')
|
||||||
|
export const electronPluginLoadEnabled = defineInvokeEventa<PluginRegistrySnapshot>('eventa:invoke:electron:plugins:load-enabled')
|
||||||
|
export const electronPluginUpdateCapability = defineInvokeEventa<PluginCapabilityState, PluginCapabilityPayload>('eventa:invoke:electron:plugins:capability:update')
|
||||||
|
export const pluginProtocolListProvidersEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers'
|
||||||
|
export const pluginProtocolListProviders = defineInvokeEventa<Array<{ name: string }>>(pluginProtocolListProvidersEventName)
|
||||||
export const captionIsFollowingWindowChanged = defineEventa<boolean>('eventa:event:electron:windows:caption-overlay:is-following-window-changed')
|
export const captionIsFollowingWindowChanged = defineEventa<boolean>('eventa:event:electron:windows:caption-overlay:is-following-window-changed')
|
||||||
export const captionGetIsFollowingWindow = defineInvokeEventa<boolean>('eventa:invoke:electron:windows:caption-overlay:get-is-following-window')
|
export const captionGetIsFollowingWindow = defineInvokeEventa<boolean>('eventa:invoke:electron:windows:caption-overlay:get-is-following-window')
|
||||||
|
|
||||||
@@ -60,6 +66,33 @@ export interface WidgetSnapshot {
|
|||||||
ttlMs: number
|
ttlMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginManifestSummary {
|
||||||
|
name: string
|
||||||
|
entrypoints: Record<string, string | undefined>
|
||||||
|
path: string
|
||||||
|
enabled: boolean
|
||||||
|
loaded: boolean
|
||||||
|
isNew: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginRegistrySnapshot {
|
||||||
|
root: string
|
||||||
|
plugins: PluginManifestSummary[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginCapabilityPayload {
|
||||||
|
key: string
|
||||||
|
state: 'announced' | 'ready'
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginCapabilityState {
|
||||||
|
key: string
|
||||||
|
state: 'announced' | 'ready'
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
export const widgetsOpenWindow = defineInvokeEventa<void, { id?: string }>('eventa:invoke:electron:windows:widgets:open')
|
export const widgetsOpenWindow = defineInvokeEventa<void, { id?: string }>('eventa:invoke:electron:windows:widgets:open')
|
||||||
export const widgetsAdd = defineInvokeEventa<string | undefined, WidgetsAddPayload>('eventa:invoke:electron:windows:widgets:add')
|
export const widgetsAdd = defineInvokeEventa<string | undefined, WidgetsAddPayload>('eventa:invoke:electron:windows:widgets:add')
|
||||||
export const widgetsRemove = defineInvokeEventa<void, { id: string }>('eventa:invoke:electron:windows:widgets:remove')
|
export const widgetsRemove = defineInvokeEventa<void, { id: string }>('eventa:invoke:electron:windows:widgets:remove')
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ AIRI is a multi-node system where plugins, bridges, and viewers communicate over
|
|||||||
|
|
||||||
AIRI needs to run across desktop, web, and mobile while keeping one clean API surface. Plugins must be able to register UI, declare capabilities, and exchange data with device-specific bridges. To keep the system scalable, high-rate streams must be separated from lifecycle and configuration traffic.
|
AIRI needs to run across desktop, web, and mobile while keeping one clean API surface. Plugins must be able to register UI, declare capabilities, and exchange data with device-specific bridges. To keep the system scalable, high-rate streams must be separated from lifecycle and configuration traffic.
|
||||||
|
|
||||||
|
Runtime dependency orchestration is intentionally split into a dedicated design document to keep this architecture document focused on platform shape and planes.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|
||||||
- Provide a single plugin API surface across runtimes.
|
- Provide a single plugin API surface across runtimes.
|
||||||
@@ -112,7 +114,7 @@ Viewers render UI and character output. Examples:
|
|||||||
### Plugin Lifecycle Overview
|
### Plugin Lifecycle Overview
|
||||||
|
|
||||||
The lifecycle below mirrors the detailed lifecycle comment in
|
The lifecycle below mirrors the detailed lifecycle comment in
|
||||||
`packages/plugin-sdk/src/plugin-host/index.ts` and focuses on the module
|
`packages/plugin-sdk/src/plugin-host/core.ts` and focuses on the module
|
||||||
announcement, configuration, and capability phases.
|
announcement, configuration, and capability phases.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -132,6 +134,8 @@ flowchart TD
|
|||||||
M --> N[module:status ready]
|
M --> N[module:status ready]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Detailed capability dependency orchestration, waiting phases, and readiness gates are defined in `capability-orchestration.md`.
|
||||||
|
|
||||||
### Bridges And Remote Plugins
|
### Bridges And Remote Plugins
|
||||||
|
|
||||||
Bridges connect external devices and services to AIRI. They do not own UI; they only provide data and actions. Examples:
|
Bridges connect external devices and services to AIRI. They do not own UI; they only provide data and actions. Examples:
|
||||||
@@ -210,6 +214,7 @@ Active design.
|
|||||||
### Next Steps
|
### Next Steps
|
||||||
|
|
||||||
- Align runtime docs with updated plugin context and transport strategy.
|
- Align runtime docs with updated plugin context and transport strategy.
|
||||||
|
- Integrate capability registry and readiness-gate lifecycle transitions from `capability-orchestration.md`.
|
||||||
- Expand remote plugin examples by language.
|
- Expand remote plugin examples by language.
|
||||||
|
|
||||||
## Reviews
|
## Reviews
|
||||||
@@ -227,4 +232,5 @@ Active design.
|
|||||||
|
|
||||||
### Related Documentations
|
### Related Documentations
|
||||||
|
|
||||||
- [Multi-Transport Plugin Contexts](../../../../packages/plugin-sdk/docs/design/multi-transport.md)
|
- [Multi-Transport Plugin Contexts](./multi-transport.md)
|
||||||
|
- [Capability-Oriented Module Orchestration](./capability-orchestration.md)
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Capability-Oriented Module Orchestration
|
||||||
|
|
||||||
|
- [Summary](#summary)
|
||||||
|
- [Background](#background)
|
||||||
|
- [Context](#context)
|
||||||
|
- [Goals](#goals)
|
||||||
|
- [Non-goals](#non-goals)
|
||||||
|
- [Proposal](#proposal)
|
||||||
|
- [Vision](#vision)
|
||||||
|
- [Design Details](#design-details)
|
||||||
|
- [Lifecycle Model](#lifecycle-model)
|
||||||
|
- [Capability Registry Model](#capability-registry-model)
|
||||||
|
- [Use Cases](#use-cases)
|
||||||
|
- [Verify & Test](#verify--test)
|
||||||
|
- [Criteria](#criteria)
|
||||||
|
- [Test & QA](#test--qa)
|
||||||
|
- [Progress](#progress)
|
||||||
|
- [Status](#status)
|
||||||
|
- [Next Steps](#next-steps)
|
||||||
|
- [Reviews](#reviews)
|
||||||
|
- [Q&A](#qa)
|
||||||
|
- [Related Documentations](#related-documentations)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Define a capability-oriented orchestration model for AIRI plugins and modules where dependency resolution is runtime-driven instead of static metadata-driven. Plugin Host coordinates module lifecycle using a stateful capability registry and readiness gates, enabling multi-runtime deployments (Electron, Web, Pocket) without hardcoding stage-first boot order.
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Current plugin lifecycle in `PluginHost` is phase-based but does not yet enforce dynamic dependency waiting between modules and platform-provided APIs. In practice, APIs like provider listing can be available only after platform runtime, stage runtime, and UI/store initialization complete. This creates race conditions when modules initialize before required capabilities are actually ready.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The AIRI ecosystem is moving toward:
|
||||||
|
|
||||||
|
- Multiple plugin hosts (Electron now, Pocket and Web later).
|
||||||
|
- Multiple stage instances per host/runtime.
|
||||||
|
- Platform-specific extension APIs that should be discoverable through one control-plane model.
|
||||||
|
- Plugin modules that may provide capabilities required by other modules and by stage/configurator flows.
|
||||||
|
|
||||||
|
The design must avoid privileged hardcoded ordering such as "stage always loads first", while still giving deterministic and testable startup behavior.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Support runtime capability discovery and readiness gating without static dependency metadata.
|
||||||
|
- Keep lifecycle orchestration host-driven and runtime-neutral.
|
||||||
|
- Allow module dependencies to target capabilities, not implementation modules.
|
||||||
|
- Support multiple stage instances and platform-specific APIs in one model.
|
||||||
|
- Prevent missed readiness signals by relying on stateful registry snapshots, not one-shot events.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Defining the final production policy engine (priority, quotas, fairness) in this iteration.
|
||||||
|
- Designing UI/UX details of configurator pages.
|
||||||
|
- Finalizing transport federation protocol between all runtimes in this document.
|
||||||
|
|
||||||
|
## Proposal
|
||||||
|
|
||||||
|
Introduce a host-owned capability registry and make module initialization capability-aware:
|
||||||
|
|
||||||
|
1. Modules announce possible capabilities early (`announced`/`preparing` phase).
|
||||||
|
2. Modules mark capabilities as ready once invokable.
|
||||||
|
3. Modules declare runtime requirements as capability predicates.
|
||||||
|
4. Host transitions modules into `waiting-deps` until requirements resolve.
|
||||||
|
5. Host resumes module preparation/setup when required capabilities become ready.
|
||||||
|
|
||||||
|
Readiness and availability are stateful in registry snapshots, with event notifications as incremental updates.
|
||||||
|
|
||||||
|
## Vision
|
||||||
|
|
||||||
|
AIRI plugin orchestration should behave like an extensible runtime kernel:
|
||||||
|
|
||||||
|
- Hosts are runtime adapters (Electron, Pocket, Web), not policy exceptions.
|
||||||
|
- Stages are normal modules that publish and consume capabilities.
|
||||||
|
- Modules compose through capability contracts, not hardcoded start order.
|
||||||
|
- Adding new platform APIs means registering new capabilities, not rewriting lifecycle code.
|
||||||
|
|
||||||
|
## Design Details
|
||||||
|
|
||||||
|
Capability-first lifecycle orchestration with dynamic dependency resolution.
|
||||||
|
|
||||||
|
### Lifecycle Model
|
||||||
|
|
||||||
|
Proposed host-side phases for each module:
|
||||||
|
|
||||||
|
- `loading`
|
||||||
|
- `loaded`
|
||||||
|
- `authenticating`
|
||||||
|
- `authenticated`
|
||||||
|
- `announced`
|
||||||
|
- `preparing`
|
||||||
|
- `waiting-deps`
|
||||||
|
- `prepared`
|
||||||
|
- `configuration-needed`
|
||||||
|
- `configured`
|
||||||
|
- `ready`
|
||||||
|
- `degraded`
|
||||||
|
- `failed`
|
||||||
|
- `stopped`
|
||||||
|
|
||||||
|
Key rules:
|
||||||
|
|
||||||
|
- `preparing -> waiting-deps` if required capabilities are unresolved.
|
||||||
|
- `waiting-deps -> prepared` when all required capability predicates resolve.
|
||||||
|
- `ready -> degraded` when previously bound capability is withdrawn/degraded.
|
||||||
|
- `degraded -> ready` when dependency set becomes healthy again.
|
||||||
|
|
||||||
|
### Capability Registry Model
|
||||||
|
|
||||||
|
Registry properties:
|
||||||
|
|
||||||
|
- Stateful snapshot store (authoritative readiness state).
|
||||||
|
- Capability records scoped by `hostId`, `instanceId`, and runtime.
|
||||||
|
- Support both direct key lookup and predicate matching.
|
||||||
|
- Version and health metadata per capability record.
|
||||||
|
|
||||||
|
Capability record baseline:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CapabilityRecord {
|
||||||
|
capabilityId: string
|
||||||
|
providerModuleId: string
|
||||||
|
hostId: string
|
||||||
|
instanceId?: string
|
||||||
|
runtime: 'electron' | 'web' | 'pocket' | 'node'
|
||||||
|
state: 'announced' | 'ready' | 'degraded' | 'withdrawn'
|
||||||
|
version?: string
|
||||||
|
health?: 'ok' | 'degraded' | 'unknown'
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Requirement baseline:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CapabilityRequirement {
|
||||||
|
allOf?: string[]
|
||||||
|
anyOf?: string[]
|
||||||
|
predicate?: (record: CapabilityRecord) => boolean
|
||||||
|
timeoutMs?: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use Cases
|
||||||
|
|
||||||
|
1. Stage provider catalog dependency chain
|
||||||
|
- Provider-definition plugin announces capability.
|
||||||
|
- Stage/configurator module announces provider catalog capability when store is mounted.
|
||||||
|
- Consumer plugin module waits for both capabilities, then configures provider-backed features.
|
||||||
|
|
||||||
|
2. Multi-stage instance isolation
|
||||||
|
- Two stage instances in one host register same capability class under different `instanceId`.
|
||||||
|
- Module requests capability in same `instanceId` scope.
|
||||||
|
- Host binds consumer to correct stage-local capability.
|
||||||
|
|
||||||
|
3. Pocket runtime API extension
|
||||||
|
- Pocket runtime module registers `mobile.sensor` capability.
|
||||||
|
- Plugins targeting mobile predicates can activate on pocket host without Electron-specific assumptions.
|
||||||
|
|
||||||
|
4. Late readiness and replay safety
|
||||||
|
- Capability became ready before module started waiting.
|
||||||
|
- Module receives snapshot-based resolution immediately and skips unnecessary wait.
|
||||||
|
|
||||||
|
## Verify & Test
|
||||||
|
|
||||||
|
### Criteria
|
||||||
|
|
||||||
|
- Module lifecycle correctly enters `waiting-deps` when required capabilities are missing.
|
||||||
|
- Module resumes deterministically when capabilities become ready.
|
||||||
|
- Snapshot-based registry prevents missed-ready race conditions.
|
||||||
|
- Same module capability requirements work across Electron and at least one non-Electron runtime adapter.
|
||||||
|
- Capability scoping by `instanceId` prevents cross-stage binding errors.
|
||||||
|
|
||||||
|
### Test & QA
|
||||||
|
|
||||||
|
- Unit test: registry stores and replays announced/ready/degraded states.
|
||||||
|
- Unit test: module transitions `preparing -> waiting-deps -> prepared`.
|
||||||
|
- Unit test: pre-existing ready capability resolves wait immediately.
|
||||||
|
- Unit test: degraded dependency transitions module from `ready` to `degraded`.
|
||||||
|
- Integration test: stage-side capability registration unblocks plugin module initialization.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
### Status
|
||||||
|
|
||||||
|
Proposed.
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
|
||||||
|
1. Add host registry abstraction and lifecycle integration in `packages/plugin-sdk/src/plugin-host`.
|
||||||
|
2. Introduce `waiting-deps` and `degraded` transitions to host state machine and tests.
|
||||||
|
3. Define minimal protocol events for capability announce/ready/degraded/snapshot.
|
||||||
|
4. Wire stage-side capability publication in Electron host integration first.
|
||||||
|
|
||||||
|
## Reviews
|
||||||
|
|
||||||
|
### Q&A
|
||||||
|
|
||||||
|
- Q: Why not use static plugin dependency metadata?
|
||||||
|
A: Runtime capability dependencies are more flexible for multi-stage and multi-runtime setups where availability depends on live initialization state, not package declarations.
|
||||||
|
|
||||||
|
- Q: How does this avoid missed readiness events?
|
||||||
|
A: Registry snapshot is authoritative. Events are incremental signals only; late consumers always query snapshot state.
|
||||||
|
|
||||||
|
- Q: Does this force one core stage ordering?
|
||||||
|
A: No. Ordering emerges from capability availability and requirement predicates, not privileged host rules.
|
||||||
|
|
||||||
|
### Related Documentations
|
||||||
|
|
||||||
|
- [AIRI Plugin Platform](./architecture.md)
|
||||||
|
- [Multi-Transport Plugin Contexts](./multi-transport.md)
|
||||||
@@ -39,6 +39,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@moeru/eventa": "catalog:",
|
"@moeru/eventa": "catalog:",
|
||||||
"@proj-airi/server-shared": "workspace:*"
|
"@proj-airi/plugin-protocol": "workspace:*",
|
||||||
|
"@proj-airi/server-shared": "workspace:*",
|
||||||
|
"valibot": "^1.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,20 @@ import { join } from 'node:path'
|
|||||||
import { createContext, defineEventa, defineInvokeHandler } from '@moeru/eventa'
|
import { createContext, defineEventa, defineInvokeHandler } from '@moeru/eventa'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { FileSystemLoader } from '.'
|
import { FileSystemLoader, PluginHost } from '.'
|
||||||
import { createApis } from '../plugin/apis/client'
|
import { createApis } from '../plugin/apis/client'
|
||||||
import { protocolProviders } from '../plugin/apis/protocol'
|
import { protocolCapabilityWait, protocolProviders } from '../plugin/apis/protocol'
|
||||||
|
|
||||||
|
function reportPluginCapability(
|
||||||
|
host: PluginHost,
|
||||||
|
payload: { key: string, state: 'announced' | 'ready', metadata?: Record<string, unknown> },
|
||||||
|
) {
|
||||||
|
if (payload.state === 'announced') {
|
||||||
|
return host.announceCapability(payload.key, payload.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
return host.markCapabilityReady(payload.key, payload.metadata)
|
||||||
|
}
|
||||||
|
|
||||||
describe('for FileSystemPluginHost', () => {
|
describe('for FileSystemPluginHost', () => {
|
||||||
it('should load test-normal-plugin from manifest', async () => {
|
it('should load test-normal-plugin from manifest', async () => {
|
||||||
@@ -18,7 +29,7 @@ describe('for FileSystemPluginHost', () => {
|
|||||||
entrypoints: {
|
entrypoints: {
|
||||||
electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'),
|
electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'),
|
||||||
},
|
},
|
||||||
}, { cwd: '' })
|
}, { cwd: '', runtime: 'electron' })
|
||||||
|
|
||||||
const ctx = createContext()
|
const ctx = createContext()
|
||||||
const apis = createApis(ctx)
|
const apis = createApis(ctx)
|
||||||
@@ -29,6 +40,22 @@ describe('for FileSystemPluginHost', () => {
|
|||||||
expect(onVitestCall).toHaveBeenCalledTimes(1)
|
expect(onVitestCall).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should resolve runtime-specific entrypoint with node fallback', async () => {
|
||||||
|
const host = new FileSystemLoader()
|
||||||
|
|
||||||
|
const pluginDef = await host.loadPluginFor({
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'manifest.plugin.airi.moeru.ai',
|
||||||
|
name: 'test-plugin',
|
||||||
|
entrypoints: {
|
||||||
|
node: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'),
|
||||||
|
},
|
||||||
|
}, { cwd: '', runtime: 'node' })
|
||||||
|
|
||||||
|
expect(pluginDef).toBeDefined()
|
||||||
|
expect(typeof pluginDef.init).toBe('function')
|
||||||
|
})
|
||||||
|
|
||||||
it('should be able to handle test-error-plugin from manifest', async () => {
|
it('should be able to handle test-error-plugin from manifest', async () => {
|
||||||
const host = new FileSystemLoader()
|
const host = new FileSystemLoader()
|
||||||
|
|
||||||
@@ -39,15 +66,86 @@ describe('for FileSystemPluginHost', () => {
|
|||||||
entrypoints: {
|
entrypoints: {
|
||||||
electron: join(import.meta.dirname, 'testdata', 'test-error-plugin.ts'),
|
electron: join(import.meta.dirname, 'testdata', 'test-error-plugin.ts'),
|
||||||
},
|
},
|
||||||
}, { cwd: '' })).rejects.toThrow('Test error plugin always throws an error during loading.')
|
}, { cwd: '', runtime: 'electron' })).rejects.toThrow('Test error plugin always throws an error during loading.')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('for PluginHost', () => {
|
describe('for PluginHost', () => {
|
||||||
it('should be able to expose setupModules', async () => {
|
const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers'
|
||||||
const host = new FileSystemLoader()
|
const testManifest = {
|
||||||
|
apiVersion: 'v1' as const,
|
||||||
|
kind: 'manifest.plugin.airi.moeru.ai' as const,
|
||||||
|
name: 'test-plugin',
|
||||||
|
entrypoints: {
|
||||||
|
electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
const pluginDef = await host.loadPluginFor({
|
it('should run plugin lifecycle to ready in-memory', 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(testManifest, { cwd: '' })
|
||||||
|
|
||||||
|
await host.markConfigurationNeeded(session.id, 'manual-check')
|
||||||
|
|
||||||
|
expect(session.phase).toBe('configuration-needed')
|
||||||
|
|
||||||
|
await host.applyConfiguration(session.id, {
|
||||||
|
configId: `${session.identity.id}:manual`,
|
||||||
|
revision: 2,
|
||||||
|
schemaVersion: 1,
|
||||||
|
full: { mode: 'manual' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(session.phase).toBe('configured')
|
||||||
|
|
||||||
|
const stopped = host.stop(session.id)
|
||||||
|
expect(stopped?.phase).toBe('stopped')
|
||||||
|
expect(host.getSession(session.id)).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail initialization when plugin init returns false', async () => {
|
||||||
|
const host = new PluginHost({
|
||||||
|
runtime: 'electron',
|
||||||
|
transport: { kind: 'in-memory' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = await host.load({
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'manifest.plugin.airi.moeru.ai',
|
||||||
|
name: 'test-plugin-no-connect',
|
||||||
|
entrypoints: {
|
||||||
|
electron: join(import.meta.dirname, 'testdata', 'test-no-connect-plugin.ts'),
|
||||||
|
},
|
||||||
|
}, { cwd: '' })
|
||||||
|
|
||||||
|
await expect(host.init(session.id)).rejects.toThrow('Plugin initialization aborted by plugin: test-plugin-no-connect')
|
||||||
|
|
||||||
|
const latest = host.getSession(session.id)
|
||||||
|
expect(latest?.phase).toBe('failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject non in-memory transport for MVP', async () => {
|
||||||
|
const host = new PluginHost({
|
||||||
|
runtime: 'electron',
|
||||||
|
transport: { kind: 'websocket', url: 'ws://localhost:3000' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(host.start(testManifest, { cwd: '' })).rejects.toThrow('Only in-memory transport is currently supported by PluginHost alpha.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should be able to expose setupModules', async () => {
|
||||||
|
const loader = new FileSystemLoader()
|
||||||
|
|
||||||
|
const pluginDef = await loader.loadPluginFor({
|
||||||
apiVersion: 'v1',
|
apiVersion: 'v1',
|
||||||
kind: 'manifest.plugin.airi.moeru.ai',
|
kind: 'manifest.plugin.airi.moeru.ai',
|
||||||
name: 'test-plugin',
|
name: 'test-plugin',
|
||||||
@@ -69,10 +167,60 @@ describe('for PluginHost', () => {
|
|||||||
{ name: 'provider1' },
|
{ name: 'provider1' },
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
defineInvokeHandler(ctx, protocolCapabilityWait, async () => {
|
||||||
|
return {
|
||||||
|
key: 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers',
|
||||||
|
state: 'ready',
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const onProviderListCall = vi.fn()
|
const onProviderListCall = vi.fn()
|
||||||
ctx.on(protocolProviders.listProviders.sendEvent, onProviderListCall)
|
ctx.on(protocolProviders.listProviders.sendEvent, onProviderListCall)
|
||||||
await expect(pluginDef.setupModules?.({ channels: { host: ctx }, apis })).resolves.not.toThrow()
|
await expect(pluginDef.setupModules?.({ channels: { host: ctx }, apis })).resolves.not.toThrow()
|
||||||
expect(onProviderListCall).toHaveBeenCalledTimes(1)
|
expect(onProviderListCall).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should wait for required capabilities before proceeding init', async () => {
|
||||||
|
const host = new PluginHost({
|
||||||
|
runtime: 'electron',
|
||||||
|
transport: { kind: 'in-memory' },
|
||||||
|
})
|
||||||
|
reportPluginCapability(host, {
|
||||||
|
key: providersCapability,
|
||||||
|
state: 'ready',
|
||||||
|
metadata: { source: 'test' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const started = host.start(testManifest, {
|
||||||
|
cwd: '',
|
||||||
|
requiredCapabilities: ['cap:providers:list'],
|
||||||
|
capabilityWaitTimeoutMs: 2000,
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20))
|
||||||
|
const loadingSession = host.listSessions().find(item => item.manifest.name === testManifest.name)
|
||||||
|
expect(loadingSession?.phase).toBe('waiting-deps')
|
||||||
|
|
||||||
|
reportPluginCapability(host, {
|
||||||
|
key: 'cap:providers:list',
|
||||||
|
state: 'ready',
|
||||||
|
metadata: { source: 'test' },
|
||||||
|
})
|
||||||
|
const session = await started
|
||||||
|
expect(session.phase).toBe('ready')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail when required capabilities timeout', async () => {
|
||||||
|
const host = new PluginHost({
|
||||||
|
runtime: 'electron',
|
||||||
|
transport: { kind: 'in-memory' },
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(host.start(testManifest, {
|
||||||
|
cwd: '',
|
||||||
|
requiredCapabilities: ['cap:missing'],
|
||||||
|
capabilityWaitTimeoutMs: 10,
|
||||||
|
})).rejects.toThrow('Capability `cap:missing` is not ready after 10ms.')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,45 @@
|
|||||||
|
import type {
|
||||||
|
ProtocolEvents,
|
||||||
|
ModuleConfigEnvelope as ProtocolModuleConfigEnvelope,
|
||||||
|
ModuleIdentity as ProtocolModuleIdentity,
|
||||||
|
ModulePhase as ProtocolModulePhase,
|
||||||
|
PluginIdentity as ProtocolPluginIdentity,
|
||||||
|
} from '@proj-airi/plugin-protocol/types'
|
||||||
|
|
||||||
import type { definePlugin } from '../plugin'
|
import type { definePlugin } from '../plugin'
|
||||||
|
import type { createApis } from '../plugin/apis/client'
|
||||||
|
import type { CapabilityDescriptor } from '../plugin/apis/protocol'
|
||||||
import type { Plugin } from '../plugin/shared'
|
import type { Plugin } from '../plugin/shared'
|
||||||
|
import type { PluginTransport } from './transports'
|
||||||
|
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { cwd } from 'node:process'
|
import { cwd } from 'node:process'
|
||||||
|
|
||||||
|
import { defineInvokeHandler } from '@moeru/eventa'
|
||||||
|
import {
|
||||||
|
moduleAnnounce,
|
||||||
|
moduleAuthenticate,
|
||||||
|
moduleAuthenticated,
|
||||||
|
moduleCompatibilityRequest,
|
||||||
|
moduleCompatibilityResult,
|
||||||
|
moduleConfigurationConfigured,
|
||||||
|
moduleConfigurationNeeded,
|
||||||
|
modulePrepared,
|
||||||
|
moduleStatus,
|
||||||
|
registryModulesSync,
|
||||||
|
} from '@proj-airi/plugin-protocol/types'
|
||||||
|
import {
|
||||||
|
literal,
|
||||||
|
object,
|
||||||
|
optional,
|
||||||
|
string,
|
||||||
|
} from 'valibot'
|
||||||
|
|
||||||
|
import { createApis as createBoundApis } from '../plugin/apis/client'
|
||||||
|
import { protocolCapabilitySnapshot, protocolCapabilityWait } from '../plugin/apis/protocol'
|
||||||
|
import { protocolListProvidersEventName, protocolProviders } from '../plugin/apis/protocol/resources/providers'
|
||||||
|
import { createPluginContext } from './runtimes/node'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin Host lifecycle overview (transport-aware):
|
* Plugin Host lifecycle overview (transport-aware):
|
||||||
*
|
*
|
||||||
@@ -117,53 +153,722 @@ import { cwd } from 'node:process'
|
|||||||
* Plugin Host should treat the Module to be un-prepared status, the needed procedure will be called.
|
* Plugin Host should treat the Module to be un-prepared status, the needed procedure will be called.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export class PluginHost {
|
const lifecycleTransitionRules: Record<PluginSessionPhase, PluginSessionPhase[]> = {
|
||||||
constructor() {
|
'loading': ['loaded', 'failed'],
|
||||||
|
'loaded': ['authenticating', 'stopped', 'failed'],
|
||||||
|
'authenticating': ['authenticated', 'failed'],
|
||||||
|
'authenticated': ['announced', 'failed'],
|
||||||
|
'announced': ['preparing', 'configuration-needed', 'failed', 'stopped'],
|
||||||
|
'preparing': ['waiting-deps', 'prepared', 'failed'],
|
||||||
|
'waiting-deps': ['prepared', 'failed'],
|
||||||
|
'prepared': ['configuration-needed', 'configured', 'failed'],
|
||||||
|
'configuration-needed': ['configured', 'failed'],
|
||||||
|
'configured': ['ready', 'failed'],
|
||||||
|
'ready': ['announced', 'configuration-needed', 'failed', 'stopped'],
|
||||||
|
'failed': ['stopped'],
|
||||||
|
'stopped': [],
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertTransition(session: PluginHostSession, to: PluginSessionPhase) {
|
||||||
|
const allowed = lifecycleTransitionRules[session.phase]
|
||||||
|
if (!allowed.includes(to)) {
|
||||||
|
throw new Error(`Invalid plugin lifecycle transition: ${session.phase} -> ${to} for module ${session.identity.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
session.phase = to
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPluginDefinition(value: unknown): value is ReturnType<typeof definePlugin> {
|
||||||
|
return typeof value === 'object'
|
||||||
|
&& value !== null
|
||||||
|
&& 'setup' in value
|
||||||
|
&& typeof (value as { setup?: unknown }).setup === 'function'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function coercePluginFromModule(moduleValue: unknown): Promise<Plugin> {
|
||||||
|
if (isPluginDefinition(moduleValue)) {
|
||||||
|
return await moduleValue.setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof moduleValue === 'object' && moduleValue !== null) {
|
||||||
|
if ('default' in moduleValue && isPluginDefinition((moduleValue as { default?: unknown }).default)) {
|
||||||
|
return await (moduleValue as { default: ReturnType<typeof definePlugin> }).default.setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('default' in moduleValue && typeof (moduleValue as { default?: unknown }).default === 'object') {
|
||||||
|
const defaultPlugin = (moduleValue as { default: Plugin }).default
|
||||||
|
if (typeof defaultPlugin.init === 'function' || typeof defaultPlugin.setupModules === 'function') {
|
||||||
|
return defaultPlugin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = moduleValue as Plugin
|
||||||
|
if (typeof plugin.init === 'function' || typeof plugin.setupModules === 'function') {
|
||||||
|
return plugin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Failed to resolve plugin module. The entrypoint must export either definePlugin(...) or Plugin hooks.')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createModuleIdentity(name: string, index: number): ModuleIdentity {
|
||||||
|
const sanitizedName = name.trim() || 'plugin'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${sanitizedName}-${index}`,
|
||||||
|
kind: 'plugin',
|
||||||
|
plugin: {
|
||||||
|
id: sanitizedName,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PluginRuntime = 'electron' | 'node' | 'web'
|
||||||
|
|
||||||
|
export type ModulePhase = ProtocolModulePhase
|
||||||
|
|
||||||
|
export type PluginSessionPhase
|
||||||
|
= | 'loading'
|
||||||
|
| 'loaded'
|
||||||
|
| 'authenticating'
|
||||||
|
| 'authenticated'
|
||||||
|
| 'waiting-deps'
|
||||||
|
| ModulePhase
|
||||||
|
| 'stopped'
|
||||||
|
|
||||||
|
export type PluginIdentity = ProtocolPluginIdentity
|
||||||
|
|
||||||
|
export type ModuleIdentity = ProtocolModuleIdentity
|
||||||
|
|
||||||
|
export type ModuleConfigEnvelope<C = Record<string, unknown>> = ProtocolModuleConfigEnvelope<C>
|
||||||
|
|
||||||
|
export type ModuleCompatibilityRequest = ProtocolEvents['module:compatibility:request']
|
||||||
|
|
||||||
|
export type ModuleCompatibilityResult = ProtocolEvents['module:compatibility:result']
|
||||||
|
|
||||||
export interface ManifestV1 {
|
export interface ManifestV1 {
|
||||||
apiVersion: 'v1'
|
apiVersion: 'v1'
|
||||||
kind: 'manifest.plugin.airi.moeru.ai'
|
kind: 'manifest.plugin.airi.moeru.ai'
|
||||||
name: string
|
name: string
|
||||||
entrypoints: {
|
entrypoints: {
|
||||||
|
default?: string
|
||||||
electron?: string
|
electron?: string
|
||||||
|
node?: string
|
||||||
|
web?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const manifestV1Schema = object({
|
||||||
|
apiVersion: literal('v1'),
|
||||||
|
kind: literal('manifest.plugin.airi.moeru.ai'),
|
||||||
|
name: string(),
|
||||||
|
entrypoints: object({
|
||||||
|
default: optional(string()),
|
||||||
|
electron: optional(string()),
|
||||||
|
node: optional(string()),
|
||||||
|
web: optional(string()),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export interface PluginLoadOptions {
|
||||||
|
cwd?: string
|
||||||
|
runtime?: PluginRuntime
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginHostOptions {
|
||||||
|
runtime?: PluginRuntime
|
||||||
|
transport?: PluginTransport
|
||||||
|
protocolVersion?: string
|
||||||
|
apiVersion?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginStartOptions {
|
||||||
|
cwd?: string
|
||||||
|
runtime?: PluginRuntime
|
||||||
|
requireConfiguration?: boolean
|
||||||
|
compatibility?: Omit<ModuleCompatibilityRequest, 'protocolVersion' | 'apiVersion'>
|
||||||
|
requiredCapabilities?: string[]
|
||||||
|
capabilityWaitTimeoutMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginHostSession {
|
||||||
|
manifest: ManifestV1
|
||||||
|
plugin: Plugin
|
||||||
|
id: string
|
||||||
|
index: number
|
||||||
|
identity: ModuleIdentity
|
||||||
|
phase: PluginSessionPhase
|
||||||
|
transport: PluginTransport
|
||||||
|
runtime: PluginRuntime
|
||||||
|
channels: {
|
||||||
|
host: ReturnType<typeof createPluginContext>
|
||||||
|
}
|
||||||
|
apis: ReturnType<typeof createApis>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory Plugin Host MVP.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export class PluginHost {
|
||||||
|
private readonly loader: FileSystemLoader
|
||||||
|
private readonly sessions = new Map<string, PluginHostSession>()
|
||||||
|
private readonly runtime: PluginRuntime
|
||||||
|
private readonly transport: PluginTransport
|
||||||
|
private readonly protocolVersion: string
|
||||||
|
private readonly apiVersion: string
|
||||||
|
private readonly capabilities = new Map<string, CapabilityDescriptor>()
|
||||||
|
private readonly capabilityWaiters = new Map<string, Set<(descriptor: CapabilityDescriptor) => void>>()
|
||||||
|
private providersListResolver: () => Promise<Array<{ name: string }>> | Array<{ name: string }> = () => []
|
||||||
|
private sessionCounter = 0
|
||||||
|
|
||||||
|
constructor(options: PluginHostOptions = {}) {
|
||||||
|
this.loader = new FileSystemLoader()
|
||||||
|
this.runtime = options.runtime ?? 'electron'
|
||||||
|
this.transport = options.transport ?? { kind: 'in-memory' }
|
||||||
|
this.protocolVersion = options.protocolVersion ?? 'v1'
|
||||||
|
this.apiVersion = options.apiVersion ?? 'v1'
|
||||||
|
this.markCapabilityReady(protocolListProvidersEventName, { source: 'plugin-host' })
|
||||||
|
}
|
||||||
|
|
||||||
|
listSessions() {
|
||||||
|
return [...this.sessions.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
getSession(sessionId: string) {
|
||||||
|
return this.sessions.get(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
const transport = this.transport
|
||||||
|
|
||||||
|
// TODO: implement other transports and runtime bindings.
|
||||||
|
// alpha scope guard:
|
||||||
|
// we intentionally fail fast for non in-memory transports while iterating on lifecycle design.
|
||||||
|
if (transport.kind !== 'in-memory') {
|
||||||
|
throw new Error(`Only in-memory transport is currently supported by PluginHost alpha. Got: ${transport.kind}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build deterministic per-session identity.
|
||||||
|
// `sessionCounter` gives stable ordering for registry sync and debugging.
|
||||||
|
const sessionIndex = this.sessionCounter
|
||||||
|
this.sessionCounter += 1
|
||||||
|
|
||||||
|
const id = `plugin-session-${sessionIndex}`
|
||||||
|
const identity = createModuleIdentity(manifest.name, sessionIndex)
|
||||||
|
|
||||||
|
// Step 1 (connect/control-plane prep): create an isolated Eventa context per plugin.
|
||||||
|
// All invokes/events for this plugin go through this context to prevent cross-talk.
|
||||||
|
const hostChannel = createPluginContext(transport)
|
||||||
|
defineInvokeHandler(hostChannel, protocolCapabilityWait, async (payload) => {
|
||||||
|
return await this.waitForCapability(payload.key, payload?.timeoutMs)
|
||||||
|
})
|
||||||
|
defineInvokeHandler(hostChannel, protocolCapabilitySnapshot, async () => {
|
||||||
|
return this.listCapabilities()
|
||||||
|
})
|
||||||
|
defineInvokeHandler(hostChannel, protocolProviders.listProviders, async () => {
|
||||||
|
return await this.providersListResolver()
|
||||||
|
})
|
||||||
|
|
||||||
|
const session: PluginHostSession = {
|
||||||
|
manifest,
|
||||||
|
plugin: {},
|
||||||
|
id,
|
||||||
|
index: sessionIndex,
|
||||||
|
identity,
|
||||||
|
phase: 'loading',
|
||||||
|
transport,
|
||||||
|
runtime,
|
||||||
|
channels: {
|
||||||
|
host: hostChannel,
|
||||||
|
},
|
||||||
|
apis: createBoundApis(hostChannel),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register session before loading so failure paths still have observable state.
|
||||||
|
this.sessions.set(id, session)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load plugin module from manifest-selected runtime entrypoint.
|
||||||
|
// This is where malformed entrypoints or import errors surface.
|
||||||
|
session.plugin = await this.loader.loadPluginFor(manifest, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
runtime,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Assert lifecycle progression (`loading` -> `loaded`) to keep transition rules explicit.
|
||||||
|
// This prevents accidental phase drift if the method evolves later.
|
||||||
|
assertTransition(session, 'loaded')
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// Load failure is terminal for this session (`loading` -> `failed`).
|
||||||
|
// Emit status so Configurator/observers can show deterministic diagnostics.
|
||||||
|
assertTransition(session, 'failed')
|
||||||
|
session.channels.host.emit(moduleStatus, {
|
||||||
|
identity: session.identity,
|
||||||
|
phase: 'failed',
|
||||||
|
reason: error instanceof Error ? error.message : 'Failed to load plugin.',
|
||||||
|
})
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(sessionId: string, options: PluginStartOptions = {}): Promise<PluginHostSession> {
|
||||||
|
// `init` starts at procedure step 2 (authenticate) and drives lifecycle to ready.
|
||||||
|
const session = this.sessions.get(sessionId)
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Unable to initialize plugin session: ${sessionId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safety gate: initialization can only begin from a successfully loaded plugin.
|
||||||
|
if (session.phase !== 'loaded') {
|
||||||
|
throw new Error(`Session ${sessionId} cannot initialize from phase ${session.phase}. Expected loaded.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let preparedEmitted = false
|
||||||
|
|
||||||
|
// Step 2: authenticate module against host control plane.
|
||||||
|
assertTransition(session, 'authenticating')
|
||||||
|
session.channels.host.emit(moduleAuthenticate, {
|
||||||
|
token: `${session.id}:${session.identity.id}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mark local lifecycle after authentication handshake.
|
||||||
|
assertTransition(session, 'authenticated')
|
||||||
|
session.channels.host.emit(moduleAuthenticated, { authenticated: true })
|
||||||
|
|
||||||
|
// Step 3: protocol/api compatibility negotiation.
|
||||||
|
const compatibilityRequest: ModuleCompatibilityRequest = {
|
||||||
|
protocolVersion: this.protocolVersion,
|
||||||
|
apiVersion: this.apiVersion,
|
||||||
|
supportedProtocolVersions: options.compatibility?.supportedProtocolVersions,
|
||||||
|
supportedApiVersions: options.compatibility?.supportedApiVersions,
|
||||||
|
}
|
||||||
|
|
||||||
|
session.channels.host.emit(moduleCompatibilityRequest, compatibilityRequest)
|
||||||
|
session.channels.host.emit(moduleCompatibilityResult, {
|
||||||
|
protocolVersion: compatibilityRequest.protocolVersion,
|
||||||
|
apiVersion: compatibilityRequest.apiVersion,
|
||||||
|
mode: 'exact',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 4: broadcast currently known modules for dependency discovery/bootstrap.
|
||||||
|
session.channels.host.emit(registryModulesSync, {
|
||||||
|
modules: this.listSessions()
|
||||||
|
.filter(item => item.phase !== 'stopped')
|
||||||
|
.map(item => ({
|
||||||
|
name: item.manifest.name,
|
||||||
|
index: item.index,
|
||||||
|
identity: item.identity,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 5: module announcement to the shared control plane.
|
||||||
|
assertTransition(session, 'announced')
|
||||||
|
session.channels.host.emit(moduleAnnounce, {
|
||||||
|
name: session.manifest.name,
|
||||||
|
identity: session.identity,
|
||||||
|
possibleEvents: [],
|
||||||
|
})
|
||||||
|
session.channels.host.emit(moduleStatus, {
|
||||||
|
identity: session.identity,
|
||||||
|
phase: 'announced',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 6/7: preparing phase (dependency/config preparation may happen inside plugin init).
|
||||||
|
assertTransition(session, 'preparing')
|
||||||
|
session.channels.host.emit(moduleStatus, {
|
||||||
|
identity: session.identity,
|
||||||
|
phase: 'preparing',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Optional dependency gate before plugin-owned initialization.
|
||||||
|
if (options.requiredCapabilities?.length) {
|
||||||
|
assertTransition(session, 'waiting-deps')
|
||||||
|
session.channels.host.emit(moduleStatus, {
|
||||||
|
identity: session.identity,
|
||||||
|
phase: 'preparing',
|
||||||
|
reason: `Waiting for capabilities: ${options.requiredCapabilities.join(', ')}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.waitForCapabilities(options.requiredCapabilities, options.capabilityWaitTimeoutMs)
|
||||||
|
assertTransition(session, 'prepared')
|
||||||
|
session.channels.host.emit(modulePrepared, {
|
||||||
|
identity: session.identity,
|
||||||
|
})
|
||||||
|
session.channels.host.emit(moduleStatus, {
|
||||||
|
identity: session.identity,
|
||||||
|
phase: 'prepared',
|
||||||
|
})
|
||||||
|
preparedEmitted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run plugin-owned init hook. Returning `false` explicitly aborts startup.
|
||||||
|
const initResult = await session.plugin.init?.({
|
||||||
|
channels: session.channels,
|
||||||
|
apis: session.apis,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (initResult === false) {
|
||||||
|
throw new Error(`Plugin initialization aborted by plugin: ${session.manifest.name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8/10: module prepared.
|
||||||
|
if (!preparedEmitted) {
|
||||||
|
assertTransition(session, 'prepared')
|
||||||
|
session.channels.host.emit(modulePrepared, {
|
||||||
|
identity: session.identity,
|
||||||
|
})
|
||||||
|
session.channels.host.emit(moduleStatus, {
|
||||||
|
identity: session.identity,
|
||||||
|
phase: 'prepared',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 9/11: allow host to stop at explicit "configuration-needed".
|
||||||
|
if (options.requireConfiguration) {
|
||||||
|
assertTransition(session, 'configuration-needed')
|
||||||
|
session.channels.host.emit(moduleConfigurationNeeded, {
|
||||||
|
identity: session.identity,
|
||||||
|
reason: 'Host requested configuration before activation.',
|
||||||
|
})
|
||||||
|
session.channels.host.emit(moduleStatus, {
|
||||||
|
identity: session.identity,
|
||||||
|
phase: 'configuration-needed',
|
||||||
|
})
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 12/13: apply default config path for alpha when no manual configuration is required.
|
||||||
|
await this.applyConfiguration(session.id, {
|
||||||
|
configId: `${session.identity.id}:default`,
|
||||||
|
revision: 1,
|
||||||
|
schemaVersion: 1,
|
||||||
|
full: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 14/15: plugin contributes modules/capabilities in setup hook.
|
||||||
|
await session.plugin.setupModules?.({
|
||||||
|
channels: session.channels,
|
||||||
|
apis: session.apis,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Step 16: mark ready after setup/contribution flow completes.
|
||||||
|
assertTransition(session, 'ready')
|
||||||
|
session.channels.host.emit(moduleStatus, {
|
||||||
|
identity: session.identity,
|
||||||
|
phase: 'ready',
|
||||||
|
})
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
// Any init failure is normalized into failed phase + status event for observability.
|
||||||
|
const currentPhase = session.phase
|
||||||
|
if (lifecycleTransitionRules[currentPhase].includes('failed')) {
|
||||||
|
assertTransition(session, 'failed')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
session.phase = 'failed'
|
||||||
|
}
|
||||||
|
|
||||||
|
session.channels.host.emit(moduleStatus, {
|
||||||
|
identity: session.identity,
|
||||||
|
phase: 'failed',
|
||||||
|
reason: error instanceof Error ? error.message : 'Plugin host initialization failed.',
|
||||||
|
})
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(manifest: ManifestV1, options: PluginStartOptions = {}) {
|
||||||
|
// Convenience wrapper: "start" = load + init in sequence.
|
||||||
|
// Keep this tiny so callers can still call `load`/`init` separately when needed.
|
||||||
|
const session = await this.load(manifest, {
|
||||||
|
cwd: options.cwd,
|
||||||
|
runtime: options.runtime,
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.init(session.id, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyConfiguration(sessionId: string, config: ModuleConfigEnvelope) {
|
||||||
|
// Configuration is allowed only after prepare, during configuration-needed, or while re-configuring.
|
||||||
|
const session = this.sessions.get(sessionId)
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Unable to configure plugin session: ${sessionId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['prepared', 'configuration-needed', 'configured'].includes(session.phase)) {
|
||||||
|
throw new Error(`Session ${sessionId} cannot accept configuration during phase ${session.phase}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move into configured once per cycle; repeated apply is allowed while already configured.
|
||||||
|
if (session.phase !== 'configured') {
|
||||||
|
assertTransition(session, 'configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit configured payload + status so Configurator can sync active config state.
|
||||||
|
session.channels.host.emit(moduleConfigurationConfigured, {
|
||||||
|
identity: session.identity,
|
||||||
|
config,
|
||||||
|
})
|
||||||
|
|
||||||
|
session.channels.host.emit(moduleStatus, {
|
||||||
|
identity: session.identity,
|
||||||
|
phase: 'configured',
|
||||||
|
})
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
setProvidersListResolver(resolver: () => Promise<Array<{ name: string }>> | Array<{ name: string }>) {
|
||||||
|
this.providersListResolver = resolver
|
||||||
|
this.markCapabilityReady(protocolListProvidersEventName, { source: 'plugin-host-override' })
|
||||||
|
}
|
||||||
|
|
||||||
|
announceCapability(key: string, metadata?: Record<string, unknown>) {
|
||||||
|
const current = this.capabilities.get(key)
|
||||||
|
const descriptor: CapabilityDescriptor = {
|
||||||
|
key,
|
||||||
|
state: 'announced',
|
||||||
|
metadata: metadata ?? current?.metadata,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.capabilities.set(key, descriptor)
|
||||||
|
return descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
markCapabilityReady(key: string, metadata?: Record<string, unknown>) {
|
||||||
|
const current = this.capabilities.get(key)
|
||||||
|
const descriptor: CapabilityDescriptor = {
|
||||||
|
key,
|
||||||
|
state: 'ready',
|
||||||
|
metadata: metadata ?? current?.metadata,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
this.capabilities.set(key, descriptor)
|
||||||
|
const waiters = this.capabilityWaiters.get(key)
|
||||||
|
if (waiters) {
|
||||||
|
for (const resolve of waiters) {
|
||||||
|
resolve(descriptor)
|
||||||
|
}
|
||||||
|
this.capabilityWaiters.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return descriptor
|
||||||
|
}
|
||||||
|
|
||||||
|
listCapabilities() {
|
||||||
|
return [...this.capabilities.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
isCapabilityReady(key: string) {
|
||||||
|
return this.capabilities.get(key)?.state === 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForCapabilities(keys: string[], timeoutMs: number = 15000) {
|
||||||
|
await Promise.all(keys.map(async key => await this.waitForCapability(key, timeoutMs)))
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForCapability(key: string, timeoutMs: number = 15000) {
|
||||||
|
const existing = this.capabilities.get(key)
|
||||||
|
if (existing?.state === 'ready') {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise<CapabilityDescriptor>((resolve, reject) => {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const onReady = (descriptor: CapabilityDescriptor) => {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
resolve(descriptor)
|
||||||
|
}
|
||||||
|
|
||||||
|
const waiters = this.capabilityWaiters.get(key) ?? new Set()
|
||||||
|
waiters.add(onReady)
|
||||||
|
this.capabilityWaiters.set(key, waiters)
|
||||||
|
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
const currentWaiters = this.capabilityWaiters.get(key)
|
||||||
|
currentWaiters?.delete(onReady)
|
||||||
|
if (currentWaiters && currentWaiters.size === 0) {
|
||||||
|
this.capabilityWaiters.delete(key)
|
||||||
|
}
|
||||||
|
reject(new Error(`Capability \`${key}\` is not ready after ${timeoutMs}ms.`))
|
||||||
|
}, timeoutMs)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
markConfigurationNeeded(sessionId: string, reason?: string) {
|
||||||
|
// Explicit rollback/forward hook into "configuration-needed" phase.
|
||||||
|
// Mirrors procedure step 17 where module may request reconfiguration.
|
||||||
|
const session = this.sessions.get(sessionId)
|
||||||
|
if (!session) {
|
||||||
|
throw new Error(`Unable to update plugin session: ${sessionId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['prepared', 'configured', 'ready', 'announced'].includes(session.phase)) {
|
||||||
|
throw new Error(`Session ${sessionId} cannot move to configuration-needed from ${session.phase}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert guarded transition to avoid illegal phase jumps.
|
||||||
|
assertTransition(session, 'configuration-needed')
|
||||||
|
session.channels.host.emit(moduleConfigurationNeeded, {
|
||||||
|
identity: session.identity,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
session.channels.host.emit(moduleStatus, {
|
||||||
|
identity: session.identity,
|
||||||
|
phase: 'configuration-needed',
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(sessionId: string) {
|
||||||
|
// Stop removes session from active registry. Lifecycle first transitions to `stopped`.
|
||||||
|
const session = this.sessions.get(sessionId)
|
||||||
|
if (!session) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer guarded transition when allowed; otherwise force-close as a safety fallback.
|
||||||
|
if (session.phase !== 'stopped') {
|
||||||
|
if (lifecycleTransitionRules[session.phase].includes('stopped')) {
|
||||||
|
assertTransition(session, 'stopped')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
session.phase = 'stopped'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sessions.delete(session.id)
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
async reload(sessionId: string, options: PluginStartOptions = {}) {
|
||||||
|
// Reload preserves manifest/runtime intent, then performs stop + fresh start.
|
||||||
|
// This intentionally creates a new session identity for deterministic re-bootstrap.
|
||||||
|
const previous = this.sessions.get(sessionId)
|
||||||
|
if (!previous) {
|
||||||
|
throw new Error(`Unable to reload missing plugin session: ${sessionId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = previous.manifest
|
||||||
|
const cwdValue = options.cwd
|
||||||
|
this.stop(sessionId)
|
||||||
|
return this.start(manifest, {
|
||||||
|
...options,
|
||||||
|
cwd: cwdValue,
|
||||||
|
runtime: options.runtime ?? previous.runtime,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class FileSystemLoader {
|
export class FileSystemLoader {
|
||||||
|
/**
|
||||||
|
* Filesystem-backed plugin module loader.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Resolve runtime-specific entrypoints from `ManifestV1`.
|
||||||
|
* - Import plugin modules from local disk.
|
||||||
|
* - Normalize module exports into either:
|
||||||
|
* - lazy plugin definition (`definePlugin(...)`) via `loadLazyPluginFor`, or
|
||||||
|
* - executable plugin hooks (`Plugin`) via `loadPluginFor`.
|
||||||
|
*/
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadLazyPluginFor(manifest: ManifestV1, options?: { cwd?: string }) {
|
/**
|
||||||
|
* Resolve a manifest entrypoint for the requested runtime.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* 1) `entrypoints.<runtime>`
|
||||||
|
* 2) `entrypoints.default`
|
||||||
|
* 3) `entrypoints.electron` (legacy fallback for current local plugin manifests)
|
||||||
|
*
|
||||||
|
* Throws an actionable error when no entrypoint can be selected.
|
||||||
|
*/
|
||||||
|
resolveEntrypointFor(manifest: ManifestV1, options?: PluginLoadOptions) {
|
||||||
|
const runtime = options?.runtime ?? 'electron'
|
||||||
const root = options?.cwd ?? cwd()
|
const root = options?.cwd ?? cwd()
|
||||||
if (!manifest.entrypoints.electron) {
|
const entrypoint
|
||||||
|
= manifest.entrypoints[runtime]
|
||||||
|
?? manifest.entrypoints.default
|
||||||
|
?? manifest.entrypoints.electron
|
||||||
|
|
||||||
|
if (!entrypoint) {
|
||||||
throw new Error(''
|
throw new Error(''
|
||||||
+ 'For locally installed, defined plugin, electron entrypoint is required.'
|
+ `Plugin entrypoint is required for runtime \`${runtime}\`. `
|
||||||
+ 'The value of `entrypoints.electron` should be the relative path to the '
|
+ 'Define one of `entrypoints.<runtime>`, `entrypoints.default`, '
|
||||||
+ 'root of app.getPath(\'userData\').',
|
+ 'or `entrypoints.electron` in the plugin manifest.',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const entrypoint = join(root, manifest.entrypoints.electron)
|
return join(root, entrypoint)
|
||||||
const pluginModule = await import(entrypoint) as { default: ReturnType<typeof definePlugin> }
|
|
||||||
return pluginModule.default
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadPluginFor(manifest: ManifestV1, options?: { cwd?: string }) {
|
/**
|
||||||
const root = options?.cwd ?? cwd()
|
* Load a lazy plugin definition (`definePlugin(...)`) without executing setup.
|
||||||
if (!manifest.entrypoints.electron) {
|
*
|
||||||
throw new Error(''
|
* Use this when host logic wants to inspect plugin metadata/setup contract first
|
||||||
+ 'For locally installed, defined plugin, electron entrypoint is required.'
|
* and control when `setup()` is called.
|
||||||
+ 'The value of `entrypoints.electron` should be the relative path to the '
|
*/
|
||||||
+ 'root of app.getPath(\'userData\').',
|
async loadLazyPluginFor(manifest: ManifestV1, options?: PluginLoadOptions) {
|
||||||
)
|
const entrypoint = this.resolveEntrypointFor(manifest, options)
|
||||||
|
const pluginModule = await import(entrypoint)
|
||||||
|
|
||||||
|
if (isPluginDefinition(pluginModule)) {
|
||||||
|
return pluginModule
|
||||||
}
|
}
|
||||||
|
|
||||||
const entrypoint = join(root, manifest.entrypoints.electron)
|
if (typeof pluginModule === 'object' && pluginModule !== null) {
|
||||||
const pluginModule = await import(entrypoint) as Plugin
|
const defaultExport = (pluginModule as { default?: unknown }).default
|
||||||
return pluginModule
|
if (isPluginDefinition(defaultExport)) {
|
||||||
|
return defaultExport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Plugin lazy loader expects a definePlugin(...) export.')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and normalize a plugin entrypoint into executable `Plugin` hooks.
|
||||||
|
*
|
||||||
|
* Accepts:
|
||||||
|
* - a direct `Plugin` export
|
||||||
|
* - a default `Plugin` export
|
||||||
|
* - `definePlugin(...)` (calls `setup()` and returns the resulting `Plugin`)
|
||||||
|
*/
|
||||||
|
async loadPluginFor(manifest: ManifestV1, options?: PluginLoadOptions) {
|
||||||
|
const entrypoint = this.resolveEntrypointFor(manifest, options)
|
||||||
|
const pluginModule = await import(entrypoint)
|
||||||
|
return coercePluginFromModule(pluginModule)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ import type { EventContext } from '@moeru/eventa'
|
|||||||
|
|
||||||
import { defineInvoke } from '@moeru/eventa'
|
import { defineInvoke } from '@moeru/eventa'
|
||||||
|
|
||||||
import { protocolListProviders } from '../../../protocol/resources/providers'
|
import { protocolCapabilityWait } from '../../../protocol/capabilities'
|
||||||
|
import { protocolListProviders, protocolListProvidersEventName } from '../../../protocol/resources/providers'
|
||||||
|
|
||||||
export function createProviders(ctx: EventContext<any, any>) {
|
export function createProviders(ctx: EventContext<any, any>) {
|
||||||
return {
|
return {
|
||||||
listProviders() {
|
async listProviders() {
|
||||||
|
const waitForCapability = defineInvoke(ctx, protocolCapabilityWait)
|
||||||
|
await waitForCapability({
|
||||||
|
key: protocolListProvidersEventName,
|
||||||
|
})
|
||||||
|
|
||||||
const func = defineInvoke(ctx, protocolListProviders)
|
const func = defineInvoke(ctx, protocolListProviders)
|
||||||
return func()
|
return await func()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineInvokeEventa } from '@moeru/eventa'
|
||||||
|
|
||||||
|
export interface CapabilityDescriptor {
|
||||||
|
key: string
|
||||||
|
state: 'announced' | 'ready'
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
updatedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const protocolCapabilityWait = defineInvokeEventa<CapabilityDescriptor, {
|
||||||
|
key: string
|
||||||
|
timeoutMs?: number
|
||||||
|
}>('proj-airi:plugin-sdk:apis:protocol:capabilities:wait')
|
||||||
|
|
||||||
|
export const protocolCapabilitySnapshot = defineInvokeEventa<CapabilityDescriptor[]>(
|
||||||
|
'proj-airi:plugin-sdk:apis:protocol:capabilities:snapshot',
|
||||||
|
)
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from './capabilities'
|
||||||
export * from './resources'
|
export * from './resources'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { defineInvokeEventa } from '@moeru/eventa'
|
import { defineInvokeEventa } from '@moeru/eventa'
|
||||||
|
|
||||||
export const protocolListProviders = defineInvokeEventa<{ name: string }[]>('proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers')
|
export const protocolListProvidersEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers'
|
||||||
|
export const protocolListProviders = defineInvokeEventa<{ name: string }[]>(protocolListProvidersEventName)
|
||||||
|
|
||||||
export const protocolProviders = {
|
export const protocolProviders = {
|
||||||
listProviders: protocolListProviders,
|
listProviders: protocolListProviders,
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||||
|
|
||||||
|
import { listProviders } from '../libs/providers/providers'
|
||||||
|
|
||||||
|
export interface PluginHostProviderSummary {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listProvidersForPluginHost(): PluginHostProviderSummary[] {
|
||||||
|
return listProviders().map(provider => ({ name: provider.name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldPublishPluginHostCapabilities() {
|
||||||
|
return isStageTamagotchi()
|
||||||
|
}
|
||||||
Generated
+12
-3
@@ -911,6 +911,9 @@ importers:
|
|||||||
'@proj-airi/i18n':
|
'@proj-airi/i18n':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../packages/i18n
|
version: link:../../packages/i18n
|
||||||
|
'@proj-airi/plugin-sdk':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../../packages/plugin-sdk
|
||||||
'@proj-airi/server-runtime':
|
'@proj-airi/server-runtime':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../packages/server-runtime
|
version: link:../../packages/server-runtime
|
||||||
@@ -1984,9 +1987,15 @@ importers:
|
|||||||
'@moeru/eventa':
|
'@moeru/eventa':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.0.0-alpha.14(electron@40.0.0)(h3@2.0.1-rc.5(crossws@0.4.3(srvx@0.10.1)))
|
version: 1.0.0-alpha.14(electron@40.0.0)(h3@2.0.1-rc.5(crossws@0.4.3(srvx@0.10.1)))
|
||||||
|
'@proj-airi/plugin-protocol':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../plugin-protocol
|
||||||
'@proj-airi/server-shared':
|
'@proj-airi/server-shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../server-shared
|
version: link:../server-shared
|
||||||
|
valibot:
|
||||||
|
specifier: ^1.2.0
|
||||||
|
version: 1.2.0(typescript@5.9.3)
|
||||||
|
|
||||||
packages/server-runtime:
|
packages/server-runtime:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3033,7 +3042,7 @@ importers:
|
|||||||
version: 14.1.0(vue@3.5.26(typescript@5.9.3))
|
version: 14.1.0(vue@3.5.26(typescript@5.9.3))
|
||||||
'@wxt-dev/module-vue':
|
'@wxt-dev/module-vue':
|
||||||
specifier: ^1.0.3
|
specifier: ^1.0.3
|
||||||
version: 1.0.3(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
version: 1.0.3(vite@8.0.0-beta.9(@types/node@24.10.9)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
|
||||||
nanoid:
|
nanoid:
|
||||||
specifier: ^5.1.6
|
specifier: ^5.1.6
|
||||||
version: 5.1.6
|
version: 5.1.6
|
||||||
@@ -23282,9 +23291,9 @@ snapshots:
|
|||||||
'@types/filesystem': 0.0.36
|
'@types/filesystem': 0.0.36
|
||||||
'@types/har-format': 1.2.16
|
'@types/har-format': 1.2.16
|
||||||
|
|
||||||
'@wxt-dev/module-vue@1.0.3(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
'@wxt-dev/module-vue@1.0.3(vite@8.0.0-beta.9(@types/node@24.10.9)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))(wxt@0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitejs/plugin-vue': 6.0.3(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
|
'@vitejs/plugin-vue': 6.0.3(vite@8.0.0-beta.9(@types/node@24.10.9)(esbuild@0.27.2)(jiti@2.6.1)(less@4.5.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3))
|
||||||
wxt: 0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
wxt: 0.20.13(@types/node@24.10.9)(jiti@2.6.1)(less@4.5.1)(lightningcss@1.30.2)(rollup@4.55.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- vite
|
- vite
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
projects: [
|
projects: [
|
||||||
'apps/server',
|
'apps/server',
|
||||||
|
'apps/stage-tamagotchi',
|
||||||
'packages/stage-ui',
|
'packages/stage-ui',
|
||||||
'packages/plugin-sdk',
|
'packages/plugin-sdk',
|
||||||
'packages/vite-plugin-warpdrive',
|
'packages/vite-plugin-warpdrive',
|
||||||
|
|||||||
Reference in New Issue
Block a user