perf: cache stable gateway metadata

This commit is contained in:
Peter Steinberger
2026-05-24 02:47:12 +01:00
parent fc3c9791ad
commit 12f82270cf
10 changed files with 400 additions and 56 deletions

View File

@@ -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.

View File

@@ -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();

View File

@@ -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);

View File

@@ -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");
});
});

View File

@@ -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;

View File

@@ -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";

View File

@@ -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);
}

View File

@@ -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");

View File

@@ -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,

View File

@@ -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;
}