mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 03:15:46 -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:
+147
-1
@@ -67,6 +67,16 @@ const updateNpmInstalledPlugins = vi.fn();
|
||||
const loadInstalledPluginIndexInstallRecords = vi.fn(
|
||||
async (params: { config?: OpenClawConfig } = {}) => params.config?.plugins?.installs ?? {},
|
||||
);
|
||||
const readPersistedInstalledPluginIndex = vi.fn(async () => null);
|
||||
const restorePersistedInstalledPluginIndex = vi.fn(async () => undefined);
|
||||
const restorePersistedInstalledPluginIndexIfCurrent = vi.fn<
|
||||
typeof import("../plugins/installed-plugin-index-store.js").restorePersistedInstalledPluginIndexIfCurrent
|
||||
>(async () => true);
|
||||
const writePersistedInstalledPluginIndexInstallRecords = vi.fn(async () => undefined);
|
||||
const writePersistedInstalledPluginIndexInstallRecordsWithLease = vi.fn(async () => ({
|
||||
previous: null,
|
||||
revision: 1,
|
||||
}));
|
||||
const checkShellCompletionStatus = vi.fn();
|
||||
const ensureCompletionCacheExists = vi.fn();
|
||||
const installCompletion = vi.fn();
|
||||
@@ -292,7 +302,19 @@ vi.mock("../plugins/installed-plugin-index-records.js", async (importOriginal) =
|
||||
return {
|
||||
...actual,
|
||||
loadInstalledPluginIndexInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecords: vi.fn(async () => undefined),
|
||||
writePersistedInstalledPluginIndexInstallRecords,
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugins/installed-plugin-index-store.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../plugins/installed-plugin-index-store.js")>();
|
||||
return {
|
||||
...actual,
|
||||
readPersistedInstalledPluginIndex,
|
||||
restorePersistedInstalledPluginIndex,
|
||||
restorePersistedInstalledPluginIndexIfCurrent,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1310,6 +1332,14 @@ describe("update-cli", () => {
|
||||
delete process.env[GATEWAY_SERVICE_RUNTIME_PID_ENV];
|
||||
restartHealthTestControl.snapshot = undefined;
|
||||
vi.clearAllMocks();
|
||||
readPersistedInstalledPluginIndex.mockResolvedValue(null);
|
||||
restorePersistedInstalledPluginIndex.mockResolvedValue(undefined);
|
||||
restorePersistedInstalledPluginIndexIfCurrent.mockResolvedValue(true);
|
||||
writePersistedInstalledPluginIndexInstallRecords.mockResolvedValue(undefined);
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease.mockResolvedValue({
|
||||
previous: null,
|
||||
revision: 1,
|
||||
});
|
||||
resetRuntimeCapture();
|
||||
spawn.mockImplementation(() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
@@ -1777,6 +1807,21 @@ describe("update-cli", () => {
|
||||
readPackageVersion.mockImplementation(async (pkgRoot: string) =>
|
||||
pkgRoot === root ? "0.0.1" : "2026.5.28",
|
||||
);
|
||||
const preUpdateConfig = {
|
||||
plugins: {
|
||||
entries: {
|
||||
msteams: { enabled: false },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
|
||||
...baseSnapshot,
|
||||
parsed: preUpdateConfig,
|
||||
sourceConfig: preUpdateConfig,
|
||||
resolved: preUpdateConfig,
|
||||
config: preUpdateConfig,
|
||||
runtimeConfig: preUpdateConfig,
|
||||
});
|
||||
const pluginInstallRecords = {
|
||||
msteams: {
|
||||
source: "npm",
|
||||
@@ -1819,6 +1864,107 @@ describe("update-cli", () => {
|
||||
integrity: "sha512-newer",
|
||||
},
|
||||
});
|
||||
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).toHaveBeenCalledWith(
|
||||
capturedRecords,
|
||||
{
|
||||
config: preUpdateConfig,
|
||||
lease: expect.anything(),
|
||||
},
|
||||
);
|
||||
expect(restorePersistedInstalledPluginIndexIfCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores the exact plugin index revision when post-core handoff fails", async () => {
|
||||
const { root } = setupUpdatedRootRefresh();
|
||||
readPackageVersion.mockImplementation(async (pkgRoot: string) =>
|
||||
pkgRoot === root ? "0.0.1" : "2026.5.28",
|
||||
);
|
||||
const previousPersistedIndex = {
|
||||
policyHash: "previous-policy",
|
||||
installRecords: {
|
||||
msteams: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/msteams",
|
||||
resolvedVersion: "1.0.0",
|
||||
},
|
||||
} satisfies Record<string, PluginInstallRecord>,
|
||||
};
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease.mockResolvedValue({
|
||||
previous: previousPersistedIndex as never,
|
||||
revision: 17,
|
||||
});
|
||||
loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(
|
||||
previousPersistedIndex.installRecords,
|
||||
);
|
||||
spawn.mockImplementationOnce(() => {
|
||||
throw new Error("post-core spawn failed");
|
||||
});
|
||||
|
||||
await expect(updateCommand({ yes: true, restart: false })).rejects.toThrow(
|
||||
"post-core spawn failed",
|
||||
);
|
||||
|
||||
expect(writePersistedInstalledPluginIndexInstallRecordsWithLease).toHaveBeenCalledTimes(1);
|
||||
expect(restorePersistedInstalledPluginIndexIfCurrent).toHaveBeenCalledWith(
|
||||
previousPersistedIndex,
|
||||
17,
|
||||
{ lease: expect.anything() },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a child-committed plugin index when the post-core handoff is signaled", async () => {
|
||||
const { root } = setupUpdatedRootRefresh();
|
||||
readPackageVersion.mockImplementation(async (pkgRoot: string) =>
|
||||
pkgRoot === root ? "0.0.1" : "2026.5.28",
|
||||
);
|
||||
const previousPersistedIndex = {
|
||||
policyHash: "previous-policy",
|
||||
installRecords: {
|
||||
msteams: {
|
||||
source: "npm",
|
||||
spec: "@openclaw/msteams",
|
||||
resolvedVersion: "1.0.0",
|
||||
},
|
||||
} satisfies Record<string, PluginInstallRecord>,
|
||||
};
|
||||
let currentRevision = 17;
|
||||
writePersistedInstalledPluginIndexInstallRecordsWithLease.mockResolvedValue({
|
||||
previous: previousPersistedIndex as never,
|
||||
revision: currentRevision,
|
||||
});
|
||||
restorePersistedInstalledPluginIndexIfCurrent.mockImplementation(
|
||||
async (_index, expectedRevision) => {
|
||||
if (currentRevision !== expectedRevision) {
|
||||
return false;
|
||||
}
|
||||
currentRevision += 1;
|
||||
return true;
|
||||
},
|
||||
);
|
||||
loadInstalledPluginIndexInstallRecords.mockResolvedValueOnce(
|
||||
previousPersistedIndex.installRecords,
|
||||
);
|
||||
spawn.mockImplementationOnce(() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
once: EventEmitter["once"];
|
||||
};
|
||||
currentRevision = 18;
|
||||
queueMicrotask(() => {
|
||||
child.emit("exit", null, "SIGTERM");
|
||||
});
|
||||
return child;
|
||||
});
|
||||
|
||||
await expect(updateCommand({ yes: true, restart: false })).rejects.toThrow(
|
||||
"post-update process terminated by signal SIGTERM",
|
||||
);
|
||||
|
||||
expect(restorePersistedInstalledPluginIndexIfCurrent).toHaveBeenCalledWith(
|
||||
previousPersistedIndex,
|
||||
17,
|
||||
{ lease: expect.anything() },
|
||||
);
|
||||
expect(currentRevision).toBe(18);
|
||||
});
|
||||
|
||||
it("respawns into the updated git root before requested channel persistence", async () => {
|
||||
|
||||
Reference in New Issue
Block a user