mirror of
https://github.com/moeru-ai/airi.git
synced 2026-10-30 19:34:10 +08:00
feat(plugin-host): add activation planner (#2572)
## Summary - Add a pure, deterministic Activation Planner for Extension lifecycle transitions. - Resolve static Host and Extension Kit declarations before runtime changes. - Generate Provider-first load order and Consumer-first unload order. - Use one Planner path for startup, enablement, raw lifecycle operations, reload, and shutdown. - Keep runtime and asset cleanup ownership until each cleanup step succeeds. - Preserve the original cleanup errors during shutdown. - Add installable Provider and Consumer examples for the Extension Host Inspector. ## Scope This PR implements Phase 2 of the plugin-system roadmap. The Planner treats `kits.provides` as a static declaration. The example Provider does not register a runtime Kit, and the example Consumer does not resolve or call one. Extension-hosted Kit registration and cross-Extension Kit routing remain Phase 3 work. Part of #2507. ## Examples - `activation-planner-provider` declares `dev.airi.example-activation@1.0.0`. - `activation-planner-consumer` requires `dev.airi.example-activation@^1.0.0`. - Both folders can be imported from `/devtools/plugin-host`. - The Host integration test installs the checked-in example files and verifies Provider-first load and Consumer-first stop. ## Tests - `vitest run src/plugin-host/activation-plan.test.ts` - 26 tests passed. - `vitest run src/main/services/airi/plugins/index.test.ts src/main/services/airi/plugins/features/static-assets/index.test.ts` - 71 tests passed. - `tsc --noEmit` in `packages/plugin-sdk`. - `sem diff --staged --no-cosmetics -v --file-exts .ts .tsx`. - `git diff --check`. - Both example manifests parse as JSON. ## Validation notes - `pnpm typecheck` and `pnpm lint` did not start because pnpm rejected the shared worktree `node_modules` link with `ERR_PNPM_UNSAFE_MODULES_DIR`. - The direct Plugin SDK typecheck passed. - The direct Stage Vue typecheck remains blocked by existing workspace declaration and dependency errors. No new error points to the modified plugin Host test. - ESLint did not start because `eslint-plugin-slop` is missing from the shared dependency tree. - The final Standards and Spec reviews for the example commit reported no findings. - GitHub CI passed Unit Test, Lint, Type Check, all build checks, provenance, preview preparation, and autofix. ## Visual changes Not applicable. This PR does not change user-visible UI. ## Runtime evidence The checked-in Provider and Consumer examples were copied into an isolated `APP_USER_DATA_PATH` and run in the built stage-tamagotchi Electron app through the Plugin Host Inspector. - The Host discovered two manifests. - The Provider loaded before the Consumer in the Extension Host log. - After a refresh, both examples were enabled, loaded, and in the `ready` phase. - The isolated profile linked `@proj-airi/plugin-sdk` to the built workspace package because these are source examples. The example source files did not change. - The screenshot proves the final runtime state. Provider-first and Consumer-first lifecycle ordering remain covered by the integration test.  --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
parent
d16a278942
commit
29cf656f41
@@ -0,0 +1,10 @@
|
||||
# Extension Host Examples
|
||||
|
||||
Use these folders with the **Extension Host Inspector** at `/devtools/plugin-host`.
|
||||
|
||||
- `devtools-sample-plugin` verifies the basic Extension lifecycle and permissions.
|
||||
- `activation-planner-provider` and `activation-planner-consumer` verify dependency planning.
|
||||
|
||||
Import both Activation Planner examples. Enable the Provider first. Then enable the Consumer. The Host loads the Provider before the Consumer and stops the Consumer before the Provider.
|
||||
|
||||
The Provider only declares a Kit in its manifest. It does not register a runtime Kit implementation. Runtime Kit registration is outside the Phase 2 boundary.
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Activation Planner Consumer
|
||||
|
||||
This example requires `dev.airi.example-activation` with the version range `^1.0.0`.
|
||||
|
||||
Use it with the `activation-planner-provider` example:
|
||||
|
||||
1. Open `/devtools/plugin-host` in Stage Tamagotchi.
|
||||
2. Import the Provider folder next to this folder.
|
||||
3. Import this folder.
|
||||
4. Enable the Provider.
|
||||
5. Enable this Consumer.
|
||||
6. Load the enabled Extensions.
|
||||
|
||||
The Host loads the Provider before this Consumer. When the system stops, the Host stops this Consumer before the Provider.
|
||||
|
||||
This example does not call a runtime Kit. Its `kits.uses` entry only creates a static Phase 2 dependency. Runtime Kit lookup belongs to Phase 3.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { defineExtension } from '@proj-airi/plugin-sdk'
|
||||
|
||||
/**
|
||||
* Requires the example Provider through a static Kit declaration.
|
||||
*
|
||||
* Phase 2 does not resolve or call a runtime Kit implementation.
|
||||
*/
|
||||
export default defineExtension({
|
||||
id: 'activation-planner-consumer',
|
||||
setup() {
|
||||
console.info('[activation-planner-consumer] setup')
|
||||
},
|
||||
})
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"manifestVersion": 2,
|
||||
"kind": "manifest.extension.airi.moeru.ai",
|
||||
"id": "activation-planner-consumer",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"airi": "*",
|
||||
"runtimes": [
|
||||
"electron"
|
||||
]
|
||||
},
|
||||
"entrypoints": {
|
||||
"electron": "./activation-planner-consumer.mjs"
|
||||
},
|
||||
"permissions": {},
|
||||
"kits": {
|
||||
"uses": [
|
||||
{
|
||||
"id": "dev.airi.example-activation",
|
||||
"version": "^1.0.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Activation Planner Provider
|
||||
|
||||
This example declares `dev.airi.example-activation` version `1.0.0` in its manifest.
|
||||
|
||||
Use it with the `activation-planner-consumer` example:
|
||||
|
||||
1. Open `/devtools/plugin-host` in Stage Tamagotchi.
|
||||
2. Import this folder.
|
||||
3. Import the Consumer folder next to this folder.
|
||||
4. Enable this Provider.
|
||||
5. Enable the Consumer.
|
||||
6. Load the enabled Extensions.
|
||||
|
||||
The Host loads this Provider before the Consumer. It also rejects disabling or unloading this Provider while the Consumer still requires it.
|
||||
|
||||
This example has no runtime Kit implementation. Its `kits.provides` entry is a static Phase 2 declaration. Runtime Kit registration belongs to Phase 3.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { defineExtension } from '@proj-airi/plugin-sdk'
|
||||
|
||||
/**
|
||||
* Declares a Kit that the Activation Planner can use for static dependency planning.
|
||||
*
|
||||
* Phase 2 does not register a runtime Kit implementation.
|
||||
*/
|
||||
export default defineExtension({
|
||||
id: 'activation-planner-provider',
|
||||
setup() {
|
||||
console.info('[activation-planner-provider] setup')
|
||||
},
|
||||
})
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"manifestVersion": 2,
|
||||
"kind": "manifest.extension.airi.moeru.ai",
|
||||
"id": "activation-planner-provider",
|
||||
"version": "1.0.0",
|
||||
"engines": {
|
||||
"airi": "*",
|
||||
"runtimes": [
|
||||
"electron"
|
||||
]
|
||||
},
|
||||
"entrypoints": {
|
||||
"electron": "./activation-planner-provider.mjs"
|
||||
},
|
||||
"permissions": {},
|
||||
"kits": {
|
||||
"provides": [
|
||||
{
|
||||
"id": "dev.airi.example-activation",
|
||||
"version": "1.0.0",
|
||||
"exposure": "local-only"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+59
@@ -242,6 +242,46 @@ describe('createExtensionAssetService', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('retries a cookie removal after the server session was already revoked', async () => {
|
||||
const session = createSession('retry-cookie-session')
|
||||
const server = createFakeServer({
|
||||
baseUrl: 'http://127.0.0.1:48123',
|
||||
createSessionResult: session,
|
||||
})
|
||||
server.revokeByOwnerSessionId
|
||||
.mockReturnValueOnce([session])
|
||||
.mockReturnValue([])
|
||||
const { adapter } = createFakeCookieAdapter()
|
||||
adapter.removeCookie.mockRejectedValueOnce(new Error('cookie jar unavailable'))
|
||||
mockState.createStaticAssetService.mockReturnValue(server)
|
||||
|
||||
const service = createExtensionAssetService({
|
||||
getManifestEntryByExtensionId: () => new Map(),
|
||||
cookieAdapter: adapter,
|
||||
})
|
||||
await service.createAssetSession({
|
||||
extensionId: 'airi-plugin-game-chess',
|
||||
version: '1.0.0',
|
||||
ownerSessionId: 'owner-session-1',
|
||||
routeAssetPath: 'assets/app.js',
|
||||
pathPrefix: 'assets/',
|
||||
ttlMs: 60_000,
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The server forgets a revoked asset session before Electron removes its
|
||||
// cookie. If cookie removal fails, asking the server again returns no session
|
||||
// metadata, so the stale cookie cannot be retried without a separate ledger.
|
||||
await expect(service.revokeByOwnerSessionId('owner-session-1'))
|
||||
.rejects
|
||||
.toThrow('cookie jar unavailable')
|
||||
await service.revokeByOwnerSessionId('owner-session-1')
|
||||
|
||||
expect(server.revokeByOwnerSessionId).toHaveBeenCalledTimes(2)
|
||||
expect(adapter.removeCookie).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('revokes all sessions and removes cookies before stopping the server', async () => {
|
||||
const allSession = createSession('stop-asset-session')
|
||||
const server = createFakeServer({
|
||||
@@ -271,4 +311,23 @@ describe('createExtensionAssetService', () => {
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stops the server when cookie cleanup fails', async () => {
|
||||
const session = createSession('failed-stop-cookie-session')
|
||||
const server = createFakeServer({
|
||||
baseUrl: 'http://127.0.0.1:48123',
|
||||
revokeAllResult: [session],
|
||||
})
|
||||
const { adapter } = createFakeCookieAdapter()
|
||||
adapter.removeCookie.mockRejectedValue(new Error('cookie removal failed'))
|
||||
mockState.createStaticAssetService.mockReturnValue(server)
|
||||
|
||||
const service = createExtensionAssetService({
|
||||
getManifestEntryByExtensionId: () => new Map(),
|
||||
cookieAdapter: adapter,
|
||||
})
|
||||
|
||||
await expect(service.stop()).rejects.toThrow('cookie removal failed')
|
||||
expect(server.stop).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
+109
-15
@@ -146,6 +146,13 @@ export interface ExtensionAssetService extends ServerManager {
|
||||
revokeAll: () => Promise<void>
|
||||
}
|
||||
|
||||
interface OwnedAssetCookie {
|
||||
assetSessionId: string
|
||||
ownerSessionId?: string
|
||||
extensionId?: string
|
||||
cookie: ExtensionAssetCookie
|
||||
}
|
||||
|
||||
function createExtensionAssetCookie(baseUrl: string, session: StaticAssetSession): ExtensionAssetCookie {
|
||||
return {
|
||||
name: session.cookieName,
|
||||
@@ -175,6 +182,7 @@ export function createExtensionAssetService(options: {
|
||||
cookieAdapter: ExtensionAssetCookieAdapter
|
||||
}): ExtensionAssetService {
|
||||
const server = createStaticAssetService({ getManifestEntryByExtensionId: options.getManifestEntryByExtensionId })
|
||||
const ownedAssetCookies = new Map<string, OwnedAssetCookie>()
|
||||
let lastBaseUrl: string | undefined
|
||||
|
||||
const readBaseUrl = () => {
|
||||
@@ -183,15 +191,50 @@ export function createExtensionAssetService(options: {
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
const revokeSessions = async (sessions: readonly StaticAssetSession[]) => {
|
||||
const rememberRevokedSessions = (
|
||||
sessions: readonly StaticAssetSession[],
|
||||
ownership: { ownerSessionId?: string, extensionId?: string } = {},
|
||||
) => {
|
||||
const baseUrl = readBaseUrl() ?? lastBaseUrl
|
||||
if (!baseUrl) {
|
||||
return
|
||||
return []
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
sessions.map(session => options.cookieAdapter.removeCookie(createExtensionAssetCookie(baseUrl, session))),
|
||||
)
|
||||
return sessions.map((session) => {
|
||||
const existing = ownedAssetCookies.get(session.assetSessionId)
|
||||
const ownedCookie: OwnedAssetCookie = {
|
||||
assetSessionId: session.assetSessionId,
|
||||
ownerSessionId: ownership.ownerSessionId ?? existing?.ownerSessionId,
|
||||
extensionId: ownership.extensionId ?? existing?.extensionId,
|
||||
cookie: createExtensionAssetCookie(baseUrl, session),
|
||||
}
|
||||
ownedAssetCookies.set(session.assetSessionId, ownedCookie)
|
||||
return ownedCookie
|
||||
})
|
||||
}
|
||||
|
||||
const removeOwnedCookies = async (cookies: readonly OwnedAssetCookie[]) => {
|
||||
const failures: unknown[] = []
|
||||
for (const ownedCookie of cookies) {
|
||||
try {
|
||||
await options.cookieAdapter.removeCookie(ownedCookie.cookie)
|
||||
ownedAssetCookies.delete(ownedCookie.assetSessionId)
|
||||
}
|
||||
catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length === 1) {
|
||||
throw failures[0]
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures, 'Extension asset cookie cleanup failed.')
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueOwnedCookies = (cookies: readonly OwnedAssetCookie[]) => {
|
||||
return [...new Map(cookies.map(cookie => [cookie.assetSessionId, cookie])).values()]
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -200,8 +243,36 @@ export function createExtensionAssetService(options: {
|
||||
await server.start()
|
||||
},
|
||||
async stop() {
|
||||
await revokeSessions(server.revokeAll())
|
||||
await server.stop()
|
||||
const failures: unknown[] = []
|
||||
let serverCookies: OwnedAssetCookie[] = []
|
||||
try {
|
||||
serverCookies = rememberRevokedSessions(server.revokeAll())
|
||||
}
|
||||
catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
try {
|
||||
await removeOwnedCookies(uniqueOwnedCookies([
|
||||
...serverCookies,
|
||||
...ownedAssetCookies.values(),
|
||||
]))
|
||||
}
|
||||
catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
try {
|
||||
await server.stop()
|
||||
}
|
||||
catch (error) {
|
||||
failures.push(error)
|
||||
}
|
||||
|
||||
if (failures.length === 1) {
|
||||
throw failures[0]
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures, 'Extension asset service stop failed.')
|
||||
}
|
||||
},
|
||||
getBaseUrl() {
|
||||
return readBaseUrl()
|
||||
@@ -233,6 +304,12 @@ export function createExtensionAssetService(options: {
|
||||
|
||||
const cookie = createExtensionAssetCookie(baseUrl, session)
|
||||
await options.cookieAdapter.setCookie(cookie)
|
||||
ownedAssetCookies.set(session.assetSessionId, {
|
||||
assetSessionId: session.assetSessionId,
|
||||
ownerSessionId: input.ownerSessionId,
|
||||
extensionId: input.extensionId,
|
||||
cookie,
|
||||
})
|
||||
|
||||
return {
|
||||
url: new URL(mountedPath, baseUrl).toString(),
|
||||
@@ -248,20 +325,37 @@ export function createExtensionAssetService(options: {
|
||||
},
|
||||
async revokeSession(assetSessionId) {
|
||||
const session = server.revokeSession(assetSessionId)
|
||||
if (!session) {
|
||||
return
|
||||
}
|
||||
|
||||
await revokeSessions([session])
|
||||
const serverCookies = session ? rememberRevokedSessions([session]) : []
|
||||
const pendingCookie = ownedAssetCookies.get(assetSessionId)
|
||||
await removeOwnedCookies(uniqueOwnedCookies([
|
||||
...serverCookies,
|
||||
...(pendingCookie ? [pendingCookie] : []),
|
||||
]))
|
||||
},
|
||||
async revokeByOwnerSessionId(ownerSessionId) {
|
||||
await revokeSessions(server.revokeByOwnerSessionId(ownerSessionId))
|
||||
const serverCookies = rememberRevokedSessions(
|
||||
server.revokeByOwnerSessionId(ownerSessionId),
|
||||
{ ownerSessionId },
|
||||
)
|
||||
const pendingCookies = [...ownedAssetCookies.values()]
|
||||
.filter(cookie => cookie.ownerSessionId === ownerSessionId)
|
||||
await removeOwnedCookies(uniqueOwnedCookies([...serverCookies, ...pendingCookies]))
|
||||
},
|
||||
async revokeByExtensionId(extensionId) {
|
||||
await revokeSessions(server.revokeByExtensionId(extensionId))
|
||||
const serverCookies = rememberRevokedSessions(
|
||||
server.revokeByExtensionId(extensionId),
|
||||
{ extensionId },
|
||||
)
|
||||
const pendingCookies = [...ownedAssetCookies.values()]
|
||||
.filter(cookie => cookie.extensionId === extensionId)
|
||||
await removeOwnedCookies(uniqueOwnedCookies([...serverCookies, ...pendingCookies]))
|
||||
},
|
||||
async revokeAll() {
|
||||
await revokeSessions(server.revokeAll())
|
||||
const serverCookies = rememberRevokedSessions(server.revokeAll())
|
||||
await removeOwnedCookies(uniqueOwnedCookies([
|
||||
...serverCookies,
|
||||
...ownedAssetCookies.values(),
|
||||
]))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { buildPluginRegistrySnapshot } from './registry'
|
||||
* - `host` is the initialized extension host instance
|
||||
* - `manifestEntryByExtensionId` contains entries for any extension-owned modules being inspected
|
||||
* - `extensionAssetService` owns extension asset URL/session lifecycle when mounted asset URLs are needed
|
||||
* - `canMaterializeAssetSession` rejects inactive and cleanup-pending session owners
|
||||
*
|
||||
* Returns:
|
||||
* - A full debug snapshot with registry, sessions, kits, modules, and capabilities
|
||||
@@ -30,17 +31,23 @@ export function buildPluginHostDebugSnapshot(options: {
|
||||
loaded: Set<string>
|
||||
manifestEntryByExtensionId: Map<string, ManifestEntry>
|
||||
extensionAssetService?: ExtensionAssetSnapshotService
|
||||
canMaterializeAssetSession: (input: { extensionId: string, sessionId: string }) => boolean
|
||||
}): Promise<PluginHostDebugSnapshot> {
|
||||
const extensionAssetService = options.extensionAssetService
|
||||
const modules = Promise.all(options.host
|
||||
.listBindings()
|
||||
.map(module =>
|
||||
rewriteWidgetModuleAssetUrl(
|
||||
.map((module) => {
|
||||
const canMaterializeAssetSession = options.canMaterializeAssetSession({
|
||||
extensionId: module.ownerExtensionId,
|
||||
sessionId: module.ownerSessionId,
|
||||
})
|
||||
|
||||
return rewriteWidgetModuleAssetUrl(
|
||||
module,
|
||||
options.manifestEntryByExtensionId,
|
||||
{
|
||||
extensionAssetBaseUrl: extensionAssetService?.getBaseUrl(),
|
||||
...(extensionAssetService
|
||||
...(extensionAssetService && canMaterializeAssetSession
|
||||
? {
|
||||
createAssetSession: ({ extensionId, version, sessionId, routeAssetPath, sessionPathPrefix }: {
|
||||
extensionId: string
|
||||
@@ -58,8 +65,8 @@ export function buildPluginHostDebugSnapshot(options: {
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
),
|
||||
))
|
||||
)
|
||||
}))
|
||||
|
||||
return modules.then(resolvedModules => ({
|
||||
registry: buildPluginRegistrySnapshot({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { TamagotchiToolRegistry } from '@proj-airi/plugin-sdk-tamagotchi/tools'
|
||||
import type { ExtensionActivationPlan } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import type {
|
||||
ExtensionDirectoryImportPlan,
|
||||
PluginHostDebugSnapshot,
|
||||
@@ -11,11 +12,14 @@ import type {
|
||||
ExtensionAssetSnapshotService,
|
||||
} from '../features/static-assets'
|
||||
import type { ExtensionHostService, SetupExtensionHostOptions } from '../types'
|
||||
import type { SessionCleanupReport } from './managed-sessions'
|
||||
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
import { useLogg } from '@guiiai/logg'
|
||||
import { ExtensionHost } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { ExtensionHost, planExtensionActivation } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import { Mutex } from 'async-mutex'
|
||||
import { app, session as electronSession } from 'electron'
|
||||
|
||||
import { createExtensionAutoReloadFeature } from '../features/auto-reload'
|
||||
@@ -24,6 +28,7 @@ import { createBuiltInExtensionKitRuntime } from '../kits'
|
||||
import { createExtensionHostConfigStore } from './config'
|
||||
import { buildPluginHostDebugSnapshot } from './debug'
|
||||
import { ExtensionDirectoryImporter } from './directory-import'
|
||||
import { ManagedExtensionSessions } from './managed-sessions'
|
||||
import {
|
||||
buildPluginRegistrySnapshot,
|
||||
createExtensionHostRegistry,
|
||||
@@ -34,6 +39,28 @@ import {
|
||||
|
||||
const extensionAssetSessionTtlMs = 30 * 24 * 60 * 60 * 1000
|
||||
|
||||
/** Separates runtime failures from static Planner diagnostics. */
|
||||
interface ActivationExecutionReport {
|
||||
plan: ExtensionActivationPlan
|
||||
loadedExtensionIds: string[]
|
||||
unloadedExtensionIds: string[]
|
||||
skippedExtensionIds: Array<{
|
||||
extensionId: string
|
||||
reason: 'required-provider-failed'
|
||||
}>
|
||||
failures: Array<{
|
||||
extensionId: string
|
||||
operation: 'load' | 'unload'
|
||||
owner?: 'runtime' | 'assets'
|
||||
error: unknown
|
||||
message: string
|
||||
}>
|
||||
}
|
||||
|
||||
type ExtensionLoadResult
|
||||
= | { ok: true }
|
||||
| { ok: false, cleanup: SessionCleanupReport }
|
||||
|
||||
function createElectronExtensionAssetCookieAdapter() {
|
||||
return {
|
||||
async setCookie(cookie: ExtensionAssetCookie) {
|
||||
@@ -72,6 +99,14 @@ export interface ExtensionHostServiceInternal extends ExtensionHostService {
|
||||
/** Tamagotchi-owned extension tool registry used by IPC tool bridges. */
|
||||
tools: TamagotchiToolRegistry
|
||||
|
||||
/**
|
||||
* Applies the main-process system activation state without changing enabled intent.
|
||||
*
|
||||
* A disabled system stops live sessions in Consumer-first order. Re-enabling the
|
||||
* system restores the persisted enabled set in Provider-first order.
|
||||
*/
|
||||
setSystemEnabled: (enabled: boolean) => Promise<ActivationExecutionReport>
|
||||
|
||||
/** Reads and validates one selected folder without executing Extension code. */
|
||||
prepareDirectoryImport: (sourcePath: string, securityScopedBookmark?: string) => Promise<ExtensionDirectoryImportPlan>
|
||||
|
||||
@@ -213,7 +248,10 @@ export interface ExtensionHostServiceInternal extends ExtensionHostService {
|
||||
* - Disposal may be called after partial startup or after prior plugin failures
|
||||
*
|
||||
* Returns:
|
||||
* - A promise that resolves after feature and asset cleanup finish
|
||||
* - A promise that resolves after all cleanup attempts finish
|
||||
*
|
||||
* Failures:
|
||||
* - Rejects with the remaining failure or an AggregateError after all cleanup attempts finish
|
||||
*/
|
||||
dispose: () => Promise<void>
|
||||
}
|
||||
@@ -244,34 +282,18 @@ export async function setupExtensionHostServiceInternal(
|
||||
|
||||
// Kit API, Host
|
||||
const builtInKitRuntime = createBuiltInExtensionKitRuntime(options)
|
||||
const airiVersion = app.getVersion()
|
||||
const host = new ExtensionHost({
|
||||
airiVersion: app.getVersion(),
|
||||
airiVersion,
|
||||
runtime: 'electron',
|
||||
})
|
||||
const activationMutex = new Mutex()
|
||||
let systemEnabled = true
|
||||
log.withFields({ extensionsRoot }).log('loading extension manifests')
|
||||
builtInKitRuntime.registerHostKits(host)
|
||||
|
||||
// extension registry
|
||||
// Extension registry and asset service
|
||||
const extensionRegistry = createExtensionHostRegistry({ extensionsRoot, log })
|
||||
const loaded = new Set<string>()
|
||||
const loadedSessionIds = new Map<string, string>()
|
||||
const directoryImporter = new ExtensionDirectoryImporter(
|
||||
extensionsRoot,
|
||||
extensionId => Boolean(extensionRegistry.findManifestEntry(extensionId)) || loaded.has(extensionId),
|
||||
(bookmark) => {
|
||||
const stopAccessing = app.startAccessingSecurityScopedResource(bookmark)
|
||||
return () => stopAccessing()
|
||||
},
|
||||
)
|
||||
await directoryImporter.initialize()
|
||||
|
||||
await extensionRegistry.refresh()
|
||||
log.withFields({ count: extensionRegistry.listEntries().length }).log('extension manifests loaded')
|
||||
for (const entry of extensionRegistry.listEntries()) {
|
||||
log.withFields({ name: manifestIdOf(entry.manifest), path: entry.path }).log('extension manifest found')
|
||||
}
|
||||
|
||||
// Extension feature: Static Assets serving
|
||||
const extensionAssetService = createExtensionAssetService({
|
||||
getManifestEntryByExtensionId: () => new Map(
|
||||
[...extensionRegistry.getManifestEntryByExtensionId()].map(([extensionId, entry]) => [
|
||||
@@ -284,7 +306,6 @@ export async function setupExtensionHostServiceInternal(
|
||||
),
|
||||
cookieAdapter: createElectronExtensionAssetCookieAdapter(),
|
||||
})
|
||||
await extensionAssetService.start()
|
||||
|
||||
const moduleAssetSessionCache = new Map<string, ExtensionAssetSession>()
|
||||
|
||||
@@ -296,14 +317,34 @@ export async function setupExtensionHostServiceInternal(
|
||||
}
|
||||
}
|
||||
|
||||
const clearModuleAssetSessionCacheByOwnerSessionId = (ownerSessionId: string) => {
|
||||
for (const key of moduleAssetSessionCache.keys()) {
|
||||
const segments = key.split(':')
|
||||
if (segments[2] === ownerSessionId) {
|
||||
moduleAssetSessionCache.delete(key)
|
||||
}
|
||||
}
|
||||
const managedSessions = new ManagedExtensionSessions({
|
||||
stopRuntime: async (sessionId) => {
|
||||
await host.stop(sessionId)
|
||||
},
|
||||
revokeAssets: async (sessionId) => {
|
||||
await extensionAssetService.revokeByOwnerSessionId(sessionId)
|
||||
},
|
||||
})
|
||||
const getLoadedExtensionIds = () => managedSessions.snapshot().loadedExtensionIds
|
||||
const getLoadedExtensionIdSet = () => new Set(getLoadedExtensionIds())
|
||||
const getLoadedManifests = () => managedSessions.snapshot().loadedManifests
|
||||
|
||||
const directoryImporter = new ExtensionDirectoryImporter(
|
||||
extensionsRoot,
|
||||
extensionId => Boolean(extensionRegistry.findManifestEntry(extensionId)) || managedSessions.hasOwnership(extensionId),
|
||||
(bookmark) => {
|
||||
const stopAccessing = app.startAccessingSecurityScopedResource(bookmark)
|
||||
return () => stopAccessing()
|
||||
},
|
||||
)
|
||||
await directoryImporter.initialize()
|
||||
|
||||
await extensionRegistry.refresh()
|
||||
log.withFields({ count: extensionRegistry.listEntries().length }).log('extension manifests loaded')
|
||||
for (const entry of extensionRegistry.listEntries()) {
|
||||
log.withFields({ name: manifestIdOf(entry.manifest), path: entry.path }).log('extension manifest found')
|
||||
}
|
||||
await extensionAssetService.start()
|
||||
|
||||
const refreshManifests = async () => {
|
||||
await extensionRegistry.refresh()
|
||||
@@ -311,12 +352,33 @@ export async function setupExtensionHostServiceInternal(
|
||||
|
||||
const getConfig = () => extensionConfig.get()
|
||||
|
||||
const planActivation = (
|
||||
proposedEnabledExtensionIds: readonly string[],
|
||||
restartExtensionIds: readonly string[] = [],
|
||||
proposedSystemEnabled = systemEnabled,
|
||||
) => {
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: extensionRegistry.listManifests(),
|
||||
loadedManifests: getLoadedManifests(),
|
||||
proposedEnabledExtensionIds,
|
||||
restartExtensionIds,
|
||||
hostProvidedKits: builtInKitRuntime.hostProvidedKits,
|
||||
runtime: 'electron',
|
||||
airiVersion,
|
||||
systemEnabled: proposedSystemEnabled,
|
||||
})
|
||||
if (!result.ok) {
|
||||
throw new Error(result.diagnostics.map(diagnostic => diagnostic.message).join('\n'))
|
||||
}
|
||||
return result.plan
|
||||
}
|
||||
|
||||
const listSnapshot = (): PluginRegistrySnapshot => {
|
||||
return buildPluginRegistrySnapshot({
|
||||
extensionsRoot,
|
||||
entries: extensionRegistry.listEntries(),
|
||||
config: getConfig(),
|
||||
loaded,
|
||||
loaded: getLoadedExtensionIdSet(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -365,18 +427,25 @@ export async function setupExtensionHostServiceInternal(
|
||||
extensionsRoot,
|
||||
entries: extensionRegistry.listEntries(),
|
||||
config: getConfig(),
|
||||
loaded,
|
||||
loaded: getLoadedExtensionIdSet(),
|
||||
manifestEntryByExtensionId: extensionRegistry.getManifestEntryByExtensionId(),
|
||||
extensionAssetService: extensionAssetSnapshotService,
|
||||
canMaterializeAssetSession: ({ extensionId, sessionId }) => {
|
||||
return managedSessions.isLoadedSession(extensionId, sessionId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const loadExtensionById = async (
|
||||
extensionId: string,
|
||||
loadOptions: { cacheBustKey?: string } = {},
|
||||
) => {
|
||||
if (loaded.has(extensionId)) {
|
||||
return
|
||||
): Promise<ExtensionLoadResult> => {
|
||||
const preparation = await managedSessions.prepareForLoad(extensionId)
|
||||
if (preparation === 'already-loaded') {
|
||||
return { ok: true }
|
||||
}
|
||||
if (preparation !== 'ready') {
|
||||
return { ok: false, cleanup: preparation }
|
||||
}
|
||||
|
||||
const entry = extensionRegistry.findManifestEntry(extensionId)
|
||||
@@ -386,26 +455,163 @@ export async function setupExtensionHostServiceInternal(
|
||||
|
||||
const manifestForLoad = createManifestForLoad(entry, loadOptions)
|
||||
const session = await host.start(manifestForLoad, { cwd: dirname(entry.path) })
|
||||
loaded.add(extensionId)
|
||||
loadedSessionIds.set(extensionId, session.id)
|
||||
managedSessions.registerLoaded({
|
||||
extensionId,
|
||||
sessionId: session.id,
|
||||
manifest: manifestForLoad,
|
||||
})
|
||||
log.withFields({ extensionId, sessionId: session.id }).log('extension loaded')
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
const stopLoadedExtensionById = async (extensionId: string) => {
|
||||
const sessionId = loadedSessionIds.get(extensionId)
|
||||
if (!sessionId) {
|
||||
loaded.delete(extensionId)
|
||||
return
|
||||
// The module asset cache is a synchronous local projection. It does not own
|
||||
// retryable cleanup work, so clear it before the session state transition.
|
||||
clearModuleAssetSessionCacheByExtensionId(extensionId)
|
||||
const report = await managedSessions.cleanup(extensionId)
|
||||
if (report.complete) {
|
||||
log.withFields({ extensionId }).log('extension unloaded')
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
let autoReloadFeature: ReturnType<typeof createExtensionAutoReloadFeature>
|
||||
|
||||
const unloadExtensionById = async (extensionId: string) => {
|
||||
autoReloadFeature.clearExtension(extensionId)
|
||||
return await stopLoadedExtensionById(extensionId)
|
||||
}
|
||||
|
||||
const executeActivationPlan = async (
|
||||
plan: ExtensionActivationPlan,
|
||||
options: { cacheBustKey?: string, rejectOnFailure?: boolean } = {},
|
||||
): Promise<ActivationExecutionReport> => {
|
||||
const report: ActivationExecutionReport = {
|
||||
plan,
|
||||
loadedExtensionIds: [],
|
||||
unloadedExtensionIds: [],
|
||||
skippedExtensionIds: [],
|
||||
failures: [],
|
||||
}
|
||||
const failedExtensionIds = new Set<string>()
|
||||
const runtimeFailures: unknown[] = []
|
||||
const executeUnload = async (extensionId: string) => {
|
||||
try {
|
||||
const cleanupReport: SessionCleanupReport = await unloadExtensionById(extensionId)
|
||||
if (cleanupReport.complete) {
|
||||
report.unloadedExtensionIds.push(extensionId)
|
||||
return
|
||||
}
|
||||
|
||||
failedExtensionIds.add(extensionId)
|
||||
for (const failure of cleanupReport.failures) {
|
||||
runtimeFailures.push(failure.error)
|
||||
report.failures.push({
|
||||
extensionId,
|
||||
operation: 'unload',
|
||||
owner: failure.owner,
|
||||
error: failure.error,
|
||||
message: errorMessageFrom(failure.error) ?? 'Unknown Extension unload failure.',
|
||||
})
|
||||
log.withError(failure.error).withFields({ extensionId, owner: failure.owner }).error('extension failed to stop')
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
runtimeFailures.push(error)
|
||||
failedExtensionIds.add(extensionId)
|
||||
report.failures.push({
|
||||
extensionId,
|
||||
operation: 'unload',
|
||||
error,
|
||||
message: errorMessageFrom(error) ?? 'Unknown Extension unload failure.',
|
||||
})
|
||||
log.withError(error).withFields({ extensionId }).error('extension failed to stop')
|
||||
}
|
||||
}
|
||||
for (const extensionId of plan.unloadOrder) {
|
||||
await executeUnload(extensionId)
|
||||
}
|
||||
|
||||
await host.stop(sessionId)
|
||||
loadedSessionIds.delete(extensionId)
|
||||
loaded.delete(extensionId)
|
||||
// Cleanup debt is intentionally absent from the Planner's loaded snapshot.
|
||||
// Retry owners that are also absent from the target state after planned stops.
|
||||
const targetLoadedExtensionIdSet = new Set(plan.targetLoadedExtensionIds)
|
||||
const plannedUnloadExtensionIdSet = new Set(plan.unloadOrder)
|
||||
const pendingCleanupExtensionIds = managedSessions.snapshot().cleanupPendingExtensionIds.filter(extensionId => !targetLoadedExtensionIdSet.has(extensionId)).filter(extensionId => !plannedUnloadExtensionIdSet.has(extensionId))
|
||||
for (const extensionId of pendingCleanupExtensionIds) {
|
||||
await executeUnload(extensionId)
|
||||
}
|
||||
|
||||
clearModuleAssetSessionCacheByOwnerSessionId(sessionId)
|
||||
await extensionAssetService.revokeByOwnerSessionId(sessionId)
|
||||
for (const extensionId of plan.loadOrder) {
|
||||
if (failedExtensionIds.has(extensionId)) {
|
||||
log.withFields({ extensionId }).error('extension restart skipped because stop failed')
|
||||
continue
|
||||
}
|
||||
const unavailableProviderIds = plan.resolutions
|
||||
.filter(resolution => resolution.consumerExtensionId === extensionId && !resolution.optional)
|
||||
.flatMap((resolution) => {
|
||||
if (resolution.provider.kind !== 'extension') {
|
||||
return []
|
||||
}
|
||||
return failedExtensionIds.has(resolution.provider.extensionId)
|
||||
? [resolution.provider.extensionId]
|
||||
: []
|
||||
})
|
||||
if (unavailableProviderIds.length > 0) {
|
||||
failedExtensionIds.add(extensionId)
|
||||
report.skippedExtensionIds.push({
|
||||
extensionId,
|
||||
reason: 'required-provider-failed',
|
||||
})
|
||||
log.withFields({ extensionId, unavailableProviderIds }).error('extension start skipped because a required Provider failed')
|
||||
continue
|
||||
}
|
||||
|
||||
log.withFields({ extensionId, sessionId }).log('extension unloaded')
|
||||
try {
|
||||
const loadResult = await loadExtensionById(extensionId, options)
|
||||
if (!loadResult.ok) {
|
||||
failedExtensionIds.add(extensionId)
|
||||
for (const failure of loadResult.cleanup.failures) {
|
||||
runtimeFailures.push(failure.error)
|
||||
report.failures.push({
|
||||
extensionId,
|
||||
operation: 'load',
|
||||
owner: failure.owner,
|
||||
error: failure.error,
|
||||
message: errorMessageFrom(failure.error) ?? 'Unknown Extension cleanup failure before load.',
|
||||
})
|
||||
log.withError(failure.error).withFields({ extensionId, owner: failure.owner }).error('extension cleanup failed before start')
|
||||
}
|
||||
continue
|
||||
}
|
||||
report.loadedExtensionIds.push(extensionId)
|
||||
}
|
||||
catch (error) {
|
||||
runtimeFailures.push(error)
|
||||
failedExtensionIds.add(extensionId)
|
||||
report.failures.push({
|
||||
extensionId,
|
||||
operation: 'load',
|
||||
error,
|
||||
message: errorMessageFrom(error) ?? 'Unknown Extension load failure.',
|
||||
})
|
||||
log.withError(error).withFields({ extensionId }).error('extension failed to start')
|
||||
}
|
||||
}
|
||||
|
||||
if (report.failures.length > 0 || report.skippedExtensionIds.length > 0) {
|
||||
log.withFields({ report }).error('extension activation finished with runtime failures')
|
||||
}
|
||||
|
||||
if (options.rejectOnFailure) {
|
||||
if (runtimeFailures.length === 1) {
|
||||
throw runtimeFailures[0]
|
||||
}
|
||||
if (runtimeFailures.length > 1) {
|
||||
throw new AggregateError(runtimeFailures, 'Extension activation failed.')
|
||||
}
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
const resolveAutoReloadWatchPaths = (extensionId: string) => {
|
||||
@@ -419,48 +625,45 @@ export async function setupExtensionHostServiceInternal(
|
||||
}
|
||||
|
||||
// Extension feature: Auto-reload for plugins
|
||||
const autoReloadFeature = createExtensionAutoReloadFeature({
|
||||
autoReloadFeature = createExtensionAutoReloadFeature({
|
||||
log,
|
||||
getConfig,
|
||||
listEntries: () => extensionRegistry.listEntries(),
|
||||
isLoaded: extensionId => loaded.has(extensionId),
|
||||
isLoaded: extensionId => managedSessions.isLoaded(extensionId),
|
||||
resolveWatchPaths: resolveAutoReloadWatchPaths,
|
||||
reload: async (extensionId) => {
|
||||
await stopLoadedExtensionById(extensionId)
|
||||
await refreshManifests()
|
||||
await loadExtensionById(extensionId, { cacheBustKey: `auto-reload-${Date.now()}` })
|
||||
await activationMutex.runExclusive(async () => {
|
||||
await refreshManifests()
|
||||
const plan = planActivation(getLoadedExtensionIds(), [extensionId])
|
||||
await executeActivationPlan(plan, { cacheBustKey: `auto-reload-${Date.now()}` })
|
||||
autoReloadFeature.sync()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const unloadExtensionById = async (extensionId: string) => {
|
||||
autoReloadFeature.clearExtension(extensionId)
|
||||
await stopLoadedExtensionById(extensionId)
|
||||
}
|
||||
|
||||
const loadEnabledExtensions = async () => {
|
||||
const config = getConfig()
|
||||
for (const entry of extensionRegistry.listEntries()) {
|
||||
const extensionId = manifestIdOf(entry.manifest)
|
||||
if (!config.enabled.includes(extensionId)) {
|
||||
continue
|
||||
}
|
||||
if (loaded.has(extensionId)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await loadExtensionById(extensionId)
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error).withFields({ extensionId }).error('extension failed to start')
|
||||
}
|
||||
const plan = planActivation(config.enabled)
|
||||
// loadEnabled reconciles persisted intent without undoing Devtools raw loads.
|
||||
// Raw-loaded sessions are runtime facts, so this operation ignores planned unloads.
|
||||
const loadOnlyPlan: ExtensionActivationPlan = {
|
||||
...plan,
|
||||
unloadOrder: [],
|
||||
}
|
||||
|
||||
const report = await executeActivationPlan(loadOnlyPlan)
|
||||
autoReloadFeature.sync()
|
||||
return report
|
||||
}
|
||||
|
||||
await refreshManifests()
|
||||
await loadEnabledExtensions()
|
||||
await activationMutex.runExclusive(async () => {
|
||||
await refreshManifests()
|
||||
try {
|
||||
await loadEnabledExtensions()
|
||||
}
|
||||
catch (error) {
|
||||
log.withError(error).error('extension activation plan rejected during startup')
|
||||
}
|
||||
})
|
||||
autoReloadFeature.sync()
|
||||
|
||||
return {
|
||||
@@ -501,32 +704,39 @@ export async function setupExtensionHostServiceInternal(
|
||||
return listSnapshot()
|
||||
},
|
||||
async setEnabled(payload) {
|
||||
await refreshManifests()
|
||||
return await activationMutex.runExclusive(async () => {
|
||||
await refreshManifests()
|
||||
|
||||
const config = getConfig()
|
||||
const enabled = new Set(config.enabled)
|
||||
if (payload.enabled) {
|
||||
enabled.add(payload.extensionId)
|
||||
}
|
||||
else {
|
||||
enabled.delete(payload.extensionId)
|
||||
clearModuleAssetSessionCacheByExtensionId(payload.extensionId)
|
||||
await extensionAssetService.revokeByExtensionId(payload.extensionId)
|
||||
}
|
||||
const config = getConfig()
|
||||
const enabled = new Set(config.enabled)
|
||||
if (payload.enabled) {
|
||||
enabled.add(payload.extensionId)
|
||||
}
|
||||
else {
|
||||
enabled.delete(payload.extensionId)
|
||||
}
|
||||
|
||||
const entry = extensionRegistry.findManifestEntry(payload.extensionId)
|
||||
const manifestPath = entry?.path ?? payload.path ?? ''
|
||||
extensionConfig.update({
|
||||
enabled: [...enabled],
|
||||
autoReload: config.autoReload,
|
||||
known: {
|
||||
...config.known,
|
||||
[payload.extensionId]: { path: manifestPath },
|
||||
},
|
||||
planActivation([...enabled])
|
||||
|
||||
const entry = extensionRegistry.findManifestEntry(payload.extensionId)
|
||||
const manifestPath = entry?.path ?? payload.path ?? ''
|
||||
extensionConfig.update({
|
||||
enabled: [...enabled],
|
||||
autoReload: config.autoReload,
|
||||
known: {
|
||||
...config.known,
|
||||
[payload.extensionId]: { path: manifestPath },
|
||||
},
|
||||
})
|
||||
|
||||
if (!payload.enabled) {
|
||||
clearModuleAssetSessionCacheByExtensionId(payload.extensionId)
|
||||
await extensionAssetService.revokeByExtensionId(payload.extensionId)
|
||||
}
|
||||
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
})
|
||||
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
},
|
||||
async setAutoReload(payload) {
|
||||
await refreshManifests()
|
||||
@@ -549,38 +759,120 @@ export async function setupExtensionHostServiceInternal(
|
||||
return listSnapshot()
|
||||
},
|
||||
async loadEnabled() {
|
||||
await refreshManifests()
|
||||
await loadEnabledExtensions()
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
return await activationMutex.runExclusive(async () => {
|
||||
await refreshManifests()
|
||||
await loadEnabledExtensions()
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
})
|
||||
},
|
||||
async load(extensionId) {
|
||||
await refreshManifests()
|
||||
await loadExtensionById(extensionId)
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
return await activationMutex.runExclusive(async () => {
|
||||
await refreshManifests()
|
||||
const plan = planActivation([...getLoadedExtensionIds(), extensionId])
|
||||
await executeActivationPlan(plan, { rejectOnFailure: true })
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
})
|
||||
},
|
||||
async unload(extensionId) {
|
||||
await unloadExtensionById(extensionId)
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
return await activationMutex.runExclusive(async () => {
|
||||
await refreshManifests()
|
||||
const proposedLoadedExtensionIds = getLoadedExtensionIds()
|
||||
.filter(loadedExtensionId => loadedExtensionId !== extensionId)
|
||||
const plan = planActivation(proposedLoadedExtensionIds)
|
||||
await executeActivationPlan(plan, { rejectOnFailure: true })
|
||||
autoReloadFeature.sync()
|
||||
return listSnapshot()
|
||||
})
|
||||
},
|
||||
async setSystemEnabled(enabled) {
|
||||
return await activationMutex.runExclusive(async () => {
|
||||
await refreshManifests()
|
||||
const plan = planActivation(getConfig().enabled, [], enabled)
|
||||
systemEnabled = enabled
|
||||
const report = await executeActivationPlan(plan)
|
||||
autoReloadFeature.sync()
|
||||
return report
|
||||
})
|
||||
},
|
||||
async inspect() {
|
||||
await refreshManifests()
|
||||
autoReloadFeature.sync()
|
||||
return await inspectSnapshot()
|
||||
return await activationMutex.runExclusive(async () => {
|
||||
await refreshManifests()
|
||||
autoReloadFeature.sync()
|
||||
return await inspectSnapshot()
|
||||
})
|
||||
},
|
||||
getAssetBaseUrl() {
|
||||
return extensionAssetService.getBaseUrl() ?? ''
|
||||
},
|
||||
async dispose() {
|
||||
await directoryImporter.dispose()
|
||||
autoReloadFeature.dispose()
|
||||
builtInKitRuntime.dispose()
|
||||
await activationMutex.runExclusive(async () => {
|
||||
const cleanupFailures: unknown[] = []
|
||||
const managedManifests = managedSessions.snapshot().ownedManifests
|
||||
const shutdownResult = planExtensionActivation({
|
||||
installedManifests: [],
|
||||
loadedManifests: managedManifests,
|
||||
proposedEnabledExtensionIds: [],
|
||||
hostProvidedKits: builtInKitRuntime.hostProvidedKits,
|
||||
runtime: 'electron',
|
||||
airiVersion,
|
||||
systemEnabled: false,
|
||||
})
|
||||
if (!shutdownResult.ok) {
|
||||
cleanupFailures.push(new Error(
|
||||
shutdownResult.diagnostics.map(diagnostic => diagnostic.message).join('\n'),
|
||||
))
|
||||
}
|
||||
else {
|
||||
const report = await executeActivationPlan(shutdownResult.plan)
|
||||
for (const failure of report.failures) {
|
||||
cleanupFailures.push(failure.error)
|
||||
}
|
||||
}
|
||||
|
||||
moduleAssetSessionCache.clear()
|
||||
await extensionAssetService.revokeAll()
|
||||
await extensionAssetService.stop()
|
||||
// Session cleanup runs first because Extension stop handlers can still use
|
||||
// Kit and asset services. Shared owners shut down even when a stop fails.
|
||||
try {
|
||||
await directoryImporter.dispose()
|
||||
}
|
||||
catch (error) {
|
||||
cleanupFailures.push(error)
|
||||
}
|
||||
try {
|
||||
autoReloadFeature.dispose()
|
||||
}
|
||||
catch (error) {
|
||||
cleanupFailures.push(error)
|
||||
}
|
||||
try {
|
||||
builtInKitRuntime.dispose()
|
||||
}
|
||||
catch (error) {
|
||||
cleanupFailures.push(error)
|
||||
}
|
||||
|
||||
moduleAssetSessionCache.clear()
|
||||
try {
|
||||
await extensionAssetService.revokeAll()
|
||||
}
|
||||
catch (error) {
|
||||
cleanupFailures.push(error)
|
||||
}
|
||||
try {
|
||||
await extensionAssetService.stop()
|
||||
}
|
||||
catch (error) {
|
||||
cleanupFailures.push(error)
|
||||
}
|
||||
|
||||
if (cleanupFailures.length === 1) {
|
||||
throw cleanupFailures[0]
|
||||
}
|
||||
if (cleanupFailures.length > 1) {
|
||||
throw new AggregateError(cleanupFailures, 'Extension Host disposal failed.')
|
||||
}
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ExtensionManifestV2 } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ManagedExtensionSessions } from './managed-sessions'
|
||||
|
||||
describe('managedExtensionSessions', () => {
|
||||
it('correlates loaded ownership with the exact session identity', () => {
|
||||
const extensionId = 'session-correlation-extension'
|
||||
const sessionId = 'loaded-session'
|
||||
const manifest: ExtensionManifestV2 = {
|
||||
manifestVersion: 2,
|
||||
kind: 'manifest.extension.airi.moeru.ai',
|
||||
id: extensionId,
|
||||
version: '1.0.0',
|
||||
engines: { airi: '*', runtimes: ['electron'] },
|
||||
permissions: {},
|
||||
entrypoints: { electron: './extension.ts' },
|
||||
}
|
||||
const sessions = new ManagedExtensionSessions({
|
||||
stopRuntime: vi.fn(async () => {}),
|
||||
revokeAssets: vi.fn(async () => {}),
|
||||
})
|
||||
sessions.registerLoaded({ extensionId, sessionId, manifest })
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// An Extension-only or session-only lookup can accept the wrong asset owner.
|
||||
// We fixed this by matching the Extension ID, loaded phase, and session ID.
|
||||
expect(sessions.isLoadedSession(extensionId, sessionId)).toBe(true)
|
||||
expect(sessions.isLoadedSession(extensionId, 'different-session')).toBe(false)
|
||||
expect(sessions.isLoadedSession('different-extension', sessionId)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { ExtensionManifestV2 } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
type CleanupStepState = 'required' | 'complete'
|
||||
|
||||
interface ManagedExtensionSessionBase {
|
||||
extensionId: string
|
||||
sessionId: string
|
||||
manifest: ExtensionManifestV2
|
||||
runtimeCleanup: CleanupStepState
|
||||
assetCleanup: CleanupStepState
|
||||
}
|
||||
|
||||
interface LoadedExtensionSession extends ManagedExtensionSessionBase {
|
||||
phase: 'loaded'
|
||||
}
|
||||
|
||||
interface CleanupPendingExtensionSession extends ManagedExtensionSessionBase {
|
||||
phase: 'cleanup-pending'
|
||||
cleanupSequence: number
|
||||
}
|
||||
|
||||
type ManagedExtensionSession = LoadedExtensionSession | CleanupPendingExtensionSession
|
||||
|
||||
/** Separates active projections from session ownership retained for cleanup. */
|
||||
export interface ManagedSessionSnapshot {
|
||||
/** Extensions that callers may treat as active. */
|
||||
loadedExtensionIds: string[]
|
||||
/** Manifests for active Extensions passed to the activation Planner. */
|
||||
loadedManifests: ExtensionManifestV2[]
|
||||
/** Extensions hidden from active projections but still awaiting cleanup. */
|
||||
cleanupPendingExtensionIds: string[]
|
||||
/** Manifests for every session still owned by the Electron Host. */
|
||||
ownedManifests: ExtensionManifestV2[]
|
||||
}
|
||||
|
||||
/** Identifies one cleanup owner that failed without hiding other cleanup results. */
|
||||
export interface SessionCleanupFailure {
|
||||
/** Subsystem that still owns cleanup work. */
|
||||
owner: 'runtime' | 'assets'
|
||||
/** Original failure returned by the cleanup subsystem. */
|
||||
error: unknown
|
||||
}
|
||||
|
||||
/** Reports whether all cleanup owners released one Extension session. */
|
||||
export interface SessionCleanupReport {
|
||||
/** Extension whose managed session was inspected or cleaned. */
|
||||
extensionId: string
|
||||
/** Whether no cleanup ownership remains for this Extension. */
|
||||
complete: boolean
|
||||
/** Cleanup owners that still need a later retry. */
|
||||
failures: SessionCleanupFailure[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns Electron extension sessions until runtime and asset cleanup both succeed.
|
||||
*
|
||||
* Loaded state is only an activation projection. A session that leaves that
|
||||
* projection can still remain here as cleanup debt. Each cleanup owner records
|
||||
* completion independently, so retries never repeat a completed side effect.
|
||||
*/
|
||||
export class ManagedExtensionSessions {
|
||||
private readonly sessions = new Map<string, ManagedExtensionSession>()
|
||||
private nextCleanupSequence = 0
|
||||
|
||||
constructor(private readonly dependencies: {
|
||||
stopRuntime: (sessionId: string) => Promise<void>
|
||||
revokeAssets: (sessionId: string) => Promise<void>
|
||||
}) {}
|
||||
|
||||
registerLoaded(input: {
|
||||
extensionId: string
|
||||
sessionId: string
|
||||
manifest: ExtensionManifestV2
|
||||
}): void {
|
||||
if (this.sessions.has(input.extensionId)) {
|
||||
throw new Error(`Extension "${input.extensionId}" still owns a managed session.`)
|
||||
}
|
||||
|
||||
this.sessions.set(input.extensionId, {
|
||||
extensionId: input.extensionId,
|
||||
sessionId: input.sessionId,
|
||||
manifest: input.manifest,
|
||||
phase: 'loaded',
|
||||
runtimeCleanup: 'required',
|
||||
assetCleanup: 'required',
|
||||
})
|
||||
}
|
||||
|
||||
hasOwnership(extensionId: string): boolean {
|
||||
return this.sessions.has(extensionId)
|
||||
}
|
||||
|
||||
isLoaded(extensionId: string): boolean {
|
||||
return this.sessions.get(extensionId)?.phase === 'loaded'
|
||||
}
|
||||
|
||||
/** Returns true when the exact runtime session can create owned assets. */
|
||||
isLoadedSession(extensionId: string, sessionId: string): boolean {
|
||||
const session = this.sessions.get(extensionId)
|
||||
return session?.phase === 'loaded' && session.sessionId === sessionId
|
||||
}
|
||||
|
||||
snapshot(): ManagedSessionSnapshot {
|
||||
const sessions = [...this.sessions.values()]
|
||||
|
||||
return {
|
||||
loadedExtensionIds: sessions
|
||||
.filter(session => session.phase === 'loaded')
|
||||
.map(session => session.extensionId),
|
||||
loadedManifests: sessions
|
||||
.filter(session => session.phase === 'loaded')
|
||||
.map(session => session.manifest),
|
||||
cleanupPendingExtensionIds: sessions
|
||||
.filter((session): session is CleanupPendingExtensionSession => session.phase === 'cleanup-pending')
|
||||
.sort((left, right) => left.cleanupSequence - right.cleanupSequence)
|
||||
.map(session => session.extensionId),
|
||||
ownedManifests: sessions.map(session => session.manifest),
|
||||
}
|
||||
}
|
||||
|
||||
async prepareForLoad(extensionId: string): Promise<'ready' | 'already-loaded' | SessionCleanupReport> {
|
||||
const session = this.sessions.get(extensionId)
|
||||
if (!session) {
|
||||
return 'ready'
|
||||
}
|
||||
if (session.phase === 'loaded') {
|
||||
return 'already-loaded'
|
||||
}
|
||||
|
||||
const report = await this.cleanup(extensionId)
|
||||
return report.complete ? 'ready' : report
|
||||
}
|
||||
|
||||
async cleanup(extensionId: string): Promise<SessionCleanupReport> {
|
||||
let session = this.sessions.get(extensionId)
|
||||
if (!session) {
|
||||
return { extensionId, complete: true, failures: [] }
|
||||
}
|
||||
|
||||
if (session.phase === 'loaded') {
|
||||
session = {
|
||||
...session,
|
||||
phase: 'cleanup-pending',
|
||||
cleanupSequence: this.nextCleanupSequence,
|
||||
}
|
||||
this.sessions.set(extensionId, session)
|
||||
this.nextCleanupSequence += 1
|
||||
}
|
||||
const failures: SessionCleanupFailure[] = []
|
||||
|
||||
if (session.runtimeCleanup === 'required') {
|
||||
try {
|
||||
await this.dependencies.stopRuntime(session.sessionId)
|
||||
session.runtimeCleanup = 'complete'
|
||||
}
|
||||
catch (error) {
|
||||
failures.push({ owner: 'runtime', error })
|
||||
}
|
||||
}
|
||||
|
||||
if (session.assetCleanup === 'required') {
|
||||
try {
|
||||
await this.dependencies.revokeAssets(session.sessionId)
|
||||
session.assetCleanup = 'complete'
|
||||
}
|
||||
catch (error) {
|
||||
failures.push({
|
||||
owner: 'assets',
|
||||
error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const complete = session.runtimeCleanup === 'complete' && session.assetCleanup === 'complete'
|
||||
if (complete) {
|
||||
this.sessions.delete(extensionId)
|
||||
}
|
||||
|
||||
return { extensionId, complete, failures }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createBuiltInExtensionKitRuntime } from './index'
|
||||
|
||||
describe('createBuiltInExtensionKitRuntime', () => {
|
||||
it('exposes one declaration for each built-in Host Kit', () => {
|
||||
const runtime = createBuiltInExtensionKitRuntime({
|
||||
widgetsManager: {
|
||||
openWindow: vi.fn(),
|
||||
pushWidget: vi.fn(),
|
||||
updateWidget: vi.fn(),
|
||||
removeWidget: vi.fn(),
|
||||
getWidgetSnapshot: vi.fn(),
|
||||
requestWidgetIframe: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
expect(runtime.hostProvidedKits).toEqual([
|
||||
{ id: 'kit.gamelet', version: '1.0.0' },
|
||||
{ id: 'kit.tool', version: '1.0.0' },
|
||||
{ id: 'kit.widget', version: '1.0.0' },
|
||||
])
|
||||
runtime.dispose()
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { KitRef } from '@proj-airi/plugin-sdk'
|
||||
import type { ToolKitRuntime } from '@proj-airi/plugin-sdk-tamagotchi/tools'
|
||||
import type { ExtensionHost } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
import type { ExtensionHost, HostProvidedKitDeclaration, KitDescriptor } from '@proj-airi/plugin-sdk/plugin-host'
|
||||
|
||||
import type { SetupExtensionHostOptions } from '../types'
|
||||
import type { GameletOrchestrationRuntime } from './gamelet/orchestration'
|
||||
@@ -8,9 +8,9 @@ import type { GameletOrchestrationRuntime } from './gamelet/orchestration'
|
||||
import { gameletKit, toolKit } from '@proj-airi/plugin-sdk-tamagotchi'
|
||||
import { TamagotchiToolRegistry } from '@proj-airi/plugin-sdk-tamagotchi/tools'
|
||||
|
||||
import { registerGameletPluginKit } from './gamelet'
|
||||
import { gameletPluginKitDescriptor, registerGameletPluginKit } from './gamelet'
|
||||
import { createGameletOrchestrationRuntime } from './gamelet/orchestration'
|
||||
import { registerWidgetPluginKit } from './widget'
|
||||
import { registerWidgetPluginKit, widgetPluginKitDescriptor } from './widget'
|
||||
|
||||
type GameletKitClient = ReturnType<typeof gameletKit.createClient>
|
||||
type ToolKitClient = ReturnType<typeof toolKit.createClient>
|
||||
@@ -79,6 +79,41 @@ function createHostToolKit(options: { tools: TamagotchiToolRegistry }): KitRef<T
|
||||
}
|
||||
}
|
||||
|
||||
type HostKitDeclarationSource
|
||||
= | Pick<KitDescriptor, 'kitId' | 'version'>
|
||||
| Pick<KitRef<unknown>, 'id' | 'version'>
|
||||
|
||||
/**
|
||||
* Collects stable Host Kit declarations from descriptors and runtime Kit references.
|
||||
*
|
||||
* The function merges matching declarations. It throws when one Kit ID has
|
||||
* different versions because the Planner cannot select one Host contract.
|
||||
*
|
||||
* @example
|
||||
* collectHostProvidedKitDeclarations([
|
||||
* { kitId: 'kit.gamelet', version: '1.0.0' },
|
||||
* { id: 'kit.gamelet', version: '1.0.0' },
|
||||
* ])
|
||||
* // => [{ id: 'kit.gamelet', version: '1.0.0' }]
|
||||
*/
|
||||
function collectHostProvidedKitDeclarations(
|
||||
sources: readonly HostKitDeclarationSource[],
|
||||
): HostProvidedKitDeclaration[] {
|
||||
const versionByKitId = new Map<string, string>()
|
||||
for (const source of sources) {
|
||||
const id = 'id' in source ? source.id : source.kitId
|
||||
const currentVersion = versionByKitId.get(id)
|
||||
if (currentVersion && currentVersion !== source.version) {
|
||||
throw new Error(`Host Kit "${id}" has conflicting versions ${currentVersion} and ${source.version}.`)
|
||||
}
|
||||
versionByKitId.set(id, source.version)
|
||||
}
|
||||
|
||||
return [...versionByKitId]
|
||||
.map(([id, version]) => ({ id, version }))
|
||||
.sort((left, right) => left.id.localeCompare(right.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the built-in kit runtime installed by the Electron extension host.
|
||||
*
|
||||
@@ -94,19 +129,28 @@ function createHostToolKit(options: { tools: TamagotchiToolRegistry }): KitRef<T
|
||||
*/
|
||||
export function createBuiltInExtensionKitRuntime(options: SetupExtensionHostOptions): {
|
||||
registerHostKits: (host: ExtensionHost) => void
|
||||
hostProvidedKits: readonly HostProvidedKitDeclaration[]
|
||||
tools: TamagotchiToolRegistry
|
||||
dispose: () => void
|
||||
} {
|
||||
const gamelets = createGameletOrchestrationRuntime(options.widgetsManager)
|
||||
const tools = new TamagotchiToolRegistry()
|
||||
const toolKitRef = createHostToolKit({ tools })
|
||||
const hostProvidedKits = collectHostProvidedKitDeclarations([
|
||||
widgetPluginKitDescriptor,
|
||||
gameletPluginKitDescriptor,
|
||||
gameletKit,
|
||||
toolKitRef,
|
||||
])
|
||||
|
||||
return {
|
||||
registerHostKits(host) {
|
||||
registerWidgetPluginKit(host)
|
||||
registerGameletPluginKit(host)
|
||||
host.registerKitApi(createHostGameletKit({ host, gamelets }))
|
||||
host.registerKitApi(createHostToolKit({ tools }))
|
||||
host.registerKitApi(toolKitRef)
|
||||
},
|
||||
hostProvidedKits,
|
||||
tools,
|
||||
dispose() {
|
||||
gamelets.dispose()
|
||||
|
||||
@@ -24,7 +24,7 @@ Each installable Extension package has an `extension.airi.json` file at its root
|
||||
"uses": [
|
||||
{
|
||||
"id": "dev.airi.example",
|
||||
"version": "1.0.0",
|
||||
"version": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
]
|
||||
@@ -32,7 +32,11 @@ Each installable Extension package has an `extension.airi.json` file at its root
|
||||
}
|
||||
```
|
||||
|
||||
Manifest parsing is strict. Unknown fields, unsafe ids, empty entrypoints, and missing runtime entrypoints fail validation. Kit declarations use exact semantic versions. The manifest owns the Extension version used by the Host session. `defineExtension(...)` owns runtime setup and must use the same Extension id.
|
||||
Manifest parsing is strict. Unknown fields, unsafe ids, empty entrypoints, and missing runtime entrypoints fail validation.
|
||||
|
||||
Kit Provider declarations use exact semantic versions. Kit Consumer declarations accept semantic version ranges.
|
||||
|
||||
The manifest owns the Extension version for the Host session. `defineExtension(...)` must use the same Extension id.
|
||||
|
||||
## Kit API Naming
|
||||
|
||||
|
||||
@@ -0,0 +1,831 @@
|
||||
import type { ExtensionManifestV2 } from './shared/types'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { planExtensionActivation } from '.'
|
||||
import { planExtensionActivation as planNodeExtensionActivation } from './runtimes/node'
|
||||
import { planExtensionActivation as planWebExtensionActivation } from './runtimes/web'
|
||||
|
||||
function createManifest(
|
||||
id: string,
|
||||
kits?: ExtensionManifestV2['kits'],
|
||||
): ExtensionManifestV2 {
|
||||
return {
|
||||
manifestVersion: 2,
|
||||
kind: 'manifest.extension.airi.moeru.ai',
|
||||
id,
|
||||
version: '1.0.0',
|
||||
engines: {
|
||||
airi: '^1.0.0',
|
||||
runtimes: ['electron'],
|
||||
},
|
||||
entrypoints: {
|
||||
electron: './extension.mjs',
|
||||
},
|
||||
permissions: {},
|
||||
kits,
|
||||
}
|
||||
}
|
||||
|
||||
describe('planExtensionActivation', () => {
|
||||
it('is available through Node and Web package entrypoints', () => {
|
||||
expect(planNodeExtensionActivation).toBe(planExtensionActivation)
|
||||
expect(planWebExtensionActivation).toBe(planExtensionActivation)
|
||||
})
|
||||
|
||||
it('loads a required Provider before its Consumer', () => {
|
||||
const provider = createManifest('provider-extension', {
|
||||
provides: [{
|
||||
id: 'dev.airi.example',
|
||||
version: '1.2.0',
|
||||
exposure: 'local-only',
|
||||
}],
|
||||
})
|
||||
const consumer = createManifest('consumer-extension', {
|
||||
uses: [{
|
||||
id: 'dev.airi.example',
|
||||
version: '^1.0.0',
|
||||
}],
|
||||
})
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [consumer, provider],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [consumer.id, provider.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
plan: {
|
||||
enabledExtensionIds: ['consumer-extension', 'provider-extension'],
|
||||
targetLoadedExtensionIds: ['consumer-extension', 'provider-extension'],
|
||||
unloadOrder: [],
|
||||
loadOrder: ['provider-extension', 'consumer-extension'],
|
||||
restartExtensionIds: [],
|
||||
resolutions: [{
|
||||
consumerExtensionId: 'consumer-extension',
|
||||
kitId: 'dev.airi.example',
|
||||
requestedVersion: '^1.0.0',
|
||||
optional: false,
|
||||
provider: {
|
||||
kind: 'extension',
|
||||
extensionId: 'provider-extension',
|
||||
version: '1.2.0',
|
||||
},
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('uses global Extension ID order when newly ready nodes compete with unrelated nodes', () => {
|
||||
const provider = createManifest('a-provider', {
|
||||
provides: [{
|
||||
id: 'dev.airi.example',
|
||||
version: '1.0.0',
|
||||
exposure: 'local-only',
|
||||
}],
|
||||
})
|
||||
const consumer = createManifest('b-consumer', {
|
||||
uses: [{
|
||||
id: 'dev.airi.example',
|
||||
version: '^1.0.0',
|
||||
}],
|
||||
})
|
||||
const independent = createManifest('z-independent')
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [independent, consumer, provider],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [independent.id, consumer.id, provider.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) {
|
||||
return
|
||||
}
|
||||
expect(result.plan.loadOrder).toEqual([
|
||||
'a-provider',
|
||||
'b-consumer',
|
||||
'z-independent',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a missing required Kit', () => {
|
||||
const consumer = createManifest('consumer-extension', {
|
||||
uses: [{
|
||||
id: 'dev.airi.missing',
|
||||
version: '^1.0.0',
|
||||
}],
|
||||
})
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [consumer],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [consumer.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'missing-required-kit',
|
||||
extensionIds: ['consumer-extension'],
|
||||
kitId: 'dev.airi.missing',
|
||||
requestedVersion: '^1.0.0',
|
||||
message: 'Extension "consumer-extension" requires missing Kit "dev.airi.missing" (^1.0.0).',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves a required Kit from the Host', () => {
|
||||
const consumer = createManifest('consumer-extension', {
|
||||
uses: [{
|
||||
id: 'dev.airi.host-example',
|
||||
version: '^2.0.0',
|
||||
}],
|
||||
})
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [consumer],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [consumer.id],
|
||||
hostProvidedKits: [{ id: 'dev.airi.host-example', version: '2.1.0' }],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
plan: {
|
||||
enabledExtensionIds: ['consumer-extension'],
|
||||
targetLoadedExtensionIds: ['consumer-extension'],
|
||||
unloadOrder: [],
|
||||
loadOrder: ['consumer-extension'],
|
||||
restartExtensionIds: [],
|
||||
resolutions: [{
|
||||
consumerExtensionId: 'consumer-extension',
|
||||
kitId: 'dev.airi.host-example',
|
||||
requestedVersion: '^2.0.0',
|
||||
optional: false,
|
||||
provider: { kind: 'host', version: '2.1.0' },
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects duplicate Extension Providers for one Kit', () => {
|
||||
const firstProvider = createManifest('first-provider', {
|
||||
provides: [{
|
||||
id: 'dev.airi.example',
|
||||
version: '1.0.0',
|
||||
exposure: 'local-only',
|
||||
}],
|
||||
})
|
||||
const secondProvider = createManifest('second-provider', {
|
||||
provides: [{
|
||||
id: 'dev.airi.example',
|
||||
version: '2.0.0',
|
||||
exposure: 'local-only',
|
||||
}],
|
||||
})
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [secondProvider, firstProvider],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [secondProvider.id, firstProvider.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'duplicate-extension-provider',
|
||||
extensionIds: ['first-provider', 'second-provider'],
|
||||
kitId: 'dev.airi.example',
|
||||
message: 'Extensions "first-provider" and "second-provider" both provide Kit "dev.airi.example".',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the same diagnostics for reordered Kit Provider declarations', () => {
|
||||
const createProvider = (kitIds: readonly string[]) => createManifest('extension-provider', {
|
||||
provides: kitIds.map(id => ({
|
||||
id,
|
||||
version: '1.0.0',
|
||||
exposure: 'local-only',
|
||||
})),
|
||||
})
|
||||
const plan = (provider: ExtensionManifestV2) => planExtensionActivation({
|
||||
installedManifests: [provider],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [provider.id],
|
||||
hostProvidedKits: [
|
||||
{ id: 'dev.airi.alpha', version: '1.0.0' },
|
||||
{ id: 'dev.airi.zeta', version: '1.0.0' },
|
||||
],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
const firstResult = plan(createProvider(['dev.airi.zeta', 'dev.airi.alpha']))
|
||||
const secondResult = plan(createProvider(['dev.airi.alpha', 'dev.airi.zeta']))
|
||||
|
||||
expect(firstResult).toEqual(secondResult)
|
||||
expect(firstResult).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [
|
||||
{
|
||||
code: 'host-kit-id-conflict',
|
||||
extensionIds: ['extension-provider'],
|
||||
kitId: 'dev.airi.alpha',
|
||||
providerVersion: '1.0.0',
|
||||
message: 'Extension "extension-provider" cannot provide Host Kit "dev.airi.alpha".',
|
||||
},
|
||||
{
|
||||
code: 'host-kit-id-conflict',
|
||||
extensionIds: ['extension-provider'],
|
||||
kitId: 'dev.airi.zeta',
|
||||
providerVersion: '1.0.0',
|
||||
message: 'Extension "extension-provider" cannot provide Host Kit "dev.airi.zeta".',
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an Extension Provider that conflicts with a Host Kit', () => {
|
||||
const provider = createManifest('extension-provider', {
|
||||
provides: [{
|
||||
id: 'dev.airi.host-example',
|
||||
version: '1.0.0',
|
||||
exposure: 'local-only',
|
||||
}],
|
||||
})
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [provider],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [provider.id],
|
||||
hostProvidedKits: [{ id: 'dev.airi.host-example', version: '1.0.0' }],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'host-kit-id-conflict',
|
||||
extensionIds: ['extension-provider'],
|
||||
kitId: 'dev.airi.host-example',
|
||||
providerVersion: '1.0.0',
|
||||
message: 'Extension "extension-provider" cannot provide Host Kit "dev.airi.host-example".',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an incompatible required Kit version', () => {
|
||||
const consumer = createManifest('consumer-extension', {
|
||||
uses: [{ id: 'dev.airi.example', version: '^2.0.0' }],
|
||||
})
|
||||
const provider = createManifest('provider-extension', {
|
||||
provides: [{
|
||||
id: 'dev.airi.example',
|
||||
version: '1.5.0',
|
||||
exposure: 'local-only',
|
||||
}],
|
||||
})
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [provider, consumer],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [provider.id, consumer.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'incompatible-kit-version',
|
||||
extensionIds: ['consumer-extension', 'provider-extension'],
|
||||
kitId: 'dev.airi.example',
|
||||
requestedVersion: '^2.0.0',
|
||||
providerVersion: '1.5.0',
|
||||
message: 'Extension "consumer-extension" requires Kit "dev.airi.example" ^2.0.0, but "provider-extension" provides 1.5.0.',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('matches a compound Consumer range against an exact Provider version', () => {
|
||||
const consumer = createManifest('consumer-extension', {
|
||||
uses: [{ id: 'dev.airi.example', version: '>=1 <2' }],
|
||||
})
|
||||
const provider = createManifest('provider-extension', {
|
||||
provides: [{ id: 'dev.airi.example', version: '1.5.0', exposure: 'local-only' }],
|
||||
})
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [consumer, provider],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [consumer.id, provider.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
})
|
||||
|
||||
it('matches prerelease Providers only when the Consumer range includes prereleases', () => {
|
||||
const consumer = createManifest('consumer-extension', {
|
||||
uses: [{ id: 'dev.airi.example', version: '>=1.0.0-beta.1 <1.0.0' }],
|
||||
})
|
||||
const provider = createManifest('provider-extension', {
|
||||
provides: [{ id: 'dev.airi.example', version: '1.0.0-beta.2', exposure: 'local-only' }],
|
||||
})
|
||||
const plan = (requestedVersion: string) => {
|
||||
consumer.kits = {
|
||||
uses: [{ id: 'dev.airi.example', version: requestedVersion }],
|
||||
}
|
||||
return planExtensionActivation({
|
||||
installedManifests: [consumer, provider],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [consumer.id, provider.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
expect(plan('>=1.0.0-beta.1 <1.0.0').ok).toBe(true)
|
||||
expect(plan('^1.0.0')).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'incompatible-kit-version',
|
||||
extensionIds: ['consumer-extension', 'provider-extension'],
|
||||
kitId: 'dev.airi.example',
|
||||
requestedVersion: '^1.0.0',
|
||||
providerVersion: '1.0.0-beta.2',
|
||||
message: 'Extension "consumer-extension" requires Kit "dev.airi.example" ^1.0.0, but "provider-extension" provides 1.0.0-beta.2.',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an enabled Extension that is not installed', () => {
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: ['missing-extension'],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'enabled-extension-not-installed',
|
||||
extensionIds: ['missing-extension'],
|
||||
message: 'Enabled Extension "missing-extension" is not installed.',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an Extension that does not support the Host runtime', () => {
|
||||
const extension = createManifest('electron-extension')
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [extension],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [extension.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'node',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'incompatible-runtime',
|
||||
extensionIds: ['electron-extension'],
|
||||
message: 'Extension "electron-extension" does not support runtime "node".',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an Extension that does not support the AIRI version', () => {
|
||||
const extension = createManifest('future-extension')
|
||||
extension.engines.airi = '^2.0.0'
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [extension],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [extension.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.5.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'incompatible-airi-version',
|
||||
extensionIds: ['future-extension'],
|
||||
requestedVersion: '^2.0.0',
|
||||
providerVersion: '1.5.0',
|
||||
message: 'Extension "future-extension" requires AIRI ^2.0.0, but the Host runs 1.5.0.',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects duplicate installed Extension ids', () => {
|
||||
const firstManifest = createManifest('duplicate-extension')
|
||||
const secondManifest = createManifest('duplicate-extension')
|
||||
secondManifest.version = '2.0.0'
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [secondManifest, firstManifest],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [firstManifest.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'duplicate-installed-extension',
|
||||
extensionIds: ['duplicate-extension'],
|
||||
message: 'Installed Extension id "duplicate-extension" appears more than once.',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the same diagnostics for reordered duplicate installed manifests', () => {
|
||||
const plainManifest = createManifest('duplicate-extension')
|
||||
const conflictingManifest = createManifest('duplicate-extension', {
|
||||
provides: [{ id: 'dev.airi.host-example', version: '1.0.0', exposure: 'local-only' }],
|
||||
})
|
||||
const plan = (installedManifests: readonly ExtensionManifestV2[]) => planExtensionActivation({
|
||||
installedManifests,
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: ['duplicate-extension'],
|
||||
hostProvidedKits: [{ id: 'dev.airi.host-example', version: '1.0.0' }],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
const firstResult = plan([plainManifest, conflictingManifest])
|
||||
const secondResult = plan([conflictingManifest, plainManifest])
|
||||
|
||||
expect(firstResult).toEqual(secondResult)
|
||||
expect(firstResult).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'duplicate-installed-extension',
|
||||
extensionIds: ['duplicate-extension'],
|
||||
message: 'Installed Extension id "duplicate-extension" appears more than once.',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects duplicate loaded Extension ids', () => {
|
||||
const installedManifest = createManifest('duplicate-session')
|
||||
const firstSessionManifest = createManifest('duplicate-session')
|
||||
const secondSessionManifest = createManifest('duplicate-session')
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [installedManifest],
|
||||
loadedManifests: [secondSessionManifest, firstSessionManifest],
|
||||
proposedEnabledExtensionIds: [installedManifest.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'duplicate-loaded-extension',
|
||||
extensionIds: ['duplicate-session'],
|
||||
message: 'Loaded Extension id "duplicate-session" appears more than once.',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a required dependency cycle', () => {
|
||||
const firstExtension = createManifest('first-extension', {
|
||||
provides: [{ id: 'dev.airi.first', version: '1.0.0', exposure: 'local-only' }],
|
||||
uses: [{ id: 'dev.airi.second', version: '^1.0.0' }],
|
||||
})
|
||||
const secondExtension = createManifest('second-extension', {
|
||||
provides: [{ id: 'dev.airi.second', version: '1.0.0', exposure: 'local-only' }],
|
||||
uses: [{ id: 'dev.airi.first', version: '^1.0.0' }],
|
||||
})
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [secondExtension, firstExtension],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [secondExtension.id, firstExtension.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'dependency-cycle',
|
||||
extensionIds: ['first-extension', 'second-extension'],
|
||||
message: 'Required Kit dependencies form a cycle: "first-extension", "second-extension".',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a required dependency cycle in the live session graph', () => {
|
||||
const firstExtension = createManifest('first-extension', {
|
||||
provides: [{ id: 'dev.airi.first', version: '1.0.0', exposure: 'local-only' }],
|
||||
uses: [{ id: 'dev.airi.second', version: '^1.0.0' }],
|
||||
})
|
||||
const secondExtension = createManifest('second-extension', {
|
||||
provides: [{ id: 'dev.airi.second', version: '1.0.0', exposure: 'local-only' }],
|
||||
uses: [{ id: 'dev.airi.first', version: '^1.0.0' }],
|
||||
})
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [],
|
||||
loadedManifests: [secondExtension, firstExtension],
|
||||
proposedEnabledExtensionIds: [],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: false,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'dependency-cycle',
|
||||
extensionIds: ['first-extension', 'second-extension'],
|
||||
message: 'Required Kit dependencies form a cycle: "first-extension", "second-extension".',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('stops loaded Consumers before Providers when the system is disabled', () => {
|
||||
const provider = createManifest('provider-extension', {
|
||||
provides: [{ id: 'dev.airi.example', version: '1.0.0', exposure: 'local-only' }],
|
||||
})
|
||||
const consumer = createManifest('consumer-extension', {
|
||||
uses: [{ id: 'dev.airi.example', version: '^1.0.0' }],
|
||||
})
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [consumer, provider],
|
||||
loadedManifests: [consumer, provider],
|
||||
proposedEnabledExtensionIds: [provider.id, consumer.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: false,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
plan: {
|
||||
enabledExtensionIds: ['consumer-extension', 'provider-extension'],
|
||||
targetLoadedExtensionIds: [],
|
||||
unloadOrder: ['consumer-extension', 'provider-extension'],
|
||||
loadOrder: [],
|
||||
restartExtensionIds: [],
|
||||
resolutions: [{
|
||||
consumerExtensionId: 'consumer-extension',
|
||||
kitId: 'dev.airi.example',
|
||||
requestedVersion: '^1.0.0',
|
||||
optional: false,
|
||||
provider: {
|
||||
kind: 'extension',
|
||||
extensionId: 'provider-extension',
|
||||
version: '1.0.0',
|
||||
},
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('restarts all required Consumers when a Provider restarts', () => {
|
||||
const provider = createManifest('provider-extension', {
|
||||
provides: [{ id: 'dev.airi.example', version: '1.0.0', exposure: 'local-only' }],
|
||||
})
|
||||
const consumer = createManifest('consumer-extension', {
|
||||
uses: [{ id: 'dev.airi.example', version: '^1.0.0' }],
|
||||
})
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [consumer, provider],
|
||||
loadedManifests: [consumer, provider],
|
||||
proposedEnabledExtensionIds: [consumer.id, provider.id],
|
||||
restartExtensionIds: [provider.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
plan: {
|
||||
enabledExtensionIds: ['consumer-extension', 'provider-extension'],
|
||||
targetLoadedExtensionIds: ['consumer-extension', 'provider-extension'],
|
||||
unloadOrder: ['consumer-extension', 'provider-extension'],
|
||||
loadOrder: ['provider-extension', 'consumer-extension'],
|
||||
restartExtensionIds: ['consumer-extension', 'provider-extension'],
|
||||
resolutions: [{
|
||||
consumerExtensionId: 'consumer-extension',
|
||||
kitId: 'dev.airi.example',
|
||||
requestedVersion: '^1.0.0',
|
||||
optional: false,
|
||||
provider: {
|
||||
kind: 'extension',
|
||||
extensionId: 'provider-extension',
|
||||
version: '1.0.0',
|
||||
},
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a missing optional Kit as a non-blocking resolution', () => {
|
||||
const consumer = createManifest('optional-consumer', {
|
||||
uses: [{ id: 'dev.airi.optional', version: '^1.0.0', optional: true }],
|
||||
})
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [consumer],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [consumer.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
plan: {
|
||||
enabledExtensionIds: ['optional-consumer'],
|
||||
targetLoadedExtensionIds: ['optional-consumer'],
|
||||
unloadOrder: [],
|
||||
loadOrder: ['optional-consumer'],
|
||||
restartExtensionIds: [],
|
||||
resolutions: [{
|
||||
consumerExtensionId: 'optional-consumer',
|
||||
kitId: 'dev.airi.optional',
|
||||
requestedVersion: '^1.0.0',
|
||||
optional: true,
|
||||
provider: { kind: 'unresolved', reason: 'missing' },
|
||||
}],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an Extension that requires its own Kit', () => {
|
||||
const extension = createManifest('self-dependent-extension', {
|
||||
provides: [{ id: 'dev.airi.self', version: '1.0.0', exposure: 'local-only' }],
|
||||
uses: [{ id: 'dev.airi.self', version: '^1.0.0' }],
|
||||
})
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [extension],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [extension.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'dependency-cycle',
|
||||
extensionIds: ['self-dependent-extension'],
|
||||
message: 'Required Kit dependencies form a cycle: "self-dependent-extension".',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a restart request for an Extension that is not loaded', () => {
|
||||
const extension = createManifest('stopped-extension')
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [extension],
|
||||
loadedManifests: [],
|
||||
proposedEnabledExtensionIds: [extension.id],
|
||||
restartExtensionIds: [extension.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
diagnostics: [{
|
||||
code: 'restart-extension-not-loaded',
|
||||
extensionIds: ['stopped-extension'],
|
||||
message: 'Extension "stopped-extension" cannot restart because it is not loaded.',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns the same plan for equivalent input permutations', () => {
|
||||
const provider = createManifest('provider-extension', {
|
||||
provides: [{ id: 'dev.airi.example', version: '1.0.0', exposure: 'local-only' }],
|
||||
})
|
||||
const consumer = createManifest('consumer-extension', {
|
||||
uses: [{ id: 'dev.airi.example', version: '^1.0.0' }],
|
||||
})
|
||||
const commonInput = {
|
||||
loadedManifests: [],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron' as const,
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
}
|
||||
|
||||
const firstResult = planExtensionActivation({
|
||||
...commonInput,
|
||||
installedManifests: [provider, consumer],
|
||||
proposedEnabledExtensionIds: [consumer.id, provider.id],
|
||||
})
|
||||
const secondResult = planExtensionActivation({
|
||||
...commonInput,
|
||||
installedManifests: [consumer, provider],
|
||||
proposedEnabledExtensionIds: [provider.id, consumer.id],
|
||||
})
|
||||
|
||||
expect(secondResult).toEqual(firstResult)
|
||||
})
|
||||
|
||||
it('uses the live session graph to stop old Consumers during Provider reload', () => {
|
||||
const provider = createManifest('provider-extension', {
|
||||
provides: [{ id: 'dev.airi.example', version: '1.0.0', exposure: 'local-only' }],
|
||||
})
|
||||
const loadedConsumer = createManifest('consumer-extension', {
|
||||
uses: [{ id: 'dev.airi.example', version: '^1.0.0' }],
|
||||
})
|
||||
const installedConsumer = createManifest('consumer-extension')
|
||||
|
||||
const result = planExtensionActivation({
|
||||
installedManifests: [installedConsumer, provider],
|
||||
loadedManifests: [loadedConsumer, provider],
|
||||
proposedEnabledExtensionIds: [installedConsumer.id, provider.id],
|
||||
restartExtensionIds: [provider.id],
|
||||
hostProvidedKits: [],
|
||||
runtime: 'electron',
|
||||
airiVersion: '1.0.0',
|
||||
systemEnabled: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
plan: {
|
||||
enabledExtensionIds: ['consumer-extension', 'provider-extension'],
|
||||
targetLoadedExtensionIds: ['consumer-extension', 'provider-extension'],
|
||||
unloadOrder: ['consumer-extension', 'provider-extension'],
|
||||
loadOrder: ['consumer-extension', 'provider-extension'],
|
||||
restartExtensionIds: ['consumer-extension', 'provider-extension'],
|
||||
resolutions: [],
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,582 @@
|
||||
import type { ExtensionManifestV2, PluginRuntime } from './shared/types'
|
||||
|
||||
import semver from 'semver'
|
||||
|
||||
/** Describes one Kit contract that the Host reserves and provides. */
|
||||
export interface HostProvidedKitDeclaration {
|
||||
/** Stable Kit contract identifier. */
|
||||
id: string
|
||||
/** Exact version that the Host provides. */
|
||||
version: string
|
||||
}
|
||||
|
||||
/** Identifies the Provider selected for one Kit dependency. */
|
||||
export type KitDependencyProvider
|
||||
= | { kind: 'host', version: string }
|
||||
| { kind: 'extension', extensionId: string, version: string }
|
||||
| { kind: 'unresolved', reason: 'missing' | 'incompatible-version' }
|
||||
|
||||
/** Records how the Planner resolved one Extension Kit dependency. */
|
||||
export interface KitDependencyResolution {
|
||||
/** Extension that declares the dependency. */
|
||||
consumerExtensionId: string
|
||||
/** Stable Kit contract identifier. */
|
||||
kitId: string
|
||||
/** Version range requested by the Consumer. */
|
||||
requestedVersion: string
|
||||
/** Whether a missing or incompatible Provider can be ignored. */
|
||||
optional: boolean
|
||||
/** Provider selected from the complete target configuration. */
|
||||
provider: KitDependencyProvider
|
||||
}
|
||||
|
||||
/** Stable code for one rejected activation configuration. */
|
||||
export type ActivationDiagnosticCode
|
||||
= | 'duplicate-installed-extension'
|
||||
| 'duplicate-loaded-extension'
|
||||
| 'enabled-extension-not-installed'
|
||||
| 'incompatible-airi-version'
|
||||
| 'incompatible-runtime'
|
||||
| 'duplicate-extension-provider'
|
||||
| 'host-kit-id-conflict'
|
||||
| 'missing-required-kit'
|
||||
| 'incompatible-kit-version'
|
||||
| 'dependency-cycle'
|
||||
| 'restart-extension-not-loaded'
|
||||
|
||||
/** Describes one reason that prevents an activation plan. */
|
||||
export interface ActivationDiagnostic {
|
||||
/** Stable code for caller control flow. */
|
||||
code: ActivationDiagnosticCode
|
||||
/** Extension identifiers related to the error. */
|
||||
extensionIds: string[]
|
||||
/** Related Kit identifier, when the error concerns a Kit. */
|
||||
kitId?: string
|
||||
/** Version range requested by a Consumer. */
|
||||
requestedVersion?: string
|
||||
/** Exact version exposed by the selected Provider. */
|
||||
providerVersion?: string
|
||||
/** Human-readable error description. */
|
||||
message: string
|
||||
}
|
||||
|
||||
/** Contains the complete current snapshot and proposed Extension intent. */
|
||||
export interface ActivationPlannerInput {
|
||||
/** Manifests available for the target configuration. */
|
||||
installedManifests: readonly ExtensionManifestV2[]
|
||||
/** Manifests used by current live sessions. */
|
||||
loadedManifests: readonly ExtensionManifestV2[]
|
||||
/** Complete enabled intent after the proposed configuration change. */
|
||||
proposedEnabledExtensionIds: readonly string[]
|
||||
/** Loaded Extensions that must restart during this transition. @default [] */
|
||||
restartExtensionIds?: readonly string[]
|
||||
/** Kit contracts reserved and provided by the Host. */
|
||||
hostProvidedKits: readonly HostProvidedKitDeclaration[]
|
||||
/** Runtime that will load the target Extensions. */
|
||||
runtime: PluginRuntime
|
||||
/** Running AIRI version. When omitted, the Planner skips AIRI compatibility validation. @default undefined */
|
||||
airiVersion?: string
|
||||
/** Whether the Host can run enabled Extensions. */
|
||||
systemEnabled: boolean
|
||||
}
|
||||
|
||||
/** Describes one deterministic transition from current sessions to target sessions. */
|
||||
export interface ExtensionActivationPlan {
|
||||
/** Stable copy of the complete enabled intent. */
|
||||
enabledExtensionIds: string[]
|
||||
/** Extensions that must run after the transition. */
|
||||
targetLoadedExtensionIds: string[]
|
||||
/** Current sessions to stop in Consumer-first order. */
|
||||
unloadOrder: string[]
|
||||
/** Target sessions to start in Provider-first order. */
|
||||
loadOrder: string[]
|
||||
/** Loaded Extensions included in the restart transition. */
|
||||
restartExtensionIds: string[]
|
||||
/** Resolution record for each target Kit dependency. */
|
||||
resolutions: KitDependencyResolution[]
|
||||
}
|
||||
|
||||
/** Represents a valid activation plan or all blocking diagnostics. */
|
||||
export type ActivationPlanningResult
|
||||
= | { ok: true, plan: ExtensionActivationPlan }
|
||||
| { ok: false, diagnostics: ActivationDiagnostic[] }
|
||||
|
||||
type ResolvedKitProvider
|
||||
= | { kind: 'host', version: string }
|
||||
| { kind: 'extension', extensionId: string, version: string }
|
||||
|
||||
const emptyExtensionIds: readonly string[] = Object.freeze([])
|
||||
const emptyExtensionIdSet: ReadonlySet<string> = Object.freeze(new Set<string>())
|
||||
|
||||
/**
|
||||
* Plans one Extension activation transition without runtime side effects.
|
||||
*
|
||||
* The Planner reads the complete current snapshot and target intent. It returns
|
||||
* one deterministic plan or structured diagnostics. It does not mutate config
|
||||
* or Extension lifecycle state.
|
||||
*/
|
||||
export function planExtensionActivation(input: ActivationPlannerInput): ActivationPlanningResult {
|
||||
const enabledExtensionIds = [...new Set(input.proposedEnabledExtensionIds)].sort()
|
||||
const targetLoadedExtensionIds = input.systemEnabled ? enabledExtensionIds : []
|
||||
const loadedExtensionIds = [...new Set(input.loadedManifests.map(manifest => manifest.id))].sort()
|
||||
const loadedExtensionIdSet = new Set(loadedExtensionIds)
|
||||
const requestedRestartExtensionIds = [...new Set(input.restartExtensionIds ?? emptyExtensionIds)].sort()
|
||||
const diagnostics: ActivationDiagnostic[] = []
|
||||
const installedCounts = new Map<string, number>()
|
||||
for (const manifest of input.installedManifests) {
|
||||
installedCounts.set(manifest.id, (installedCounts.get(manifest.id) ?? 0) + 1)
|
||||
}
|
||||
const duplicateInstalledExtensionIds = new Set<string>()
|
||||
for (const [extensionId, count] of [...installedCounts].sort(([left], [right]) => left.localeCompare(right))) {
|
||||
if (count > 1) {
|
||||
duplicateInstalledExtensionIds.add(extensionId)
|
||||
diagnostics.push({
|
||||
code: 'duplicate-installed-extension',
|
||||
extensionIds: [extensionId],
|
||||
message: `Installed Extension id "${extensionId}" appears more than once.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
const loadedCounts = new Map<string, number>()
|
||||
for (const manifest of input.loadedManifests) {
|
||||
loadedCounts.set(manifest.id, (loadedCounts.get(manifest.id) ?? 0) + 1)
|
||||
}
|
||||
for (const [extensionId, count] of [...loadedCounts].sort(([left], [right]) => left.localeCompare(right))) {
|
||||
if (count > 1) {
|
||||
diagnostics.push({
|
||||
code: 'duplicate-loaded-extension',
|
||||
extensionIds: [extensionId],
|
||||
message: `Loaded Extension id "${extensionId}" appears more than once.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const extensionId of requestedRestartExtensionIds) {
|
||||
if (!loadedExtensionIdSet.has(extensionId)) {
|
||||
diagnostics.push({
|
||||
code: 'restart-extension-not-loaded',
|
||||
extensionIds: [extensionId],
|
||||
message: `Extension "${extensionId}" cannot restart because it is not loaded.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
const installedById = new Map<string, ExtensionManifestV2>()
|
||||
for (const manifest of input.installedManifests) {
|
||||
if (!duplicateInstalledExtensionIds.has(manifest.id)) {
|
||||
installedById.set(manifest.id, manifest)
|
||||
}
|
||||
}
|
||||
const providersByKitId = new Map<string, ResolvedKitProvider>(
|
||||
input.hostProvidedKits.map(declaration => [
|
||||
declaration.id,
|
||||
{ kind: 'host' as const, version: declaration.version },
|
||||
]),
|
||||
)
|
||||
for (const extensionId of enabledExtensionIds) {
|
||||
if (duplicateInstalledExtensionIds.has(extensionId)) {
|
||||
continue
|
||||
}
|
||||
const manifest = installedById.get(extensionId)
|
||||
if (!manifest) {
|
||||
diagnostics.push({
|
||||
code: 'enabled-extension-not-installed',
|
||||
extensionIds: [extensionId],
|
||||
message: `Enabled Extension "${extensionId}" is not installed.`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (!manifest.engines.runtimes.includes(input.runtime)) {
|
||||
diagnostics.push({
|
||||
code: 'incompatible-runtime',
|
||||
extensionIds: [extensionId],
|
||||
message: `Extension "${extensionId}" does not support runtime "${input.runtime}".`,
|
||||
})
|
||||
}
|
||||
if (input.airiVersion && !semver.satisfies(input.airiVersion, manifest.engines.airi, { includePrerelease: true })) {
|
||||
diagnostics.push({
|
||||
code: 'incompatible-airi-version',
|
||||
extensionIds: [extensionId],
|
||||
requestedVersion: manifest.engines.airi,
|
||||
providerVersion: input.airiVersion,
|
||||
message: `Extension "${extensionId}" requires AIRI ${manifest.engines.airi}, but the Host runs ${input.airiVersion}.`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for (const extensionId of enabledExtensionIds) {
|
||||
if (duplicateInstalledExtensionIds.has(extensionId)) {
|
||||
continue
|
||||
}
|
||||
const manifest = installedById.get(extensionId)
|
||||
const providedKits = manifest?.kits?.provides
|
||||
if (!providedKits) {
|
||||
continue
|
||||
}
|
||||
const sortedProvidedKits = [...providedKits].sort((left, right) => {
|
||||
return left.id.localeCompare(right.id)
|
||||
|| left.version.localeCompare(right.version)
|
||||
|| left.exposure.localeCompare(right.exposure)
|
||||
})
|
||||
for (const declaration of sortedProvidedKits) {
|
||||
const existingProvider = providersByKitId.get(declaration.id)
|
||||
if (existingProvider?.kind === 'host') {
|
||||
diagnostics.push({
|
||||
code: 'host-kit-id-conflict',
|
||||
extensionIds: [extensionId],
|
||||
kitId: declaration.id,
|
||||
providerVersion: existingProvider.version,
|
||||
message: `Extension "${extensionId}" cannot provide Host Kit "${declaration.id}".`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (existingProvider?.kind === 'extension') {
|
||||
diagnostics.push({
|
||||
code: 'duplicate-extension-provider',
|
||||
extensionIds: [existingProvider.extensionId, extensionId].sort(),
|
||||
kitId: declaration.id,
|
||||
message: `Extensions "${existingProvider.extensionId}" and "${extensionId}" both provide Kit "${declaration.id}".`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
providersByKitId.set(declaration.id, {
|
||||
kind: 'extension',
|
||||
extensionId,
|
||||
version: declaration.version,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const dependenciesByExtensionId = new Map<string, Set<string>>()
|
||||
const resolutions: KitDependencyResolution[] = []
|
||||
for (const extensionId of enabledExtensionIds) {
|
||||
if (duplicateInstalledExtensionIds.has(extensionId)) {
|
||||
continue
|
||||
}
|
||||
const manifest = installedById.get(extensionId)
|
||||
const dependencies = new Set<string>()
|
||||
dependenciesByExtensionId.set(extensionId, dependencies)
|
||||
|
||||
const declaredUses = manifest?.kits?.uses
|
||||
if (!declaredUses) {
|
||||
continue
|
||||
}
|
||||
const uses = [...declaredUses].sort((left, right) => left.id.localeCompare(right.id))
|
||||
for (const declaration of uses) {
|
||||
const provider = providersByKitId.get(declaration.id)
|
||||
if (!provider) {
|
||||
resolutions.push({
|
||||
consumerExtensionId: extensionId,
|
||||
kitId: declaration.id,
|
||||
requestedVersion: declaration.version,
|
||||
optional: declaration.optional ?? false,
|
||||
provider: { kind: 'unresolved', reason: 'missing' },
|
||||
})
|
||||
if (!declaration.optional) {
|
||||
diagnostics.push({
|
||||
code: 'missing-required-kit',
|
||||
extensionIds: [extensionId],
|
||||
kitId: declaration.id,
|
||||
requestedVersion: declaration.version,
|
||||
message: `Extension "${extensionId}" requires missing Kit "${declaration.id}" (${declaration.version}).`,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const compatible = semver.satisfies(provider.version, declaration.version)
|
||||
resolutions.push({
|
||||
consumerExtensionId: extensionId,
|
||||
kitId: declaration.id,
|
||||
requestedVersion: declaration.version,
|
||||
optional: declaration.optional ?? false,
|
||||
provider: compatible ? provider : { kind: 'unresolved', reason: 'incompatible-version' },
|
||||
})
|
||||
if (!compatible && !declaration.optional) {
|
||||
const providerName = provider.kind === 'extension'
|
||||
? `"${provider.extensionId}"`
|
||||
: 'the Host'
|
||||
diagnostics.push({
|
||||
code: 'incompatible-kit-version',
|
||||
extensionIds: provider.kind === 'extension'
|
||||
? [extensionId, provider.extensionId].sort()
|
||||
: [extensionId],
|
||||
kitId: declaration.id,
|
||||
requestedVersion: declaration.version,
|
||||
providerVersion: provider.version,
|
||||
message: `Extension "${extensionId}" requires Kit "${declaration.id}" ${declaration.version}, but ${providerName} provides ${provider.version}.`,
|
||||
})
|
||||
}
|
||||
if (compatible && !declaration.optional && provider.kind === 'extension') {
|
||||
dependencies.add(provider.extensionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentDependencies = buildLoadedDependencyGraph(input.loadedManifests)
|
||||
const dependencyCycles = [
|
||||
...findDependencyCycles(enabledExtensionIds, dependenciesByExtensionId),
|
||||
...findDependencyCycles(loadedExtensionIds, currentDependencies),
|
||||
].sort((left, right) => left.join('\0').localeCompare(right.join('\0')))
|
||||
const reportedDependencyCycles = new Set<string>()
|
||||
for (const cycle of dependencyCycles) {
|
||||
const cycleKey = cycle.join('\0')
|
||||
if (reportedDependencyCycles.has(cycleKey)) {
|
||||
continue
|
||||
}
|
||||
reportedDependencyCycles.add(cycleKey)
|
||||
diagnostics.push({
|
||||
code: 'dependency-cycle',
|
||||
extensionIds: cycle,
|
||||
message: `Required Kit dependencies form a cycle: ${cycle.map(extensionId => `"${extensionId}"`).join(', ')}.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (diagnostics.length > 0) {
|
||||
return { ok: false, diagnostics }
|
||||
}
|
||||
|
||||
const targetLoadOrder = topologicallySort(enabledExtensionIds, dependenciesByExtensionId)
|
||||
const targetLoadedExtensionIdSet = new Set(targetLoadedExtensionIds)
|
||||
const restartDependencies = mergeDependencyGraphs(dependenciesByExtensionId, currentDependencies)
|
||||
const restartExtensionIds = collectRestartClosure(
|
||||
requestedRestartExtensionIds.filter(extensionId => targetLoadedExtensionIdSet.has(extensionId)),
|
||||
restartDependencies,
|
||||
).filter(extensionId => targetLoadedExtensionIdSet.has(extensionId))
|
||||
const currentLoadOrder = topologicallySort(loadedExtensionIds, currentDependencies)
|
||||
const unloadExtensionIds = new Set(
|
||||
loadedExtensionIds.filter(extensionId => !targetLoadedExtensionIdSet.has(extensionId)),
|
||||
)
|
||||
const loadExtensionIds = new Set(
|
||||
targetLoadedExtensionIds.filter(extensionId => !loadedExtensionIdSet.has(extensionId)),
|
||||
)
|
||||
for (const extensionId of restartExtensionIds) {
|
||||
unloadExtensionIds.add(extensionId)
|
||||
loadExtensionIds.add(extensionId)
|
||||
}
|
||||
const unloadOrder = [...currentLoadOrder]
|
||||
.reverse()
|
||||
.filter(extensionId => unloadExtensionIds.has(extensionId))
|
||||
const loadOrder = targetLoadOrder.filter(extensionId => loadExtensionIds.has(extensionId))
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
plan: {
|
||||
enabledExtensionIds,
|
||||
targetLoadedExtensionIds,
|
||||
unloadOrder,
|
||||
loadOrder,
|
||||
restartExtensionIds,
|
||||
resolutions,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDependencyGraphs(
|
||||
...graphs: ReadonlyArray<ReadonlyMap<string, ReadonlySet<string>>>
|
||||
): Map<string, Set<string>> {
|
||||
const merged = new Map<string, Set<string>>()
|
||||
for (const graph of graphs) {
|
||||
for (const [extensionId, dependencyIds] of graph) {
|
||||
let dependencies = merged.get(extensionId)
|
||||
if (!dependencies) {
|
||||
dependencies = new Set<string>()
|
||||
merged.set(extensionId, dependencies)
|
||||
}
|
||||
for (const dependencyId of dependencyIds) {
|
||||
dependencies.add(dependencyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
function collectRestartClosure(
|
||||
requestedExtensionIds: readonly string[],
|
||||
dependenciesByExtensionId: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
): string[] {
|
||||
const dependentsByExtensionId = new Map<string, Set<string>>()
|
||||
for (const [consumerExtensionId, providerExtensionIds] of dependenciesByExtensionId) {
|
||||
for (const providerExtensionId of providerExtensionIds) {
|
||||
let dependents = dependentsByExtensionId.get(providerExtensionId)
|
||||
if (!dependents) {
|
||||
dependents = new Set<string>()
|
||||
dependentsByExtensionId.set(providerExtensionId, dependents)
|
||||
}
|
||||
dependents.add(consumerExtensionId)
|
||||
}
|
||||
}
|
||||
|
||||
const closure = new Set(requestedExtensionIds)
|
||||
const pending = [...requestedExtensionIds].sort()
|
||||
while (pending.length > 0) {
|
||||
const extensionId = pending.shift()
|
||||
if (!extensionId) {
|
||||
continue
|
||||
}
|
||||
const dependents = dependentsByExtensionId.get(extensionId)
|
||||
if (!dependents) {
|
||||
continue
|
||||
}
|
||||
for (const dependentId of [...dependents].sort()) {
|
||||
if (!closure.has(dependentId)) {
|
||||
closure.add(dependentId)
|
||||
pending.push(dependentId)
|
||||
pending.sort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...closure].sort()
|
||||
}
|
||||
|
||||
function buildLoadedDependencyGraph(
|
||||
loadedManifests: readonly ExtensionManifestV2[],
|
||||
): Map<string, Set<string>> {
|
||||
const providersByKitId = new Map<string, Array<{ extensionId: string, version: string }>>()
|
||||
for (const manifest of [...loadedManifests].sort((left, right) => left.id.localeCompare(right.id))) {
|
||||
const providedKits = manifest.kits?.provides
|
||||
if (!providedKits) {
|
||||
continue
|
||||
}
|
||||
for (const declaration of providedKits) {
|
||||
let providers = providersByKitId.get(declaration.id)
|
||||
if (!providers) {
|
||||
providers = []
|
||||
providersByKitId.set(declaration.id, providers)
|
||||
}
|
||||
providers.push({ extensionId: manifest.id, version: declaration.version })
|
||||
}
|
||||
}
|
||||
|
||||
const dependenciesByExtensionId = new Map<string, Set<string>>()
|
||||
for (const manifest of [...loadedManifests].sort((left, right) => left.id.localeCompare(right.id))) {
|
||||
let dependencies = dependenciesByExtensionId.get(manifest.id)
|
||||
if (!dependencies) {
|
||||
dependencies = new Set<string>()
|
||||
dependenciesByExtensionId.set(manifest.id, dependencies)
|
||||
}
|
||||
const declaredUses = manifest.kits?.uses
|
||||
if (!declaredUses) {
|
||||
continue
|
||||
}
|
||||
for (const declaration of declaredUses) {
|
||||
if (declaration.optional) {
|
||||
continue
|
||||
}
|
||||
const providers = providersByKitId.get(declaration.id)
|
||||
if (!providers) {
|
||||
continue
|
||||
}
|
||||
for (const provider of providers) {
|
||||
if (semver.satisfies(provider.version, declaration.version)) {
|
||||
dependencies.add(provider.extensionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dependenciesByExtensionId
|
||||
}
|
||||
|
||||
function findDependencyCycles(
|
||||
extensionIds: readonly string[],
|
||||
dependenciesByExtensionId: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
): string[][] {
|
||||
const indices = new Map<string, number>()
|
||||
const lowLinks = new Map<string, number>()
|
||||
const stack: string[] = []
|
||||
const onStack = new Set<string>()
|
||||
const cycles: string[][] = []
|
||||
let nextIndex = 0
|
||||
|
||||
function visit(extensionId: string): void {
|
||||
const currentIndex = nextIndex
|
||||
nextIndex += 1
|
||||
indices.set(extensionId, currentIndex)
|
||||
lowLinks.set(extensionId, currentIndex)
|
||||
stack.push(extensionId)
|
||||
onStack.add(extensionId)
|
||||
|
||||
const dependencies = [...(dependenciesByExtensionId.get(extensionId) ?? emptyExtensionIdSet)].sort()
|
||||
for (const dependencyId of dependencies) {
|
||||
if (!indices.has(dependencyId)) {
|
||||
visit(dependencyId)
|
||||
lowLinks.set(extensionId, Math.min(
|
||||
lowLinks.get(extensionId) ?? currentIndex,
|
||||
lowLinks.get(dependencyId) ?? currentIndex,
|
||||
))
|
||||
}
|
||||
else if (onStack.has(dependencyId)) {
|
||||
lowLinks.set(extensionId, Math.min(
|
||||
lowLinks.get(extensionId) ?? currentIndex,
|
||||
indices.get(dependencyId) ?? currentIndex,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if (lowLinks.get(extensionId) !== indices.get(extensionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const component: string[] = []
|
||||
let memberId: string | undefined
|
||||
do {
|
||||
memberId = stack.pop()
|
||||
if (memberId) {
|
||||
onStack.delete(memberId)
|
||||
component.push(memberId)
|
||||
}
|
||||
} while (memberId && memberId !== extensionId)
|
||||
|
||||
const sortedComponent = component.sort()
|
||||
const hasSelfDependency = sortedComponent.length === 1
|
||||
&& dependenciesByExtensionId.get(sortedComponent[0])?.has(sortedComponent[0])
|
||||
if (sortedComponent.length > 1 || hasSelfDependency) {
|
||||
cycles.push(sortedComponent)
|
||||
}
|
||||
}
|
||||
|
||||
for (const extensionId of [...extensionIds].sort()) {
|
||||
if (!indices.has(extensionId)) {
|
||||
visit(extensionId)
|
||||
}
|
||||
}
|
||||
|
||||
return cycles.sort((left, right) => left.join('\0').localeCompare(right.join('\0')))
|
||||
}
|
||||
|
||||
function topologicallySort(
|
||||
extensionIds: readonly string[],
|
||||
dependenciesByExtensionId: ReadonlyMap<string, ReadonlySet<string>>,
|
||||
): string[] {
|
||||
const remainingDependencies = new Map(
|
||||
extensionIds.map((extensionId) => {
|
||||
const dependencies = dependenciesByExtensionId.get(extensionId)
|
||||
return [
|
||||
extensionId,
|
||||
dependencies ? new Set(dependencies) : new Set<string>(),
|
||||
] as const
|
||||
}),
|
||||
)
|
||||
const result: string[] = []
|
||||
|
||||
while (remainingDependencies.size > 0) {
|
||||
const ready = [...remainingDependencies]
|
||||
.filter(([, dependencies]) => dependencies.size === 0)
|
||||
.map(([extensionId]) => extensionId)
|
||||
.sort()
|
||||
|
||||
const extensionId = ready[0]
|
||||
if (!extensionId) {
|
||||
// planExtensionActivation rejects cycles in the target and live graphs before sorting.
|
||||
throw new Error('Dependency graph must be acyclic before topological sorting.')
|
||||
}
|
||||
|
||||
result.push(extensionId)
|
||||
remainingDependencies.delete(extensionId)
|
||||
for (const dependencies of remainingDependencies.values()) {
|
||||
dependencies.delete(extensionId)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './activation-plan'
|
||||
export * from './core'
|
||||
export * from './shared'
|
||||
export * from './transports'
|
||||
@@ -282,7 +282,7 @@ describe('extension manifest schema', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects Kit version ranges until the Host supports range resolution', () => {
|
||||
it('accepts Kit version ranges for Consumer declarations', () => {
|
||||
const result = safeParse(extensionManifestV2Schema, {
|
||||
manifestVersion: 2,
|
||||
kind: 'manifest.extension.airi.moeru.ai',
|
||||
@@ -304,6 +304,83 @@ describe('extension manifest schema', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts exact Kit versions for Consumer declarations', () => {
|
||||
const result = safeParse(extensionManifestV2Schema, {
|
||||
manifestVersion: 2,
|
||||
kind: 'manifest.extension.airi.moeru.ai',
|
||||
id: 'exact-version-consumer',
|
||||
version: '1.0.0',
|
||||
engines: { airi: '*', runtimes: ['electron'] },
|
||||
permissions: {},
|
||||
entrypoints: { electron: './extension.mjs' },
|
||||
kits: {
|
||||
uses: [{ id: 'dev.airi.agent-activity', version: '1.0.0' }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts compound Kit version ranges for Consumer declarations', () => {
|
||||
const result = safeParse(extensionManifestV2Schema, {
|
||||
manifestVersion: 2,
|
||||
kind: 'manifest.extension.airi.moeru.ai',
|
||||
id: 'compound-range-consumer',
|
||||
version: '1.0.0',
|
||||
engines: { airi: '*', runtimes: ['electron'] },
|
||||
permissions: {},
|
||||
entrypoints: { electron: './extension.mjs' },
|
||||
kits: {
|
||||
uses: [{ id: 'dev.airi.agent-activity', version: '>=1 <2' }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid Kit version ranges for Consumer declarations', () => {
|
||||
const result = safeParse(extensionManifestV2Schema, {
|
||||
manifestVersion: 2,
|
||||
kind: 'manifest.extension.airi.moeru.ai',
|
||||
id: 'invalid-range-consumer',
|
||||
version: '1.0.0',
|
||||
engines: { airi: '*', runtimes: ['electron'] },
|
||||
permissions: {},
|
||||
entrypoints: { electron: './extension.mjs' },
|
||||
kits: {
|
||||
uses: [{ id: 'dev.airi.agent-activity', version: 'not-a-range' }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects Kit version ranges for Provider declarations', () => {
|
||||
const result = safeParse(extensionManifestV2Schema, {
|
||||
manifestVersion: 2,
|
||||
kind: 'manifest.extension.airi.moeru.ai',
|
||||
id: 'agent-activity-provider',
|
||||
version: '1.0.0',
|
||||
engines: {
|
||||
airi: '*',
|
||||
runtimes: ['electron'],
|
||||
},
|
||||
permissions: {},
|
||||
entrypoints: {
|
||||
electron: './extension.mjs',
|
||||
},
|
||||
kits: {
|
||||
provides: [{
|
||||
id: 'dev.airi.agent-activity',
|
||||
version: '^1.0.0',
|
||||
exposure: 'local-only',
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
export * from './core'
|
||||
export * from './api'
|
||||
export { createPluginContext } from './runtimes/node'
|
||||
export * from './runtimes/node/loaders'
|
||||
export * from './runtimes/shared'
|
||||
export * from './shared'
|
||||
export * from './transports'
|
||||
|
||||
@@ -4,9 +4,7 @@ import type { PluginTransport } from '../../transports'
|
||||
|
||||
import { createContext } from '@moeru/eventa'
|
||||
|
||||
export * from '../../core'
|
||||
export * from '../../shared'
|
||||
export * from '../../transports'
|
||||
export * from '../../api'
|
||||
export * from './loaders'
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,9 +4,7 @@ import type { PluginTransport } from '../../transports'
|
||||
|
||||
import { createContext } from '@moeru/eventa'
|
||||
|
||||
export * from '../../core'
|
||||
export * from '../../shared'
|
||||
export * from '../../transports'
|
||||
export * from '../../api'
|
||||
|
||||
/**
|
||||
* Creates the Eventa context used by web-side extension host sessions.
|
||||
|
||||
@@ -230,7 +230,7 @@ export interface ExtensionProvidedKitDeclaration {
|
||||
export interface ExtensionUsedKitDeclaration {
|
||||
/** Stable Kit contract identifier. */
|
||||
id: string
|
||||
/** Exact Kit version required by the Consumer. */
|
||||
/** Semantic version range accepted by the Consumer. */
|
||||
version: string
|
||||
/** Whether the Extension can start without this Kit. @default false */
|
||||
optional?: boolean
|
||||
@@ -392,7 +392,7 @@ const providedKitDeclarationSchema = strictObject({
|
||||
|
||||
const usedKitDeclarationSchema = strictObject({
|
||||
id: pipe(string(), trim(), minLength(1)),
|
||||
version: exactSemanticVersionSchema,
|
||||
version: semanticVersionRangeSchema,
|
||||
optional: optional(boolean()),
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user