// Commit helpers that move transient plugin install records into the persisted install index. import { isDeepStrictEqual } from "node:util"; import { replaceConfigFile, resolveConfigWriteAfterWrite, transformConfigFileWithRetry, type ConfigMutationCommit, type ConfigReplaceResult, type ConfigMutationResult, 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; } /** Return whether config still contains legacy/transient plugin install records. */ export function hasPendingPluginInstallRecords(config: OpenClawConfig): boolean { return Object.keys(config.plugins?.installs ?? {}).length > 0; } /** Find pending install records that match the base config and can be stripped as unchanged. */ export function unchangedPendingPluginInstallRecordIds( config: OpenClawConfig, baseConfig: OpenClawConfig, ): string[] { const pendingInstalls = config.plugins?.installs ?? {}; return Object.entries(baseConfig.plugins?.installs ?? {}) .filter(([pluginId, baseInstall]) => isDeepStrictEqual(pendingInstalls[pluginId], baseInstall)) .map(([pluginId]) => pluginId); } /** Remove pending plugin install records from config, optionally only for selected ids. */ export function stripPendingPluginInstallRecords( config: OpenClawConfig, pluginIds?: Iterable, ): OpenClawConfig { if (!pluginIds) { return withoutPluginInstallRecords(config); } const removeIds = new Set(pluginIds); if (removeIds.size === 0 || !config.plugins?.installs) { return config; } const remainingInstalls = Object.fromEntries( Object.entries(config.plugins.installs).filter(([pluginId]) => !removeIds.has(pluginId)), ); if (Object.keys(remainingInstalls).length === 0) { return withoutPluginInstallRecords(config); } return { ...config, plugins: { ...config.plugins, installs: remainingInstalls, }, }; } 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 { // 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 }, ); } throw error; } } /** Persist plugin install records and commit the matching config update to disk. */ 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 } : {}), }); }, }); } /** Commit config while migrating any pending install records into the install index. */ export async function commitConfigWriteWithPendingPluginInstalls(params: { nextConfig: OpenClawConfig; writeOptions?: ConfigWriteOptions; commit: ConfigCommit; }): Promise<{ config: OpenClawConfig; installRecords: Record; movedInstallRecords: boolean; persistedHash: string | null; }> { if (!hasPendingPluginInstallRecords(params.nextConfig)) { 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 pendingInstallRecords = params.nextConfig.plugins?.installs ?? {}; 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, }; } /** Replace the config file after moving pending plugin install records into the install index. */ 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 } : {}), }); }, }); } /** Transform config with retry support while preserving plugin install index consistency. */ 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, }); }