mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 20:01:34 +00:00
fix(plugins): load active generation after upgrades (#111141)
* fix(plugins): load newest managed generation when a prior install lingers An upgrade writes a plugin's new version into a distinct managed project directory (an `__openclaw-generation__` dir) without removing the previous one. The persisted install record keeps pointing at the older, still-present directory, so `mergeRecoveredManagedNpmRecord` falls through to returning the persisted record and the runtime imports the stale version even though the newer generation is installed. `isUnavailableManagedNpmInstallRecord` only self-heals when the persisted path is gone, so an upgrading install (both directories present) is not covered — `plugins inspect <id> --runtime` reports the old version and `install`/`update` appear to silently no-op. - Recovery now selects the highest-version record per plugin id across the legacy flat dir and any generation dirs, instead of whichever project root sorts last (generation-dir suffixes are hashes, so on-disk order is unrelated to version). - When the recovered managed install is a strictly newer version at a different path, repoint to it, reusing the metadata merge the unavailable-path branch uses. The repoint is gated to persisted records that live inside the managed npm root, so intentional custom/outside npm installs are left untouched. This is a recovery-layer self-heal, so it also repairs installs that already diverged from an earlier upgrade. Refs #107228 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(plugins): honor active managed generations Recover ambiguous managed installs by package recency only when no usable active ledger path exists, and let doctor retire non-authoritative generations safely. Co-authored-by: Peter Lindsey <peter@lindsey.jp> * test(plugins): use shared temp cleanup * fix(plugins): compare active paths per platform * fix(plugins): use managed install timestamps * test(plugins): timestamp managed project roots * test(plugins): exclude retired legacy recovery * refactor(plugins): keep recovery candidate private --------- Co-authored-by: Peter Lindsey <peter@lindsey.jp> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -523,7 +523,7 @@ The local plugin registry is OpenClaw's persisted cold read model for installed
|
||||
|
||||
Use `plugins registry` to inspect whether the persisted registry is present, current, or stale. Use `--refresh` to rebuild it from the persisted plugin index, config policy, and manifest/package metadata. This is a repair path, not a runtime activation path.
|
||||
|
||||
`openclaw doctor --fix` also repairs registry-adjacent managed npm drift: if an orphaned or recovered `@openclaw/*` package under a managed plugin npm project or the legacy flat managed npm root shadows a bundled plugin, doctor removes that stale package and rebuilds the registry so startup validates against the bundled manifest. Doctor also relinks the host `openclaw` package into managed npm plugins that declare `peerDependencies.openclaw`, so package-local runtime imports such as `openclaw/plugin-sdk/*` resolve after updates or npm repairs.
|
||||
`openclaw doctor --fix` also repairs registry-adjacent managed npm drift. If an orphaned or recovered `@openclaw/*` package under a managed plugin npm project or the legacy flat managed npm root shadows a bundled plugin, doctor removes that stale package and rebuilds the registry so startup validates against the bundled manifest. When an authoritative install record selects one managed generation but older flat or generation directories remain, doctor retires those stale trees for pruning after the gateway restarts. Doctor also relinks the host `openclaw` package into managed npm plugins that declare `peerDependencies.openclaw`, so package-local runtime imports such as `openclaw/plugin-sdk/*` resolve after updates or npm repairs.
|
||||
|
||||
## Marketplace
|
||||
|
||||
|
||||
158
src/commands/doctor-plugin-generations.ts
Normal file
158
src/commands/doctor-plugin-generations.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import path from "node:path";
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js";
|
||||
import { normalizeWindowsPathForComparison } from "../infra/path-guards.js";
|
||||
import { listRecoveredManagedNpmInstallCandidates } from "../plugins/installed-plugin-index-record-reader.js";
|
||||
import {
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache,
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
type InstalledPluginIndexRecordStoreOptions,
|
||||
} from "../plugins/installed-plugin-index-records.js";
|
||||
import { markRetainedManagedNpmInstall } from "../plugins/managed-npm-retention.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
|
||||
export const PLUGIN_REGISTRY_CHECK_ID = "core/doctor/plugin-registry";
|
||||
|
||||
export type StaleManagedNpmInstallGenerationIssue = {
|
||||
kind: "stale-managed-npm-install-generation";
|
||||
activePackageDir: string;
|
||||
packageDir: string;
|
||||
pluginId: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
type PluginGenerationDoctorParams = InstalledPluginIndexRecordStoreOptions & {
|
||||
prompter: Pick<DoctorPrompter, "shouldRepair">;
|
||||
};
|
||||
|
||||
function normalizeManagedInstallPath(filePath: string): string {
|
||||
const resolved = path.resolve(filePath);
|
||||
return process.platform === "win32" ? normalizeWindowsPathForComparison(resolved) : resolved;
|
||||
}
|
||||
|
||||
export async function listStaleManagedNpmInstallGenerations(
|
||||
params: InstalledPluginIndexRecordStoreOptions,
|
||||
): Promise<StaleManagedNpmInstallGenerationIssue[]> {
|
||||
// The reader keeps a persisted active path authoritative and supplies its
|
||||
// deterministic recovery choice when that authority is absent or dangling.
|
||||
const activeRecords = await loadInstalledPluginIndexInstallRecords(params);
|
||||
const candidates = listRecoveredManagedNpmInstallCandidates(params);
|
||||
const candidatesByPluginId = new Map<string, typeof candidates>();
|
||||
for (const candidate of candidates) {
|
||||
const entries = candidatesByPluginId.get(candidate.pluginId) ?? [];
|
||||
entries.push(candidate);
|
||||
candidatesByPluginId.set(candidate.pluginId, entries);
|
||||
}
|
||||
|
||||
const stale: StaleManagedNpmInstallGenerationIssue[] = [];
|
||||
for (const [pluginId, pluginCandidates] of candidatesByPluginId) {
|
||||
const activeRecord = activeRecords[pluginId];
|
||||
if (activeRecord?.source !== "npm" || !activeRecord.installPath) {
|
||||
continue;
|
||||
}
|
||||
const activePath = normalizeManagedInstallPath(activeRecord.installPath);
|
||||
const active = pluginCandidates.find(
|
||||
(candidate) =>
|
||||
candidate.installRecord.installPath &&
|
||||
normalizeManagedInstallPath(candidate.installRecord.installPath) === activePath,
|
||||
);
|
||||
if (!active) {
|
||||
continue;
|
||||
}
|
||||
for (const candidate of pluginCandidates) {
|
||||
const packageDir = candidate.installRecord.installPath;
|
||||
if (!packageDir || normalizeManagedInstallPath(packageDir) === activePath) {
|
||||
continue;
|
||||
}
|
||||
stale.push({
|
||||
kind: "stale-managed-npm-install-generation",
|
||||
pluginId,
|
||||
activePackageDir: activeRecord.installPath,
|
||||
packageDir,
|
||||
...(candidate.installRecord.resolvedVersion
|
||||
? { version: candidate.installRecord.resolvedVersion }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
return stale.toSorted((left, right) => left.packageDir.localeCompare(right.packageDir));
|
||||
}
|
||||
|
||||
/** Marks non-authoritative managed npm trees for safe cleanup after gateway shutdown. */
|
||||
export async function maybeRepairStaleManagedNpmInstallGenerations(
|
||||
params: PluginGenerationDoctorParams,
|
||||
): Promise<boolean> {
|
||||
const stale = await listStaleManagedNpmInstallGenerations(params);
|
||||
if (stale.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (!params.prompter.shouldRepair) {
|
||||
note(
|
||||
[
|
||||
"Managed npm plugin installs have stale non-authoritative generations:",
|
||||
...stale.map(
|
||||
(generation) =>
|
||||
`- ${generation.pluginId}: ${shortenHomePath(generation.packageDir)}${generation.version ? ` (${generation.version})` : ""}`,
|
||||
),
|
||||
`Repair with ${formatCliCommand("openclaw doctor --fix")} to retire stale generations after the gateway restarts.`,
|
||||
].join("\n"),
|
||||
"Plugin registry",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const retired: StaleManagedNpmInstallGenerationIssue[] = [];
|
||||
for (const generation of stale) {
|
||||
if (
|
||||
await markRetainedManagedNpmInstall({
|
||||
packageDir: generation.packageDir,
|
||||
pluginId: generation.pluginId,
|
||||
reason: "doctor-repaired-stale-managed-npm-generation",
|
||||
})
|
||||
) {
|
||||
retired.push(generation);
|
||||
}
|
||||
}
|
||||
if (retired.length === 0) {
|
||||
return false;
|
||||
}
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache();
|
||||
note(
|
||||
[
|
||||
"Retired stale managed npm plugin generation(s); they will be pruned after the gateway restarts:",
|
||||
...retired.map(
|
||||
(generation) =>
|
||||
`- ${generation.pluginId}: ${shortenHomePath(generation.packageDir)}${generation.version ? ` (${generation.version})` : ""}`,
|
||||
),
|
||||
].join("\n"),
|
||||
"Plugin registry",
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function staleManagedNpmInstallGenerationToHealthFinding(
|
||||
issue: StaleManagedNpmInstallGenerationIssue,
|
||||
): HealthFinding {
|
||||
return {
|
||||
checkId: PLUGIN_REGISTRY_CHECK_ID,
|
||||
severity: "warning",
|
||||
message: `Managed npm plugin ${issue.pluginId}${issue.version ? `@${issue.version}` : ""} is a stale non-authoritative generation.`,
|
||||
path: issue.packageDir,
|
||||
target: issue.pluginId,
|
||||
fixHint:
|
||||
"Run `openclaw doctor --fix` to retire the stale generation for pruning after the gateway restarts.",
|
||||
};
|
||||
}
|
||||
|
||||
export function staleManagedNpmInstallGenerationToRepairEffect(
|
||||
issue: StaleManagedNpmInstallGenerationIssue,
|
||||
): HealthRepairEffect {
|
||||
return {
|
||||
kind: "package",
|
||||
action: "would-retire-stale-managed-npm-install-generation",
|
||||
target: issue.packageDir,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
}
|
||||
141
src/commands/doctor-plugin-registry-generation-repair.test.ts
Normal file
141
src/commands/doctor-plugin-registry-generation-repair.test.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import {
|
||||
resolvePluginNpmGenerationProjectDir,
|
||||
resolvePluginNpmProjectDir,
|
||||
} from "../plugins/install-paths.js";
|
||||
import {
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache,
|
||||
readPersistedInstalledPluginIndexInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
} from "../plugins/installed-plugin-index-records.js";
|
||||
import {
|
||||
cleanupRetainedManagedNpmInstallGenerations,
|
||||
hasRetainedManagedNpmInstallMarker,
|
||||
resolveRetainedManagedNpmInstallPackageInfo,
|
||||
} from "../plugins/managed-npm-retention.js";
|
||||
import { writeManagedNpmPlugin } from "../plugins/test-helpers/managed-npm-plugin.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import { maybeRepairStaleManagedNpmInstallGenerations } from "./doctor-plugin-generations.js";
|
||||
import { maybeRepairPluginRegistryState } from "./doctor-plugin-registry.js";
|
||||
|
||||
const PACKAGE_NAME = "@proof/openclaw-generation";
|
||||
const PLUGIN_ID = "generation-proof";
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function makeStateDir(): string {
|
||||
return tempDirs.make("openclaw-doctor-plugin-generation-");
|
||||
}
|
||||
|
||||
function writeManagedFlat(stateDir: string, version: string): string {
|
||||
const npmDir = path.join(stateDir, "npm");
|
||||
writeManagedNpmPlugin({ stateDir, packageName: PACKAGE_NAME, pluginId: PLUGIN_ID, version });
|
||||
return path.join(
|
||||
resolvePluginNpmProjectDir({ npmDir, packageName: PACKAGE_NAME }),
|
||||
"node_modules",
|
||||
...PACKAGE_NAME.split("/"),
|
||||
);
|
||||
}
|
||||
|
||||
function writeManagedGeneration(stateDir: string, version: string): string {
|
||||
const npmDir = path.join(stateDir, "npm");
|
||||
writeManagedNpmPlugin({ stateDir, packageName: PACKAGE_NAME, pluginId: PLUGIN_ID, version });
|
||||
const flatProjectRoot = resolvePluginNpmProjectDir({ npmDir, packageName: PACKAGE_NAME });
|
||||
const generationProjectRoot = resolvePluginNpmGenerationProjectDir({
|
||||
npmDir,
|
||||
packageName: PACKAGE_NAME,
|
||||
generationKey: `${PACKAGE_NAME}@${version}`,
|
||||
});
|
||||
fs.renameSync(flatProjectRoot, generationProjectRoot);
|
||||
return path.join(generationProjectRoot, "node_modules", ...PACKAGE_NAME.split("/"));
|
||||
}
|
||||
|
||||
function setInstallTimestamp(packageDir: string, timestamp: Date): void {
|
||||
const packageInfo = resolveRetainedManagedNpmInstallPackageInfo(packageDir);
|
||||
if (!packageInfo) {
|
||||
throw new Error(`Expected managed npm package dir: ${packageDir}`);
|
||||
}
|
||||
fs.utimesSync(path.join(packageDir, "package.json"), timestamp, timestamp);
|
||||
fs.utimesSync(packageDir, timestamp, timestamp);
|
||||
fs.utimesSync(path.join(packageInfo.projectRoot, "package.json"), timestamp, timestamp);
|
||||
fs.utimesSync(packageInfo.projectRoot, timestamp, timestamp);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache();
|
||||
});
|
||||
|
||||
describe("doctor managed npm generation repair", () => {
|
||||
it("retires the stale flat install and prunes it after gateway shutdown", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const npmDir = path.join(stateDir, "npm");
|
||||
const activePackageDir = writeManagedGeneration(stateDir, "2026.7.1");
|
||||
const stalePackageDir = writeManagedFlat(stateDir, "2026.6.11");
|
||||
await writePersistedInstalledPluginIndexInstallRecords(
|
||||
{
|
||||
[PLUGIN_ID]: {
|
||||
source: "npm",
|
||||
spec: `${PACKAGE_NAME}@latest`,
|
||||
installPath: activePackageDir,
|
||||
resolvedName: PACKAGE_NAME,
|
||||
resolvedSpec: `${PACKAGE_NAME}@2026.7.1`,
|
||||
resolvedVersion: "2026.7.1",
|
||||
version: "2026.7.1",
|
||||
},
|
||||
},
|
||||
{ stateDir, candidates: [] },
|
||||
);
|
||||
|
||||
await expect(
|
||||
maybeRepairStaleManagedNpmInstallGenerations({
|
||||
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
|
||||
prompter: { shouldRepair: true },
|
||||
stateDir,
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(hasRetainedManagedNpmInstallMarker(stalePackageDir)).toBe(true);
|
||||
expect(hasRetainedManagedNpmInstallMarker(activePackageDir)).toBe(false);
|
||||
|
||||
await expect(
|
||||
cleanupRetainedManagedNpmInstallGenerations({
|
||||
activeInstallPaths: [activePackageDir],
|
||||
npmDir,
|
||||
}),
|
||||
).resolves.toBe(1);
|
||||
expect(fs.existsSync(stalePackageDir)).toBe(false);
|
||||
expect(fs.existsSync(activePackageDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("persists the recency fallback when no authoritative record exists", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const activePackageDir = writeManagedGeneration(stateDir, "1.0.0");
|
||||
const stalePackageDir = writeManagedFlat(stateDir, "9.0.0");
|
||||
const activeTimestamp = new Date("2026-01-02T00:00:00.000Z");
|
||||
const staleTimestamp = new Date("2026-01-01T00:00:00.000Z");
|
||||
setInstallTimestamp(activePackageDir, activeTimestamp);
|
||||
setInstallTimestamp(stalePackageDir, staleTimestamp);
|
||||
await writePersistedInstalledPluginIndexInstallRecords({}, { stateDir, candidates: [] });
|
||||
vi.spyOn(process, "emitWarning").mockImplementation(() => undefined);
|
||||
|
||||
await maybeRepairPluginRegistryState({
|
||||
config: {
|
||||
plugins: {
|
||||
allow: [PLUGIN_ID],
|
||||
entries: { [PLUGIN_ID]: { enabled: true } },
|
||||
},
|
||||
},
|
||||
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
|
||||
prompter: { shouldRepair: true },
|
||||
stateDir,
|
||||
});
|
||||
|
||||
const persisted = await readPersistedInstalledPluginIndexInstallRecords({ stateDir });
|
||||
expect(persisted?.[PLUGIN_ID]?.installPath).toBe(activePackageDir);
|
||||
expect(hasRetainedManagedNpmInstallMarker(stalePackageDir)).toBe(true);
|
||||
expect(hasRetainedManagedNpmInstallMarker(activePackageDir)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,14 @@ import {
|
||||
type StaleLocalBundledPluginInstallRecord,
|
||||
} from "../plugins/stale-local-bundled-plugin-install-records.js";
|
||||
import { shortenHomePath } from "../utils.js";
|
||||
import {
|
||||
listStaleManagedNpmInstallGenerations,
|
||||
maybeRepairStaleManagedNpmInstallGenerations,
|
||||
PLUGIN_REGISTRY_CHECK_ID,
|
||||
staleManagedNpmInstallGenerationToHealthFinding,
|
||||
staleManagedNpmInstallGenerationToRepairEffect,
|
||||
type StaleManagedNpmInstallGenerationIssue,
|
||||
} from "./doctor-plugin-generations.js";
|
||||
import type { DoctorPrompter } from "./doctor-prompter.js";
|
||||
import {
|
||||
migratePluginRegistryForInstall,
|
||||
@@ -35,8 +43,6 @@ import {
|
||||
type PluginRegistryInstallMigrationParams,
|
||||
} from "./doctor/shared/plugin-registry-migration.js";
|
||||
|
||||
const PLUGIN_REGISTRY_CHECK_ID = "core/doctor/plugin-registry";
|
||||
|
||||
type PluginRegistryDoctorRepairParams = Omit<PluginRegistryInstallMigrationParams, "config"> &
|
||||
InstalledPluginIndexRecordStoreOptions & {
|
||||
config: OpenClawConfig;
|
||||
@@ -84,16 +90,16 @@ type PluginRegistryHealthIssue =
|
||||
kind: "managed-npm-package-unreadable";
|
||||
packageDir: string;
|
||||
reason: string;
|
||||
};
|
||||
}
|
||||
| StaleManagedNpmInstallGenerationIssue;
|
||||
|
||||
type ManagedNpmPackageReadFailure = {
|
||||
packageDir: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
function formatPackageReadFailure(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const formatPackageReadFailure = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error);
|
||||
|
||||
function readJsonObject(filePath: string): Record<string, unknown> | null {
|
||||
const parsed = tryReadJsonSync(filePath);
|
||||
@@ -513,6 +519,7 @@ export async function detectPluginRegistryHealthIssues(
|
||||
stalePath: record.stalePath,
|
||||
});
|
||||
}
|
||||
issues.push(...(await listStaleManagedNpmInstallGenerations(params)));
|
||||
const managedNpmAudit = await listManagedNpmOpenClawPeerLinkIssues(params);
|
||||
for (const issue of managedNpmAudit.peerLinkIssues) {
|
||||
issues.push({
|
||||
@@ -583,6 +590,8 @@ export function pluginRegistryIssueToHealthFinding(
|
||||
path: issue.packageDir,
|
||||
fixHint: "Restore access to the package files, then run `openclaw doctor` again.",
|
||||
};
|
||||
case "stale-managed-npm-install-generation":
|
||||
return staleManagedNpmInstallGenerationToHealthFinding(issue);
|
||||
}
|
||||
return assertNeverPluginRegistryIssue(issue);
|
||||
}
|
||||
@@ -626,6 +635,8 @@ export function pluginRegistryIssueToRepairEffect(
|
||||
target: issue.packageDir,
|
||||
dryRunSafe: false,
|
||||
};
|
||||
case "stale-managed-npm-install-generation":
|
||||
return staleManagedNpmInstallGenerationToRepairEffect(issue);
|
||||
}
|
||||
return assertNeverPluginRegistryIssue(issue);
|
||||
}
|
||||
@@ -657,6 +668,8 @@ export async function maybeRepairPluginRegistryState(
|
||||
const removedStaleManagedNpmBundledPlugins = maybeRepairStaleManagedNpmBundledPlugins(params);
|
||||
const removedStaleLocalBundledPluginIds =
|
||||
await maybeRepairStaleLocalBundledPluginInstallRecords(params);
|
||||
const retiredStaleManagedNpmInstallGenerations =
|
||||
await maybeRepairStaleManagedNpmInstallGenerations(params);
|
||||
const repairedManagedNpmOpenClawPeerLinks = await maybeRepairManagedNpmOpenClawPeerLinks(params);
|
||||
const stalePluginIdsToRemove = [
|
||||
...new Set([
|
||||
@@ -664,6 +677,8 @@ export async function maybeRepairPluginRegistryState(
|
||||
...removedStaleLocalBundledPluginIds,
|
||||
]),
|
||||
];
|
||||
const shouldPersistRepairedInstallRecords =
|
||||
stalePluginIdsToRemove.length > 0 || retiredStaleManagedNpmInstallGenerations;
|
||||
if (!params.prompter.shouldRepair) {
|
||||
if (preflight.action === "migrate") {
|
||||
note(
|
||||
@@ -680,7 +695,7 @@ export async function maybeRepairPluginRegistryState(
|
||||
if (preflight.action === "migrate") {
|
||||
const result = await migratePluginRegistryForInstall({
|
||||
...migrationParams,
|
||||
...(stalePluginIdsToRemove.length > 0
|
||||
...(shouldPersistRepairedInstallRecords
|
||||
? {
|
||||
installRecords: await loadInstallRecordsWithoutPluginIds(
|
||||
params,
|
||||
@@ -704,12 +719,13 @@ export async function maybeRepairPluginRegistryState(
|
||||
preflight.action === "skip-existing" ||
|
||||
removedStaleManagedNpmBundledPlugins ||
|
||||
removedStaleLocalBundledPluginIds.length > 0 ||
|
||||
retiredStaleManagedNpmInstallGenerations ||
|
||||
repairedManagedNpmOpenClawPeerLinks
|
||||
) {
|
||||
const index = await refreshPluginRegistry({
|
||||
...migrationParams,
|
||||
reason: "migration",
|
||||
...(stalePluginIdsToRemove.length > 0
|
||||
...(shouldPersistRepairedInstallRecords
|
||||
? {
|
||||
installRecords: await loadInstallRecordsWithoutPluginIds(
|
||||
params,
|
||||
|
||||
396
src/plugins/installed-plugin-index-generation-precedence.test.ts
Normal file
396
src/plugins/installed-plugin-index-generation-precedence.test.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
// Covers loader precedence between a plugin's flat project dir and newer
|
||||
// `__openclaw-generation__` dirs when reconciling persisted install records.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import {
|
||||
resolvePluginNpmGenerationProjectDir,
|
||||
resolvePluginNpmProjectDir,
|
||||
} from "./install-paths.js";
|
||||
import {
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache,
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
loadInstalledPluginIndexInstallRecordsSync,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
} from "./installed-plugin-index-records.js";
|
||||
import {
|
||||
markRetainedManagedNpmInstall,
|
||||
resolveRetainedManagedNpmInstallPackageInfo,
|
||||
} from "./managed-npm-retention.js";
|
||||
import { writeManagedNpmPlugin } from "./test-helpers/managed-npm-plugin.js";
|
||||
|
||||
const PACKAGE_NAME = "@openclaw/discord";
|
||||
const PLUGIN_ID = "discord";
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function makeStateDir(): string {
|
||||
return tempDirs.make("openclaw-plugin-generation-precedence-");
|
||||
}
|
||||
|
||||
function expectRecordFields(record: unknown, expected: Record<string, unknown>) {
|
||||
if (!record || typeof record !== "object") {
|
||||
throw new Error("Expected record");
|
||||
}
|
||||
const actual = record as Record<string, unknown>;
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
expect(actual[key]).toEqual(value);
|
||||
}
|
||||
return actual;
|
||||
}
|
||||
|
||||
/** Writes a managed plugin version into an `__openclaw-generation__` dir. */
|
||||
function writeManagedGeneration(params: {
|
||||
stateDir: string;
|
||||
version: string;
|
||||
generationKey: string;
|
||||
}): string {
|
||||
const npmDir = path.join(params.stateDir, "npm");
|
||||
writeManagedNpmPlugin({
|
||||
stateDir: params.stateDir,
|
||||
packageName: PACKAGE_NAME,
|
||||
pluginId: PLUGIN_ID,
|
||||
version: params.version,
|
||||
});
|
||||
const flatProjectRoot = resolvePluginNpmProjectDir({ npmDir, packageName: PACKAGE_NAME });
|
||||
const generationProjectRoot = resolvePluginNpmGenerationProjectDir({
|
||||
npmDir,
|
||||
packageName: PACKAGE_NAME,
|
||||
generationKey: params.generationKey,
|
||||
});
|
||||
fs.renameSync(flatProjectRoot, generationProjectRoot);
|
||||
return path.join(generationProjectRoot, "node_modules", ...PACKAGE_NAME.split("/"));
|
||||
}
|
||||
|
||||
/** Writes a managed plugin version into the flat project dir and leaves it there. */
|
||||
function writeManagedFlat(stateDir: string, version: string): string {
|
||||
const npmDir = path.join(stateDir, "npm");
|
||||
writeManagedNpmPlugin({ stateDir, packageName: PACKAGE_NAME, pluginId: PLUGIN_ID, version });
|
||||
const flatProjectRoot = resolvePluginNpmProjectDir({ npmDir, packageName: PACKAGE_NAME });
|
||||
return path.join(flatProjectRoot, "node_modules", ...PACKAGE_NAME.split("/"));
|
||||
}
|
||||
|
||||
function writeManagedLegacy(stateDir: string, version: string): string {
|
||||
return writeManagedNpmPlugin({
|
||||
stateDir,
|
||||
packageName: PACKAGE_NAME,
|
||||
pluginId: PLUGIN_ID,
|
||||
version,
|
||||
layout: "legacy",
|
||||
});
|
||||
}
|
||||
|
||||
function setInstallTimestamp(packageDir: string, timestampMs: number): void {
|
||||
const packageInfo = resolveRetainedManagedNpmInstallPackageInfo(packageDir);
|
||||
if (!packageInfo) {
|
||||
throw new Error(`Expected managed npm package dir: ${packageDir}`);
|
||||
}
|
||||
const timestamp = new Date(timestampMs);
|
||||
fs.utimesSync(path.join(packageDir, "package.json"), timestamp, timestamp);
|
||||
fs.utimesSync(packageDir, timestamp, timestamp);
|
||||
fs.utimesSync(path.join(packageInfo.projectRoot, "package.json"), timestamp, timestamp);
|
||||
fs.utimesSync(packageInfo.projectRoot, timestamp, timestamp);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache();
|
||||
});
|
||||
|
||||
describe("managed npm generation-dir loader precedence", () => {
|
||||
it("loads the authoritative generation after an upgrade leaves the old flat install", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const staleVersion = "2026.6.11";
|
||||
const activeVersion = "2026.7.1";
|
||||
|
||||
const activePackageDir = writeManagedGeneration({
|
||||
stateDir,
|
||||
version: activeVersion,
|
||||
generationKey: `discord-${activeVersion}`,
|
||||
});
|
||||
// Recreate the prior version at the flat project dir so it is still present
|
||||
// on disk (the case `isUnavailableManagedNpmInstallRecord` does not cover).
|
||||
const stalePackageDir = writeManagedFlat(stateDir, staleVersion);
|
||||
|
||||
await writePersistedInstalledPluginIndexInstallRecords(
|
||||
{
|
||||
discord: {
|
||||
source: "npm",
|
||||
spec: `${PACKAGE_NAME}@latest`,
|
||||
installPath: activePackageDir,
|
||||
version: activeVersion,
|
||||
resolvedName: PACKAGE_NAME,
|
||||
resolvedVersion: activeVersion,
|
||||
resolvedSpec: `${PACKAGE_NAME}@${activeVersion}`,
|
||||
integrity: "sha512-active",
|
||||
},
|
||||
},
|
||||
{ stateDir, candidates: [] },
|
||||
);
|
||||
|
||||
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
||||
expectRecordFields(loaded.discord, {
|
||||
source: "npm",
|
||||
spec: `${PACKAGE_NAME}@latest`,
|
||||
installPath: activePackageDir,
|
||||
version: activeVersion,
|
||||
resolvedName: PACKAGE_NAME,
|
||||
resolvedVersion: activeVersion,
|
||||
resolvedSpec: `${PACKAGE_NAME}@${activeVersion}`,
|
||||
integrity: "sha512-active",
|
||||
});
|
||||
expect(fs.existsSync(stalePackageDir)).toBe(true);
|
||||
|
||||
clearLoadInstalledPluginIndexInstallRecordsCache();
|
||||
expectRecordFields(loadInstalledPluginIndexInstallRecordsSync({ stateDir }).discord, {
|
||||
installPath: activePackageDir,
|
||||
resolvedVersion: activeVersion,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an intentional downgrade when a newer generation lingers", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const newerPackageDir = writeManagedGeneration({
|
||||
stateDir,
|
||||
version: "3.0.0",
|
||||
generationKey: "discord-three",
|
||||
});
|
||||
const downgradedPackageDir = writeManagedFlat(stateDir, "1.0.0");
|
||||
|
||||
await writePersistedInstalledPluginIndexInstallRecords(
|
||||
{
|
||||
discord: {
|
||||
source: "npm",
|
||||
spec: `${PACKAGE_NAME}@1.0.0`,
|
||||
installPath: downgradedPackageDir,
|
||||
version: "1.0.0",
|
||||
resolvedName: PACKAGE_NAME,
|
||||
resolvedVersion: "1.0.0",
|
||||
resolvedSpec: `${PACKAGE_NAME}@1.0.0`,
|
||||
},
|
||||
},
|
||||
{ stateDir, candidates: [] },
|
||||
);
|
||||
|
||||
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
||||
expectRecordFields(loaded.discord, {
|
||||
installPath: downgradedPackageDir,
|
||||
resolvedVersion: "1.0.0",
|
||||
});
|
||||
expect(fs.existsSync(newerPackageDir)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches the authoritative generation case-insensitively on Windows", async () => {
|
||||
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
||||
const stateDir = makeStateDir();
|
||||
const newerPackageDir = writeManagedGeneration({
|
||||
stateDir,
|
||||
version: "3.0.0",
|
||||
generationKey: "discord-newer-on-windows",
|
||||
});
|
||||
const downgradedPackageDir = writeManagedFlat(stateDir, "1.0.0");
|
||||
setInstallTimestamp(downgradedPackageDir, Date.UTC(2026, 0, 1));
|
||||
setInstallTimestamp(newerPackageDir, Date.UTC(2026, 0, 2));
|
||||
const differentlyCasedActivePath = downgradedPackageDir.replace(
|
||||
stateDir,
|
||||
stateDir.toUpperCase(),
|
||||
);
|
||||
|
||||
await writePersistedInstalledPluginIndexInstallRecords(
|
||||
{
|
||||
discord: {
|
||||
source: "npm",
|
||||
spec: `${PACKAGE_NAME}@1.0.0`,
|
||||
installPath: differentlyCasedActivePath,
|
||||
version: "1.0.0",
|
||||
resolvedName: PACKAGE_NAME,
|
||||
resolvedVersion: "1.0.0",
|
||||
resolvedSpec: `${PACKAGE_NAME}@1.0.0`,
|
||||
},
|
||||
},
|
||||
{ stateDir, candidates: [] },
|
||||
);
|
||||
const emitWarning = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined);
|
||||
|
||||
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
||||
expectRecordFields(loaded.discord, {
|
||||
installPath: differentlyCasedActivePath,
|
||||
resolvedVersion: "1.0.0",
|
||||
});
|
||||
expect(emitWarning).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses install recency with a structured warning when no authority exists", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const recentPackageDir = writeManagedGeneration({
|
||||
stateDir,
|
||||
version: "1.0.0",
|
||||
generationKey: "discord-recent",
|
||||
});
|
||||
const olderPackageDir = writeManagedFlat(stateDir, "9.0.0");
|
||||
setInstallTimestamp(olderPackageDir, Date.UTC(2026, 0, 1));
|
||||
setInstallTimestamp(recentPackageDir, Date.UTC(2026, 0, 2));
|
||||
const emitWarning = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined);
|
||||
|
||||
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
||||
expectRecordFields(loaded.discord, {
|
||||
installPath: recentPackageDir,
|
||||
resolvedVersion: "1.0.0",
|
||||
});
|
||||
expect(emitWarning).toHaveBeenCalledWith(
|
||||
expect.stringContaining("without an authoritative active path"),
|
||||
expect.objectContaining({
|
||||
code: "OPENCLAW_PLUGIN_INSTALL_RECOVERY_FALLBACK",
|
||||
type: "OpenClawPluginRecoveryWarning",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when install recency replaces a dangling managed authority", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const npmDir = path.join(stateDir, "npm");
|
||||
const recentPackageDir = writeManagedGeneration({
|
||||
stateDir,
|
||||
version: "1.0.0",
|
||||
generationKey: "discord-recent-after-dangling",
|
||||
});
|
||||
const olderPackageDir = writeManagedFlat(stateDir, "9.0.0");
|
||||
setInstallTimestamp(olderPackageDir, Date.UTC(2026, 0, 1));
|
||||
setInstallTimestamp(recentPackageDir, Date.UTC(2026, 0, 2));
|
||||
const missingPackageDir = path.join(
|
||||
resolvePluginNpmGenerationProjectDir({
|
||||
npmDir,
|
||||
packageName: PACKAGE_NAME,
|
||||
generationKey: "discord-missing",
|
||||
}),
|
||||
"node_modules",
|
||||
...PACKAGE_NAME.split("/"),
|
||||
);
|
||||
await writePersistedInstalledPluginIndexInstallRecords(
|
||||
{
|
||||
discord: {
|
||||
source: "npm",
|
||||
spec: `${PACKAGE_NAME}@latest`,
|
||||
installPath: missingPackageDir,
|
||||
resolvedName: PACKAGE_NAME,
|
||||
resolvedVersion: "2.0.0",
|
||||
},
|
||||
},
|
||||
{ stateDir, candidates: [] },
|
||||
);
|
||||
const emitWarning = vi.spyOn(process, "emitWarning").mockImplementation(() => undefined);
|
||||
|
||||
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
||||
expectRecordFields(loaded.discord, {
|
||||
installPath: recentPackageDir,
|
||||
resolvedVersion: "1.0.0",
|
||||
});
|
||||
expect(emitWarning).toHaveBeenCalledWith(
|
||||
expect.stringContaining("without an authoritative active path"),
|
||||
expect.objectContaining({ code: "OPENCLAW_PLUGIN_INSTALL_RECOVERY_FALLBACK" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat unrelated legacy-root metadata changes as plugin recency", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const recentPackageDir = writeManagedGeneration({
|
||||
stateDir,
|
||||
version: "1.0.0",
|
||||
generationKey: "discord-recent-from-legacy",
|
||||
});
|
||||
const olderPackageDir = writeManagedLegacy(stateDir, "9.0.0");
|
||||
setInstallTimestamp(olderPackageDir, Date.UTC(2026, 0, 1));
|
||||
setInstallTimestamp(recentPackageDir, Date.UTC(2026, 0, 2));
|
||||
writeManagedNpmPlugin({
|
||||
stateDir,
|
||||
packageName: "@openclaw/unrelated",
|
||||
pluginId: "unrelated",
|
||||
version: "1.0.0",
|
||||
layout: "legacy",
|
||||
});
|
||||
vi.spyOn(process, "emitWarning").mockImplementation(() => undefined);
|
||||
|
||||
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
||||
expectRecordFields(loaded.discord, {
|
||||
installPath: recentPackageDir,
|
||||
resolvedVersion: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes doctor-retired generations when recovering without authority", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const retiredPackageDir = writeManagedGeneration({
|
||||
stateDir,
|
||||
version: "3.0.0",
|
||||
generationKey: "discord-retired",
|
||||
});
|
||||
const recoveredPackageDir = writeManagedFlat(stateDir, "1.0.0");
|
||||
await markRetainedManagedNpmInstall({
|
||||
packageDir: retiredPackageDir,
|
||||
pluginId: PLUGIN_ID,
|
||||
reason: "test-retired-generation",
|
||||
});
|
||||
|
||||
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
||||
expectRecordFields(loaded.discord, {
|
||||
installPath: recoveredPackageDir,
|
||||
resolvedVersion: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("excludes a doctor-retired legacy-root package when recovering without authority", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
const retiredPackageDir = writeManagedLegacy(stateDir, "3.0.0");
|
||||
const recoveredPackageDir = writeManagedGeneration({
|
||||
stateDir,
|
||||
version: "1.0.0",
|
||||
generationKey: "discord-after-retired-legacy",
|
||||
});
|
||||
await markRetainedManagedNpmInstall({
|
||||
packageDir: retiredPackageDir,
|
||||
pluginId: PLUGIN_ID,
|
||||
reason: "test-retired-legacy-root-package",
|
||||
});
|
||||
|
||||
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
||||
expectRecordFields(loaded.discord, {
|
||||
installPath: recoveredPackageDir,
|
||||
resolvedVersion: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not repoint an intentional custom npm install outside the managed root", async () => {
|
||||
const stateDir = makeStateDir();
|
||||
// A managed generation with a higher version exists on disk...
|
||||
writeManagedGeneration({ stateDir, version: "2.0.0", generationKey: "discord-managed" });
|
||||
// ...but the persisted record points at a custom install outside the npm root.
|
||||
const customInstallPath = path.join(stateDir, "custom", "node_modules", "@openclaw", "discord");
|
||||
|
||||
await writePersistedInstalledPluginIndexInstallRecords(
|
||||
{
|
||||
discord: {
|
||||
source: "npm",
|
||||
spec: `${PACKAGE_NAME}@beta`,
|
||||
installPath: customInstallPath,
|
||||
version: "1.0.0",
|
||||
resolvedName: PACKAGE_NAME,
|
||||
resolvedVersion: "1.0.0",
|
||||
resolvedSpec: `${PACKAGE_NAME}@1.0.0`,
|
||||
integrity: "sha512-custom",
|
||||
},
|
||||
},
|
||||
{ stateDir, candidates: [] },
|
||||
);
|
||||
|
||||
const loaded = await loadInstalledPluginIndexInstallRecords({ stateDir });
|
||||
expectRecordFields(loaded.discord, {
|
||||
source: "npm",
|
||||
spec: `${PACKAGE_NAME}@beta`,
|
||||
installPath: customInstallPath,
|
||||
resolvedVersion: "1.0.0",
|
||||
integrity: "sha512-custom",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -121,14 +121,43 @@ function resolveRecoveredManagedNpmPluginId(params: {
|
||||
return validatePluginId(pluginId) ? undefined : pluginId;
|
||||
}
|
||||
|
||||
function buildRecoveredManagedNpmInstallRecordsForRoot(
|
||||
npmRoot: string,
|
||||
): Record<string, PluginInstallRecord> {
|
||||
const rootManifest = readJsonObjectFileSync(path.join(npmRoot, "package.json"));
|
||||
type RecoveredManagedNpmInstallCandidate = {
|
||||
installRecord: PluginInstallRecord;
|
||||
installTimestampMs: number;
|
||||
pluginId: string;
|
||||
};
|
||||
|
||||
function readManagedNpmInstallTimestampMs(params: {
|
||||
packageDir: string;
|
||||
projectRoot: string;
|
||||
sharedLegacyRoot: boolean;
|
||||
}): number {
|
||||
// Isolated flat/generation roots have an OpenClaw-owned project manifest that
|
||||
// is rewritten during install. The legacy root is shared, so only its
|
||||
// package-local directory mtime can represent this plugin's install.
|
||||
const timestampPaths = params.sharedLegacyRoot
|
||||
? [params.packageDir]
|
||||
: [path.join(params.projectRoot, "package.json"), params.projectRoot];
|
||||
for (const filePath of timestampPaths) {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
} catch {
|
||||
// Recovery already verified the package directory. Missing project
|
||||
// metadata simply leaves the containing directory as the final signal.
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function buildRecoveredManagedNpmInstallCandidatesForRoot(params: {
|
||||
projectRoot: string;
|
||||
sharedLegacyRoot: boolean;
|
||||
}): RecoveredManagedNpmInstallCandidate[] {
|
||||
const rootManifest = readJsonObjectFileSync(path.join(params.projectRoot, "package.json"));
|
||||
const dependencies = readStringRecord(rootManifest?.dependencies);
|
||||
const records: Record<string, PluginInstallRecord> = {};
|
||||
const candidates: RecoveredManagedNpmInstallCandidate[] = [];
|
||||
for (const [packageName, dependencySpec] of Object.entries(dependencies)) {
|
||||
const packageDir = path.join(npmRoot, "node_modules", ...packageName.split("/"));
|
||||
const packageDir = path.join(params.projectRoot, "node_modules", ...packageName.split("/"));
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(packageDir);
|
||||
@@ -150,27 +179,42 @@ function buildRecoveredManagedNpmInstallRecordsForRoot(
|
||||
typeof packageManifest?.version === "string" && packageManifest.version.trim()
|
||||
? packageManifest.version.trim()
|
||||
: undefined;
|
||||
records[pluginId] = {
|
||||
source: "npm",
|
||||
spec: `${packageName}@${dependencySpec}`,
|
||||
installPath: packageDir,
|
||||
...(version ? { version, resolvedName: packageName, resolvedVersion: version } : {}),
|
||||
...(version ? { resolvedSpec: `${packageName}@${version}` } : {}),
|
||||
};
|
||||
candidates.push({
|
||||
pluginId,
|
||||
installTimestampMs: readManagedNpmInstallTimestampMs({
|
||||
packageDir,
|
||||
projectRoot: params.projectRoot,
|
||||
sharedLegacyRoot: params.sharedLegacyRoot,
|
||||
}),
|
||||
installRecord: {
|
||||
source: "npm",
|
||||
spec: `${packageName}@${dependencySpec}`,
|
||||
installPath: packageDir,
|
||||
...(version ? { version, resolvedName: packageName, resolvedVersion: version } : {}),
|
||||
...(version ? { resolvedSpec: `${packageName}@${version}` } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
return records;
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function buildRecoveredManagedNpmInstallRecords(
|
||||
/** Lists recoverable managed npm installs without assigning active precedence. */
|
||||
export function listRecoveredManagedNpmInstallCandidates(
|
||||
options: InstalledPluginIndexStoreOptions = {},
|
||||
): Record<string, PluginInstallRecord> {
|
||||
): RecoveredManagedNpmInstallCandidate[] {
|
||||
const npmRoot = resolveRecoveredManagedNpmRoot(options);
|
||||
const legacyRecords = buildRecoveredManagedNpmInstallRecordsForRoot(npmRoot);
|
||||
const projectRecords: Record<string, PluginInstallRecord> = {};
|
||||
for (const projectRoot of listManagedPluginNpmProjectRootsSync(npmRoot)) {
|
||||
Object.assign(projectRecords, buildRecoveredManagedNpmInstallRecordsForRoot(projectRoot));
|
||||
}
|
||||
return { ...legacyRecords, ...projectRecords };
|
||||
return [
|
||||
...buildRecoveredManagedNpmInstallCandidatesForRoot({
|
||||
projectRoot: npmRoot,
|
||||
sharedLegacyRoot: true,
|
||||
}),
|
||||
...listManagedPluginNpmProjectRootsSync(npmRoot).flatMap((projectRoot) =>
|
||||
buildRecoveredManagedNpmInstallCandidatesForRoot({
|
||||
projectRoot,
|
||||
sharedLegacyRoot: false,
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function recordsShareInstallPath(
|
||||
@@ -180,7 +224,88 @@ function recordsShareInstallPath(
|
||||
if (!left?.installPath || !right.installPath) {
|
||||
return false;
|
||||
}
|
||||
return path.resolve(left.installPath) === path.resolve(right.installPath);
|
||||
return (
|
||||
normalizeInstallPathForComparison(left.installPath) ===
|
||||
normalizeInstallPathForComparison(right.installPath)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeInstallPathForComparison(filePath: string): string {
|
||||
const resolved = path.resolve(filePath);
|
||||
return process.platform === "win32" ? normalizeWindowsPathForComparison(resolved) : resolved;
|
||||
}
|
||||
|
||||
function pickMostRecentRecoveredManagedNpmCandidate(
|
||||
candidates: readonly RecoveredManagedNpmInstallCandidate[],
|
||||
): RecoveredManagedNpmInstallCandidate {
|
||||
return candidates.toSorted((left, right) => {
|
||||
const byTimestamp = right.installTimestampMs - left.installTimestampMs;
|
||||
if (byTimestamp !== 0) {
|
||||
return byTimestamp;
|
||||
}
|
||||
return (right.installRecord.installPath ?? "").localeCompare(
|
||||
left.installRecord.installPath ?? "",
|
||||
);
|
||||
})[0]!;
|
||||
}
|
||||
|
||||
function emitManagedNpmRecoveryFallbackWarning(params: {
|
||||
pluginId: string;
|
||||
selected: RecoveredManagedNpmInstallCandidate;
|
||||
candidates: readonly RecoveredManagedNpmInstallCandidate[];
|
||||
}): void {
|
||||
process.emitWarning(
|
||||
`Managed npm recovery found ${params.candidates.length} installs for plugin "${params.pluginId}" without an authoritative active path; selected the most recently installed candidate. Run \`openclaw doctor --fix\` to persist and retire stale generations.`,
|
||||
{
|
||||
code: "OPENCLAW_PLUGIN_INSTALL_RECOVERY_FALLBACK",
|
||||
type: "OpenClawPluginRecoveryWarning",
|
||||
detail: JSON.stringify({
|
||||
pluginId: params.pluginId,
|
||||
selectedInstallPath: params.selected.installRecord.installPath,
|
||||
candidates: params.candidates.map((candidate) => ({
|
||||
installPath: candidate.installRecord.installPath,
|
||||
installTimestampMs: candidate.installTimestampMs,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function buildRecoveredManagedNpmInstallRecords(
|
||||
persisted: Record<string, PluginInstallRecord> | null,
|
||||
options: InstalledPluginIndexStoreOptions = {},
|
||||
): Record<string, PluginInstallRecord> {
|
||||
const npmRoot = resolveRecoveredManagedNpmRoot(options);
|
||||
const records: Record<string, PluginInstallRecord> = {};
|
||||
const candidatesByPluginId = new Map<string, RecoveredManagedNpmInstallCandidate[]>();
|
||||
for (const candidate of listRecoveredManagedNpmInstallCandidates(options)) {
|
||||
const candidates = candidatesByPluginId.get(candidate.pluginId) ?? [];
|
||||
candidates.push(candidate);
|
||||
candidatesByPluginId.set(candidate.pluginId, candidates);
|
||||
}
|
||||
for (const [pluginId, candidates] of candidatesByPluginId) {
|
||||
// The install ledger is the active-generation authority. Directory order,
|
||||
// version, and recency may only break ties when that authority is absent.
|
||||
const persistedRecord = persisted?.[pluginId];
|
||||
const authoritative = candidates.find((candidate) =>
|
||||
recordsShareInstallPath(persistedRecord, candidate.installRecord),
|
||||
);
|
||||
const selected = authoritative ?? pickMostRecentRecoveredManagedNpmCandidate(candidates);
|
||||
records[pluginId] = selected.installRecord;
|
||||
const recoversUnavailableManagedPath = isUnavailableManagedNpmInstallRecord({
|
||||
npmRoot,
|
||||
persisted: persistedRecord,
|
||||
recovered: selected.installRecord,
|
||||
});
|
||||
if (
|
||||
!authoritative &&
|
||||
candidates.length > 1 &&
|
||||
(!persistedRecord || recoversUnavailableManagedPath)
|
||||
) {
|
||||
emitManagedNpmRecoveryFallbackWarning({ pluginId, selected, candidates });
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
function readInstallRecordVersion(record: PluginInstallRecord | undefined): string | undefined {
|
||||
@@ -210,18 +335,14 @@ function isUnavailableManagedNpmInstallRecord(params: {
|
||||
if (!packageInfo || packageInfo.packageName !== params.recovered.resolvedName) {
|
||||
return false;
|
||||
}
|
||||
const normalizeForComparison = (value: string): string => {
|
||||
const resolved = path.resolve(value);
|
||||
return process.platform === "win32" ? normalizeWindowsPathForComparison(resolved) : resolved;
|
||||
};
|
||||
// Persisted Windows paths can differ only by casing. Use filesystem comparison
|
||||
// semantics so a managed generation is not mistaken for a custom install.
|
||||
const npmRoot = normalizeForComparison(params.npmRoot);
|
||||
const projectRoot = normalizeForComparison(packageInfo.projectRoot);
|
||||
const npmRoot = normalizeInstallPathForComparison(params.npmRoot);
|
||||
const projectRoot = normalizeInstallPathForComparison(packageInfo.projectRoot);
|
||||
return (
|
||||
projectRoot === npmRoot ||
|
||||
normalizeForComparison(path.dirname(packageInfo.projectRoot)) ===
|
||||
normalizeForComparison(resolvePluginNpmProjectsDir(params.npmRoot))
|
||||
normalizeInstallPathForComparison(path.dirname(packageInfo.projectRoot)) ===
|
||||
normalizeInstallPathForComparison(resolvePluginNpmProjectsDir(params.npmRoot))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -277,6 +398,8 @@ function mergeRecoveredManagedNpmRecord(params: {
|
||||
) {
|
||||
return mergeRecoveredManagedNpmMetadata(params.persisted, params.recovered);
|
||||
}
|
||||
// Missing managed paths were recovered above. Any remaining persisted path is
|
||||
// the active ledger choice, including an intentional downgrade or custom install.
|
||||
return params.persisted ?? params.recovered;
|
||||
}
|
||||
|
||||
@@ -285,7 +408,7 @@ function mergeRecoveredManagedNpmInstallRecords(
|
||||
options: InstalledPluginIndexStoreOptions,
|
||||
): Record<string, PluginInstallRecord> {
|
||||
const npmRoot = resolveRecoveredManagedNpmRoot(options);
|
||||
const recovered = buildRecoveredManagedNpmInstallRecords(options);
|
||||
const recovered = buildRecoveredManagedNpmInstallRecords(persisted, options);
|
||||
const merged: Record<string, PluginInstallRecord> = { ...persisted };
|
||||
for (const [pluginId, record] of Object.entries(recovered)) {
|
||||
merged[pluginId] = mergeRecoveredManagedNpmRecord({
|
||||
|
||||
Reference in New Issue
Block a user