feat(plugin-sdk,stage-tamagotchi): basic file system loader, protocol

This commit is contained in:
Neko Ayaka
2026-02-13 16:10:08 +08:00
parent 651d3f37f6
commit cae48f2801
18 changed files with 1606 additions and 40 deletions
+1
View File
@@ -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:^",
+3 -1
View File
@@ -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 }
}
+23 -1
View File
@@ -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)
+3 -1
View File
@@ -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.')
})
}) })
+726 -21
View File
@@ -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()
}
+12 -3
View File
@@ -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
+1
View File
@@ -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',