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; 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; nextInstallRecords: Record; nextConfig: OpenClawConfig; writeOptions?: ConfigWriteOptions; commit: ConfigCommit; }): Promise { 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; nextInstallRecords: Record; nextConfig: OpenClawConfig; baseHash?: string; writeOptions?: ConfigWriteOptions; }): Promise { 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; 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; 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( params: Omit, "commit">, ): Promise> { 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({ ...params, commit, }); } export async function mutateConfigWithPendingPluginInstalls( params: Omit, "commit" | "transform"> & { mutate: (draft: OpenClawConfig, context: ConfigMutationContext) => Promise | T | void; }, ): Promise> { return await transformConfigWithPendingPluginInstalls({ ...params, transform: async (currentConfig, context): Promise> => { const draft = structuredClone(currentConfig); const result = (await params.mutate(draft, context)) as T | undefined; return { nextConfig: draft, result }; }, }); }