mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 02:45:38 -06:00
fix(plugins): preserve install index state across failures (#119228)
* fix(plugins): preserve install index policy config * fix(plugins): restore complete install index state * fix(plugins): fence install index rollback * fix(plugins): fence generic install index rollback * fix(plugins): keep lifecycle lease context private * test(cli): align plugin index rollback mocks * test(plugins): enforce index rollback receipts * fix(plugins): serialize install rollback with config commit * test(plugins): keep legacy index writer mock private
This commit is contained in:
@@ -18,15 +18,21 @@ import { isPathInside } from "../infra/path-guards.js";
|
||||
import {
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
PLUGIN_INSTALLS_CONFIG_PATH,
|
||||
type InstalledPluginIndexRecordStoreOptions,
|
||||
withoutPluginInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease,
|
||||
} from "./installed-plugin-index-records.js";
|
||||
import {
|
||||
restorePersistedInstalledPluginIndexIfCurrent,
|
||||
type InstalledPluginIndexWriteReceipt,
|
||||
} from "./installed-plugin-index-store.js";
|
||||
import {
|
||||
clearRetainedManagedNpmInstallMarker,
|
||||
markRetainedManagedNpmInstall,
|
||||
resolveRetainedManagedNpmInstallPackageInfo,
|
||||
resolveRetainedManagedNpmInstallMarkerPath,
|
||||
} from "./managed-npm-retention.js";
|
||||
import { withPluginLifecycleLease } from "./plugin-lifecycle-lease.js";
|
||||
import { planPluginUninstall } from "./uninstall.js";
|
||||
|
||||
function mergeUnsetPaths(
|
||||
@@ -272,33 +278,46 @@ async function restoreClearedRetainedManagedNpmInstallMarkers(
|
||||
}
|
||||
|
||||
async function commitPluginInstallRecordsWithWriter(params: {
|
||||
previousInstallRecords?: Record<string, PluginInstallRecord>;
|
||||
nextInstallRecords: Record<string, PluginInstallRecord>;
|
||||
prepareInstallRecords: (storeOptions: InstalledPluginIndexRecordStoreOptions) => Promise<{
|
||||
previousInstallRecords: Record<string, PluginInstallRecord>;
|
||||
nextInstallRecords: Record<string, PluginInstallRecord>;
|
||||
}>;
|
||||
nextConfig: OpenClawConfig;
|
||||
writeOptions?: ConfigWriteOptions;
|
||||
commit: ConfigCommit;
|
||||
}): Promise<ConfigReplaceResult | void> {
|
||||
const previousInstallRecords =
|
||||
params.previousInstallRecords ?? (await loadInstalledPluginIndexInstallRecords());
|
||||
const retainedMarkerPaths: string[] = [];
|
||||
const clearedMarkerSnapshots: Array<{ markerPath: string; contents: string }> = [];
|
||||
try {
|
||||
await writePersistedInstalledPluginIndexInstallRecords(params.nextInstallRecords);
|
||||
}): Promise<{
|
||||
committed: ConfigReplaceResult | void;
|
||||
nextInstallRecords: Record<string, PluginInstallRecord>;
|
||||
}> {
|
||||
return await withPluginLifecycleLease({}, async (lease) => {
|
||||
let tentativeWrite: InstalledPluginIndexWriteReceipt | undefined;
|
||||
const retainedMarkerPaths: string[] = [];
|
||||
const clearedMarkerSnapshots: Array<{ markerPath: string; contents: string }> = [];
|
||||
try {
|
||||
const storeOptions = { filePath: lease.databasePath };
|
||||
const prepared = await params.prepareInstallRecords(storeOptions);
|
||||
tentativeWrite = await writePersistedInstalledPluginIndexInstallRecordsWithLease(
|
||||
prepared.nextInstallRecords,
|
||||
{
|
||||
...storeOptions,
|
||||
config: params.nextConfig,
|
||||
lease,
|
||||
},
|
||||
);
|
||||
await markRetainedReplacedManagedNpmInstallRecords({
|
||||
previousInstallRecords,
|
||||
nextInstallRecords: params.nextInstallRecords,
|
||||
// Keep partial progress visible to the outer rollback path.
|
||||
previousInstallRecords: prepared.previousInstallRecords,
|
||||
nextInstallRecords: prepared.nextInstallRecords,
|
||||
// Keep partial progress visible to the rollback path.
|
||||
createdMarkerPaths: retainedMarkerPaths,
|
||||
});
|
||||
clearedMarkerSnapshots.push(
|
||||
...(await clearActiveRetainedManagedNpmInstallMarkers(params.nextInstallRecords)),
|
||||
...(await clearActiveRetainedManagedNpmInstallMarkers(prepared.nextInstallRecords)),
|
||||
);
|
||||
const installRecordsChanged = !isDeepStrictEqual(
|
||||
previousInstallRecords,
|
||||
params.nextInstallRecords,
|
||||
prepared.previousInstallRecords,
|
||||
prepared.nextInstallRecords,
|
||||
);
|
||||
return await params.commit(params.nextConfig, {
|
||||
const committed = await params.commit(params.nextConfig, {
|
||||
...params.writeOptions,
|
||||
...(installRecordsChanged && params.writeOptions?.afterWrite === undefined
|
||||
? { afterWrite: { mode: "restart", reason: PLUGIN_SOURCE_CHANGED_RESTART_REASON } }
|
||||
@@ -307,23 +326,35 @@ async function commitPluginInstallRecordsWithWriter(params: {
|
||||
Array.from(PLUGIN_INSTALLS_CONFIG_PATH),
|
||||
]),
|
||||
});
|
||||
return { committed, nextInstallRecords: prepared.nextInstallRecords };
|
||||
} catch (error) {
|
||||
try {
|
||||
// Keep config and install index atomic from the caller's perspective.
|
||||
await writePersistedInstalledPluginIndexInstallRecords(previousInstallRecords);
|
||||
} catch (rollbackError) {
|
||||
throw new Error(
|
||||
"Failed to commit plugin install records and could not restore the previous plugin index",
|
||||
{ cause: rollbackError },
|
||||
);
|
||||
const tentative = tentativeWrite;
|
||||
if (tentative) {
|
||||
try {
|
||||
const restored = await restorePersistedInstalledPluginIndexIfCurrent(
|
||||
tentative.previous,
|
||||
tentative.revision,
|
||||
{
|
||||
filePath: lease.databasePath,
|
||||
lease,
|
||||
},
|
||||
);
|
||||
if (restored) {
|
||||
// Marker compensation belongs to the same tentative revision. A newer
|
||||
// index owner may rely on the current marker state.
|
||||
await restoreClearedRetainedManagedNpmInstallMarkers(clearedMarkerSnapshots);
|
||||
await removeCreatedRetainedManagedNpmInstallMarkers(retainedMarkerPaths);
|
||||
}
|
||||
} catch (rollbackError) {
|
||||
throw new Error(
|
||||
"Failed to commit plugin install records and could not roll back tentative plugin state",
|
||||
{ cause: rollbackError },
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
await restoreClearedRetainedManagedNpmInstallMarkers(clearedMarkerSnapshots);
|
||||
await removeCreatedRetainedManagedNpmInstallMarkers(retainedMarkerPaths);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Persist plugin install records and commit the matching config update to disk. */
|
||||
@@ -335,7 +366,14 @@ export async function commitPluginInstallRecordsWithConfig(params: {
|
||||
writeOptions?: ConfigWriteOptions;
|
||||
}): Promise<void> {
|
||||
await commitPluginInstallRecordsWithWriter({
|
||||
...params,
|
||||
prepareInstallRecords: async (storeOptions) => ({
|
||||
previousInstallRecords:
|
||||
params.previousInstallRecords ??
|
||||
(await loadInstalledPluginIndexInstallRecords(storeOptions)),
|
||||
nextInstallRecords: params.nextInstallRecords,
|
||||
}),
|
||||
nextConfig: params.nextConfig,
|
||||
...(params.writeOptions ? { writeOptions: params.writeOptions } : {}),
|
||||
commit: async (nextConfig, writeOptions) => {
|
||||
return await replaceConfigFile({
|
||||
nextConfig,
|
||||
@@ -350,12 +388,17 @@ export async function commitPluginInstallRecordsWithConfig(params: {
|
||||
export async function commitPluginInstallRecordsOnly(params: {
|
||||
previousInstallRecords?: Record<string, PluginInstallRecord>;
|
||||
nextInstallRecords: Record<string, PluginInstallRecord>;
|
||||
nextConfig: OpenClawConfig;
|
||||
verifyConfigFresh?: () => Promise<void>;
|
||||
}): Promise<void> {
|
||||
await commitPluginInstallRecordsWithWriter({
|
||||
previousInstallRecords: params.previousInstallRecords,
|
||||
nextInstallRecords: params.nextInstallRecords,
|
||||
nextConfig: {},
|
||||
prepareInstallRecords: async (storeOptions) => ({
|
||||
previousInstallRecords:
|
||||
params.previousInstallRecords ??
|
||||
(await loadInstalledPluginIndexInstallRecords(storeOptions)),
|
||||
nextInstallRecords: params.nextInstallRecords,
|
||||
}),
|
||||
nextConfig: params.nextConfig,
|
||||
commit: async () => {
|
||||
await params.verifyConfigFresh?.();
|
||||
return undefined;
|
||||
@@ -401,25 +444,28 @@ export async function commitConfigWriteWithPendingPluginInstalls(params: {
|
||||
}
|
||||
|
||||
const pendingInstallRecords = nextPendingConfig.plugins?.installs ?? {};
|
||||
const previousInstallRecords = await loadInstalledPluginIndexInstallRecords();
|
||||
const nextInstallRecords = {
|
||||
...sourceInstallRecords,
|
||||
...previousInstallRecords,
|
||||
...pendingInstallRecords,
|
||||
};
|
||||
const strippedConfig = withoutPluginInstallRecords(params.nextConfig);
|
||||
const committed = await commitPluginInstallRecordsWithWriter({
|
||||
previousInstallRecords,
|
||||
nextInstallRecords,
|
||||
const result = await commitPluginInstallRecordsWithWriter({
|
||||
prepareInstallRecords: async (storeOptions) => {
|
||||
const previousInstallRecords = await loadInstalledPluginIndexInstallRecords(storeOptions);
|
||||
return {
|
||||
previousInstallRecords,
|
||||
nextInstallRecords: {
|
||||
...sourceInstallRecords,
|
||||
...previousInstallRecords,
|
||||
...pendingInstallRecords,
|
||||
},
|
||||
};
|
||||
},
|
||||
nextConfig: strippedConfig,
|
||||
...(params.writeOptions ? { writeOptions: params.writeOptions } : {}),
|
||||
commit: params.commit,
|
||||
});
|
||||
return {
|
||||
config: strippedConfig,
|
||||
installRecords: nextInstallRecords,
|
||||
installRecords: result.nextInstallRecords,
|
||||
movedInstallRecords: true,
|
||||
persistedHash: committed?.persistedHash ?? null,
|
||||
persistedHash: result.committed?.persistedHash ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -479,8 +525,12 @@ export async function transformConfigWithPendingPluginInstalls<T = void>(
|
||||
};
|
||||
};
|
||||
|
||||
return await transformConfigFileWithRetry<T>({
|
||||
...params,
|
||||
commit,
|
||||
// The config lock is acquired inside the transform. Own the plugin lifecycle
|
||||
// lease first so pending-record commits keep the canonical lock order.
|
||||
return await withPluginLifecycleLease({}, async () => {
|
||||
return await transformConfigFileWithRetry<T>({
|
||||
...params,
|
||||
commit,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user