mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-27 03:41:19 +00:00
* perf(plugins): thread metadata snapshot and discovery through hot paths With the snapshot memo now actually hitting, route the snapshot's manifestRegistry and discovery through the helper chains that already had fast paths for them. Eliminates redundant per-call rebuilds at two big amplifiers. - Provider resolve paths (resolvePluginProviders / isPluginProvidersLoadInFlight / resolveOwningPluginIdsForProvider / resolveExternalAuthProfilesWithPlugins) self-service a snapshot once at the public entry, then thread it as a separate required arg through resolvePluginProviderLoadBase, resolveExplicitProviderOwnerPluginIds, and the setup/runtime load state helpers. Inner reads change from 'params.pluginMetadataSnapshot?.x' to 'snapshot.x', no more enrichedParams clone. loadPluginManifestRegistryForInstalledIndex fires drop ~685 -> ~10 per cold start. - Bundled-channel / auto-enable chain accepts an optional PluginDiscoveryResult. discoverOpenClawPlugins is fired once during snapshot building (resolveInstalledPluginIndexRegistry already produced it internally; now bubbled up through loadInstalledPluginIndexWithDiscovery, PluginRegistrySnapshotResult, and onto PluginMetadataSnapshot.discovery). load-context reads metadataSnapshot.discovery and passes it through applyPluginAutoEnable, so the bundled-channel cascade (collectConfiguredChannelIds, listBundledChannelIdsWith*, listPotentialConfiguredChannelPresenceSignals) short-circuits instead of each leaf re-firing discovery. Persisted-cache path is unchanged: no discovery on the snapshot, downstream chain handles its own fallback (pre-PR behavior on that path). * test(plugins): isolate snapshot memo across tests that mock manifest registry The snapshot memo is now process-scoped and effective (~98% hit rate). Three test files were depending on cache misses (because the broken cache returned them) — each test would set up its own loadPluginManifestRegistry mock and expect a fresh derive. With the cache fixed, an earlier test's mocked registry now leaks into later tests in the same file. - io.write-config.test.ts: afterEach now clears the snapshot memo so the 'demo' plugin mocked in the first test does not survive into 'keeps shipped plugin install config records when index migration fails', which expects an empty registry to surface the 'plugin not found: demo' warning. - gateway/model-pricing-cache.ts: resetGatewayModelPricingCacheForTest also clears the memo. Tests in model-pricing-cache.test.ts assert loadPluginManifestRegistryForInstalledIndex was called; the memo hit otherwise skips the call. - providers.test.ts: vi.doMock loadPluginMetadataSnapshot to wrap the existing loadPluginManifestRegistryMock fixture. The plumbing commit added an auto-fetch fall-through in resolveOwningPluginIdsForProvider; without the mock, providers tests hit real disk reads and return empty registries (which is what surfaced as 9 unrelated-looking failures in the prior CI run). * fix(plugins): preserve setup.cliBackends owner matching in provider scan resolveOwningPluginIdsForProvider now also checks plugin.setup?.cliBackends. The pre-PR no-registry fallback used resolvePluginContributionOwners which includes both top-level cliBackends and setup.cliBackends; the PR's manifest scan replacement was missing the setup case. * fix(plugins): inherit active registry workspaceDir before loading metadata snapshot isPluginProvidersLoadInFlight and resolvePluginProviders now resolve env and workspaceDir once at the entry point (falling back to getActivePluginRegistryWorkspaceDir) and pass them into both loadPluginMetadataSnapshot and resolvePluginProviderLoadBase. Pre-fix the snapshot used params.workspaceDir raw while the load base inherited the active workspace, so workspace-scoped provider plugins could be absent from the snapshot manifest registry even though owner resolution expected them. Regression test asserts the snapshot mock receives the active workspaceDir when the caller omits it. * perf(gateway): thread discovery into applyPluginAutoEnable call sites Every gateway applyPluginAutoEnable call now passes the snapshot's PluginDiscoveryResult so the bundled-channel cascade (collectConfiguredChannelIds → listBundledChannelIdsWith* → listPotentialConfiguredChannelPresenceSignals) short-circuits instead of each leaf re-firing discovery. Startup-time sites pull discovery from the snapshot/lookup-table they already hold: - server-plugin-bootstrap.ts (pluginLookUpTable) - server-startup-plugins.ts (pluginMetadataSnapshot) - server-startup-config.ts (pluginMetadataSnapshot) - server-plugins.ts (pluginLookUpTable, both call sites) Per-RPC sites (server.impl getRuntimeConfig callback, server-methods/channels status + start handlers, server-methods/send) source discovery via getCurrentPluginMetadataSnapshot using the runtime config to validate compatibility. Falls through to the original slow path when the snapshot is absent or incompatible.
425 lines
15 KiB
TypeScript
425 lines
15 KiB
TypeScript
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { resolveUserPath } from "../utils.js";
|
|
import { resolveBundledPluginsDir } from "./bundled-dir.js";
|
|
import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js";
|
|
import type { PluginDiscoveryResult } from "./discovery.js";
|
|
import { fileSignatureMatches } from "./installed-plugin-index-hash.js";
|
|
import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js";
|
|
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js";
|
|
import {
|
|
inspectPersistedInstalledPluginIndex,
|
|
readPersistedInstalledPluginIndexSync,
|
|
refreshPersistedInstalledPluginIndex,
|
|
type InstalledPluginIndexStoreInspection,
|
|
type InstalledPluginIndexStoreOptions,
|
|
} from "./installed-plugin-index-store.js";
|
|
import {
|
|
getInstalledPluginRecord,
|
|
extractPluginInstallRecordsFromInstalledPluginIndex,
|
|
isInstalledPluginEnabled,
|
|
listInstalledPluginRecords,
|
|
loadInstalledPluginIndexWithDiscovery,
|
|
resolveInstalledPluginIndexPolicyHash,
|
|
type InstalledPluginIndex,
|
|
type InstalledPluginIndexRecord,
|
|
type LoadInstalledPluginIndexParams,
|
|
type RefreshInstalledPluginIndexParams,
|
|
} from "./installed-plugin-index.js";
|
|
import type { PluginRegistrySnapshotSource } from "./plugin-registry-snapshot.types.js";
|
|
|
|
export type PluginRegistrySnapshot = InstalledPluginIndex;
|
|
export type PluginRegistryRecord = InstalledPluginIndexRecord;
|
|
export type PluginRegistryInspection = InstalledPluginIndexStoreInspection;
|
|
export type { PluginRegistrySnapshotSource } from "./plugin-registry-snapshot.types.js";
|
|
export type PluginRegistrySnapshotDiagnosticCode =
|
|
| "persisted-registry-disabled"
|
|
| "persisted-registry-missing"
|
|
| "persisted-registry-stale-policy"
|
|
| "persisted-registry-stale-source";
|
|
|
|
export type PluginRegistrySnapshotDiagnostic = {
|
|
level: "info" | "warn";
|
|
code: PluginRegistrySnapshotDiagnosticCode;
|
|
message: string;
|
|
};
|
|
|
|
export type PluginRegistrySnapshotResult = {
|
|
snapshot: PluginRegistrySnapshot;
|
|
source: PluginRegistrySnapshotSource;
|
|
diagnostics: readonly PluginRegistrySnapshotDiagnostic[];
|
|
discovery?: PluginDiscoveryResult;
|
|
};
|
|
|
|
export const DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV = "OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY";
|
|
|
|
function formatDeprecatedPersistedRegistryDisableWarning(): string {
|
|
return `${DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV} is a deprecated break-glass compatibility switch; use \`openclaw plugins registry --refresh\` or \`openclaw doctor --fix\` to repair registry state.`;
|
|
}
|
|
|
|
export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams &
|
|
InstalledPluginIndexStoreOptions & {
|
|
index?: PluginRegistrySnapshot;
|
|
preferPersisted?: boolean;
|
|
};
|
|
|
|
export type GetPluginRecordParams = LoadPluginRegistryParams & {
|
|
pluginId: string;
|
|
};
|
|
|
|
function hasEnvFlag(env: NodeJS.ProcessEnv, name: string): boolean {
|
|
const value = env[name]?.trim().toLowerCase();
|
|
return Boolean(value && value !== "0" && value !== "false" && value !== "no");
|
|
}
|
|
|
|
function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams): boolean {
|
|
return (
|
|
params.preferPersisted !== false &&
|
|
params.stateDir === undefined &&
|
|
params.filePath === undefined &&
|
|
params.pluginIndexFilePath === undefined &&
|
|
params.installRecords === undefined &&
|
|
params.candidates === undefined &&
|
|
params.diagnostics === undefined &&
|
|
params.now === undefined
|
|
);
|
|
}
|
|
|
|
function loadCurrentPluginRegistrySnapshotResult(
|
|
params: LoadPluginRegistryParams,
|
|
): PluginRegistrySnapshotResult | undefined {
|
|
if (!canReuseCurrentPluginMetadataSnapshot(params)) {
|
|
return undefined;
|
|
}
|
|
const env = params.env ?? process.env;
|
|
const current = getCurrentPluginMetadataSnapshot({
|
|
config: params.config,
|
|
env,
|
|
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
|
|
...(params.workspaceDir === undefined ? { allowWorkspaceScopedSnapshot: true } : {}),
|
|
});
|
|
if (!current || current.registryDiagnostics.length > 0) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
snapshot: current.index,
|
|
source: "provided",
|
|
diagnostics: current.registryDiagnostics,
|
|
};
|
|
}
|
|
|
|
function hasMissingPersistedPluginSource(index: InstalledPluginIndex): boolean {
|
|
return index.plugins.some((plugin) => {
|
|
if (!plugin.enabled) {
|
|
return false;
|
|
}
|
|
return (
|
|
!fs.existsSync(plugin.rootDir) ||
|
|
(!hasOptionalMissingPluginManifestFile(plugin) && !fs.existsSync(plugin.manifestPath)) ||
|
|
(plugin.source ? !fs.existsSync(plugin.source) : false) ||
|
|
(plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false)
|
|
);
|
|
});
|
|
}
|
|
|
|
function resolveComparablePath(filePath: string): string {
|
|
try {
|
|
return fs.realpathSync(filePath);
|
|
} catch {
|
|
return path.resolve(filePath);
|
|
}
|
|
}
|
|
|
|
function isRelativePathInsideOrEqual(relativePath: string): boolean {
|
|
return (
|
|
relativePath === "" ||
|
|
(relativePath !== ".." &&
|
|
!relativePath.startsWith(`..${path.sep}`) &&
|
|
!path.isAbsolute(relativePath))
|
|
);
|
|
}
|
|
|
|
function isPathInsideOrEqual(childPath: string, parentPath: string): boolean {
|
|
const relative = path.relative(
|
|
resolveComparablePath(parentPath),
|
|
resolveComparablePath(childPath),
|
|
);
|
|
return isRelativePathInsideOrEqual(relative);
|
|
}
|
|
|
|
function hasMismatchedPersistedBundledPluginRoot(
|
|
index: InstalledPluginIndex,
|
|
env: NodeJS.ProcessEnv,
|
|
): boolean {
|
|
const bundledPluginsDir = resolveBundledPluginsDir(env);
|
|
if (!bundledPluginsDir) {
|
|
return false;
|
|
}
|
|
return index.plugins.some(
|
|
(plugin) =>
|
|
plugin.origin === "bundled" && !isPathInsideOrEqual(plugin.rootDir, bundledPluginsDir),
|
|
);
|
|
}
|
|
|
|
function hashExistingFile(filePath: string): string | null {
|
|
try {
|
|
return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveRecordPackageJsonPath(plugin: InstalledPluginIndexRecord): string | null {
|
|
const packageJsonPath = plugin.packageJson?.path;
|
|
if (!packageJsonPath) {
|
|
return null;
|
|
}
|
|
const rootDir = plugin.rootDir || path.dirname(plugin.manifestPath);
|
|
const resolved = path.resolve(rootDir, packageJsonPath);
|
|
const relative = path.relative(rootDir, resolved);
|
|
if (!isRelativePathInsideOrEqual(relative)) {
|
|
return null;
|
|
}
|
|
const realRelative = path.relative(
|
|
resolveComparablePath(rootDir),
|
|
resolveComparablePath(resolved),
|
|
);
|
|
return isRelativePathInsideOrEqual(realRelative) ? resolved : null;
|
|
}
|
|
|
|
function hasStalePersistedPluginDiagnostics(index: InstalledPluginIndex): boolean {
|
|
return index.diagnostics.some((diag) => {
|
|
const source = diag.source;
|
|
return (
|
|
typeof diag.pluginId === "string" &&
|
|
diag.pluginId.trim().length > 0 &&
|
|
typeof source === "string" &&
|
|
path.isAbsolute(source) &&
|
|
!fs.existsSync(source)
|
|
);
|
|
});
|
|
}
|
|
|
|
function hasStalePersistedPluginMetadata(index: InstalledPluginIndex): boolean {
|
|
return index.plugins.some((plugin) => {
|
|
if (!hasOptionalMissingPluginManifestFile(plugin)) {
|
|
const manifestSignatureMatches = fileSignatureMatches(
|
|
plugin.manifestPath,
|
|
plugin.manifestFile,
|
|
);
|
|
if (manifestSignatureMatches !== true) {
|
|
const manifestHash = hashExistingFile(plugin.manifestPath);
|
|
if (manifestHash && manifestHash !== plugin.manifestHash) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
const packageJsonPath = resolveRecordPackageJsonPath(plugin);
|
|
if (!plugin.packageJson?.hash) {
|
|
return false;
|
|
}
|
|
if (!packageJsonPath) {
|
|
return true;
|
|
}
|
|
const packageJsonSignatureMatches = fileSignatureMatches(
|
|
packageJsonPath,
|
|
plugin.packageJson.fileSignature,
|
|
);
|
|
if (packageJsonSignatureMatches === true && plugin.origin === "bundled") {
|
|
return false;
|
|
}
|
|
if (packageJsonSignatureMatches === false) {
|
|
return hashExistingFile(packageJsonPath) !== plugin.packageJson.hash;
|
|
}
|
|
// Fast same-size rewrites can preserve observable stat fields on some filesystems.
|
|
const packageJsonHash = hashExistingFile(packageJsonPath);
|
|
return packageJsonHash !== plugin.packageJson.hash;
|
|
});
|
|
}
|
|
|
|
function loadSnapshotInstallRecords(params: LoadPluginRegistryParams, env: NodeJS.ProcessEnv) {
|
|
return loadInstalledPluginIndexInstallRecordsSync({
|
|
env,
|
|
...(params.stateDir ? { stateDir: params.stateDir } : {}),
|
|
...(params.filePath
|
|
? { filePath: params.filePath }
|
|
: params.pluginIndexFilePath
|
|
? { filePath: params.pluginIndexFilePath }
|
|
: {}),
|
|
});
|
|
}
|
|
|
|
function hasRecoveredInstallRecordsMissingFromPersistedIndex(
|
|
index: InstalledPluginIndex,
|
|
installRecords: ReturnType<typeof loadInstalledPluginIndexInstallRecordsSync>,
|
|
env: NodeJS.ProcessEnv,
|
|
): boolean {
|
|
const persistedRecords = extractPluginInstallRecordsFromInstalledPluginIndex(index);
|
|
const persistedPluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId));
|
|
return Object.entries(installRecords).some(([pluginId, record]) => {
|
|
if (persistedRecords[pluginId] && persistedPluginIds.has(pluginId)) {
|
|
return false;
|
|
}
|
|
const installPaths = [record.installPath, record.sourcePath].filter(
|
|
(candidate): candidate is string =>
|
|
typeof candidate === "string" && candidate.trim().length > 0,
|
|
);
|
|
if (installPaths.length === 0) {
|
|
return true;
|
|
}
|
|
return installPaths.some((installPath) => fs.existsSync(resolveUserPath(installPath, env)));
|
|
});
|
|
}
|
|
|
|
export function loadPluginRegistrySnapshotWithMetadata(
|
|
params: LoadPluginRegistryParams = {},
|
|
): PluginRegistrySnapshotResult {
|
|
if (params.index) {
|
|
return {
|
|
snapshot: params.index,
|
|
source: "provided",
|
|
diagnostics: [],
|
|
};
|
|
}
|
|
const current = loadCurrentPluginRegistrySnapshotResult(params);
|
|
if (current) {
|
|
return current;
|
|
}
|
|
|
|
const env = params.env ?? process.env;
|
|
const diagnostics: PluginRegistrySnapshotDiagnostic[] = [];
|
|
const disabledByCaller = params.preferPersisted === false;
|
|
const disabledByEnv = hasEnvFlag(env, DISABLE_PERSISTED_PLUGIN_REGISTRY_ENV);
|
|
const persistedReadsEnabled = !disabledByCaller && !disabledByEnv;
|
|
const persistedInstallRecordReadsEnabled = !disabledByEnv;
|
|
let persistedIndex: InstalledPluginIndex | null = null;
|
|
if (persistedInstallRecordReadsEnabled) {
|
|
persistedIndex = readPersistedInstalledPluginIndexSync(params);
|
|
if (persistedReadsEnabled && persistedIndex) {
|
|
if (
|
|
params.config &&
|
|
persistedIndex.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config)
|
|
) {
|
|
diagnostics.push({
|
|
level: "warn",
|
|
code: "persisted-registry-stale-policy",
|
|
message:
|
|
"Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
|
});
|
|
} else if (hasMissingPersistedPluginSource(persistedIndex)) {
|
|
diagnostics.push({
|
|
level: "warn",
|
|
code: "persisted-registry-stale-source",
|
|
message:
|
|
"Persisted plugin registry points at missing plugin files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
|
});
|
|
} else if (hasMismatchedPersistedBundledPluginRoot(persistedIndex, env)) {
|
|
diagnostics.push({
|
|
level: "warn",
|
|
code: "persisted-registry-stale-source",
|
|
message:
|
|
"Persisted plugin registry points at a different bundled plugin tree; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
|
});
|
|
} else if (hasStalePersistedPluginDiagnostics(persistedIndex)) {
|
|
diagnostics.push({
|
|
level: "warn",
|
|
code: "persisted-registry-stale-source",
|
|
message:
|
|
"Persisted plugin registry contains diagnostics referencing missing paths; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
|
});
|
|
} else if (hasStalePersistedPluginMetadata(persistedIndex)) {
|
|
diagnostics.push({
|
|
level: "warn",
|
|
code: "persisted-registry-stale-source",
|
|
message:
|
|
"Persisted plugin registry metadata no longer matches plugin manifest or package files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
|
});
|
|
} else if (
|
|
hasRecoveredInstallRecordsMissingFromPersistedIndex(
|
|
persistedIndex,
|
|
loadSnapshotInstallRecords(params, env),
|
|
env,
|
|
)
|
|
) {
|
|
diagnostics.push({
|
|
level: "warn",
|
|
code: "persisted-registry-stale-source",
|
|
message:
|
|
"Persisted plugin registry is missing recoverable managed npm plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.",
|
|
});
|
|
} else {
|
|
const persistedResult: PluginRegistrySnapshotResult = {
|
|
snapshot: persistedIndex,
|
|
source: "persisted",
|
|
diagnostics,
|
|
};
|
|
return persistedResult;
|
|
}
|
|
} else if (persistedReadsEnabled) {
|
|
diagnostics.push({
|
|
level: "info",
|
|
code: "persisted-registry-missing",
|
|
message: "Persisted plugin registry is missing or invalid; using derived plugin index.",
|
|
});
|
|
}
|
|
} else {
|
|
diagnostics.push({
|
|
level: "warn",
|
|
code: "persisted-registry-disabled",
|
|
message: disabledByEnv
|
|
? `${formatDeprecatedPersistedRegistryDisableWarning()} Using legacy derived plugin index.`
|
|
: "Persisted plugin registry reads are disabled by the caller; using derived plugin index.",
|
|
});
|
|
}
|
|
|
|
const derived = loadInstalledPluginIndexWithDiscovery({
|
|
...params,
|
|
installRecords: persistedInstallRecordReadsEnabled
|
|
? params.installRecords
|
|
: (params.installRecords ?? {}),
|
|
});
|
|
return {
|
|
snapshot: derived.index,
|
|
source: "derived",
|
|
diagnostics,
|
|
discovery: derived.discovery,
|
|
};
|
|
}
|
|
|
|
function resolveSnapshot(params: LoadPluginRegistryParams = {}): PluginRegistrySnapshot {
|
|
return loadPluginRegistrySnapshotWithMetadata(params).snapshot;
|
|
}
|
|
|
|
export function loadPluginRegistrySnapshot(
|
|
params: LoadPluginRegistryParams = {},
|
|
): PluginRegistrySnapshot {
|
|
return resolveSnapshot(params);
|
|
}
|
|
|
|
export function listPluginRecords(
|
|
params: LoadPluginRegistryParams = {},
|
|
): readonly PluginRegistryRecord[] {
|
|
return listInstalledPluginRecords(resolveSnapshot(params));
|
|
}
|
|
|
|
export function getPluginRecord(params: GetPluginRecordParams): PluginRegistryRecord | undefined {
|
|
return getInstalledPluginRecord(resolveSnapshot(params), params.pluginId);
|
|
}
|
|
|
|
export function isPluginEnabled(params: GetPluginRecordParams): boolean {
|
|
return isInstalledPluginEnabled(resolveSnapshot(params), params.pluginId, params.config);
|
|
}
|
|
|
|
export function inspectPluginRegistry(
|
|
params: LoadInstalledPluginIndexParams & InstalledPluginIndexStoreOptions = {},
|
|
): Promise<PluginRegistryInspection> {
|
|
return inspectPersistedInstalledPluginIndex(params);
|
|
}
|
|
|
|
export function refreshPluginRegistry(
|
|
params: RefreshInstalledPluginIndexParams & InstalledPluginIndexStoreOptions,
|
|
): Promise<PluginRegistrySnapshot> {
|
|
return refreshPersistedInstalledPluginIndex(params);
|
|
}
|