diff --git a/apps/stage-tamagotchi/package.json b/apps/stage-tamagotchi/package.json index 5c90f7855..ed11f7449 100644 --- a/apps/stage-tamagotchi/package.json +++ b/apps/stage-tamagotchi/package.json @@ -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:^", diff --git a/apps/stage-tamagotchi/src/main/index.ts b/apps/stage-tamagotchi/src/main/index.ts index 0fd2296dc..46ae73545 100644 --- a/apps/stage-tamagotchi/src/main/index.ts +++ b/apps/stage-tamagotchi/src/main/index.ts @@ -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, }) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts new file mode 100644 index 000000000..f43c66c8e --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.test.ts @@ -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> | 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 })) + }) +}) diff --git a/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts new file mode 100644 index 000000000..528e43735 --- /dev/null +++ b/apps/stage-tamagotchi/src/main/services/airi/plugins/index.ts @@ -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 }>) => void + announceCapability: (key: string, metadata?: Record) => { + key: string + state: 'announced' | 'ready' + metadata?: Record + updatedAt: number + } + markCapabilityReady: (key: string, metadata?: Record) => { + key: string + state: 'announced' | 'ready' + metadata?: Record + updatedAt: number + } +} + +interface PluginConfig { + enabled: string[] + known: Record +} + +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): Promise { + 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): 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 { + 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() + + 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 } +} diff --git a/apps/stage-tamagotchi/src/renderer/App.vue b/apps/stage-tamagotchi/src/renderer/App.vue index 8912909e9..9c6a5c60e 100644 --- a/apps/stage-tamagotchi/src/renderer/App.vue +++ b/apps/stage-tamagotchi/src/renderer/App.vue @@ -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')) }) diff --git a/apps/stage-tamagotchi/src/shared/eventa.ts b/apps/stage-tamagotchi/src/shared/eventa.ts index 2a3da64e9..f539de8bd 100644 --- a/apps/stage-tamagotchi/src/shared/eventa.ts +++ b/apps/stage-tamagotchi/src/shared/eventa.ts @@ -9,6 +9,12 @@ export const electronOpenSettingsDevtools = defineInvokeEventa('eventa:invoke:el export const electronOpenDevtoolsWindow = defineInvokeEventa('eventa:invoke:electron:windows:devtools:open') export const electronStartWebSocketServer = defineInvokeEventa('eventa:invoke:electron:start-websocket-server') export const electronRestartWebSocketServer = defineInvokeEventa('eventa:invoke:electron:restart-websocket-server') +export const electronPluginList = defineInvokeEventa('eventa:invoke:electron:plugins:list') +export const electronPluginSetEnabled = defineInvokeEventa('eventa:invoke:electron:plugins:set-enabled') +export const electronPluginLoadEnabled = defineInvokeEventa('eventa:invoke:electron:plugins:load-enabled') +export const electronPluginUpdateCapability = defineInvokeEventa('eventa:invoke:electron:plugins:capability:update') +export const pluginProtocolListProvidersEventName = 'proj-airi:plugin-sdk:apis:protocol:resources:providers:list-providers' +export const pluginProtocolListProviders = defineInvokeEventa>(pluginProtocolListProvidersEventName) export const captionIsFollowingWindowChanged = defineEventa('eventa:event:electron:windows:caption-overlay:is-following-window-changed') export const captionGetIsFollowingWindow = defineInvokeEventa('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 + 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 +} + +export interface PluginCapabilityState { + key: string + state: 'announced' | 'ready' + metadata?: Record + updatedAt: number +} + export const widgetsOpenWindow = defineInvokeEventa('eventa:invoke:electron:windows:widgets:open') export const widgetsAdd = defineInvokeEventa('eventa:invoke:electron:windows:widgets:add') export const widgetsRemove = defineInvokeEventa('eventa:invoke:electron:windows:widgets:remove') diff --git a/packages/plugin-sdk/docs/design/architecture.md b/packages/plugin-sdk/docs/design/architecture.md index 433cfc30e..d5330f9d7 100644 --- a/packages/plugin-sdk/docs/design/architecture.md +++ b/packages/plugin-sdk/docs/design/architecture.md @@ -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) diff --git a/packages/plugin-sdk/docs/design/capability-orchestration.md b/packages/plugin-sdk/docs/design/capability-orchestration.md new file mode 100644 index 000000000..e99041705 --- /dev/null +++ b/packages/plugin-sdk/docs/design/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 +} +``` + +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) diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index bc99c052b..16293e1f4 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -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" } } diff --git a/packages/plugin-sdk/src/plugin-host/core.test.ts b/packages/plugin-sdk/src/plugin-host/core.test.ts index d62ef0387..f3cc802b2 100644 --- a/packages/plugin-sdk/src/plugin-host/core.test.ts +++ b/packages/plugin-sdk/src/plugin-host/core.test.ts @@ -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 }, +) { + 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.') + }) }) diff --git a/packages/plugin-sdk/src/plugin-host/core.ts b/packages/plugin-sdk/src/plugin-host/core.ts index 5f7821a50..d728e50d1 100644 --- a/packages/plugin-sdk/src/plugin-host/core.ts +++ b/packages/plugin-sdk/src/plugin-host/core.ts @@ -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 = { + '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 { + return typeof value === 'object' + && value !== null + && 'setup' in value + && typeof (value as { setup?: unknown }).setup === 'function' +} + +async function coercePluginFromModule(moduleValue: unknown): Promise { + 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 }).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> = ProtocolModuleConfigEnvelope + +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 + 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 + } + apis: ReturnType +} + +/** + * 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() + private readonly runtime: PluginRuntime + private readonly transport: PluginTransport + private readonly protocolVersion: string + private readonly apiVersion: string + private readonly capabilities = new Map() + private readonly capabilityWaiters = new Map void>>() + private providersListResolver: () => Promise> | 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 { + // 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 { + // `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 }>) { + this.providersListResolver = resolver + this.markCapabilityReady(protocolListProvidersEventName, { source: 'plugin-host-override' }) + } + + announceCapability(key: string, metadata?: Record) { + 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) { + 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((resolve, reject) => { + let timeout: ReturnType | 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.` + * 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.`, `entrypoints.default`, ' + + 'or `entrypoints.electron` in the plugin manifest.', ) } - const entrypoint = join(root, manifest.entrypoints.electron) - const pluginModule = await import(entrypoint) as { default: ReturnType } - 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) } } diff --git a/packages/plugin-sdk/src/plugin/apis/client/resources/providers/index.ts b/packages/plugin-sdk/src/plugin/apis/client/resources/providers/index.ts index 9c2ead17a..d8e041233 100644 --- a/packages/plugin-sdk/src/plugin/apis/client/resources/providers/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/client/resources/providers/index.ts @@ -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) { return { - listProviders() { + async listProviders() { + const waitForCapability = defineInvoke(ctx, protocolCapabilityWait) + await waitForCapability({ + key: protocolListProvidersEventName, + }) + const func = defineInvoke(ctx, protocolListProviders) - return func() + return await func() }, } } diff --git a/packages/plugin-sdk/src/plugin/apis/protocol/capabilities/index.ts b/packages/plugin-sdk/src/plugin/apis/protocol/capabilities/index.ts new file mode 100644 index 000000000..5a17feb50 --- /dev/null +++ b/packages/plugin-sdk/src/plugin/apis/protocol/capabilities/index.ts @@ -0,0 +1,17 @@ +import { defineInvokeEventa } from '@moeru/eventa' + +export interface CapabilityDescriptor { + key: string + state: 'announced' | 'ready' + metadata?: Record + updatedAt: number +} + +export const protocolCapabilityWait = defineInvokeEventa('proj-airi:plugin-sdk:apis:protocol:capabilities:wait') + +export const protocolCapabilitySnapshot = defineInvokeEventa( + 'proj-airi:plugin-sdk:apis:protocol:capabilities:snapshot', +) diff --git a/packages/plugin-sdk/src/plugin/apis/protocol/index.ts b/packages/plugin-sdk/src/plugin/apis/protocol/index.ts index 3e022166d..a50f239a2 100644 --- a/packages/plugin-sdk/src/plugin/apis/protocol/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/protocol/index.ts @@ -1 +1,2 @@ +export * from './capabilities' export * from './resources' diff --git a/packages/plugin-sdk/src/plugin/apis/protocol/resources/providers/index.ts b/packages/plugin-sdk/src/plugin/apis/protocol/resources/providers/index.ts index 839f22685..6fa60eef4 100644 --- a/packages/plugin-sdk/src/plugin/apis/protocol/resources/providers/index.ts +++ b/packages/plugin-sdk/src/plugin/apis/protocol/resources/providers/index.ts @@ -1,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, diff --git a/packages/stage-ui/src/stores/plugin-host-capabilities.ts b/packages/stage-ui/src/stores/plugin-host-capabilities.ts new file mode 100644 index 000000000..0c7c032d7 --- /dev/null +++ b/packages/stage-ui/src/stores/plugin-host-capabilities.ts @@ -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() +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9be86f99..429b2f748 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/vitest.config.ts b/vitest.config.ts index 0324ab497..233efa589 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ test: { projects: [ 'apps/server', + 'apps/stage-tamagotchi', 'packages/stage-ui', 'packages/plugin-sdk', 'packages/vite-plugin-warpdrive',