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-xiaolai": "workspace:^",
|
||||
"@proj-airi/i18n": "workspace:^",
|
||||
"@proj-airi/plugin-sdk": "workspace:^",
|
||||
"@proj-airi/server-runtime": "workspace:^",
|
||||
"@proj-airi/server-sdk": "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 { setElectronMainDirname } from './libs/electron/location'
|
||||
import { setupServerChannelHandlers } from './services/airi/channel-server'
|
||||
import { setupPluginHost } from './services/airi/plugins'
|
||||
import { setupAutoUpdater } from './services/electron/auto-updater'
|
||||
import { setupTray } from './tray'
|
||||
import { setupAboutWindowReusable } from './windows/about'
|
||||
@@ -73,6 +74,7 @@ app.whenReady().then(async () => {
|
||||
injeca.setLogger(createLoggLogger(useLogg('injeca').useGlobalConfig()))
|
||||
|
||||
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 widgetsManager = injeca.provide('windows:widgets', () => setupWidgetsWindowManager())
|
||||
const noticeWindow = injeca.provide('windows:notice', () => setupNoticeWindowManager())
|
||||
@@ -111,7 +113,7 @@ app.whenReady().then(async () => {
|
||||
})
|
||||
|
||||
injeca.invoke({
|
||||
dependsOn: { mainWindow, tray, serverChannel },
|
||||
dependsOn: { mainWindow, tray, serverChannel, pluginHost },
|
||||
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 { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
|
||||
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 { useTheme } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -21,7 +22,14 @@ import { toast, Toaster } from 'vue-sonner'
|
||||
|
||||
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'
|
||||
|
||||
const { isDark: dark } = useTheme()
|
||||
@@ -67,8 +75,22 @@ onMounted(async () => {
|
||||
|
||||
const context = useElectronEventaContext()
|
||||
const startTrackingCursorPoint = defineInvoke(context.value, electronStartTrackMousePosition)
|
||||
const reportPluginCapability = defineInvoke(context.value, electronPluginUpdateCapability)
|
||||
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
|
||||
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 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 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 captionGetIsFollowingWindow = defineInvokeEventa<boolean>('eventa:invoke:electron:windows:caption-overlay:get-is-following-window')
|
||||
|
||||
@@ -60,6 +66,33 @@ export interface WidgetSnapshot {
|
||||
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 widgetsAdd = defineInvokeEventa<string | undefined, WidgetsAddPayload>('eventa:invoke:electron:windows:widgets:add')
|
||||
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.
|
||||
|
||||
Runtime dependency orchestration is intentionally split into a dedicated design document to keep this architecture document focused on platform shape and planes.
|
||||
|
||||
## Goals
|
||||
|
||||
- Provide a single plugin API surface across runtimes.
|
||||
@@ -112,7 +114,7 @@ Viewers render UI and character output. Examples:
|
||||
### Plugin Lifecycle Overview
|
||||
|
||||
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.
|
||||
|
||||
```mermaid
|
||||
@@ -132,6 +134,8 @@ flowchart TD
|
||||
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 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
|
||||
|
||||
- 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.
|
||||
|
||||
## Reviews
|
||||
@@ -227,4 +232,5 @@ Active design.
|
||||
|
||||
### 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": {
|
||||
"@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 { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { FileSystemLoader } from '.'
|
||||
import { FileSystemLoader, PluginHost } from '.'
|
||||
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', () => {
|
||||
it('should load test-normal-plugin from manifest', async () => {
|
||||
@@ -18,7 +29,7 @@ describe('for FileSystemPluginHost', () => {
|
||||
entrypoints: {
|
||||
electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'),
|
||||
},
|
||||
}, { cwd: '' })
|
||||
}, { cwd: '', runtime: 'electron' })
|
||||
|
||||
const ctx = createContext()
|
||||
const apis = createApis(ctx)
|
||||
@@ -29,6 +40,22 @@ describe('for FileSystemPluginHost', () => {
|
||||
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 () => {
|
||||
const host = new FileSystemLoader()
|
||||
|
||||
@@ -39,15 +66,86 @@ describe('for FileSystemPluginHost', () => {
|
||||
entrypoints: {
|
||||
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', () => {
|
||||
it('should be able to expose setupModules', async () => {
|
||||
const host = new FileSystemLoader()
|
||||
const providersCapability = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers'
|
||||
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',
|
||||
kind: 'manifest.plugin.airi.moeru.ai',
|
||||
name: 'test-plugin',
|
||||
@@ -69,10 +167,60 @@ describe('for PluginHost', () => {
|
||||
{ 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()
|
||||
ctx.on(protocolProviders.listProviders.sendEvent, onProviderListCall)
|
||||
await expect(pluginDef.setupModules?.({ channels: { host: ctx }, apis })).resolves.not.toThrow()
|
||||
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 { createApis } from '../plugin/apis/client'
|
||||
import type { CapabilityDescriptor } from '../plugin/apis/protocol'
|
||||
import type { Plugin } from '../plugin/shared'
|
||||
import type { PluginTransport } from './transports'
|
||||
|
||||
import { join } from 'node:path'
|
||||
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):
|
||||
*
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
export class PluginHost {
|
||||
constructor() {
|
||||
const lifecycleTransitionRules: Record<PluginSessionPhase, PluginSessionPhase[]> = {
|
||||
'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 {
|
||||
apiVersion: 'v1'
|
||||
kind: 'manifest.plugin.airi.moeru.ai'
|
||||
name: string
|
||||
entrypoints: {
|
||||
default?: 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 {
|
||||
/**
|
||||
* 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() {
|
||||
|
||||
}
|
||||
|
||||
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()
|
||||
if (!manifest.entrypoints.electron) {
|
||||
const entrypoint
|
||||
= manifest.entrypoints[runtime]
|
||||
?? manifest.entrypoints.default
|
||||
?? manifest.entrypoints.electron
|
||||
|
||||
if (!entrypoint) {
|
||||
throw new Error(''
|
||||
+ 'For locally installed, defined plugin, electron entrypoint is required.'
|
||||
+ 'The value of `entrypoints.electron` should be the relative path to the '
|
||||
+ 'root of app.getPath(\'userData\').',
|
||||
+ `Plugin entrypoint is required for runtime \`${runtime}\`. `
|
||||
+ 'Define one of `entrypoints.<runtime>`, `entrypoints.default`, '
|
||||
+ 'or `entrypoints.electron` in the plugin manifest.',
|
||||
)
|
||||
}
|
||||
|
||||
const entrypoint = join(root, manifest.entrypoints.electron)
|
||||
const pluginModule = await import(entrypoint) as { default: ReturnType<typeof definePlugin> }
|
||||
return pluginModule.default
|
||||
return join(root, entrypoint)
|
||||
}
|
||||
|
||||
async loadPluginFor(manifest: ManifestV1, options?: { cwd?: string }) {
|
||||
const root = options?.cwd ?? cwd()
|
||||
if (!manifest.entrypoints.electron) {
|
||||
throw new Error(''
|
||||
+ 'For locally installed, defined plugin, electron entrypoint is required.'
|
||||
+ 'The value of `entrypoints.electron` should be the relative path to the '
|
||||
+ 'root of app.getPath(\'userData\').',
|
||||
)
|
||||
/**
|
||||
* Load a lazy plugin definition (`definePlugin(...)`) without executing setup.
|
||||
*
|
||||
* Use this when host logic wants to inspect plugin metadata/setup contract first
|
||||
* and control when `setup()` is called.
|
||||
*/
|
||||
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)
|
||||
const pluginModule = await import(entrypoint) as Plugin
|
||||
return pluginModule
|
||||
if (typeof pluginModule === 'object' && pluginModule !== null) {
|
||||
const defaultExport = (pluginModule as { default?: unknown }).default
|
||||
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 { protocolListProviders } from '../../../protocol/resources/providers'
|
||||
import { protocolCapabilityWait } from '../../../protocol/capabilities'
|
||||
import { protocolListProviders, protocolListProvidersEventName } from '../../../protocol/resources/providers'
|
||||
|
||||
export function createProviders(ctx: EventContext<any, any>) {
|
||||
return {
|
||||
listProviders() {
|
||||
async listProviders() {
|
||||
const waitForCapability = defineInvoke(ctx, protocolCapabilityWait)
|
||||
await waitForCapability({
|
||||
key: protocolListProvidersEventName,
|
||||
})
|
||||
|
||||
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'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 = {
|
||||
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':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/i18n
|
||||
'@proj-airi/plugin-sdk':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/plugin-sdk
|
||||
'@proj-airi/server-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/server-runtime
|
||||
@@ -1984,9 +1987,15 @@ importers:
|
||||
'@moeru/eventa':
|
||||
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)))
|
||||
'@proj-airi/plugin-protocol':
|
||||
specifier: workspace:*
|
||||
version: link:../plugin-protocol
|
||||
'@proj-airi/server-shared':
|
||||
specifier: workspace:*
|
||||
version: link:../server-shared
|
||||
valibot:
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.0(typescript@5.9.3)
|
||||
|
||||
packages/server-runtime:
|
||||
dependencies:
|
||||
@@ -3033,7 +3042,7 @@ importers:
|
||||
version: 14.1.0(vue@3.5.26(typescript@5.9.3))
|
||||
'@wxt-dev/module-vue':
|
||||
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:
|
||||
specifier: ^5.1.6
|
||||
version: 5.1.6
|
||||
@@ -23282,9 +23291,9 @@ snapshots:
|
||||
'@types/filesystem': 0.0.36
|
||||
'@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:
|
||||
'@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)
|
||||
transitivePeerDependencies:
|
||||
- vite
|
||||
|
||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
||||
test: {
|
||||
projects: [
|
||||
'apps/server',
|
||||
'apps/stage-tamagotchi',
|
||||
'packages/stage-ui',
|
||||
'packages/plugin-sdk',
|
||||
'packages/vite-plugin-warpdrive',
|
||||
|
||||
Reference in New Issue
Block a user