Files
openclaw/src/cli/plugins-install-record-commit.ts
Gio Della-Libera abf59205fc fix(config): return persisted config write responses (#81445)
Merged via squash.

Prepared head SHA: 8f549e0621
Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com>
Co-authored-by: altaywtf <9790196+altaywtf@users.noreply.github.com>
Reviewed-by: @altaywtf
2026-05-15 00:35:15 +03:00

223 lines
7.5 KiB
TypeScript

import { isDeepStrictEqual } from "node:util";
import {
replaceConfigFile,
resolveConfigWriteAfterWrite,
transformConfigFileWithRetry,
type ConfigMutationCommit,
type ConfigReplaceResult,
type ConfigMutationResult,
type ConfigMutationContext,
type ConfigTransformResult,
type TransformConfigFileWithRetryParams,
} from "../config/config.js";
import type { ConfigWriteOptions } from "../config/io.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import {
loadInstalledPluginIndexInstallRecords,
PLUGIN_INSTALLS_CONFIG_PATH,
withoutPluginInstallRecords,
writePersistedInstalledPluginIndexInstallRecords,
} from "../plugins/installed-plugin-index-records.js";
function mergeUnsetPaths(
left?: ConfigWriteOptions["unsetPaths"],
right?: ConfigWriteOptions["unsetPaths"],
): ConfigWriteOptions["unsetPaths"] | undefined {
const merged = [...(left ?? []), ...(right ?? [])];
return merged.length > 0 ? merged : undefined;
}
type ConfigCommit = (
config: OpenClawConfig,
writeOptions?: ConfigWriteOptions,
) => Promise<ConfigReplaceResult | void>;
const PLUGIN_SOURCE_CHANGED_RESTART_REASON = "plugin source changed";
function mergeAfterWrite(
writeOptions: ConfigWriteOptions | undefined,
afterWrite: ConfigWriteOptions["afterWrite"],
): ConfigWriteOptions | undefined {
if (afterWrite === undefined) {
return writeOptions;
}
return {
...writeOptions,
afterWrite,
};
}
async function commitPluginInstallRecordsWithWriter(params: {
previousInstallRecords?: Record<string, PluginInstallRecord>;
nextInstallRecords: Record<string, PluginInstallRecord>;
nextConfig: OpenClawConfig;
writeOptions?: ConfigWriteOptions;
commit: ConfigCommit;
}): Promise<ConfigReplaceResult | void> {
const previousInstallRecords =
params.previousInstallRecords ?? (await loadInstalledPluginIndexInstallRecords());
await writePersistedInstalledPluginIndexInstallRecords(params.nextInstallRecords);
try {
const installRecordsChanged = !isDeepStrictEqual(
previousInstallRecords,
params.nextInstallRecords,
);
return await params.commit(params.nextConfig, {
...params.writeOptions,
...(installRecordsChanged && params.writeOptions?.afterWrite === undefined
? { afterWrite: { mode: "restart", reason: PLUGIN_SOURCE_CHANGED_RESTART_REASON } }
: {}),
unsetPaths: mergeUnsetPaths(params.writeOptions?.unsetPaths, [
Array.from(PLUGIN_INSTALLS_CONFIG_PATH),
]),
});
} catch (error) {
try {
await writePersistedInstalledPluginIndexInstallRecords(previousInstallRecords);
} catch (rollbackError) {
throw new Error(
"Failed to commit plugin install records and could not restore the previous plugin index",
{ cause: rollbackError },
);
}
throw error;
}
}
export async function commitPluginInstallRecordsWithConfig(params: {
previousInstallRecords?: Record<string, PluginInstallRecord>;
nextInstallRecords: Record<string, PluginInstallRecord>;
nextConfig: OpenClawConfig;
baseHash?: string;
writeOptions?: ConfigWriteOptions;
}): Promise<void> {
await commitPluginInstallRecordsWithWriter({
...params,
commit: async (nextConfig, writeOptions) => {
return await replaceConfigFile({
nextConfig,
...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}),
...(writeOptions ? { writeOptions } : {}),
});
},
});
}
export async function commitConfigWriteWithPendingPluginInstalls(params: {
nextConfig: OpenClawConfig;
writeOptions?: ConfigWriteOptions;
commit: ConfigCommit;
}): Promise<{
config: OpenClawConfig;
installRecords: Record<string, PluginInstallRecord>;
movedInstallRecords: boolean;
persistedHash: string | null;
}> {
const pendingInstallRecords = params.nextConfig.plugins?.installs ?? {};
if (Object.keys(pendingInstallRecords).length === 0) {
const committed = params.writeOptions
? await params.commit(params.nextConfig, params.writeOptions)
: await params.commit(params.nextConfig);
return {
config: params.nextConfig,
installRecords: {},
movedInstallRecords: false,
persistedHash: committed?.persistedHash ?? null,
};
}
const previousInstallRecords = await loadInstalledPluginIndexInstallRecords();
const nextInstallRecords = {
...previousInstallRecords,
...pendingInstallRecords,
};
const strippedConfig = withoutPluginInstallRecords(params.nextConfig);
const committed = await commitPluginInstallRecordsWithWriter({
previousInstallRecords,
nextInstallRecords,
nextConfig: strippedConfig,
...(params.writeOptions ? { writeOptions: params.writeOptions } : {}),
commit: params.commit,
});
return {
config: strippedConfig,
installRecords: nextInstallRecords,
movedInstallRecords: true,
persistedHash: committed?.persistedHash ?? null,
};
}
export async function commitConfigWithPendingPluginInstalls(params: {
nextConfig: OpenClawConfig;
baseHash?: string;
writeOptions?: ConfigWriteOptions;
}): Promise<{
config: OpenClawConfig;
installRecords: Record<string, PluginInstallRecord>;
movedInstallRecords: boolean;
persistedHash: string | null;
}> {
return await commitConfigWriteWithPendingPluginInstalls({
nextConfig: params.nextConfig,
...(params.writeOptions ? { writeOptions: params.writeOptions } : {}),
commit: async (nextConfig, writeOptions) => {
return await replaceConfigFile({
nextConfig,
...(params.baseHash !== undefined ? { baseHash: params.baseHash } : {}),
...(writeOptions ? { writeOptions } : {}),
});
},
});
}
export async function transformConfigWithPendingPluginInstalls<T = void>(
params: Omit<TransformConfigFileWithRetryParams<T>, "commit">,
): Promise<ConfigMutationResult<T>> {
const commit: ConfigMutationCommit = async ({ nextConfig, snapshot, baseHash, writeOptions }) => {
const requestedAfterWrite = params.afterWrite ?? params.writeOptions?.afterWrite;
const committed = await commitConfigWriteWithPendingPluginInstalls({
nextConfig,
...(writeOptions ? { writeOptions: mergeAfterWrite(writeOptions, params.afterWrite) } : {}),
commit: async (config, commitWriteOptions) => {
return await replaceConfigFile({
nextConfig: config,
snapshot,
writeOptions: commitWriteOptions ?? {},
...(baseHash !== undefined ? { baseHash } : {}),
});
},
});
const afterWrite = resolveConfigWriteAfterWrite(
requestedAfterWrite ??
(committed.movedInstallRecords
? { mode: "restart", reason: PLUGIN_SOURCE_CHANGED_RESTART_REASON }
: undefined),
);
return {
config: committed.config,
persistedHash: committed.persistedHash,
afterWrite,
};
};
return await transformConfigFileWithRetry<T>({
...params,
commit,
});
}
export async function mutateConfigWithPendingPluginInstalls<T = void>(
params: Omit<TransformConfigFileWithRetryParams<T>, "commit" | "transform"> & {
mutate: (draft: OpenClawConfig, context: ConfigMutationContext) => Promise<T | void> | T | void;
},
): Promise<ConfigMutationResult<T>> {
return await transformConfigWithPendingPluginInstalls<T>({
...params,
transform: async (currentConfig, context): Promise<ConfigTransformResult<T>> => {
const draft = structuredClone(currentConfig);
const result = (await params.mutate(draft, context)) as T | undefined;
return { nextConfig: draft, result };
},
});
}