refactor: add plugin lookup table

This commit is contained in:
Shakker
2026-04-27 07:02:47 +01:00
parent 66f4b52db3
commit 3f38d3af88
6 changed files with 412 additions and 65 deletions

View File

@@ -20,8 +20,14 @@ const repairBundledRuntimeDepsInstallRootAsync = vi.hoisted(() =>
const resolveBundledRuntimeDependencyPackageInstallRoot = vi.hoisted(() =>
vi.fn((_packageRoot: string, _params: unknown) => "/runtime"),
);
const resolveConfiguredDeferredChannelPluginIds = vi.hoisted(() => vi.fn((_params: unknown) => []));
const resolveGatewayStartupPluginIds = vi.hoisted(() => vi.fn((_params: unknown) => ["telegram"]));
const loadPluginLookUpTable = vi.hoisted(() =>
vi.fn((_params: unknown) => ({
startup: {
configuredDeferredChannelPluginIds: [],
pluginIds: ["telegram"],
},
})),
);
const resolveOpenClawPackageRootSync = vi.hoisted(() => vi.fn((_params: unknown) => "/package"));
const runChannelPluginStartupMaintenance = vi.hoisted(() =>
vi.fn(async (_params: unknown) => undefined),
@@ -65,10 +71,8 @@ vi.mock("../plugins/bundled-runtime-deps.js", () => ({
scanBundledPluginRuntimeDeps: (params: unknown) => scanBundledPluginRuntimeDeps(params),
}));
vi.mock("../plugins/channel-plugin-ids.js", () => ({
resolveConfiguredDeferredChannelPluginIds: (params: unknown) =>
resolveConfiguredDeferredChannelPluginIds(params),
resolveGatewayStartupPluginIds: (params: unknown) => resolveGatewayStartupPluginIds(params),
vi.mock("../plugins/plugin-lookup-table.js", () => ({
loadPluginLookUpTable: (params: unknown) => loadPluginLookUpTable(params),
}));
vi.mock("../plugins/registry.js", () => ({
@@ -112,8 +116,12 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => {
loadGatewayStartupPlugins.mockClear();
repairBundledRuntimeDepsInstallRootAsync.mockReset().mockResolvedValue({});
resolveBundledRuntimeDependencyPackageInstallRoot.mockClear();
resolveConfiguredDeferredChannelPluginIds.mockClear();
resolveGatewayStartupPluginIds.mockClear().mockReturnValue(["telegram"]);
loadPluginLookUpTable.mockClear().mockReturnValue({
startup: {
configuredDeferredChannelPluginIds: [],
pluginIds: ["telegram"],
},
});
resolveOpenClawPackageRootSync.mockClear().mockReturnValue("/package");
runChannelPluginStartupMaintenance.mockClear();
runStartupSessionMigration.mockClear();
@@ -143,6 +151,7 @@ describe("prepareGatewayPluginBootstrap runtime-deps staging", () => {
});
expect(loadGatewayStartupPlugins).toHaveBeenCalledOnce();
expect(loadPluginLookUpTable).toHaveBeenCalledOnce();
expect(scanBundledPluginRuntimeDeps).toHaveBeenCalledWith(
expect.objectContaining({
selectedPluginIds: ["telegram"],

View File

@@ -9,10 +9,7 @@ import {
resolveBundledRuntimeDependencyPackageInstallRoot,
scanBundledPluginRuntimeDeps,
} from "../plugins/bundled-runtime-deps.js";
import {
resolveConfiguredDeferredChannelPluginIds,
resolveGatewayStartupPluginIds,
} from "../plugins/channel-plugin-ids.js";
import { loadPluginLookUpTable } from "../plugins/plugin-lookup-table.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { getActivePluginRegistry, setActivePluginRegistry } from "../plugins/runtime.js";
import { listGatewayMethods } from "./server-methods-list.js";
@@ -139,21 +136,18 @@ export async function prepareGatewayPluginBootstrap(params: {
}).config;
const defaultAgentId = resolveDefaultAgentId(gatewayPluginConfigAtStart);
const defaultWorkspaceDir = resolveAgentWorkspaceDir(gatewayPluginConfigAtStart, defaultAgentId);
const deferredConfiguredChannelPluginIds = params.minimalTestGateway
? []
: resolveConfiguredDeferredChannelPluginIds({
config: gatewayPluginConfigAtStart,
workspaceDir: defaultWorkspaceDir,
env: process.env,
});
const startupPluginIds = params.minimalTestGateway
? []
: resolveGatewayStartupPluginIds({
const pluginLookUpTable = params.minimalTestGateway
? undefined
: loadPluginLookUpTable({
config: gatewayPluginConfigAtStart,
activationSourceConfig: params.cfgAtStart,
workspaceDir: defaultWorkspaceDir,
env: process.env,
});
const deferredConfiguredChannelPluginIds = [
...(pluginLookUpTable?.startup.configuredDeferredChannelPluginIds ?? []),
];
const startupPluginIds = [...(pluginLookUpTable?.startup.pluginIds ?? [])];
const baseMethods = listGatewayMethods();
const emptyPluginRegistry = createEmptyPluginRegistry();

View File

@@ -14,6 +14,9 @@ export {
export {
resolveChannelPluginIds,
resolveChannelPluginIdsFromRegistry,
resolveConfiguredDeferredChannelPluginIds,
resolveConfiguredDeferredChannelPluginIdsFromRegistry,
resolveGatewayStartupPluginIds,
resolveGatewayStartupPluginIdsFromRegistry,
} from "./gateway-startup-plugin-ids.js";

View File

@@ -190,26 +190,63 @@ export function resolveChannelPluginIds(params: {
env: params.env,
includeDisabled: true,
});
return resolveChannelPluginIdsFromRegistry({ manifestRegistry });
}
export function resolveChannelPluginIdsFromRegistry(params: {
manifestRegistry: PluginManifestRegistry;
}): string[] {
const { manifestRegistry } = params;
return manifestRegistry.plugins
.filter((plugin) => plugin.channels.length > 0)
.map((plugin) => plugin.id);
}
export function resolveConfiguredDeferredChannelPluginIdsFromRegistry(params: {
config: OpenClawConfig;
env: NodeJS.ProcessEnv;
index: ReturnType<typeof loadPluginRegistrySnapshot>;
manifestRegistry: PluginManifestRegistry;
}): string[] {
const configuredChannelIds = new Set(listPotentialEnabledChannelIds(params.config, params.env));
if (configuredChannelIds.size === 0) {
return [];
}
const pluginsConfig = normalizePluginsConfigWithRegistry(params.config.plugins, params.index);
const activationSource = {
plugins: pluginsConfig,
rootConfig: params.config,
};
return params.index.plugins
.filter(
(plugin) =>
hasConfiguredStartupChannel({
plugin,
manifestRegistry: params.manifestRegistry,
configuredChannelIds,
}) &&
plugin.startup.deferConfiguredChannelFullLoadUntilAfterListen &&
canStartConfiguredChannelPlugin({
plugin,
config: params.config,
pluginsConfig,
activationSource,
manifestRegistry: params.manifestRegistry,
}),
)
.map((plugin) => plugin.pluginId);
}
export function resolveConfiguredDeferredChannelPluginIds(params: {
config: OpenClawConfig;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
}): string[] {
const configuredChannelIds = new Set(listPotentialEnabledChannelIds(params.config, params.env));
if (configuredChannelIds.size === 0) {
return [];
}
const index = loadPluginRegistrySnapshot({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const pluginsConfig = normalizePluginsConfigWithRegistry(params.config.plugins, index);
const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({
index,
config: params.config,
@@ -217,53 +254,30 @@ export function resolveConfiguredDeferredChannelPluginIds(params: {
env: params.env,
includeDisabled: true,
});
const activationSource = {
plugins: pluginsConfig,
rootConfig: params.config,
};
return index.plugins
.filter(
(plugin) =>
hasConfiguredStartupChannel({ plugin, manifestRegistry, configuredChannelIds }) &&
plugin.startup.deferConfiguredChannelFullLoadUntilAfterListen &&
canStartConfiguredChannelPlugin({
plugin,
config: params.config,
pluginsConfig,
activationSource,
manifestRegistry,
}),
)
.map((plugin) => plugin.pluginId);
return resolveConfiguredDeferredChannelPluginIdsFromRegistry({
config: params.config,
env: params.env,
index,
manifestRegistry,
});
}
export function resolveGatewayStartupPluginIds(params: {
export function resolveGatewayStartupPluginIdsFromRegistry(params: {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
index: ReturnType<typeof loadPluginRegistrySnapshot>;
manifestRegistry: PluginManifestRegistry;
}): string[] {
const configuredChannelIds = new Set(listPotentialEnabledChannelIds(params.config, params.env));
const index = loadPluginRegistrySnapshot({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const pluginsConfig = normalizePluginsConfigWithRegistry(params.config.plugins, index);
const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({
index,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
includeDisabled: true,
});
const pluginsConfig = normalizePluginsConfigWithRegistry(params.config.plugins, params.index);
// Startup must classify allowlist exceptions against the raw config snapshot,
// not the auto-enabled effective snapshot, or configured-only channels can be
// misclassified as explicit enablement.
const activationSourceConfig = params.activationSourceConfig ?? params.config;
const activationSourcePlugins = normalizePluginsConfigWithRegistry(
activationSourceConfig.plugins,
index,
params.index,
);
const activationSource = {
plugins: activationSourcePlugins,
@@ -276,17 +290,23 @@ export function resolveGatewayStartupPluginIds(params: {
const memorySlotStartupPluginId = resolveMemorySlotStartupPluginId({
activationSourceConfig,
activationSourcePlugins,
normalizePluginId: createPluginRegistryIdNormalizer(index),
normalizePluginId: createPluginRegistryIdNormalizer(params.index),
});
return index.plugins
return params.index.plugins
.filter((plugin) => {
if (hasConfiguredStartupChannel({ plugin, manifestRegistry, configuredChannelIds })) {
if (
hasConfiguredStartupChannel({
plugin,
manifestRegistry: params.manifestRegistry,
configuredChannelIds,
})
) {
return canStartConfiguredChannelPlugin({
plugin,
config: params.config,
pluginsConfig,
activationSource,
manifestRegistry,
manifestRegistry: params.manifestRegistry,
});
}
if (
@@ -329,3 +349,32 @@ export function resolveGatewayStartupPluginIds(params: {
})
.map((plugin) => plugin.pluginId);
}
export function resolveGatewayStartupPluginIds(params: {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
}): string[] {
const index = loadPluginRegistrySnapshot({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
});
const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({
index,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
includeDisabled: true,
});
return resolveGatewayStartupPluginIdsFromRegistry({
config: params.config,
...(params.activationSourceConfig !== undefined
? { activationSourceConfig: params.activationSourceConfig }
: {}),
env: params.env,
index,
manifestRegistry,
});
}

View File

@@ -0,0 +1,139 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
import type { PluginRegistrySnapshot } from "./plugin-registry.js";
const listPotentialConfiguredChannelIds = vi.hoisted(() => vi.fn());
const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn());
vi.mock("../channels/config-presence.js", () => ({
hasMeaningfulChannelConfig: (value: unknown) =>
Boolean(
value &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value).some((key) => key !== "enabled"),
),
listPotentialConfiguredChannelIds: (
config: OpenClawConfig,
env: NodeJS.ProcessEnv,
options?: { includePersistedAuthState?: boolean },
) => listPotentialConfiguredChannelIds(config, env, options),
}));
vi.mock("./manifest-registry-installed.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./manifest-registry-installed.js")>();
return {
...actual,
loadPluginManifestRegistryForInstalledIndex: (params: unknown) =>
loadPluginManifestRegistryForInstalledIndex(params),
};
});
function createManifestRecord(
plugin: Partial<PluginManifestRecord> & Pick<PluginManifestRecord, "id" | "origin">,
): PluginManifestRecord {
return {
name: plugin.id,
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
rootDir: `/plugins/${plugin.id}`,
source: `/plugins/${plugin.id}/index.js`,
manifestPath: `/plugins/${plugin.id}/openclaw.plugin.json`,
...plugin,
};
}
function createIndex(plugins: readonly PluginManifestRecord[]): PluginRegistrySnapshot {
return {
version: 1,
hostContractVersion: "test",
compatRegistryVersion: "test",
migrationVersion: 1,
policyHash: "policy",
generatedAtMs: 1,
installRecords: {},
diagnostics: [],
plugins: plugins.map((plugin) => ({
pluginId: plugin.id,
manifestPath: plugin.manifestPath,
manifestHash: `${plugin.id}-hash`,
rootDir: plugin.rootDir,
origin: plugin.origin,
enabled: true,
...(plugin.enabledByDefault !== undefined
? { enabledByDefault: plugin.enabledByDefault }
: {}),
startup: {
sidecar: false,
memory: false,
deferConfiguredChannelFullLoadUntilAfterListen: Boolean(
plugin.startupDeferConfiguredChannelFullLoadUntilAfterListen,
),
agentHarnesses: [],
},
compat: [],
})),
};
}
describe("loadPluginLookUpTable", () => {
beforeEach(() => {
listPotentialConfiguredChannelIds
.mockReset()
.mockImplementation((config: OpenClawConfig) => Object.keys(config.channels ?? {}));
loadPluginManifestRegistryForInstalledIndex.mockReset();
});
it("builds owner maps and startup ids from one installed manifest registry", async () => {
const plugins = [
createManifestRecord({
id: "telegram",
origin: "bundled",
channels: ["telegram"],
}),
createManifestRecord({
id: "openai",
origin: "bundled",
providers: ["openai"],
cliBackends: ["codex-cli"],
setup: {
providers: [{ id: "openai" }],
},
}),
];
const index = createIndex(plugins);
const manifestRegistry: PluginManifestRegistry = {
plugins,
diagnostics: [],
};
loadPluginManifestRegistryForInstalledIndex.mockReturnValue(manifestRegistry);
const { loadPluginLookUpTable } = await import("./plugin-lookup-table.js");
const table = loadPluginLookUpTable({
config: {
channels: {
telegram: { token: "configured" },
},
plugins: {
slots: { memory: "none" },
},
} as OpenClawConfig,
env: {},
index,
});
expect(table.manifestRegistry).toBe(manifestRegistry);
expect(table.byPluginId.get("telegram")?.id).toBe("telegram");
expect(table.owners.channels.get("telegram")).toEqual(["telegram"]);
expect(table.owners.providers.get("openai")).toEqual(["openai"]);
expect(table.owners.cliBackends.get("codex-cli")).toEqual(["openai"]);
expect(table.owners.setupProviders.get("openai")).toEqual(["openai"]);
expect(table.startup.channelPluginIds).toEqual(["telegram"]);
expect(table.startup.configuredDeferredChannelPluginIds).toEqual([]);
expect(table.startup.pluginIds).toEqual(["telegram"]);
});
});

View File

@@ -0,0 +1,153 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
resolveChannelPluginIdsFromRegistry,
resolveConfiguredDeferredChannelPluginIdsFromRegistry,
resolveGatewayStartupPluginIdsFromRegistry,
} from "./channel-plugin-ids.js";
import { hashJson } from "./installed-plugin-index-hash.js";
import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js";
import type { PluginManifestRecord, PluginManifestRegistry } from "./manifest-registry.js";
import type { PluginDiagnostic } from "./manifest-types.js";
import {
loadPluginRegistrySnapshotWithMetadata,
type PluginRegistrySnapshot,
type PluginRegistrySnapshotDiagnostic,
} from "./plugin-registry.js";
export type PluginLookUpTableOwnerMaps = {
channels: ReadonlyMap<string, readonly string[]>;
providers: ReadonlyMap<string, readonly string[]>;
cliBackends: ReadonlyMap<string, readonly string[]>;
setupProviders: ReadonlyMap<string, readonly string[]>;
};
export type PluginLookUpTableStartupPlan = {
channelPluginIds: readonly string[];
configuredDeferredChannelPluginIds: readonly string[];
pluginIds: readonly string[];
};
export type PluginLookUpTable = {
key: string;
index: PluginRegistrySnapshot;
registryDiagnostics: readonly PluginRegistrySnapshotDiagnostic[];
manifestRegistry: PluginManifestRegistry;
plugins: readonly PluginManifestRecord[];
diagnostics: readonly PluginDiagnostic[];
byPluginId: ReadonlyMap<string, PluginManifestRecord>;
owners: PluginLookUpTableOwnerMaps;
startup: PluginLookUpTableStartupPlan;
};
export type LoadPluginLookUpTableParams = {
config: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
workspaceDir?: string;
env: NodeJS.ProcessEnv;
index?: PluginRegistrySnapshot;
};
function appendOwner(owners: Map<string, string[]>, ownedId: string, pluginId: string): void {
const existing = owners.get(ownedId);
if (existing) {
existing.push(pluginId);
return;
}
owners.set(ownedId, [pluginId]);
}
function freezeOwnerMap(owners: Map<string, string[]>): ReadonlyMap<string, readonly string[]> {
return new Map(
[...owners.entries()].map(([ownedId, pluginIds]) => [ownedId, Object.freeze([...pluginIds])]),
);
}
function buildOwnerMaps(plugins: readonly PluginManifestRecord[]): PluginLookUpTableOwnerMaps {
const channels = new Map<string, string[]>();
const providers = new Map<string, string[]>();
const cliBackends = new Map<string, string[]>();
const setupProviders = new Map<string, string[]>();
for (const plugin of plugins) {
for (const channelId of plugin.channels) {
appendOwner(channels, channelId, plugin.id);
}
for (const providerId of plugin.providers) {
appendOwner(providers, providerId, plugin.id);
}
for (const cliBackendId of plugin.cliBackends) {
appendOwner(cliBackends, cliBackendId, plugin.id);
}
for (const setupProvider of plugin.setup?.providers ?? []) {
appendOwner(setupProviders, setupProvider.id, plugin.id);
}
}
return {
channels: freezeOwnerMap(channels),
providers: freezeOwnerMap(providers),
cliBackends: freezeOwnerMap(cliBackends),
setupProviders: freezeOwnerMap(setupProviders),
};
}
export function loadPluginLookUpTable(params: LoadPluginLookUpTableParams): PluginLookUpTable {
const registryResult = loadPluginRegistrySnapshotWithMetadata({
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
...(params.index ? { index: params.index } : {}),
});
const index = registryResult.snapshot;
const manifestRegistry = loadPluginManifestRegistryForInstalledIndex({
index,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
includeDisabled: true,
});
const channelPluginIds = resolveChannelPluginIdsFromRegistry({ manifestRegistry });
const configuredDeferredChannelPluginIds = resolveConfiguredDeferredChannelPluginIdsFromRegistry({
config: params.config,
env: params.env,
index,
manifestRegistry,
});
const pluginIds = resolveGatewayStartupPluginIdsFromRegistry({
config: params.config,
...(params.activationSourceConfig !== undefined
? { activationSourceConfig: params.activationSourceConfig }
: {}),
env: params.env,
index,
manifestRegistry,
});
const byPluginId = new Map(manifestRegistry.plugins.map((plugin) => [plugin.id, plugin]));
const owners = buildOwnerMaps(manifestRegistry.plugins);
const startup = {
channelPluginIds,
configuredDeferredChannelPluginIds,
pluginIds,
};
return {
key: hashJson({
policyHash: index.policyHash,
generatedAtMs: index.generatedAtMs,
plugins: index.plugins.map((plugin) => [
plugin.pluginId,
plugin.manifestHash,
plugin.installRecordHash,
]),
startup,
}),
index,
registryDiagnostics: registryResult.diagnostics,
manifestRegistry,
plugins: manifestRegistry.plugins,
diagnostics: [...index.diagnostics, ...manifestRegistry.diagnostics],
byPluginId,
owners,
startup,
};
}