mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
perf: cache startup package metadata
This commit is contained in:
@@ -19,6 +19,7 @@ type BundledChannelCatalogEntry = {
|
||||
};
|
||||
|
||||
const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH = path.join("dist", "channel-catalog.json");
|
||||
const officialCatalogFileCache = new Map<string, ChannelCatalogEntryLike[] | null>();
|
||||
|
||||
function listPackageRoots(): string[] {
|
||||
return [
|
||||
@@ -38,15 +39,28 @@ function readBundledExtensionCatalogEntriesSync(): PluginPackageChannel[] {
|
||||
function readOfficialCatalogFileSync(): ChannelCatalogEntryLike[] {
|
||||
for (const packageRoot of listPackageRoots()) {
|
||||
const candidate = path.join(packageRoot, OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH);
|
||||
const cached = officialCatalogFileCache.get(candidate);
|
||||
if (cached !== undefined) {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(candidate)) {
|
||||
officialCatalogFileCache.set(candidate, null);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(fs.readFileSync(candidate, "utf8")) as {
|
||||
entries?: unknown;
|
||||
};
|
||||
return Array.isArray(payload.entries) ? (payload.entries as ChannelCatalogEntryLike[]) : [];
|
||||
const entries = Array.isArray(payload.entries)
|
||||
? (payload.entries as ChannelCatalogEntryLike[])
|
||||
: [];
|
||||
officialCatalogFileCache.set(candidate, entries);
|
||||
return entries;
|
||||
} catch {
|
||||
officialCatalogFileCache.set(candidate, null);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ type ExternalCatalogEntry = {
|
||||
|
||||
const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"];
|
||||
const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH = path.join("dist", "channel-catalog.json");
|
||||
const officialCatalogEntriesByPath = new Map<string, ExternalCatalogEntry[] | null>();
|
||||
|
||||
type ManifestKey = typeof MANIFEST_KEY;
|
||||
|
||||
@@ -145,6 +146,32 @@ function loadCatalogEntriesFromPaths(paths: Iterable<string>): ExternalCatalogEn
|
||||
return entries;
|
||||
}
|
||||
|
||||
function loadOfficialCatalogEntriesFromPaths(paths: Iterable<string>): ExternalCatalogEntry[] {
|
||||
const entries: ExternalCatalogEntry[] = [];
|
||||
for (const resolvedPath of paths) {
|
||||
const cached = officialCatalogEntriesByPath.get(resolvedPath);
|
||||
if (cached !== undefined) {
|
||||
if (cached) {
|
||||
entries.push(...cached);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
officialCatalogEntriesByPath.set(resolvedPath, null);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(fs.readFileSync(resolvedPath, "utf-8")) as unknown;
|
||||
const parsed = parseCatalogEntries(payload);
|
||||
officialCatalogEntriesByPath.set(resolvedPath, parsed);
|
||||
entries.push(...parsed);
|
||||
} catch {
|
||||
officialCatalogEntriesByPath.set(resolvedPath, null);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function resolveOfficialCatalogPaths(options: CatalogOptions): string[] {
|
||||
if (options.officialCatalogPaths && options.officialCatalogPaths.length > 0) {
|
||||
return options.officialCatalogPaths.map((entry) => entry.trim()).filter(Boolean);
|
||||
@@ -170,7 +197,11 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] {
|
||||
|
||||
function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] {
|
||||
const builtInEntries = parseCatalogEntries(officialExternalChannelCatalog);
|
||||
const fileEntries = loadCatalogEntriesFromPaths(resolveOfficialCatalogPaths(options));
|
||||
const officialPaths = resolveOfficialCatalogPaths(options);
|
||||
const fileEntries =
|
||||
options.officialCatalogPaths && options.officialCatalogPaths.length > 0
|
||||
? loadCatalogEntriesFromPaths(officialPaths)
|
||||
: loadOfficialCatalogEntriesFromPaths(officialPaths);
|
||||
return [...builtInEntries, ...fileEntries]
|
||||
.map((entry) => buildExternalCatalogEntry(entry))
|
||||
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const STARTUP_METADATA_FILE = "cli-startup-metadata.json";
|
||||
const startupMetadataByPath = new Map<string, Record<string, unknown> | null>();
|
||||
|
||||
function resolveStartupMetadataPathCandidates(moduleUrl: string): string[] {
|
||||
const moduleDir = path.dirname(fileURLToPath(moduleUrl));
|
||||
@@ -14,10 +15,20 @@ function resolveStartupMetadataPathCandidates(moduleUrl: string): string[] {
|
||||
|
||||
export function readCliStartupMetadata(moduleUrl: string): Record<string, unknown> | null {
|
||||
for (const metadataPath of resolveStartupMetadataPathCandidates(moduleUrl)) {
|
||||
const cached = startupMetadataByPath.get(metadataPath);
|
||||
if (cached !== undefined) {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(metadataPath, "utf8")) as Record<string, unknown>;
|
||||
const parsed = JSON.parse(fs.readFileSync(metadataPath, "utf8")) as Record<string, unknown>;
|
||||
startupMetadataByPath.set(metadataPath, parsed);
|
||||
return parsed;
|
||||
} catch {
|
||||
// Try the next bundled/source layout before falling back to dynamic startup work.
|
||||
startupMetadataByPath.set(metadataPath, null);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -25,4 +36,7 @@ export function readCliStartupMetadata(moduleUrl: string): Record<string, unknow
|
||||
|
||||
export const __testing = {
|
||||
resolveStartupMetadataPathCandidates,
|
||||
clearStartupMetadataCache(): void {
|
||||
startupMetadataByPath.clear();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -108,13 +108,18 @@ vi.mock("./openclaw-root.fs.runtime.js", () => ({
|
||||
describe("resolveOpenClawPackageRoot", () => {
|
||||
let resolveOpenClawPackageRoot: typeof import("./openclaw-root.js").resolveOpenClawPackageRoot;
|
||||
let resolveOpenClawPackageRootSync: typeof import("./openclaw-root.js").resolveOpenClawPackageRootSync;
|
||||
let clearOpenClawPackageRootCaches: typeof import("./openclaw-root.js").__testing.clearOpenClawPackageRootCaches;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ resolveOpenClawPackageRoot, resolveOpenClawPackageRootSync } =
|
||||
await import("./openclaw-root.js"));
|
||||
({
|
||||
resolveOpenClawPackageRoot,
|
||||
resolveOpenClawPackageRootSync,
|
||||
__testing: { clearOpenClawPackageRootCaches },
|
||||
} = await import("./openclaw-root.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearOpenClawPackageRootCaches();
|
||||
state.entries.clear();
|
||||
state.realpaths.clear();
|
||||
state.realpathErrors.clear();
|
||||
|
||||
@@ -3,6 +3,9 @@ import { fileURLToPath } from "node:url";
|
||||
import { openClawRootFs, openClawRootFsSync } from "./openclaw-root.fs.runtime.js";
|
||||
|
||||
const CORE_PACKAGE_NAMES = new Set(["openclaw"]);
|
||||
const packageNameCache = new Map<string, string | null>();
|
||||
const packageRootCache = new Map<string, string | null>();
|
||||
const argv1CandidateCache = new Map<string, string[]>();
|
||||
|
||||
function parsePackageName(raw: string): string | null {
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
@@ -10,19 +13,31 @@ function parsePackageName(raw: string): string | null {
|
||||
}
|
||||
|
||||
async function readPackageName(dir: string): Promise<string | null> {
|
||||
const packageJsonPath = path.join(path.resolve(dir), "package.json");
|
||||
if (packageNameCache.has(packageJsonPath)) {
|
||||
return packageNameCache.get(packageJsonPath) ?? null;
|
||||
}
|
||||
try {
|
||||
return parsePackageName(await openClawRootFs.readFile(path.join(dir, "package.json"), "utf-8"));
|
||||
const name = parsePackageName(await openClawRootFs.readFile(packageJsonPath, "utf-8"));
|
||||
packageNameCache.set(packageJsonPath, name);
|
||||
return name;
|
||||
} catch {
|
||||
packageNameCache.set(packageJsonPath, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readPackageNameSync(dir: string): string | null {
|
||||
const packageJsonPath = path.join(path.resolve(dir), "package.json");
|
||||
if (packageNameCache.has(packageJsonPath)) {
|
||||
return packageNameCache.get(packageJsonPath) ?? null;
|
||||
}
|
||||
try {
|
||||
return parsePackageName(
|
||||
openClawRootFsSync.readFileSync(path.join(dir, "package.json"), "utf-8"),
|
||||
);
|
||||
const name = parsePackageName(openClawRootFsSync.readFileSync(packageJsonPath, "utf-8"));
|
||||
packageNameCache.set(packageJsonPath, name);
|
||||
return name;
|
||||
} catch {
|
||||
packageNameCache.set(packageJsonPath, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -60,6 +75,11 @@ function* iterAncestorDirs(startDir: string, maxDepth: number): Generator<string
|
||||
}
|
||||
|
||||
function candidateDirsFromArgv1(argv1: string): string[] {
|
||||
const cacheKey = path.resolve(argv1);
|
||||
const cached = argv1CandidateCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return [...cached];
|
||||
}
|
||||
const normalized = path.resolve(argv1);
|
||||
const candidates = [path.dirname(normalized)];
|
||||
|
||||
@@ -81,7 +101,9 @@ function candidateDirsFromArgv1(argv1: string): string[] {
|
||||
const nodeModulesDir = parts.slice(0, binIndex).join(path.sep);
|
||||
candidates.push(path.join(nodeModulesDir, binName));
|
||||
}
|
||||
return candidates;
|
||||
const deduped = dedupeCandidates(candidates);
|
||||
argv1CandidateCache.set(cacheKey, deduped);
|
||||
return [...deduped];
|
||||
}
|
||||
|
||||
export async function resolveOpenClawPackageRoot(opts: {
|
||||
@@ -89,13 +111,20 @@ export async function resolveOpenClawPackageRoot(opts: {
|
||||
argv1?: string;
|
||||
moduleUrl?: string;
|
||||
}): Promise<string | null> {
|
||||
for (const candidate of buildCandidates(opts)) {
|
||||
const candidates = buildCandidates(opts);
|
||||
const cacheKey = createPackageRootCacheKey(candidates);
|
||||
if (packageRootCache.has(cacheKey)) {
|
||||
return packageRootCache.get(cacheKey) ?? null;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const found = await findPackageRoot(candidate);
|
||||
if (found) {
|
||||
packageRootCache.set(cacheKey, found);
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
packageRootCache.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -104,13 +133,20 @@ export function resolveOpenClawPackageRootSync(opts: {
|
||||
argv1?: string;
|
||||
moduleUrl?: string;
|
||||
}): string | null {
|
||||
for (const candidate of buildCandidates(opts)) {
|
||||
const candidates = buildCandidates(opts);
|
||||
const cacheKey = createPackageRootCacheKey(candidates);
|
||||
if (packageRootCache.has(cacheKey)) {
|
||||
return packageRootCache.get(cacheKey) ?? null;
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
const found = findPackageRootSync(candidate);
|
||||
if (found) {
|
||||
packageRootCache.set(cacheKey, found);
|
||||
return found;
|
||||
}
|
||||
}
|
||||
|
||||
packageRootCache.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -131,5 +167,31 @@ function buildCandidates(opts: { cwd?: string; argv1?: string; moduleUrl?: strin
|
||||
candidates.push(opts.cwd);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
return dedupeCandidates(candidates);
|
||||
}
|
||||
|
||||
function dedupeCandidates(candidates: readonly string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
const resolved = path.resolve(candidate);
|
||||
if (seen.has(resolved)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(resolved);
|
||||
deduped.push(resolved);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function createPackageRootCacheKey(candidates: readonly string[]): string {
|
||||
return candidates.join("\0");
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
clearOpenClawPackageRootCaches(): void {
|
||||
packageNameCache.clear();
|
||||
packageRootCache.clear();
|
||||
argv1CandidateCache.clear();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -358,6 +358,24 @@ describe("resolveBundledPluginsDir", () => {
|
||||
expect(fs.readdirSync(bundledDir ?? "")).toEqual([]);
|
||||
});
|
||||
|
||||
it("separates tilde override cache entries by OPENCLAW_HOME", () => {
|
||||
const homeA = makeRepoRoot("openclaw-bundled-dir-home-a-");
|
||||
const homeB = makeRepoRoot("openclaw-bundled-dir-home-b-");
|
||||
seedBundledPluginTree(homeA, "bundled", "memory-core");
|
||||
seedBundledPluginTree(homeB, "bundled", "discord");
|
||||
const envBase = {
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "~/bundled",
|
||||
OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1",
|
||||
VITEST: "true",
|
||||
} satisfies NodeJS.ProcessEnv;
|
||||
|
||||
const bundledA = resolveBundledPluginsDir({ ...envBase, OPENCLAW_HOME: homeA });
|
||||
const bundledB = resolveBundledPluginsDir({ ...envBase, OPENCLAW_HOME: homeB });
|
||||
|
||||
expect(fs.realpathSync(bundledA ?? "")).toBe(fs.realpathSync(path.join(homeA, "bundled")));
|
||||
expect(fs.realpathSync(bundledB ?? "")).toBe(fs.realpathSync(path.join(homeB, "bundled")));
|
||||
});
|
||||
|
||||
it("ignores an existing override under an argv1-derived fake package root", () => {
|
||||
const installedRoot = createOpenClawRoot({
|
||||
prefix: "openclaw-bundled-dir-argv-override-reject-",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { resolveUserPath } from "../utils.js";
|
||||
const DISABLED_BUNDLED_PLUGINS_DIR = path.join(os.tmpdir(), "openclaw-empty-bundled-plugins");
|
||||
const TEST_TRUST_BUNDLED_PLUGINS_DIR_ENV = "OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR";
|
||||
let bundledPluginsDirOverrideForTest: string | undefined;
|
||||
const bundledPluginsDirCache = new Map<string, string | undefined>();
|
||||
|
||||
export type SourceCheckoutDependencyDiagnostic = {
|
||||
source: string;
|
||||
@@ -192,7 +193,25 @@ function resolveBundledDirFromPackageRoot(packageRoot: string): string | undefin
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
function createBundledPluginsDirCacheKey(env: NodeJS.ProcessEnv): string {
|
||||
return JSON.stringify({
|
||||
disabled: env.OPENCLAW_DISABLE_BUNDLED_PLUGINS ?? "",
|
||||
override: env.OPENCLAW_BUNDLED_PLUGINS_DIR ?? "",
|
||||
trustOverride: env[TEST_TRUST_BUNDLED_PLUGINS_DIR_ENV] ?? "",
|
||||
processTrustOverride: process.env[TEST_TRUST_BUNDLED_PLUGINS_DIR_ENV] ?? "",
|
||||
vitest: env.VITEST ?? "",
|
||||
processVitest: process.env.VITEST ?? "",
|
||||
nodeEnv: process.env.NODE_ENV ?? "",
|
||||
argv1: process.argv[1] ?? "",
|
||||
execPath: process.execPath,
|
||||
openClawHome: env.OPENCLAW_HOME ?? "",
|
||||
home: env.HOME ?? "",
|
||||
userProfile: env.USERPROFILE ?? "",
|
||||
testOverride: bundledPluginsDirOverrideForTest ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBundledPluginsDirUncached(env: NodeJS.ProcessEnv): string | undefined {
|
||||
if (areBundledPluginsDisabled(env)) {
|
||||
return resolveDisabledBundledPluginsDir();
|
||||
}
|
||||
@@ -278,9 +297,20 @@ export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env):
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveBundledPluginsDir(env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
const cacheKey = createBundledPluginsDirCacheKey(env);
|
||||
if (bundledPluginsDirCache.has(cacheKey)) {
|
||||
return bundledPluginsDirCache.get(cacheKey);
|
||||
}
|
||||
const resolved = resolveBundledPluginsDirUncached(env);
|
||||
bundledPluginsDirCache.set(cacheKey, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function setBundledPluginsDirOverrideForTest(dir: string | undefined): void {
|
||||
if (process.env.VITEST !== "true" && process.env.NODE_ENV !== "test") {
|
||||
throw new Error("setBundledPluginsDirOverrideForTest is only available in tests");
|
||||
}
|
||||
bundledPluginsDirOverrideForTest = dir;
|
||||
bundledPluginsDirCache.clear();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import type { PluginDiagnostic } from "./manifest-types.js";
|
||||
|
||||
export type InstalledPluginFileSignature = {
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
ctimeMs?: number;
|
||||
};
|
||||
|
||||
function hashString(value: string): string {
|
||||
return crypto.createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
@@ -32,3 +38,40 @@ export function safeHashFile(params: {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function safeFileSignature(filePath: string): InstalledPluginFileSignature | undefined {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile()) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
ctimeMs: stat.ctimeMs,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function fileSignatureMatches(
|
||||
filePath: string,
|
||||
signature: InstalledPluginFileSignature | undefined,
|
||||
): boolean | undefined {
|
||||
if (!signature) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof signature.ctimeMs !== "number") {
|
||||
return undefined;
|
||||
}
|
||||
const current = safeFileSignature(filePath);
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
current.size === signature.size &&
|
||||
current.mtimeMs === signature.mtimeMs &&
|
||||
current.ctimeMs === signature.ctimeMs
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-st
|
||||
import type { PluginCandidate } from "./discovery.js";
|
||||
import type { PluginInstallSourceInfo } from "./install-source-info.js";
|
||||
import { describePluginInstallSource } from "./install-source-info.js";
|
||||
import { hashJson, safeHashFile } from "./installed-plugin-index-hash.js";
|
||||
import { hashJson, safeFileSignature, safeHashFile } from "./installed-plugin-index-hash.js";
|
||||
import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js";
|
||||
import type {
|
||||
InstalledPluginIndexRecord,
|
||||
@@ -109,9 +109,11 @@ function resolvePackageJsonRecord(params: {
|
||||
if (!hash) {
|
||||
return undefined;
|
||||
}
|
||||
const fileSignature = safeFileSignature(params.packageJsonPath);
|
||||
return {
|
||||
path: resolvePackageJsonRelativePath(params.candidate.rootDir, params.packageJsonPath),
|
||||
hash,
|
||||
...(fileSignature ? { fileSignature } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -210,6 +212,9 @@ export function buildInstalledPluginIndexRecords(params: {
|
||||
record.packageChannel ?? candidate?.packageManifest?.channel,
|
||||
);
|
||||
const manifestHash = resolveManifestHash({ record, diagnostics: params.diagnostics });
|
||||
const manifestFile = hasOptionalMissingPluginManifestFile(record)
|
||||
? undefined
|
||||
: safeFileSignature(record.manifestPath);
|
||||
const packageJson = resolvePackageJsonRecord({
|
||||
candidate,
|
||||
packageJsonPath,
|
||||
@@ -227,6 +232,7 @@ export function buildInstalledPluginIndexRecords(params: {
|
||||
pluginId: record.id,
|
||||
manifestPath: record.manifestPath,
|
||||
manifestHash,
|
||||
...(manifestFile ? { manifestFile } : {}),
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
origin: record.origin,
|
||||
|
||||
@@ -50,6 +50,12 @@ const InstalledPluginIndexStartupSchema = z.object({
|
||||
agentHarnesses: StringArraySchema,
|
||||
});
|
||||
|
||||
const InstalledPluginFileSignatureSchema = z.object({
|
||||
size: z.number(),
|
||||
mtimeMs: z.number(),
|
||||
ctimeMs: z.number().optional(),
|
||||
});
|
||||
|
||||
const InstalledPluginIndexRecordSchema = z.object({
|
||||
pluginId: z.string(),
|
||||
packageName: z.string().optional(),
|
||||
@@ -60,6 +66,7 @@ const InstalledPluginIndexRecordSchema = z.object({
|
||||
packageChannel: z.unknown().optional(),
|
||||
manifestPath: z.string(),
|
||||
manifestHash: z.string(),
|
||||
manifestFile: InstalledPluginFileSignatureSchema.optional(),
|
||||
format: z.string().optional(),
|
||||
bundleFormat: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
@@ -68,6 +75,7 @@ const InstalledPluginIndexRecordSchema = z.object({
|
||||
.object({
|
||||
path: z.string(),
|
||||
hash: z.string(),
|
||||
fileSignature: InstalledPluginFileSignatureSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
rootDir: z.string(),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||
import type { PluginCompatCode } from "./compat/registry.js";
|
||||
import type { PluginCandidate } from "./discovery.js";
|
||||
import type { PluginInstallSourceInfo } from "./install-source-info.js";
|
||||
import type { InstalledPluginFileSignature } from "./installed-plugin-index-hash.js";
|
||||
import type { PluginManifestRecord } from "./manifest-registry.js";
|
||||
import type { PluginDiagnostic } from "./manifest-types.js";
|
||||
import type { PluginPackageChannel } from "./manifest.js";
|
||||
@@ -81,6 +82,7 @@ export type InstalledPluginIndexRecord = {
|
||||
packageChannel?: InstalledPluginPackageChannelInfo;
|
||||
manifestPath: string;
|
||||
manifestHash: string;
|
||||
manifestFile?: InstalledPluginFileSignature;
|
||||
format?: PluginManifestRecord["format"];
|
||||
bundleFormat?: PluginManifestRecord["bundleFormat"];
|
||||
source?: string;
|
||||
@@ -88,6 +90,7 @@ export type InstalledPluginIndexRecord = {
|
||||
packageJson?: {
|
||||
path: string;
|
||||
hash: string;
|
||||
fileSignature?: InstalledPluginFileSignature;
|
||||
};
|
||||
rootDir: string;
|
||||
origin: PluginManifestRecord["origin"];
|
||||
|
||||
@@ -690,6 +690,17 @@ function readPackageVersionForCache(packageJsonPath: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
const bundledPackageCacheIdentityByStockRoot = new Map<
|
||||
string,
|
||||
{
|
||||
packageJson: string;
|
||||
packageRoot: string;
|
||||
packageVersion: string;
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
>();
|
||||
|
||||
function resolveBundledPackageCacheIdentity(stockRoot?: string):
|
||||
| {
|
||||
packageJson: string;
|
||||
@@ -703,24 +714,33 @@ function resolveBundledPackageCacheIdentity(stockRoot?: string):
|
||||
if (!packageRoot) {
|
||||
return undefined;
|
||||
}
|
||||
const stockRootKey = path.resolve(stockRoot);
|
||||
const cached = bundledPackageCacheIdentityByStockRoot.get(stockRootKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const packageJsonPath = path.join(packageRoot, "package.json");
|
||||
try {
|
||||
const stat = fs.statSync(packageJsonPath);
|
||||
return {
|
||||
const identity = {
|
||||
packageJson: safeRealpathOrResolve(packageJsonPath),
|
||||
packageRoot: safeRealpathOrResolve(packageRoot),
|
||||
packageVersion: readPackageVersionForCache(packageJsonPath),
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
};
|
||||
bundledPackageCacheIdentityByStockRoot.set(stockRootKey, identity);
|
||||
return identity;
|
||||
} catch {
|
||||
return {
|
||||
const identity = {
|
||||
packageJson: path.resolve(packageJsonPath),
|
||||
packageRoot: safeRealpathOrResolve(packageRoot),
|
||||
packageVersion: "missing",
|
||||
size: -1,
|
||||
mtimeMs: -1,
|
||||
};
|
||||
bundledPackageCacheIdentityByStockRoot.set(stockRootKey, identity);
|
||||
return identity;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { writePersistedInstalledPluginIndexSync } from "./installed-plugin-index-store.js";
|
||||
import { loadInstalledPluginIndex, type InstalledPluginIndex } from "./installed-plugin-index.js";
|
||||
import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js";
|
||||
@@ -9,6 +9,7 @@ import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fi
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
cleanupTrackedTempDirs(tempDirs);
|
||||
});
|
||||
|
||||
@@ -29,6 +30,33 @@ function writeManifestlessClaudeBundle(rootDir: string) {
|
||||
fs.writeFileSync(path.join(rootDir, "skills", "SKILL.md"), "# Workspace skill\n", "utf8");
|
||||
}
|
||||
|
||||
function writePackagePlugin(rootDir: string) {
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(rootDir, "index.ts"), "export default { register() {} };\n", "utf8");
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "demo",
|
||||
name: "Demo",
|
||||
description: "one",
|
||||
configSchema: { type: "object" },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "package.json"),
|
||||
JSON.stringify({ name: "demo", version: "1.0.0" }),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
function replaceFilePreservingSizeAndMtime(filePath: string, contents: string) {
|
||||
const previous = fs.statSync(filePath);
|
||||
expect(Buffer.byteLength(contents)).toBe(previous.size);
|
||||
fs.writeFileSync(filePath, contents, "utf8");
|
||||
fs.utimesSync(filePath, previous.atime, previous.mtime);
|
||||
}
|
||||
|
||||
function createManifestlessClaudeBundleIndex(params: {
|
||||
rootDir: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
@@ -48,7 +76,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const rootDir = path.join(tempRoot, "workspace");
|
||||
const stateDir = path.join(tempRoot, "state");
|
||||
const env = createHermeticEnv(tempRoot);
|
||||
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
||||
const config = {
|
||||
plugins: {
|
||||
load: { paths: [rootDir] },
|
||||
@@ -67,4 +95,106 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => {
|
||||
expect(result.source).toBe("persisted");
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps persisted package plugins on the fast path when file signatures match", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const rootDir = path.join(tempRoot, "workspace");
|
||||
const stateDir = path.join(tempRoot, "state");
|
||||
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
||||
const config = {
|
||||
plugins: {
|
||||
load: { paths: [rootDir] },
|
||||
},
|
||||
};
|
||||
writePackagePlugin(rootDir);
|
||||
const index = loadInstalledPluginIndex({ config, env });
|
||||
const [record] = index.plugins;
|
||||
expect(record?.manifestFile).toBeDefined();
|
||||
expect(record?.packageJson?.fileSignature).toBeDefined();
|
||||
writePersistedInstalledPluginIndexSync(index, { stateDir });
|
||||
|
||||
const readFileSyncSpy = vi.spyOn(fs, "readFileSync");
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({
|
||||
config,
|
||||
env,
|
||||
stateDir,
|
||||
});
|
||||
const pluginMetadataFileReads = readFileSyncSpy.mock.calls.filter((call) => {
|
||||
const filePath = String(call[0]);
|
||||
return (
|
||||
filePath === path.join(rootDir, "openclaw.plugin.json") ||
|
||||
filePath === path.join(rootDir, "package.json")
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.source).toBe("persisted");
|
||||
expect(pluginMetadataFileReads).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects same-size same-mtime manifest replacements", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const rootDir = path.join(tempRoot, "workspace");
|
||||
const stateDir = path.join(tempRoot, "state");
|
||||
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
||||
const config = {
|
||||
plugins: {
|
||||
load: { paths: [rootDir] },
|
||||
},
|
||||
};
|
||||
writePackagePlugin(rootDir);
|
||||
const index = loadInstalledPluginIndex({ config, env });
|
||||
writePersistedInstalledPluginIndexSync(index, { stateDir });
|
||||
|
||||
replaceFilePreservingSizeAndMtime(
|
||||
path.join(rootDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "demo",
|
||||
name: "Demo",
|
||||
description: "two",
|
||||
configSchema: { type: "object" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({
|
||||
config,
|
||||
env,
|
||||
stateDir,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("derived");
|
||||
expect(result.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: "persisted-registry-stale-source" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects same-size same-mtime package.json replacements", () => {
|
||||
const tempRoot = makeTempDir();
|
||||
const rootDir = path.join(tempRoot, "workspace");
|
||||
const stateDir = path.join(tempRoot, "state");
|
||||
const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" };
|
||||
const config = {
|
||||
plugins: {
|
||||
load: { paths: [rootDir] },
|
||||
},
|
||||
};
|
||||
writePackagePlugin(rootDir);
|
||||
const index = loadInstalledPluginIndex({ config, env });
|
||||
writePersistedInstalledPluginIndexSync(index, { stateDir });
|
||||
|
||||
replaceFilePreservingSizeAndMtime(
|
||||
path.join(rootDir, "package.json"),
|
||||
JSON.stringify({ name: "demo", version: "1.0.1" }),
|
||||
);
|
||||
|
||||
const result = loadPluginRegistrySnapshotWithMetadata({
|
||||
config,
|
||||
env,
|
||||
stateDir,
|
||||
});
|
||||
|
||||
expect(result.source).toBe("derived");
|
||||
expect(result.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: "persisted-registry-stale-source" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveBundledPluginsDir } from "./bundled-dir.js";
|
||||
import { fileSignatureMatches } from "./installed-plugin-index-hash.js";
|
||||
import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js";
|
||||
import {
|
||||
inspectPersistedInstalledPluginIndex,
|
||||
@@ -132,9 +133,22 @@ function resolveRecordPackageJsonPath(plugin: InstalledPluginIndexRecord): strin
|
||||
function hasStalePersistedPluginMetadata(index: InstalledPluginIndex): boolean {
|
||||
return index.plugins.some((plugin) => {
|
||||
if (!hasOptionalMissingPluginManifestFile(plugin)) {
|
||||
const manifestHash = hashExistingFile(plugin.manifestPath);
|
||||
if (manifestHash && manifestHash !== plugin.manifestHash) {
|
||||
return true;
|
||||
const manifestSignatureMatches = fileSignatureMatches(
|
||||
plugin.manifestPath,
|
||||
plugin.manifestFile,
|
||||
);
|
||||
if (manifestSignatureMatches === true) {
|
||||
// Stored stat signature is unchanged; avoid hashing on startup.
|
||||
} else if (manifestSignatureMatches === false) {
|
||||
const manifestHash = hashExistingFile(plugin.manifestPath);
|
||||
if (manifestHash && manifestHash !== plugin.manifestHash) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
const manifestHash = hashExistingFile(plugin.manifestPath);
|
||||
if (manifestHash && manifestHash !== plugin.manifestHash) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const packageJsonPath = resolveRecordPackageJsonPath(plugin);
|
||||
@@ -144,6 +158,16 @@ function hasStalePersistedPluginMetadata(index: InstalledPluginIndex): boolean {
|
||||
if (!packageJsonPath) {
|
||||
return true;
|
||||
}
|
||||
const packageJsonSignatureMatches = fileSignatureMatches(
|
||||
packageJsonPath,
|
||||
plugin.packageJson.fileSignature,
|
||||
);
|
||||
if (packageJsonSignatureMatches === true) {
|
||||
return false;
|
||||
}
|
||||
if (packageJsonSignatureMatches === false) {
|
||||
return hashExistingFile(packageJsonPath) !== plugin.packageJson.hash;
|
||||
}
|
||||
const packageJsonHash = hashExistingFile(packageJsonPath);
|
||||
return packageJsonHash !== plugin.packageJson.hash;
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ type PluginSdkPackageJson = {
|
||||
};
|
||||
|
||||
const STARTUP_ARGV1 = process.argv[1];
|
||||
const pluginSdkPackageJsonByRoot = new Map<string, PluginSdkPackageJson | null>();
|
||||
|
||||
export function normalizeJitiAliasTargetPath(targetPath: string): string {
|
||||
return process.platform === "win32" ? targetPath.replace(/\\/g, "/") : targetPath;
|
||||
@@ -32,10 +33,17 @@ function resolveLoaderModulePath(params: LoaderModuleResolveParams = {}): string
|
||||
}
|
||||
|
||||
function readPluginSdkPackageJson(packageRoot: string): PluginSdkPackageJson | null {
|
||||
const cacheKey = path.resolve(packageRoot);
|
||||
if (pluginSdkPackageJsonByRoot.has(cacheKey)) {
|
||||
return pluginSdkPackageJsonByRoot.get(cacheKey) ?? null;
|
||||
}
|
||||
try {
|
||||
const pkgRaw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8");
|
||||
return JSON.parse(pkgRaw) as PluginSdkPackageJson;
|
||||
const parsed = JSON.parse(pkgRaw) as PluginSdkPackageJson;
|
||||
pluginSdkPackageJsonByRoot.set(cacheKey, parsed);
|
||||
return parsed;
|
||||
} catch {
|
||||
pluginSdkPackageJsonByRoot.set(cacheKey, null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user