mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:10:44 +00:00
perf(plugins): cache manifest metadata loads
This commit is contained in:
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Channels/Discord: suppress duplicate gateway monitors when multiple enabled accounts resolve to the same bot token, preferring config tokens over default env fallback and reporting skipped duplicates as disabled. Supersedes #73608. Thanks @kagura-agent.
|
||||
- Channels/Discord: ignore stale route-shaped conversation bindings after a Discord channel is reconfigured to another agent, while preserving explicit focus and subagent bindings. Fixes #73626. Thanks @ramitrkar-hash.
|
||||
- Gateway/sessions: add conservative stuck-session recovery that releases only stale session lanes while active embedded runs, reply operations, and lane tasks remain serialized, so queued follow-ups can drain without aborting legitimate long-running turns. Refs #73581, #73655, #73652, #73705, #73647, #73602, #73592, and #73601. Thanks @WS-Q0758, @bryangauvin, @spenceryang1996-dot, @bmilne1981, @mattmcintyre, @Vksh07, and @Spolen23.
|
||||
- Plugins: cache unchanged plugin manifest loads by file signature, reducing repeated JSON/JSON5 parsing and manifest normalization in bursty startup and runtime registry paths. Refs #73532 and #73647; carries forward #73678. Thanks @TheDutchRuler.
|
||||
- NVIDIA/NIM: persist the `NVIDIA_API_KEY` provider marker and mark bundled NVIDIA Chat Completions models as string-content compatible, so NIM models load from `models.json` and OpenAI-compatible subagent calls send plain text content. Fixes #73013 and #50107; refs #73014. Thanks @bautrey, @iot2edge, @ifearghal, and @futhgar.
|
||||
- Channels/Discord: let text-only configs drop the `GuildVoiceStates` gateway intent and expose a bounded `/gateway/bot` metadata timeout with rate-limited fallback logs, reducing idle CPU and warning floods. Fixes #73709 and #73585. Thanks @sanchezm86 and @trac3r00.
|
||||
- Agents/sessions: mark same-turn `sessions_send` and A2A reply prompts with an inter-session `isUser=false` envelope before they reach the model, so foreign session output no longer lands as bare active user text. Fixes #73702; refs #73698, #73609, #73595, and #73622. Thanks @alvelda.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { buildChannelConfigSchema, GoogleChatConfigSchema } from "../runtime-api.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
GoogleChatConfigSchema,
|
||||
} from "openclaw/plugin-sdk/bundled-channel-config-schema";
|
||||
|
||||
export const GoogleChatChannelConfigSchema = buildChannelConfigSchema(GoogleChatConfigSchema);
|
||||
|
||||
@@ -2,7 +2,11 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import JSON5 from "json5";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { loadPluginManifest, MAX_PLUGIN_MANIFEST_BYTES } from "./manifest.js";
|
||||
import {
|
||||
clearPluginManifestLoadCache,
|
||||
loadPluginManifest,
|
||||
MAX_PLUGIN_MANIFEST_BYTES,
|
||||
} from "./manifest.js";
|
||||
import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -13,6 +17,7 @@ function makeTempDir() {
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearPluginManifestLoadCache();
|
||||
cleanupTrackedTempDirs(tempDirs);
|
||||
});
|
||||
|
||||
@@ -53,6 +58,26 @@ describe("loadPluginManifest JSON5 tolerance", () => {
|
||||
expect(json5Parse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses unchanged manifest loads by file signature", () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "cached-json",
|
||||
configSchema: { type: "object" },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
const readFileSync = vi.spyOn(fs, "readFileSync");
|
||||
|
||||
const first = loadPluginManifest(dir, false);
|
||||
const second = loadPluginManifest(dir, false);
|
||||
|
||||
expect(first.ok).toBe(true);
|
||||
expect(second.ok).toBe(true);
|
||||
expect(readFileSync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("parses a manifest with trailing commas", () => {
|
||||
const dir = makeTempDir();
|
||||
const json5Content = `{
|
||||
|
||||
@@ -33,6 +33,20 @@ import type { PluginKind } from "./plugin-kind.types.js";
|
||||
export const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json";
|
||||
export const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const;
|
||||
export const MAX_PLUGIN_MANIFEST_BYTES = 256 * 1024;
|
||||
const MAX_PLUGIN_MANIFEST_LOAD_CACHE_ENTRIES = 512;
|
||||
|
||||
type PluginManifestLoadCacheEntry = {
|
||||
result: PluginManifestLoadResult;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs: number;
|
||||
};
|
||||
|
||||
const pluginManifestLoadCache = new Map<string, PluginManifestLoadCacheEntry>();
|
||||
|
||||
export function clearPluginManifestLoadCache(): void {
|
||||
pluginManifestLoadCache.clear();
|
||||
}
|
||||
|
||||
export type PluginManifestChannelConfig = {
|
||||
schema: JsonSchemaObject;
|
||||
@@ -1148,6 +1162,62 @@ export function resolvePluginManifestPath(rootDir: string): string {
|
||||
return path.join(rootDir, PLUGIN_MANIFEST_FILENAME);
|
||||
}
|
||||
|
||||
function buildPluginManifestLoadCacheKey(params: {
|
||||
manifestPath: string;
|
||||
rejectHardlinks: boolean;
|
||||
rootRealPath?: string;
|
||||
stats: fs.Stats;
|
||||
}): string {
|
||||
return JSON.stringify([
|
||||
path.resolve(params.manifestPath),
|
||||
params.rejectHardlinks,
|
||||
params.rootRealPath ?? "",
|
||||
params.stats.dev,
|
||||
params.stats.ino,
|
||||
params.stats.size,
|
||||
params.stats.mtimeMs,
|
||||
params.stats.ctimeMs,
|
||||
]);
|
||||
}
|
||||
|
||||
function getCachedPluginManifestLoadResult(
|
||||
key: string,
|
||||
stats: fs.Stats,
|
||||
): PluginManifestLoadResult | undefined {
|
||||
const entry = pluginManifestLoadCache.get(key);
|
||||
if (
|
||||
!entry ||
|
||||
entry.size !== stats.size ||
|
||||
entry.mtimeMs !== stats.mtimeMs ||
|
||||
entry.ctimeMs !== stats.ctimeMs
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
pluginManifestLoadCache.delete(key);
|
||||
pluginManifestLoadCache.set(key, entry);
|
||||
return entry.result;
|
||||
}
|
||||
|
||||
function setCachedPluginManifestLoadResult(
|
||||
key: string,
|
||||
stats: fs.Stats,
|
||||
result: PluginManifestLoadResult,
|
||||
): void {
|
||||
pluginManifestLoadCache.set(key, {
|
||||
result,
|
||||
size: stats.size,
|
||||
mtimeMs: stats.mtimeMs,
|
||||
ctimeMs: stats.ctimeMs,
|
||||
});
|
||||
if (pluginManifestLoadCache.size <= MAX_PLUGIN_MANIFEST_LOAD_CACHE_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
const oldestKey = pluginManifestLoadCache.keys().next().value;
|
||||
if (typeof oldestKey === "string") {
|
||||
pluginManifestLoadCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function parsePluginKind(raw: unknown): PluginKind | PluginKind[] | undefined {
|
||||
if (typeof raw === "string") {
|
||||
return raw as PluginKind;
|
||||
@@ -1186,28 +1256,44 @@ export function loadPluginManifest(
|
||||
}),
|
||||
});
|
||||
}
|
||||
const stats = opened.stat;
|
||||
const cacheKey = buildPluginManifestLoadCacheKey({
|
||||
manifestPath,
|
||||
rejectHardlinks,
|
||||
...(rootRealPath !== undefined ? { rootRealPath } : {}),
|
||||
stats,
|
||||
});
|
||||
const cached = getCachedPluginManifestLoadResult(cacheKey, stats);
|
||||
if (cached) {
|
||||
fs.closeSync(opened.fd);
|
||||
return cached;
|
||||
}
|
||||
const cacheResult = (result: PluginManifestLoadResult): PluginManifestLoadResult => {
|
||||
setCachedPluginManifestLoadResult(cacheKey, stats, result);
|
||||
return result;
|
||||
};
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = parseJsonWithJson5Fallback(fs.readFileSync(opened.fd, "utf-8"));
|
||||
} catch (err) {
|
||||
return {
|
||||
return cacheResult({
|
||||
ok: false,
|
||||
error: `failed to parse plugin manifest: ${String(err)}`,
|
||||
manifestPath,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
fs.closeSync(opened.fd);
|
||||
}
|
||||
if (!isRecord(raw)) {
|
||||
return { ok: false, error: "plugin manifest must be an object", manifestPath };
|
||||
return cacheResult({ ok: false, error: "plugin manifest must be an object", manifestPath });
|
||||
}
|
||||
const id = normalizeOptionalString(raw.id) ?? "";
|
||||
if (!id) {
|
||||
return { ok: false, error: "plugin manifest requires id", manifestPath };
|
||||
return cacheResult({ ok: false, error: "plugin manifest requires id", manifestPath });
|
||||
}
|
||||
const configSchema = isRecord(raw.configSchema) ? raw.configSchema : null;
|
||||
if (!configSchema) {
|
||||
return { ok: false, error: "plugin manifest requires configSchema", manifestPath };
|
||||
return cacheResult({ ok: false, error: "plugin manifest requires configSchema", manifestPath });
|
||||
}
|
||||
|
||||
const kind = parsePluginKind(raw.kind);
|
||||
@@ -1260,7 +1346,7 @@ export function loadPluginManifest(
|
||||
uiHints = raw.uiHints as Record<string, PluginConfigUiHint>;
|
||||
}
|
||||
|
||||
return {
|
||||
return cacheResult({
|
||||
ok: true,
|
||||
manifest: {
|
||||
id,
|
||||
@@ -1302,7 +1388,7 @@ export function loadPluginManifest(
|
||||
channelConfigs,
|
||||
},
|
||||
manifestPath,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// package.json "openclaw" metadata (used for setup/catalog)
|
||||
|
||||
Reference in New Issue
Block a user