mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-02 15:21:04 +00:00
perf: cache stable gateway metadata
This commit is contained in:
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Changes
|
||||
|
||||
- Gateway/perf: reuse process-stable channel catalog reads, avoid repeated bundled-channel boundary checks, and rotate gateway watch CPU profiles so benchmark runs do not accumulate unbounded artifacts.
|
||||
- Gateway/perf: cache stable install-record, channel-catalog, bundled-channel, and Telegram session-store metadata during process-local hot paths to reduce repeated JSON and manifest reads.
|
||||
- Gateway/perf: reuse immutable plugin metadata snapshots across startup, config, model, channel, setup, and secret metadata readers so hot paths avoid repeated plugin file stats and manifest registry reloads.
|
||||
- Talk/realtime: let WebUI and Discord voice callers ask for active OpenClaw run status, cancel, steer, or queue follow-up work while a consult is still running. (#84231) Thanks @Solvely-Colin.
|
||||
- Gateway/perf: lazy-load startup-idle plugin work, core gateway method handlers, and the embedded ACPX runtime so Gateway health and ready signals no longer wait on unused handler trees or ACPX probes.
|
||||
|
||||
@@ -235,23 +235,48 @@ type DispatchTelegramMessageParams = {
|
||||
type TelegramReasoningLevel = "off" | "on" | "stream";
|
||||
|
||||
type TelegramTranscriptMirrorPayload = { text?: string; mediaUrls?: string[] };
|
||||
type TelegramSessionStore = ReturnType<typeof loadSessionStore>;
|
||||
type FreshTelegramSessionStoreLoader = ((agentId: string) => {
|
||||
storePath: string;
|
||||
store: TelegramSessionStore;
|
||||
}) & {
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
function createFreshTelegramSessionStoreLoader(params: {
|
||||
cfg: OpenClawConfig;
|
||||
telegramDeps: TelegramBotDeps;
|
||||
}): FreshTelegramSessionStoreLoader {
|
||||
const storesByPath = new Map<string, TelegramSessionStore>();
|
||||
const load = ((agentId: string) => {
|
||||
const storePath = params.telegramDeps.resolveStorePath(params.cfg.session?.store, { agentId });
|
||||
const cachedStore = storesByPath.get(storePath);
|
||||
if (cachedStore) {
|
||||
return { storePath, store: cachedStore };
|
||||
}
|
||||
const store = (params.telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, {
|
||||
skipCache: true,
|
||||
});
|
||||
storesByPath.set(storePath, store);
|
||||
return { storePath, store };
|
||||
}) as FreshTelegramSessionStoreLoader;
|
||||
load.clear = () => storesByPath.clear();
|
||||
return load;
|
||||
}
|
||||
|
||||
function resolveTelegramReasoningLevel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey?: string;
|
||||
agentId: string;
|
||||
telegramDeps: TelegramBotDeps;
|
||||
loadFreshSessionStore: FreshTelegramSessionStoreLoader;
|
||||
}): TelegramReasoningLevel {
|
||||
const { cfg, sessionKey, agentId, telegramDeps } = params;
|
||||
const { cfg, sessionKey, agentId } = params;
|
||||
const configDefault = resolveTelegramConfigReasoningDefault(cfg, agentId);
|
||||
if (!sessionKey) {
|
||||
return configDefault;
|
||||
}
|
||||
try {
|
||||
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, { agentId });
|
||||
const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, {
|
||||
skipCache: true,
|
||||
});
|
||||
const { store } = params.loadFreshSessionStore(agentId);
|
||||
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
|
||||
const level = entry?.reasoningLevel;
|
||||
if (level === "on" || level === "stream" || level === "off") {
|
||||
@@ -285,19 +310,14 @@ async function mirrorTelegramAssistantReplyToTranscript(params: {
|
||||
cfg: OpenClawConfig;
|
||||
route: TelegramMessageContext["route"];
|
||||
sessionKey: string;
|
||||
telegramDeps: TelegramBotDeps;
|
||||
loadFreshSessionStore: FreshTelegramSessionStoreLoader;
|
||||
payload: TelegramTranscriptMirrorPayload;
|
||||
}) {
|
||||
const text = resolveTelegramMirroredTranscriptText(params.payload);
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const storePath = params.telegramDeps.resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.route.agentId,
|
||||
});
|
||||
const store = (params.telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, {
|
||||
skipCache: true,
|
||||
});
|
||||
const { storePath, store } = params.loadFreshSessionStore(params.route.agentId);
|
||||
const sessionEntry = resolveSessionStoreEntry({
|
||||
store,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -384,6 +404,7 @@ export const dispatchTelegramMessage = async ({
|
||||
const dispatchStartedAt = Date.now();
|
||||
const telegramDeps =
|
||||
injectedTelegramDeps ?? (await import("./bot-deps.js")).defaultTelegramBotDeps;
|
||||
const loadFreshSessionStore = createFreshTelegramSessionStoreLoader({ cfg, telegramDeps });
|
||||
const {
|
||||
ctxPayload,
|
||||
msg,
|
||||
@@ -499,7 +520,7 @@ export const dispatchTelegramMessage = async ({
|
||||
cfg,
|
||||
sessionKey: ctxPayload.SessionKey,
|
||||
agentId: route.agentId,
|
||||
telegramDeps,
|
||||
loadFreshSessionStore,
|
||||
});
|
||||
const forceBlockStreamingForReasoning = resolvedReasoningLevel === "on";
|
||||
const streamReasoningDraft = resolvedReasoningLevel === "stream";
|
||||
@@ -960,12 +981,7 @@ export const dispatchTelegramMessage = async ({
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, {
|
||||
skipCache: true,
|
||||
});
|
||||
const { storePath, store } = loadFreshSessionStore(route.agentId);
|
||||
const sessionEntry = resolveSessionStoreEntry({
|
||||
store,
|
||||
sessionKey,
|
||||
@@ -1020,7 +1036,7 @@ export const dispatchTelegramMessage = async ({
|
||||
cfg,
|
||||
route,
|
||||
sessionKey,
|
||||
telegramDeps,
|
||||
loadFreshSessionStore,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
@@ -1285,12 +1301,7 @@ export const dispatchTelegramMessage = async ({
|
||||
|
||||
if (isDmTopic) {
|
||||
try {
|
||||
const storePath = telegramDeps.resolveStorePath(cfg.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath, {
|
||||
skipCache: true,
|
||||
});
|
||||
const { store } = loadFreshSessionStore(route.agentId);
|
||||
const sessionKey = ctxPayload.SessionKey;
|
||||
if (sessionKey) {
|
||||
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
|
||||
@@ -1302,6 +1313,7 @@ export const dispatchTelegramMessage = async ({
|
||||
logVerbose(`auto-topic-label: session store error: ${formatErrorMessage(err)}`);
|
||||
}
|
||||
}
|
||||
loadFreshSessionStore.clear();
|
||||
|
||||
if (statusReactionController && !isRoomEvent) {
|
||||
void statusReactionController.setThinking();
|
||||
|
||||
@@ -90,6 +90,8 @@ type BundledChannelLoadContext = {
|
||||
ChannelId,
|
||||
NonNullable<ChannelPlugin["config"]["inspectAccount"]> | null
|
||||
>;
|
||||
metadataById: Map<ChannelId, BundledChannelPluginMetadata | null>;
|
||||
metadataLoaded: boolean;
|
||||
};
|
||||
|
||||
const log = createSubsystemLogger("channels");
|
||||
@@ -373,6 +375,8 @@ function createBundledChannelLoadContext(): BundledChannelLoadContext {
|
||||
lazySecretsById: new Map(),
|
||||
lazySetupSecretsById: new Map(),
|
||||
lazyAccountInspectorsById: new Map(),
|
||||
metadataById: new Map(),
|
||||
metadataLoaded: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -502,19 +506,39 @@ export function hasBundledChannelPackageSetupFeature(
|
||||
id: ChannelId,
|
||||
feature: BundledChannelPackageSetupFeature,
|
||||
): boolean {
|
||||
const rootScope = resolveBundledChannelRootScope();
|
||||
const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope();
|
||||
return (
|
||||
resolveBundledChannelMetadata(id, rootScope)?.packageManifest?.setupFeatures?.[feature] === true
|
||||
resolveBundledChannelMetadata(id, rootScope, loadContext)?.packageManifest?.setupFeatures?.[
|
||||
feature
|
||||
] === true
|
||||
);
|
||||
}
|
||||
|
||||
function resolveBundledChannelMetadata(
|
||||
id: ChannelId,
|
||||
rootScope: BundledChannelRootScope,
|
||||
loadContext: BundledChannelLoadContext,
|
||||
): BundledChannelPluginMetadata | undefined {
|
||||
return listBundledChannelMetadata(rootScope).find(
|
||||
(metadata) => metadata.manifest.id === id || metadata.manifest.channels?.includes(id),
|
||||
);
|
||||
if (loadContext.metadataById.has(id)) {
|
||||
return loadContext.metadataById.get(id) ?? undefined;
|
||||
}
|
||||
if (loadContext.metadataLoaded) {
|
||||
loadContext.metadataById.set(id, null);
|
||||
return undefined;
|
||||
}
|
||||
for (const metadata of listBundledChannelMetadata(rootScope)) {
|
||||
const ids = new Set<ChannelId>([metadata.manifest.id, ...(metadata.manifest.channels ?? [])]);
|
||||
for (const metadataId of ids) {
|
||||
loadContext.metadataById.set(metadataId, metadata);
|
||||
}
|
||||
}
|
||||
loadContext.metadataLoaded = true;
|
||||
const metadata = loadContext.metadataById.get(id);
|
||||
if (metadata) {
|
||||
return metadata;
|
||||
}
|
||||
loadContext.metadataById.set(id, null);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getLazyGeneratedBundledChannelEntryForRoot(
|
||||
@@ -529,7 +553,7 @@ function getLazyGeneratedBundledChannelEntryForRoot(
|
||||
if (previous === null) {
|
||||
return null;
|
||||
}
|
||||
const metadata = resolveBundledChannelMetadata(id, rootScope);
|
||||
const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext);
|
||||
if (!metadata) {
|
||||
loadContext.lazyEntriesById.set(id, null);
|
||||
return null;
|
||||
@@ -577,7 +601,7 @@ function getLazyGeneratedBundledChannelSetupEntryForRoot(
|
||||
if (loadContext.lazySetupEntriesById.has(id)) {
|
||||
return loadContext.lazySetupEntriesById.get(id) ?? null;
|
||||
}
|
||||
const metadata = resolveBundledChannelMetadata(id, rootScope);
|
||||
const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext);
|
||||
if (!metadata) {
|
||||
loadContext.lazySetupEntriesById.set(id, null);
|
||||
return null;
|
||||
@@ -615,7 +639,7 @@ function getBundledChannelPluginForRoot(
|
||||
}
|
||||
loadContext.pluginLoadInProgressIds.add(id);
|
||||
try {
|
||||
const metadata = resolveBundledChannelMetadata(id, rootScope);
|
||||
const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext);
|
||||
const plugin = entry.loadChannelPlugin() as ChannelPlugin | undefined;
|
||||
if (!plugin) {
|
||||
loadContext.lazyPluginsById.set(id, null);
|
||||
|
||||
@@ -65,6 +65,30 @@ function firstDiscoverOptions(discoverSpy: ReturnType<typeof vi.fn>): Record<str
|
||||
return options as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function createChannelCandidate(params: {
|
||||
idHint?: string;
|
||||
pluginId?: string;
|
||||
bundledPluginId?: string;
|
||||
origin?: PluginCandidate["origin"];
|
||||
}): PluginCandidate {
|
||||
return {
|
||||
idHint: params.idHint ?? "hint-plugin",
|
||||
source: "/tmp/openclaw-test-plugin/index.js",
|
||||
rootDir: "/tmp/openclaw-test-plugin",
|
||||
origin: params.origin ?? "global",
|
||||
packageName: "@vendor/openclaw-test-plugin",
|
||||
packageManifest: {
|
||||
...(params.pluginId ? { plugin: { id: params.pluginId } } : {}),
|
||||
channel: {
|
||||
id: "test-channel",
|
||||
name: "Test Channel",
|
||||
description: "Test channel",
|
||||
},
|
||||
},
|
||||
...(params.bundledPluginId ? { bundledManifestId: params.bundledPluginId } : {}),
|
||||
} as PluginCandidate;
|
||||
}
|
||||
|
||||
describe("listChannelCatalogEntries", () => {
|
||||
it("forwards lazily loaded install records to discovery when origin is unspecified", async () => {
|
||||
const { module, discoverSpy, loadRecordsSpy } = await loadWithMocks({});
|
||||
@@ -134,4 +158,53 @@ describe("listChannelCatalogEntries", () => {
|
||||
expect(discoverSpy).toHaveBeenCalledTimes(1);
|
||||
expect(firstDiscoverOptions(discoverSpy)).not.toHaveProperty("installRecords");
|
||||
});
|
||||
|
||||
it("uses discovered package metadata for channel plugin ids", async () => {
|
||||
const { module, loadRecordsSpy } = await loadWithMocks({});
|
||||
|
||||
expect(
|
||||
module.listChannelCatalogEntries({
|
||||
installRecords: {},
|
||||
discovery: {
|
||||
candidates: [createChannelCandidate({ pluginId: "package-plugin" })],
|
||||
diagnostics: [],
|
||||
},
|
||||
}),
|
||||
).toStrictEqual([
|
||||
{
|
||||
pluginId: "package-plugin",
|
||||
origin: "global",
|
||||
packageName: "@vendor/openclaw-test-plugin",
|
||||
workspaceDir: undefined,
|
||||
rootDir: "/tmp/openclaw-test-plugin",
|
||||
channel: {
|
||||
id: "test-channel",
|
||||
name: "Test Channel",
|
||||
description: "Test channel",
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(loadRecordsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers bundled manifest ids over package id hints", async () => {
|
||||
const { module } = await loadWithMocks({});
|
||||
|
||||
expect(
|
||||
module.listChannelCatalogEntries({
|
||||
installRecords: {},
|
||||
discovery: {
|
||||
candidates: [
|
||||
createChannelCandidate({
|
||||
idHint: "hint-plugin",
|
||||
pluginId: "package-plugin",
|
||||
bundledPluginId: "bundled-plugin",
|
||||
origin: "bundled",
|
||||
}),
|
||||
],
|
||||
diagnostics: [],
|
||||
},
|
||||
})[0]?.pluginId,
|
||||
).toBe("bundled-plugin");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import { discoverOpenClawPlugins, type PluginDiscoveryResult } from "./discovery.js";
|
||||
import { shouldRejectHardlinkedPluginFiles } from "./hardlink-policy.js";
|
||||
import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js";
|
||||
import {
|
||||
loadPluginManifest,
|
||||
type PluginPackageChannel,
|
||||
type PluginPackageInstall,
|
||||
} from "./manifest.js";
|
||||
import type { PluginPackageChannel, PluginPackageInstall } from "./manifest.js";
|
||||
import type { PluginOrigin } from "./plugin-origin.types.js";
|
||||
|
||||
export type PluginChannelCatalogEntry = {
|
||||
@@ -50,20 +45,13 @@ export function listChannelCatalogEntries(
|
||||
if (!channel?.id) {
|
||||
return [];
|
||||
}
|
||||
const manifest = loadPluginManifest(
|
||||
candidate.rootDir,
|
||||
shouldRejectHardlinkedPluginFiles({
|
||||
origin: candidate.origin,
|
||||
rootDir: candidate.rootDir,
|
||||
env: params.env,
|
||||
}),
|
||||
);
|
||||
if (!manifest.ok) {
|
||||
const pluginId = resolveChannelCatalogPluginId(candidate);
|
||||
if (!pluginId) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
pluginId: manifest.manifest.id,
|
||||
pluginId,
|
||||
origin: candidate.origin,
|
||||
packageName: candidate.packageName,
|
||||
workspaceDir: candidate.workspaceDir,
|
||||
@@ -77,6 +65,21 @@ export function listChannelCatalogEntries(
|
||||
});
|
||||
}
|
||||
|
||||
function resolveOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function resolveChannelCatalogPluginId(
|
||||
candidate: PluginDiscoveryResult["candidates"][number],
|
||||
): string | undefined {
|
||||
return (
|
||||
resolveOptionalString(candidate.bundledManifest?.id) ??
|
||||
resolveOptionalString(candidate.bundledManifestId) ??
|
||||
resolveOptionalString(candidate.packageManifest?.plugin?.id) ??
|
||||
resolveOptionalString(candidate.idHint)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInstallRecords(params: {
|
||||
origin?: PluginOrigin;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
|
||||
@@ -71,6 +71,7 @@ export type PluginCandidate = {
|
||||
packageManifest?: OpenClawPackageManifest;
|
||||
packageDependencies?: PluginDependencySpecMap;
|
||||
packageOptionalDependencies?: PluginDependencySpecMap;
|
||||
bundledManifestId?: string;
|
||||
bundledManifest?: PluginManifest;
|
||||
bundledManifestPath?: string;
|
||||
rawPackageManifest?: PackageManifest;
|
||||
@@ -628,6 +629,7 @@ function addCandidate(params: {
|
||||
workspaceDir?: string;
|
||||
manifest?: PackageManifest | null;
|
||||
packageDir?: string;
|
||||
bundledManifestId?: string;
|
||||
bundledManifest?: PluginManifest;
|
||||
bundledManifestPath?: string;
|
||||
realpathCache: Map<string, string>;
|
||||
@@ -675,6 +677,7 @@ function addCandidate(params: {
|
||||
packageDependencies: packageDependencies.dependencies,
|
||||
packageOptionalDependencies: packageDependencies.optionalDependencies,
|
||||
rawPackageManifest: manifest ?? undefined,
|
||||
bundledManifestId: params.bundledManifestId,
|
||||
bundledManifest: params.bundledManifest,
|
||||
bundledManifestPath: params.bundledManifestPath,
|
||||
});
|
||||
@@ -731,6 +734,8 @@ function discoverBundleInRoot(params: {
|
||||
workspaceDir: params.workspaceDir,
|
||||
manifest: params.manifest,
|
||||
packageDir: params.rootDir,
|
||||
bundledManifestId: bundleManifest.manifest.id,
|
||||
bundledManifestPath: bundleManifest.manifestPath,
|
||||
realpathCache: params.realpathCache,
|
||||
});
|
||||
return "added";
|
||||
|
||||
@@ -80,6 +80,12 @@ function readManifestPluginId(packageDir: string): string | undefined {
|
||||
return id || undefined;
|
||||
}
|
||||
|
||||
function resolveRecoveredManagedNpmRoot(options: InstalledPluginIndexStoreOptions = {}): string {
|
||||
return path.resolve(
|
||||
options.stateDir ? path.join(options.stateDir, "npm") : resolveDefaultPluginNpmDir(options.env),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRecoveredManagedNpmPluginId(params: {
|
||||
packageName: string;
|
||||
packageDir: string;
|
||||
@@ -99,9 +105,7 @@ function resolveRecoveredManagedNpmPluginId(params: {
|
||||
function buildRecoveredManagedNpmInstallRecords(
|
||||
options: InstalledPluginIndexStoreOptions = {},
|
||||
): Record<string, PluginInstallRecord> {
|
||||
const npmRoot = options.stateDir
|
||||
? path.join(options.stateDir, "npm")
|
||||
: resolveDefaultPluginNpmDir(options.env);
|
||||
const npmRoot = resolveRecoveredManagedNpmRoot(options);
|
||||
const rootManifest = readJsonObjectFileSync(path.join(npmRoot, "package.json"));
|
||||
const dependencies = readStringRecord(rootManifest?.dependencies);
|
||||
const records: Record<string, PluginInstallRecord> = {};
|
||||
@@ -229,24 +233,90 @@ export function readPersistedInstalledPluginIndexInstallRecordsSync(
|
||||
return extractPluginInstallRecordsFromPersistedInstalledPluginIndex(parsed);
|
||||
}
|
||||
|
||||
type InstallRecordsCacheEntry = {
|
||||
records: Record<string, PluginInstallRecord>;
|
||||
signature: string;
|
||||
};
|
||||
|
||||
const installRecordsCache = new Map<string, InstallRecordsCacheEntry>();
|
||||
|
||||
function readFileSignature(filePath: string): string {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
return `${stat.mtimeMs}:${stat.size}`;
|
||||
} catch {
|
||||
return "missing";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInstallRecordsCacheKey(options: InstalledPluginIndexStoreOptions): string {
|
||||
return [
|
||||
path.resolve(resolveInstalledPluginIndexStorePath(options)),
|
||||
resolveRecoveredManagedNpmRoot(options),
|
||||
].join("\0");
|
||||
}
|
||||
|
||||
function resolveManagedNpmInstallSignature(options: InstalledPluginIndexStoreOptions): string {
|
||||
const npmRoot = resolveRecoveredManagedNpmRoot(options);
|
||||
const rootManifestPath = path.join(npmRoot, "package.json");
|
||||
const rootManifest = readJsonObjectFileSync(rootManifestPath);
|
||||
const dependencies = readStringRecord(rootManifest?.dependencies);
|
||||
const packageSignatures = Object.keys(dependencies).map((packageName) => {
|
||||
const packageDir = path.join(npmRoot, "node_modules", packageName);
|
||||
return [
|
||||
packageName,
|
||||
readFileSignature(path.join(packageDir, "package.json")),
|
||||
readFileSignature(path.join(packageDir, "openclaw.plugin.json")),
|
||||
].join(":");
|
||||
});
|
||||
return [readFileSignature(rootManifestPath), ...packageSignatures].join("\0");
|
||||
}
|
||||
|
||||
function resolveInstallRecordsCacheSignature(options: InstalledPluginIndexStoreOptions): string {
|
||||
return [
|
||||
readFileSignature(path.resolve(resolveInstalledPluginIndexStorePath(options))),
|
||||
resolveManagedNpmInstallSignature(options),
|
||||
].join("\0");
|
||||
}
|
||||
|
||||
export function clearLoadInstalledPluginIndexInstallRecordsCache(): void {
|
||||
installRecordsCache.clear();
|
||||
}
|
||||
|
||||
export async function loadInstalledPluginIndexInstallRecords(
|
||||
params: InstalledPluginIndexStoreOptions = {},
|
||||
): Promise<Record<string, PluginInstallRecord>> {
|
||||
return cloneInstallRecords(
|
||||
const cacheKey = resolveInstallRecordsCacheKey(params);
|
||||
const signature = resolveInstallRecordsCacheSignature(params);
|
||||
const cached = installRecordsCache.get(cacheKey);
|
||||
if (cached?.signature === signature) {
|
||||
return cloneInstallRecords(cached.records);
|
||||
}
|
||||
const records = cloneInstallRecords(
|
||||
mergeRecoveredManagedNpmInstallRecords(
|
||||
await readPersistedInstalledPluginIndexInstallRecords(params),
|
||||
params,
|
||||
),
|
||||
);
|
||||
installRecordsCache.set(cacheKey, { records, signature });
|
||||
return cloneInstallRecords(records);
|
||||
}
|
||||
|
||||
export function loadInstalledPluginIndexInstallRecordsSync(
|
||||
params: InstalledPluginIndexStoreOptions = {},
|
||||
): Record<string, PluginInstallRecord> {
|
||||
return cloneInstallRecords(
|
||||
const cacheKey = resolveInstallRecordsCacheKey(params);
|
||||
const signature = resolveInstallRecordsCacheSignature(params);
|
||||
const cached = installRecordsCache.get(cacheKey);
|
||||
if (cached?.signature === signature) {
|
||||
return cloneInstallRecords(cached.records);
|
||||
}
|
||||
const records = cloneInstallRecords(
|
||||
mergeRecoveredManagedNpmInstallRecords(
|
||||
readPersistedInstalledPluginIndexInstallRecordsSync(params),
|
||||
params,
|
||||
),
|
||||
);
|
||||
installRecordsCache.set(cacheKey, { records, signature });
|
||||
return cloneInstallRecords(records);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import type { PluginCandidate } from "./discovery.js";
|
||||
import {
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache,
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
loadInstalledPluginIndexInstallRecordsSync,
|
||||
readPersistedInstalledPluginIndexInstallRecords,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
resolveInstalledPluginIndexRecordsStorePath,
|
||||
withoutPluginInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecordsSync,
|
||||
} from "./installed-plugin-index-records.js";
|
||||
import { writeManagedNpmPlugin } from "./test-helpers/managed-npm-plugin.js";
|
||||
|
||||
@@ -57,6 +59,7 @@ function expectRecordFields(record: unknown, expected: Record<string, unknown>)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -170,6 +173,111 @@ describe("plugin index install records store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("returns cloned cached records", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const candidate = createPluginCandidate(stateDir, "cached");
|
||||
await writePersistedInstalledPluginIndexInstallRecords(
|
||||
{
|
||||
cached: {
|
||||
source: "npm",
|
||||
spec: "cached@1.0.0",
|
||||
},
|
||||
},
|
||||
{ stateDir, candidates: [candidate] },
|
||||
);
|
||||
|
||||
const first = loadInstalledPluginIndexInstallRecordsSync({ stateDir });
|
||||
first.cached.spec = "mutated@1.0.0";
|
||||
|
||||
expect(loadInstalledPluginIndexInstallRecordsSync({ stateDir })).toEqual({
|
||||
cached: {
|
||||
source: "npm",
|
||||
spec: "cached@1.0.0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates cached records when the persisted index is rewritten", () => {
|
||||
const stateDir = makeStateDir();
|
||||
const first = createPluginCandidate(stateDir, "first");
|
||||
writePersistedInstalledPluginIndexInstallRecordsSync(
|
||||
{
|
||||
first: {
|
||||
source: "npm",
|
||||
spec: "first@1.0.0",
|
||||
},
|
||||
},
|
||||
{ stateDir, candidates: [first] },
|
||||
);
|
||||
expect(loadInstalledPluginIndexInstallRecordsSync({ stateDir })).toEqual({
|
||||
first: {
|
||||
source: "npm",
|
||||
spec: "first@1.0.0",
|
||||
},
|
||||
});
|
||||
|
||||
const second = createPluginCandidate(stateDir, "second");
|
||||
writePersistedInstalledPluginIndexInstallRecordsSync(
|
||||
{
|
||||
second: {
|
||||
source: "npm",
|
||||
spec: "second@1.0.0",
|
||||
},
|
||||
},
|
||||
{ stateDir, candidates: [second] },
|
||||
);
|
||||
|
||||
expect(loadInstalledPluginIndexInstallRecordsSync({ stateDir })).toEqual({
|
||||
second: {
|
||||
source: "npm",
|
||||
spec: "second@1.0.0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reloads cached records after an external index write", () => {
|
||||
const stateDir = makeStateDir();
|
||||
const candidate = createPluginCandidate(stateDir, "external");
|
||||
writePersistedInstalledPluginIndexInstallRecordsSync(
|
||||
{
|
||||
external: {
|
||||
source: "npm",
|
||||
spec: "external@1.0.0",
|
||||
},
|
||||
},
|
||||
{ stateDir, candidates: [candidate] },
|
||||
);
|
||||
expect(loadInstalledPluginIndexInstallRecordsSync({ stateDir })).toEqual({
|
||||
external: {
|
||||
source: "npm",
|
||||
spec: "external@1.0.0",
|
||||
},
|
||||
});
|
||||
|
||||
const indexPath = resolveInstalledPluginIndexRecordsStorePath({ stateDir });
|
||||
const persisted = JSON.parse(fs.readFileSync(indexPath, "utf8")) as Record<string, unknown>;
|
||||
fs.writeFileSync(
|
||||
indexPath,
|
||||
JSON.stringify({
|
||||
...persisted,
|
||||
installRecords: {
|
||||
external: {
|
||||
source: "npm",
|
||||
spec: "external-plugin@2.0.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(loadInstalledPluginIndexInstallRecordsSync({ stateDir })).toEqual({
|
||||
external: {
|
||||
source: "npm",
|
||||
spec: "external-plugin@2.0.0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("reads legacy persisted records when the plugin index has no plugin list", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const indexPath = resolveInstalledPluginIndexRecordsStorePath({ stateDir });
|
||||
@@ -319,6 +427,49 @@ describe("plugin index install records store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reloads recovered managed npm records after package manifest changes", () => {
|
||||
const stateDir = makeStateDir();
|
||||
const codexDir = writeManagedNpmPlugin({
|
||||
stateDir,
|
||||
packageName: "@openclaw/codex",
|
||||
pluginId: "codex",
|
||||
version: "2026.5.18-beta.1",
|
||||
});
|
||||
const indexPath = resolveInstalledPluginIndexRecordsStorePath({ stateDir });
|
||||
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
|
||||
fs.writeFileSync(indexPath, JSON.stringify({ installRecords: {}, plugins: [] }), "utf8");
|
||||
|
||||
expectRecordFields(loadInstalledPluginIndexInstallRecordsSync({ stateDir }).codex, {
|
||||
source: "npm",
|
||||
spec: "@openclaw/codex@2026.5.18-beta.1",
|
||||
installPath: codexDir,
|
||||
version: "2026.5.18-beta.1",
|
||||
});
|
||||
|
||||
const packagePath = path.join(codexDir, "package.json");
|
||||
const packageManifest = JSON.parse(fs.readFileSync(packagePath, "utf8")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
fs.writeFileSync(
|
||||
packagePath,
|
||||
JSON.stringify({
|
||||
...packageManifest,
|
||||
version: "2026.5.19-beta.1",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expectRecordFields(loadInstalledPluginIndexInstallRecordsSync({ stateDir }).codex, {
|
||||
source: "npm",
|
||||
spec: "@openclaw/codex@2026.5.18-beta.1",
|
||||
installPath: codexDir,
|
||||
version: "2026.5.19-beta.1",
|
||||
resolvedVersion: "2026.5.19-beta.1",
|
||||
resolvedSpec: "@openclaw/codex@2026.5.19-beta.1",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves git install resolution fields in persisted records", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const candidate = createPluginCandidate(stateDir, "git-demo");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import {
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache,
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
loadInstalledPluginIndexInstallRecordsSync,
|
||||
readPersistedInstalledPluginIndexInstallRecords,
|
||||
@@ -15,6 +16,7 @@ import { type RefreshInstalledPluginIndexParams } from "./installed-plugin-index
|
||||
import { recordPluginInstall, type PluginInstallUpdate } from "./installs.js";
|
||||
|
||||
export {
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache,
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
loadInstalledPluginIndexInstallRecordsSync,
|
||||
readPersistedInstalledPluginIndexInstallRecords,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { clearCurrentPluginMetadataSnapshotState } from "./current-plugin-metada
|
||||
import { isPluginEnabledByDefaultForPlatform } from "./default-enablement.js";
|
||||
import { hashJson } from "./installed-plugin-index-hash.js";
|
||||
import { resolveCompatRegistryVersion } from "./installed-plugin-index-policy.js";
|
||||
import { clearLoadInstalledPluginIndexInstallRecordsCache } from "./installed-plugin-index-record-reader.js";
|
||||
import {
|
||||
resolveInstalledPluginIndexStorePath,
|
||||
type InstalledPluginIndexStoreOptions,
|
||||
@@ -187,6 +188,7 @@ export async function writePersistedInstalledPluginIndex(
|
||||
},
|
||||
);
|
||||
clearCurrentPluginMetadataSnapshotState();
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache();
|
||||
return filePath;
|
||||
}
|
||||
|
||||
@@ -197,6 +199,7 @@ export function writePersistedInstalledPluginIndexSync(
|
||||
const filePath = resolveInstalledPluginIndexStorePath(options);
|
||||
saveJsonFile(filePath, { ...index, warning: INSTALLED_PLUGIN_INDEX_WARNING });
|
||||
clearCurrentPluginMetadataSnapshotState();
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache();
|
||||
return filePath;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user