diff --git a/packages/plugin-sdk/docs/design/_template.md b/packages/plugin-sdk/docs/design/_template.md new file mode 100644 index 000000000..e69f9129b --- /dev/null +++ b/packages/plugin-sdk/docs/design/_template.md @@ -0,0 +1,59 @@ +# {title} + +{toc} + +## Summary + +{summary} + +## Background + +{background} + +## Goals + +{goals} + +## Non-goals + +{non-goals} + +## Proposal + +{proposal} + +## Design Detials + +{tagline of design details} + +### {optional sub-sections} + +{sub-sections} + +### {optional sub-sections} + +{sub-sections} + +## Verify & Test + +### Criteria + +{how it can be seens as completed, or could be moved to next phase} + +### Test & QA + +{how to test} + +## Progress + +{multiple sections of progress} + +## Reviews + +### Q&A + +{multiple Q&A for explaining what possible questions or challenges could have or being asked} + +### Related Documentations + +- [{title of the related documentation}]({related path to the related documentation}) diff --git a/packages/plugin-sdk/docs/design/architecture.md b/packages/plugin-sdk/docs/design/architecture.md new file mode 100644 index 000000000..70bad606c --- /dev/null +++ b/packages/plugin-sdk/docs/design/architecture.md @@ -0,0 +1,230 @@ +--- +title: AIRI Plugin Platform +description: Architecture for plugins, bridges, and multi-device orchestration +--- + +# AIRI Plugin Platform + +- [Summary](#summary) +- [Background](#background) +- [Goals](#goals) +- [Non-goals](#non-goals) +- [Proposal](#proposal) +- [Design Detials](#design-detials) +- [Control And Data Planes](#control-and-data-planes) +- [Plugin Host And Viewers](#plugin-host-and-viewers) +- [Plugin Lifecycle Overview](#plugin-lifecycle-overview) +- [Bridges And Remote Plugins](#bridges-and-remote-plugins) +- [Transport Abstraction](#transport-abstraction) +- [Capability Model](#capability-model) +- [Deployment Modes](#deployment-modes) +- [Manifest And Entrypoints](#manifest-and-entrypoints) +- [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 + +AIRI is a multi-node system where plugins, bridges, and viewers communicate over Eventa transports. A Plugin Host loads plugins, provides a single API surface, and routes control and data across devices. The platform separates the control plane from the data plane, supports local and remote plugins, and enables multi-device orchestration without changing plugin APIs. + +## Background + +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. + +## Goals + +- Provide a single plugin API surface across runtimes. +- Separate lifecycle and configuration traffic from high-rate streams. +- Support local plugins and remote plugins with the same protocol. +- Allow multiple viewers and bridges to coordinate through a shared control plane. +- Keep deployment flexible: embedded, external, or remote Plugin Host. + +## Non-goals + +- Defining the full plugin lifecycle state machine in this document. +- Specifying detailed UI layouts or viewer implementations. +- Implementing a new transport beyond Eventa adapters. + +## Proposal + +- Use Eventa for all control and data traffic. +- Use a dedicated control plane for configuration, permissions, UI, and routing. +- Use a data plane for high-volume streams like audio, vision, and telemetry. +- Run plugins inside a Plugin Host that loads plugin entrypoints and exposes the SDK. +- Treat bridges as device-specific integrations that only provide data and actions. + +## Design Detials + +The platform design focuses on consistent APIs, transport-agnostic integration, and multi-device orchestration. + +### Control And Data Planes + +Control plane purpose: lifecycle, configuration, routing policy, permissions, and UI contributions. + +Typical control messages: + +- control:hello +- control:announce +- control:plugin:register +- control:plugin:config:get +- control:plugin:config:set +- control:capability:grant +- control:capability:revoke +- control:ui:register + +Data plane purpose: real-time and high-throughput streams. + +Typical data messages: + +- data:context:update +- data:vision:frame +- data:audio:stream +- data:transcript +- data:character:output + +Both planes use Eventa messages. Transport options: + +- Two WebSocket endpoints +- One multiplexed connection with namespaces + +### Plugin Host And Viewers + +The Plugin Host is a Node process that: + +- Loads plugin entrypoints. +- Exposes the AIRI SDK. +- Registers UI contributions. +- Negotiates capabilities. +- Connects to control and data planes. + +Viewers render UI and character output. Examples: + +- Electron Stage with Configurator features. +- Web Configurator client. +- Pocket Stage client. + +### 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 +announcement, configuration, and capability phases. + +```mermaid +flowchart TD + A[Connect to control plane] --> B[Authenticate] + B --> C[Host sends registry:modules:sync] + C --> D[Module emits module:announce] + D --> E[Module declares deps + initial config] + E --> F{Dependencies resolved?} + F -- no --> G[module:status emitted] + F -- yes --> H[module:prepared] + H --> I[module:configuration:needed] + I --> J[validate/plan/commit config] + J --> K[module:configuration:configured] + K --> L[Offer capabilities] + L --> M[Capability configuration phase] + M --> N[module:status ready] +``` + +### Bridges And Remote Plugins + +Bridges connect external devices and services to AIRI. They do not own UI; they only provide data and actions. Examples: + +- VS Code extension for editor context and commands. +- Browser extension for page context. +- Minecraft service for game events and commands. + +Remote plugins are services in any language that connect over Eventa and register capabilities. They are preferred for server integrations and non-JS/TS stacks. + +### Transport Abstraction + +All SDK calls are transport-agnostic. The host controls whether communication is local IPC or remote RPC without changing plugin APIs. + +### Capability Model + +Each node announces capabilities on registration. The control plane grants or denies permissions and routes requests based on policy. + +Example capabilities: + +- context.read +- context.write +- ui.panel +- ui.widget +- vision.capture +- vision.stream +- device.mobile.sensors + +### Deployment Modes + +The Plugin Host can run in three modes: + +1. Embedded in Electron main for install-and-go. +2. External Node process for hot reload and isolation. +3. Remote server for cross-device continuity. + +### Manifest And Entrypoints + +Plugins declare metadata in a manifest file and provide runtime entrypoints. + +Example: + +```json +{ + "id": "airi.vscode", + "name": "AIRI VS Code", + "version": "1.0.0", + "capabilities": ["context.read", "ui.panel", "commands"], + "entrypoints": { + "node": "./dist/node/index.js" + } +} +``` + +## Verify & Test + +### Criteria + +- Plugins can be loaded by the host and register UI and capabilities. +- Bridges can connect and be discovered by the control plane. +- Data plane streams stay isolated from control plane traffic. +- The same plugin API surface works across desktop, web, and mobile. + +### Test & QA + +- Integration test: host + viewer + bridge with control plane routing. +- Integration test: data plane streaming with a high-rate source. +- Compatibility test: same plugin entrypoints across multiple runtimes. + +## Progress + +### Status + +Active design. + +### Next Steps + +- Align runtime docs with updated plugin context and transport strategy. +- Expand remote plugin examples by language. + +## Reviews + +### Q&A + +- Q: Why split control and data planes? + A: Lifecycle traffic and high-rate streams have different reliability and QoS needs. + +- Q: Do bridges render UI? + A: No. UI is contributed to viewers through the control plane. + +- Q: Can remote plugins be written without JS or npm? + A: Yes. They only need to speak the Eventa protocol over WebSocket. + +### Related Documentations + +- [Multi-Transport Plugin Contexts](../../../../packages/plugin-sdk/docs/design/multi-transport.md) diff --git a/packages/plugin-sdk/docs/design/multi-transport.md b/packages/plugin-sdk/docs/design/multi-transport.md new file mode 100644 index 000000000..87f200aeb --- /dev/null +++ b/packages/plugin-sdk/docs/design/multi-transport.md @@ -0,0 +1,176 @@ +# Multi-Transport Plugin Contexts + +- [Summary](#summary) +- [Background](#background) +- [Goals](#goals) +- [Non-goals](#non-goals) +- [Proposal](#proposal) +- [Design Detials](#design-detials) + - [Context And Transport Model](#context-and-transport-model) + - [Lifecycle Placement](#lifecycle-placement) + - [Host Runtime Layout](#host-runtime-layout) + - [API Binding Strategy](#api-binding-strategy) + - [Local Vs Remote Plugins](#local-vs-remote-plugins) + - [Multi-Plugin Isolation](#multi-plugin-isolation) +- [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 + +Introduce a host-side transport-aware context factory that provides one Eventa context per plugin instance. Plugin SDK APIs become context-bound factories, allowing local (in-memory/worker) and remote (WebSocket) plugins to share the same API surface while using different transports. This enables multiple plugins within a single Plugin Host without cross-talk or global channel coupling. + +## Background + +`plugin-sdk` currently exposes APIs (for example `providers.listProviders`) that call `defineInvoke` on a globally imported channel. This couples plugins to a single shared context and prevents the host from isolating multiple plugins or using different transports per plugin. We also need a path to support local plugins (in-process or worker) and remote plugins (WebSocket) with consistent ergonomics. + +Eventa is context-oriented: contexts are created per transport (in-memory, WebSocket, worker, electron) and the invoke/handler APIs attach to that context. Multiple contexts can co-exist in the same process. + +## Goals + +- Provide one context per plugin instance, scoped by transport. +- Allow the same API surface to work for local and remote plugins. +- Keep transport selection under Plugin Host control, not plugin control. +- Support multiple plugins within one host without channel conflicts. +- Keep the API ergonomics for plugin authors simple and explicit. + +## Non-goals + +- Designing the full plugin lifecycle orchestration (phase transitions, capability config, etc.). +- Implementing a new transport stack beyond Eventa adapters (unless required by runtime gaps). +- Defining plugin packaging or distribution formats beyond `ManifestV1` entrypoints. + +## Proposal + +1. Introduce a host-side `createPluginContext(transport)` factory that returns an Eventa context bound to the plugin's transport. +2. Convert plugin SDK APIs to context-bound factories (`createApis(ctx)`), replacing global channel usage. +3. Resolve transport per plugin instance during host setup and pass the created context into plugin `init()`. +4. Add runtime-specific implementations under `plugin-host/runtimes/node` and `plugin-host/runtimes/web` to handle different transport adapters. +5. Optional: introduce shared reliable WebSocket helpers if needed, but prefer Eventa adapters first. + +## Design Detials + +Transport-aware contexts for isolated multi-plugin hosts. + +### Context And Transport Model + +Define a small transport config type owned by the Plugin Host: + +```ts +export type PluginTransport + = | { kind: 'in-memory' } + | { kind: 'websocket', url: string, protocols?: string[] } + | { kind: 'web-worker', worker: Worker } + | { kind: 'node-worker', worker: import('node:worker_threads').Worker } + | { kind: 'electron', target: 'main' | 'renderer', webContentsId?: number } +``` + +`createPluginContext(transport)` creates and returns an Eventa context based on the transport adapter (in-memory, WebSocket, worker, electron). + +### Lifecycle Placement + +Context creation happens during host setup, before any plugin lifecycle method is called. + +1. Load plugin module (FileSystemLoader / UrlLoader). +2. Resolve transport for the plugin (manifest + host config). +3. Create context via `createPluginContext(transport)`. +4. Bind APIs with `createApis(ctx)`. +5. Call `plugin.init({ host: ctx, apis })`. + +### Host Runtime Layout + +- `packages/plugin-sdk/src/plugin-host/transports/`: + - transport type definitions and helpers +- `packages/plugin-sdk/src/plugin-host/runtimes/node/`: + - in-memory, node-worker, websocket implementations +- `packages/plugin-sdk/src/plugin-host/runtimes/web/`: + - web-worker, websocket implementations +- `packages/plugin-sdk/src/plugin-host/index.ts`: + - exports the runtime-appropriate `createPluginContext` via conditional exports + +### API Binding Strategy + +Replace direct channel usage with context-bound factories: + +```ts +export function createProviders(ctx: EventaContext) { + return { + listProviders() { + return defineInvoke(ctx, protocolListProviders)() + }, + } +} + +export function createApis(ctx: EventaContext) { + return { providers: createProviders(ctx) } +} +``` + +Plugins call `createApis(ctx)` provided by the host instead of importing global singletons. + +### Local Vs Remote Plugins + +- Local plugins: + - `in-memory` for simplest case + - `node-worker` or `web-worker` for isolation +- Remote plugins: + - `websocket` transport bound to a specific URL or connection + +Transport selection is a host concern; plugins are transport-agnostic. + +### Multi-Plugin Isolation + +Each plugin has its own context and transport. APIs are bound to that context, preventing cross-talk. The host keeps a registry mapping plugin ID to its context, transport, and loaded module for lifecycle management. + +## Verify & Test + +### Criteria + +- Multiple plugins can be loaded in one host without shared global channels. +- Local plugin calls use in-memory or worker contexts without manual wiring in plugin code. +- Remote plugin calls use WebSocket contexts and do not affect local plugins. +- Existing plugin tests can be updated to pass by injecting a context into APIs. + +### Test & QA + +- Unit test: create two plugin contexts in the same process, verify isolated invoke/handler pairs. +- Unit test: FileSystemLoader + in-memory context binds correctly to `createApis(ctx)`. +- Integration test (optional): WebSocket adapter roundtrip using a stub server. + +## Progress + +### Status + +Planned. + +### Next Steps + +- Implement `createApis(ctx)` and migrate current API modules. +- Implement `createPluginContext` in node runtime (in-memory + websocket). +- Update tests to construct APIs with a provided context. + +## Reviews + +### Q&A + +- Q: Why not keep global channels and just switch the active channel? + A: Global channels make multi-plugin isolation impossible and require global state mutation. Context-per-plugin avoids cross-talk and matches Eventa's design. + +- Q: Do plugins need to know about transports? + A: No. The host injects the context and APIs; plugins remain transport-agnostic. + +- Q: Should we build a shared reliable WebSocket package? + A: Only if we need custom reconnection/heartbeat logic across multiple packages. Start with Eventa adapters; factor out shared logic later if required. + +- Q: Can workers be used for plugin isolation? + A: Yes. Use Eventa web-worker or node-worker adapters to bridge a per-plugin context to the worker. + +### Related Documentations + +- [Plugin Lifecycle](./plugin-lifecycle.md) diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index c765319d8..b73cc1490 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -33,6 +33,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@moeru/eventa": "catalog:" + "@moeru/eventa": "catalog:", + "@proj-airi/server-shared": "workspace:*" } } diff --git a/packages/plugin-sdk/src/channels/index.ts b/packages/plugin-sdk/src/channels/index.ts new file mode 100644 index 000000000..efdcccdfe --- /dev/null +++ b/packages/plugin-sdk/src/channels/index.ts @@ -0,0 +1,30 @@ +import type { EventContext } from '@moeru/eventa' + +import { createContext } from '@moeru/eventa' + +export const channels = { + /** + * Channel for talking to Plugin Host. + * Can be seen as Control plane. + * + * createContext() here is for fallback internal channel preventing undefined access. + * In real usage, either local/* or remote/* channel implementation should be set as active channel. + */ + host: createContext(), + /** + * Channel for initialized plugin to transmit events to each other, includes plugins, and stage, configurator, etc. + * Can be seen as Data plane. + * + * createContext() here is for fallback internal channel preventing undefined access. + * In real usage, either local/* or remote/* channel implementation should be set as active channel. + */ + data: createContext(), +} + +export function setActiveHostChannel(context: EventContext) { + channels.host = context +} + +export function setActiveDataChannel(context: EventContext) { + channels.data = context +} diff --git a/packages/plugin-sdk/src/channels/local/event-target/index.ts b/packages/plugin-sdk/src/channels/local/event-target/index.ts new file mode 100644 index 000000000..bce68f23b --- /dev/null +++ b/packages/plugin-sdk/src/channels/local/event-target/index.ts @@ -0,0 +1,11 @@ +import { createContext } from '@moeru/eventa/adapters/event-target' + +export function createEventTargetHostChannel(eventTarget: EventTarget) { + // TODO: implement actual event target based host channel + return createContext(eventTarget) +} + +export function createEventTargetDataChannel(eventTarget: EventTarget) { + // TODO: implement actual event target based data channel + return createContext(eventTarget) +} diff --git a/packages/plugin-sdk/src/channels/remote/websocket/index.ts b/packages/plugin-sdk/src/channels/remote/websocket/index.ts new file mode 100644 index 000000000..8ff6981d1 --- /dev/null +++ b/packages/plugin-sdk/src/channels/remote/websocket/index.ts @@ -0,0 +1,11 @@ +import { createContext } from '@moeru/eventa/adapters/websocket/native' + +export function createWebSocketHostChannel(webSocket: WebSocket) { + // TODO: make sure to setup proper event handling on the webSocket + return createContext(webSocket) +} + +export function createWebSocketDataChannel(webSocket: WebSocket) { + // TODO: make sure to setup proper event handling on the webSocket + return createContext(webSocket) +} diff --git a/packages/plugin-sdk/src/channels/shared.ts b/packages/plugin-sdk/src/channels/shared.ts new file mode 100644 index 000000000..ea1e8d969 --- /dev/null +++ b/packages/plugin-sdk/src/channels/shared.ts @@ -0,0 +1,3 @@ +import type { createContext } from '@moeru/eventa' + +export type ChannelControlPlane = ReturnType diff --git a/packages/plugin-sdk/src/plugin-host/index.test.ts b/packages/plugin-sdk/src/plugin-host/index.test.ts new file mode 100644 index 000000000..d0b7c3486 --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/index.test.ts @@ -0,0 +1,76 @@ +import { join } from 'node:path' + +import { createContext, defineEventa, defineInvokeHandler } from '@moeru/eventa' +import { describe, expect, it, vi } from 'vitest' + +import { FileSystemLoader } from '.' +import { channels } from '../channels' +import { protocolProviders } from '../plugin/apis/protocol' + +describe('for FileSystemPluginHost', () => { + it('should load test-normal-plugin from manifest', async () => { + const host = new FileSystemLoader() + + const pluginDef = await host.loadPluginFor({ + apiVersion: 'v1', + kind: 'manifest.plugin.airi.moeru.ai', + name: 'test-plugin', + entrypoints: { + electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'), + }, + }, { cwd: '' }) + + const ctx = createContext() + const onVitestCall = vi.fn() + ctx.on(defineEventa('vitest-call:init'), onVitestCall) + + await expect(pluginDef.init({ host: ctx })).resolves.not.toThrow() + expect(onVitestCall).toHaveBeenCalledTimes(1) + }) + + it('should be able to handle test-error-plugin from manifest', async () => { + const host = new FileSystemLoader() + + await expect(host.loadPluginFor({ + apiVersion: 'v1', + kind: 'manifest.plugin.airi.moeru.ai', + name: 'test-plugin', + entrypoints: { + electron: join(import.meta.dirname, 'testdata', 'test-error-plugin.ts'), + }, + }, { cwd: '' })).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 pluginDef = await host.loadPluginFor({ + apiVersion: 'v1', + kind: 'manifest.plugin.airi.moeru.ai', + name: 'test-plugin', + entrypoints: { + electron: join(import.meta.dirname, 'testdata', 'test-normal-plugin.ts'), + }, + }, { cwd: '' }) + + const ctx = createContext() + const onVitestCall = vi.fn() + ctx.on(defineEventa('vitest-call:init'), onVitestCall) + + await expect(pluginDef.init({ host: ctx })).resolves.not.toThrow() + expect(onVitestCall).toHaveBeenCalledTimes(1) + + defineInvokeHandler(channels.data, protocolProviders.listProviders, async () => { + return [ + { name: 'provider1' }, + ] + }) + + const onProviderListCall = vi.fn() + ctx.on(protocolProviders.listProviders.sendEvent, onProviderListCall) + await expect(pluginDef.setupModules?.()).resolves.not.toThrow() + expect(onProviderListCall).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/plugin-sdk/src/plugin-host/index.ts b/packages/plugin-sdk/src/plugin-host/index.ts new file mode 100644 index 000000000..5f6781afd --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/index.ts @@ -0,0 +1,158 @@ +import type { definePlugin } from '../plugin' +import type { Plugin } from '../plugin/shared' + +import { join } from 'node:path' +import { cwd } from 'node:process' + +/** + * Plugin Host lifecycle overview (transport-aware): + * + * - The host loads a plugin entrypoint (local or remote). + * - The host resolves a per-plugin transport (in-memory, worker, WebSocket, electron). + * - The host creates an Eventa context bound to that transport. + * - The host binds SDK APIs to the context and passes them into plugin.init. + * + * This design allows multiple plugins in one host without shared global channels. + * Each plugin instance has its own context and transport, so local and remote + * plugins share the same API surface while remaining isolated. + */ +/** + * One plugin could contribute multiple modules. + * + * For plugin itself, there are two ways to implement it, either local plugin, or remote plugin. + * Since we have @moeru/eventa as underlying event transmission, we can drive everything in event. + * + * It's ok that local plugin doesn't implement the remote protocol to handle the remote plugin + * RPC if doesn't wish for. Purely local UI manipulation or local resource registration is normal. + * + * In another word, we could implement the plugin in same eventa definition, while switching + * between two different transport. + * + * For local plugin, local context for in-memory transport will be used. + * For remote plugin, server-runtime for WebSocket based transport will be used. + * + * + * The procedure looks like this (regardless to the underlying transport since we will implement + * in both): + * + * 0. Channel Gateway sits on top of all channels + * 1. Connect to control plane channel (from plugin-sdk, or any language implementation will impl) + * 2. Authenticate with module:authenticate + * 3. Plugin Host will send registry:modules:sync, this ensures the auto plugin / dependency discovery + * 4. Module will now announce itself to the entire system through module:announce + * 5. Module will now sync to Plugin Host that module now preparing, declaring its: + * 1. Dependencies to other plugins / modules + * 2. Initial Configuration (doesn't relate to capabilities) + * Note that for capabilities requires Database configuration, and perhaps Memory manipulation, + * plugin should orchestrate itself to contribute many capabilities / features, and the needed + * configurations and credentials should be requested and configured for each capabilities + * instead. + * 6. During this phase, if module failed to find the needed dependency, module:status will be emitted + * to allow the Plugin Host to surface errors or notice up to Configurator layer, to display the + * needed warning and status. + * + * It's ok for module to stay online / connected to channels. In this phase, module:announce + * could happen multiple times. Module is ok to listen to the sync events and decide whether to enter + * the next phases if needed. + * 7. During this phase, if plugin successfully configured itself and calculated / computed the possible + * contributing capabilities / features, it will emit module:prepared. + * 8. During this phase, if module requires more configuration to fill and enable in order to go next + * phase, it's ok, it will emit module:configuration:needed. + * 8. Module should now emit module:prepared. + * 9. Module should now emit module:configuration:needed, for telling the shape to Configurator. + * In between, for user side / Configurator side: + * - module:configuration:validate:request (static check, zod/valibot or programmatic checks) + * - module:configuration:validate:status (with parent event id) + * - module:configuration:validate:response + * - module:configuration:plan:request (actually dry-run, ensures anything during runtime works) + * - module:configuration:plan:status (with parent event id) + * - module:configuration:plan:response + * - module:configuration:commit + * - module:configuration:commit:status (with parent event id) + * 9. Module previously configured will get validate, plan, and commit automatically, if failed, status + * will surface to the Configurator side for further noticing to user. + * 10. Module should now emit module:configuration:configured. + * 11. Module should now be able to calculate / compute possible capabilities / features to be able to + * contribute to the system / Plugin Host, once calculated, module:contribute:capability:offer will + * be emitted in (length of) capabilities times. + * + * This means for 1 module that offers 5 capabilities, 5 * module:contribute:capability:offer will + * be emitted. + * 12. Next, module will now enter the capability / feature fill-in phase, during this phase, it's ok + * to say that the plugin is running but nothing gets contributed if none of them were configured. + * + * For any capabilities without further configuration and fill-in from Configurator and User side, + * it can be automatically activated now (which is next phase for module:contribute:capability:* + * events), module:contribute:capability:configuration:configured, + * module:contribute:capability:activated will be emitted. + * + * If further configuration and actions needed, module:contribute:capability:configuration:needed + * will be emitted. + * + * To configure the capabilities in sequence and correct order, + * - module:contribute:capability:configuration:validate:request (static check, zod/valibot or programmatic checks) + * - module:contribute:capability:configuration:validate:status (with parent event id) + * - module:contribute:capability:configuration:validate:response + * - module:contribute:capability:configuration:plan:request (actually dry-run, ensures anything during runtime works) + * - module:contribute:capability:configuration:plan:status (with parent event id) + * - module:contribute:capability:configuration:plan:response + * - module:contribute:capability:configuration:commit + * - module:contribute:capability:configuration:commit:status (with parent event id) + * similar to module:configuration are accepted. + * + * 13. No matter what happens, the module:status should emit with ready status now. + * 14. Any time the module need to re-calculate / re-compute, or wish to be re-configured, it's ok to + * emit module:status:change with needed phase to update, if need to rollback to announced phase, + * Plugin Host should treat the Module to be un-prepared status, the needed procedure will be called. + */ + +export class PluginHost { + constructor() { + + } +} + +export interface ManifestV1 { + apiVersion: 'v1' + kind: 'manifest.plugin.airi.moeru.ai' + name: string + entrypoints: { + electron?: string + } +} + +export class FileSystemLoader { + constructor() { + + } + + async loadLazyPluginFor(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\').', + ) + } + + const entrypoint = join(root, manifest.entrypoints.electron) + const pluginModule = await import(entrypoint) as { default: ReturnType } + return pluginModule.default + } + + 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\').', + ) + } + + const entrypoint = join(root, manifest.entrypoints.electron) + const pluginModule = await import(entrypoint) as Plugin + return pluginModule + } +} diff --git a/packages/plugin-sdk/src/plugin-host/shared.ts b/packages/plugin-sdk/src/plugin-host/shared.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugin-sdk/src/plugin-host/testdata/test-error-plugin.ts b/packages/plugin-sdk/src/plugin-host/testdata/test-error-plugin.ts new file mode 100644 index 000000000..e03ef1dbc --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/testdata/test-error-plugin.ts @@ -0,0 +1 @@ +throw new Error('Test error plugin always throws an error during loading.') diff --git a/packages/plugin-sdk/src/plugin-host/testdata/test-no-connect-plugin.ts b/packages/plugin-sdk/src/plugin-host/testdata/test-no-connect-plugin.ts new file mode 100644 index 000000000..24d2f862c --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/testdata/test-no-connect-plugin.ts @@ -0,0 +1,5 @@ +import type { ContextInit } from '../../apis/plugin/shared' + +export async function init(_initContext: ContextInit) { + return false +} diff --git a/packages/plugin-sdk/src/plugin-host/testdata/test-normal-plugin.ts b/packages/plugin-sdk/src/plugin-host/testdata/test-normal-plugin.ts new file mode 100644 index 000000000..d8e8d311d --- /dev/null +++ b/packages/plugin-sdk/src/plugin-host/testdata/test-normal-plugin.ts @@ -0,0 +1,17 @@ +import type { ContextInit } from '../../plugin/shared' + +import { defineEventa } from '@moeru/eventa' + +import { channels, providers } from '../../plugin' + +export async function init(initContext: ContextInit): Promise { + initContext.host.emit(defineEventa('vitest-call:init'), undefined) +} + +export async function configure(): Promise { + +} + +export async function setupModules(): Promise { + channels.host.emit(defineEventa('vitest-call:setup-modules'), await providers.listProviders()) +} diff --git a/packages/plugin-sdk/src/plugin/apis/client/index.ts b/packages/plugin-sdk/src/plugin/apis/client/index.ts new file mode 100644 index 000000000..8e6165b61 --- /dev/null +++ b/packages/plugin-sdk/src/plugin/apis/client/index.ts @@ -0,0 +1,2 @@ +export { channels } from '../../../channels' +export * from './resources' diff --git a/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts b/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts new file mode 100644 index 000000000..f5e7d6479 --- /dev/null +++ b/packages/plugin-sdk/src/plugin/apis/client/resources/index.ts @@ -0,0 +1 @@ +export { providers } from './providers' 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 new file mode 100644 index 000000000..6588a1a76 --- /dev/null +++ b/packages/plugin-sdk/src/plugin/apis/client/resources/providers/index.ts @@ -0,0 +1,13 @@ +import { defineInvoke } from '@moeru/eventa' + +import { channels } from '../../../../../channels' +import { protocolListProviders } from '../../../protocol/resources/providers' + +export async function listProviders() { + const func = defineInvoke(channels.data, protocolListProviders) + return func() +} + +export const providers = { + listProviders, +} diff --git a/packages/plugin-sdk/src/plugin/apis/index.ts b/packages/plugin-sdk/src/plugin/apis/index.ts new file mode 100644 index 000000000..f11ab758c --- /dev/null +++ b/packages/plugin-sdk/src/plugin/apis/index.ts @@ -0,0 +1,2 @@ +export * from './client' +export * from './protocol' diff --git a/packages/plugin-sdk/src/plugin/apis/protocol/index.ts b/packages/plugin-sdk/src/plugin/apis/protocol/index.ts new file mode 100644 index 000000000..3e022166d --- /dev/null +++ b/packages/plugin-sdk/src/plugin/apis/protocol/index.ts @@ -0,0 +1 @@ +export * from './resources' diff --git a/packages/plugin-sdk/src/plugin/apis/protocol/resources/index.ts b/packages/plugin-sdk/src/plugin/apis/protocol/resources/index.ts new file mode 100644 index 000000000..1c0607ac9 --- /dev/null +++ b/packages/plugin-sdk/src/plugin/apis/protocol/resources/index.ts @@ -0,0 +1 @@ +export { protocolProviders } from './providers' 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 new file mode 100644 index 000000000..839f22685 --- /dev/null +++ b/packages/plugin-sdk/src/plugin/apis/protocol/resources/providers/index.ts @@ -0,0 +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 protocolProviders = { + listProviders: protocolListProviders, +} diff --git a/packages/plugin-sdk/src/plugin/define.ts b/packages/plugin-sdk/src/plugin/define.ts new file mode 100644 index 000000000..96b91cbfd --- /dev/null +++ b/packages/plugin-sdk/src/plugin/define.ts @@ -0,0 +1,13 @@ +import type { Plugin } from './shared' + +export function definePlugin(name: string, version: string, setup: () => Promise | Plugin): { + name: string + version: string + setup: () => Promise | Plugin +} { + return { + name, + version, + setup, + } +} diff --git a/packages/plugin-sdk/src/plugin/index.ts b/packages/plugin-sdk/src/plugin/index.ts new file mode 100644 index 000000000..55e675d85 --- /dev/null +++ b/packages/plugin-sdk/src/plugin/index.ts @@ -0,0 +1,2 @@ +export * from './apis' +export * from './define' diff --git a/packages/plugin-sdk/src/plugin/local.ts b/packages/plugin-sdk/src/plugin/local.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugin-sdk/src/plugin/local/index.ts b/packages/plugin-sdk/src/plugin/local/index.ts new file mode 100644 index 000000000..1245e6aa8 --- /dev/null +++ b/packages/plugin-sdk/src/plugin/local/index.ts @@ -0,0 +1,9 @@ +/** + * Setup the local plugin scope. + * + * TODO: now sure how this should be implemented, but perhaps it should call packages/plugin-sdk/src/channels to setup local channels + * first? Then probably some other initialization steps. + */ +export async function setupLocalPluginScope() { + +} diff --git a/packages/plugin-sdk/src/plugin/remote.ts b/packages/plugin-sdk/src/plugin/remote.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/plugin-sdk/src/plugin/remote/index.ts b/packages/plugin-sdk/src/plugin/remote/index.ts new file mode 100644 index 000000000..e05f0677d --- /dev/null +++ b/packages/plugin-sdk/src/plugin/remote/index.ts @@ -0,0 +1,9 @@ +/** + * Setup the remote plugin scope. + * + * TODO: now sure how this should be implemented, but perhaps it should call packages/plugin-sdk/src/channels to setup remote channels + * first? Then probably some other initialization steps. + */ +export async function setupRemotePluginScope() { + +} diff --git a/packages/plugin-sdk/src/plugin/shared.ts b/packages/plugin-sdk/src/plugin/shared.ts new file mode 100644 index 000000000..544e727e7 --- /dev/null +++ b/packages/plugin-sdk/src/plugin/shared.ts @@ -0,0 +1,16 @@ +import type { ChannelControlPlane } from '../channels/shared' + +export interface ContextInit { + host: ChannelControlPlane +} + +export interface Plugin { + /** + * + */ + init?: (initContext: ContextInit) => Promise + /** + * + */ + setupModules?: () => Promise +} diff --git a/packages/server-sdk/src/client.ts b/packages/server-sdk/src/client.ts index 31fa08c96..9fa68da87 100644 --- a/packages/server-sdk/src/client.ts +++ b/packages/server-sdk/src/client.ts @@ -1,5 +1,7 @@ import type { MetadataEventSource, + ModuleConfigSchema, + ModuleDependency, WebSocketBaseEvent, WebSocketEvent, WebSocketEventOptionalSource, @@ -21,6 +23,8 @@ export interface ClientOptions { possibleEvents?: Array> token?: string identity?: MetadataEventSource + dependencies?: ModuleDependency[] + configSchema?: ModuleConfigSchema heartbeat?: { readTimeout?: number message?: MessageHeartbeat | string @@ -60,8 +64,9 @@ export class Client { constructor(options: ClientOptions) { const identity = options.identity ?? { - plugin: options.name, - instanceId: createInstanceId(), + kind: 'plugin', + plugin: { id: options.name }, + id: createInstanceId(), } this.opts = { @@ -233,6 +238,8 @@ export class Client { name: this.opts.name, identity: this.identity, possibleEvents: this.opts.possibleEvents, + dependencies: this.opts.dependencies, + configSchema: this.opts.configSchema, }, }) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4dd7157b..82bde980b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1975,6 +1975,9 @@ importers: '@moeru/eventa': specifier: 'catalog:' version: 1.0.0-alpha.14(electron@40.0.0) + '@proj-airi/server-shared': + specifier: workspace:* + version: link:../server-shared packages/server-runtime: dependencies: