| import { isRecord } from "@openclaw/normalization-core/record-coerce"; |
| import { theme } from "../../../packages/terminal-core/src/theme.js"; |
| import { |
| assertConfigWriteAllowedInCurrentMode, |
| readConfigFileSnapshot, |
| } from "../../config/config.js"; |
| import type { OpenClawConfig } from "../../config/types.openclaw.js"; |
| import { |
| DEFAULT_PACKAGE_CHANNEL, |
| normalizeUpdateChannel, |
| type UpdateChannel, |
| UPDATE_EFFECTIVE_CHANNEL_ENV, |
| } from "../../infra/update-channels.js"; |
| import { resolveUpdateInstallKind } from "../../infra/update-check.js"; |
| import { UPDATE_RUN_ID_ENV } from "../../infra/update-control-plane-sentinel.js"; |
| import { normalizeUpdatePostInstallDoctorWarnings } from "../../infra/update-doctor-result.js"; |
| import { POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV } from "../../infra/update-post-core-context.js"; |
| import { |
| acknowledgeAbandonedUpdateRun, |
| getUpdateRun, |
| reconcileAbandonedUpdateRuns, |
| } from "../../infra/update-run-ledger.js"; |
| import { assertUpdateRecoveryAdmission } from "../../infra/update-run-recovery-admission.js"; |
| import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js"; |
| import { withPluginLifecycleLease } from "../../plugins/plugin-lifecycle-lease.js"; |
| import { withCommandProcessScope } from "../../process/exec-spawn.js"; |
| import { defaultRuntime } from "../../runtime.js"; |
| import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; |
| import { assertOpenClawStateWriteAllowedAtPath } from "../../state/openclaw-state-ownership.js"; |
| import { retainCliProcessJobUntilExit } from "../runtime-cleanup-scope.js"; |
| import { |
| parseTimeoutMsOrExit, |
| readPackageVersion, |
| resolveUpdateRoot, |
| tryResolveInvocationCwd, |
| tryWriteCompletionCache, |
| type UpdateFinalizeOptions, |
| } from "./shared.js"; |
| import { suppressDeprecations } from "./suppress-deprecations.js"; |
| import { createUpdateConfigSnapshot } from "./update-command-config-snapshot.js"; |
| import { |
| persistRequestedUpdateChannel, |
| preparePostCorePluginConfig, |
| persistValidatedDowngradeConfig, |
| readPostCorePreUpdateSourceConfig, |
| } from "./update-command-config.js"; |
| import { |
| completePostCorePluginUpdate, |
| runUpdateFinalizationDoctorInFreshProcess, |
| withPrePluginUpdateDoctorEnv, |
| } from "./update-command-fresh-doctor.js"; |
| import { collectPostCorePluginFailureFacts } from "./update-command-plugins-internals.js"; |
| import { |
| updatePluginsAfterCoreUpdate, |
| type PostCorePluginUpdateResult, |
| } from "./update-command-plugins.js"; |
| import { UpdateCommandFailure, withUpdateAdmissionReporting } from "./update-command-result.js"; |
| import { completeSourceUpdateRuntime } from "./update-command-runtime.js"; |
| import { resolveServiceRefreshEnv, withUpdateInProgressEnv } from "./update-command-service-env.js"; |
| import { reportPreMutationUpdateResult } from "./update-command-terminal.js"; |
| import { withUpdateFailureTriage } from "./update-command-triage.js"; |
| import { UpdateFinalizationLifecycle } from "./update-finalization-lifecycle.js"; |
|
|
| export async function updateFinalizeCommand( |
| opts: UpdateFinalizeOptions, |
| recoveryRunIds?: readonly string[], |
| ): Promise<void> { |
| const invocationCwd = tryResolveInvocationCwd(); |
| suppressDeprecations(); |
| const timeoutMs = parseTimeoutMsOrExit(opts.timeout); |
| if (timeoutMs === null) { |
| return; |
| } |
| const requestedChannel = normalizeUpdateChannel(opts.channel); |
| if (opts.channel !== undefined && !requestedChannel) { |
| defaultRuntime.error( |
| `--channel must be "stable", "extended-stable", "beta", or "dev" (got "${opts.channel}")`, |
| ); |
| defaultRuntime.exit(1); |
| return; |
| } |
|
|
| await withCommandProcessScope(async (stopChildren) => { |
| const lifecycle = new UpdateFinalizationLifecycle(Boolean(opts.json), timeoutMs, stopChildren); |
| try { |
| const { root, installKind, runId } = await withUpdateAdmissionReporting( |
| opts, |
| () => |
| withUpdateInProgressEnv(invocationCwd, () => |
| lifecycle.run("preflight", async () => { |
| |
| |
| |
| await assertUpdateRecoveryAdmission({ env: process.env }); |
| assertConfigWriteAllowedInCurrentMode(); |
| await assertOpenClawStateWriteAllowedAtPath({ |
| databasePath: resolveOpenClawStateSqlitePath(process.env), |
| recoverOrphanedSidecars: false, |
| }); |
| await retainCliProcessJobUntilExit(); |
| |
| const admittedRunId = lifecycle.attachLedger(recoveryRunIds !== undefined); |
| const resolvedRoot = await resolveUpdateRoot(); |
| const resolvedInstallKind = await resolveUpdateInstallKind(resolvedRoot, { |
| timeoutMs: lifecycle.budget("preflight"), |
| }); |
| lifecycle.recordInstallKind( |
| resolvedInstallKind, |
| await readPackageVersion(resolvedRoot), |
| ); |
| return { root: resolvedRoot, installKind: resolvedInstallKind, runId: admittedRunId }; |
| }), |
| ), |
| recoveryRunIds === undefined ? "finalize" : "unknown", |
| ); |
| lifecycle.root = root; |
| const target = { |
| root, |
| env: { |
| ...resolveServiceRefreshEnv(process.env, invocationCwd), |
| [UPDATE_RUN_ID_ENV]: runId, |
| }, |
| }; |
| await withUpdateFailureTriage( |
| { ...opts, invocationCwd, run: { runId, env: target.env } }, |
| target, |
| () => |
| withUpdateInProgressEnv(invocationCwd, async () => { |
| try { |
| const prepared = await lifecycle.run("targetConfigValidation", () => |
| prepareUpdateFinalization(opts, root, installKind, requestedChannel), |
| ); |
| await updateFinalizeCommandInternal( |
| opts, |
| prepared, |
| lifecycle, |
| recoveryRunIds ?? [], |
| runId, |
| ); |
| } catch (error) { |
| if (error instanceof UpdateCommandFailure) { |
| lifecycle.complete(error.exitCode); |
| } else { |
| lifecycle.fail(); |
| } |
| throw error; |
| } |
| }), |
| ); |
| } catch (error) { |
| if (!lifecycle.completed) { |
| lifecycle.fail(); |
| } |
| throw error; |
| } finally { |
| lifecycle.finishRecovery(); |
| } |
| }); |
| } |
|
|
| async function prepareUpdateFinalization( |
| opts: UpdateFinalizeOptions, |
| root: string, |
| installKind: "git" | "package" | "unknown", |
| requestedChannel: UpdateChannel | null, |
| ) { |
| await assertOpenClawStateWriteAllowedAtPath({ |
| databasePath: resolveOpenClawStateSqlitePath(process.env), |
| }); |
| let configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); |
| const preFinalizeConfig = |
| (await readPostCorePreUpdateSourceConfig({ |
| sourceConfigPath: process.env[POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV], |
| currentSnapshot: configSnapshot, |
| })) ?? |
| (configSnapshot.valid |
| ? { |
| sourceConfig: configSnapshot.sourceConfig, |
| authoredConfig: isRecord(configSnapshot.parsed) |
| ? (configSnapshot.parsed as OpenClawConfig) |
| : configSnapshot.sourceConfig, |
| } |
| : undefined); |
| if (requestedChannel === "extended-stable" && installKind === "git") { |
| await reportPreMutationUpdateResult({ |
| root, |
| installKind, |
| reason: "unsupported_git_channel", |
| opts, |
| controlPlaneUpdateSentinelMeta: null, |
| }); |
| } |
| const storedChannel = configSnapshot.valid |
| ? normalizeUpdateChannel(configSnapshot.config.update?.channel) |
| : null; |
| |
| |
| |
| |
| const effectiveChannel = normalizeUpdateChannel( |
| process.env[UPDATE_EFFECTIVE_CHANNEL_ENV]?.trim(), |
| ); |
| const channel = requestedChannel ?? storedChannel ?? effectiveChannel ?? DEFAULT_PACKAGE_CHANNEL; |
| if (requestedChannel) { |
| configSnapshot = await withPluginLifecycleLease({}, async () => { |
| const snapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); |
| return await persistRequestedUpdateChannel({ configSnapshot: snapshot, requestedChannel }); |
| }); |
| } |
| return { |
| root, |
| installKind, |
| configSnapshot, |
| preFinalizeConfig, |
| requestedChannel, |
| storedChannel, |
| effectiveChannel, |
| channel, |
| }; |
| } |
|
|
| async function updateFinalizeCommandInternal( |
| opts: UpdateFinalizeOptions, |
| prepared: Awaited<ReturnType<typeof prepareUpdateFinalization>>, |
| lifecycle: UpdateFinalizationLifecycle, |
| recoveryRunIds: readonly string[], |
| invokingRunId: string, |
| ): Promise<void> { |
| const { root, preFinalizeConfig, requestedChannel, storedChannel, effectiveChannel, channel } = |
| prepared; |
| let { configSnapshot } = prepared; |
| let doctorWarnings: string[] = []; |
| const onDoctorWarnings = (warnings: string[]) => { |
| doctorWarnings = normalizeUpdatePostInstallDoctorWarnings([ |
| ...new Set([...doctorWarnings, ...warnings]), |
| ]); |
| lifecycle.recordWarnings(doctorWarnings); |
| }; |
|
|
| if (prepared.installKind === "git") { |
| await withPluginLifecycleLease({}, async (lease) => { |
| await completeSourceUpdateRuntime({ root, timeoutMs: lifecycle.budget("plugins"), lease }); |
| }); |
| } |
| const initialPluginUpdate = await withPrePluginUpdateDoctorEnv(async () => { |
| await lifecycle.run("configSnapshot", createUpdateConfigSnapshot); |
| await lifecycle.run("doctor", () => |
| runUpdateFinalizationDoctorInFreshProcess({ |
| phase: "pre-plugin", |
| root, |
| runId: invokingRunId, |
| yes: opts.yes === true, |
| json: opts.json === true, |
| workspaceSuggestions: true, |
| timeoutMs: lifecycle.budget("doctor"), |
| onWarnings: onDoctorWarnings, |
| }), |
| ); |
| return await lifecycle.run( |
| "plugins", |
| () => |
| withPluginLifecycleLease({}, async () => { |
| const preparedConfig = await preparePostCorePluginConfig({ |
| requestedChannel, |
| preUpdateConfig: preFinalizeConfig, |
| }); |
| configSnapshot = preparedConfig.configSnapshot; |
| const postDoctorStoredChannel = configSnapshot.valid |
| ? normalizeUpdateChannel(configSnapshot.config.update?.channel) |
| : null; |
| const postDoctorChannel = |
| requestedChannel ?? |
| postDoctorStoredChannel ?? |
| storedChannel ?? |
| effectiveChannel ?? |
| DEFAULT_PACKAGE_CHANNEL; |
| const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords(); |
| return await updatePluginsAfterCoreUpdate({ |
| root, |
| channel: postDoctorChannel, |
| ...preparedConfig, |
| json: opts.json, |
| acceptCapabilities: opts.acceptCapabilities, |
| timeoutMs: lifecycle.budget("plugins"), |
| pluginInstallRecords, |
| }); |
| }), |
| pluginOutcome, |
| ); |
| }); |
| |
| const completedPluginUpdate = await lifecycle.run( |
| "targetConfigConvergence", |
| async () => { |
| const result = await completePostCorePluginUpdate({ |
| root, |
| runId: invokingRunId, |
| pluginUpdate: initialPluginUpdate, |
| freshDoctorRequired: initialPluginUpdate.changed, |
| yes: opts.yes === true, |
| json: opts.json === true, |
| timeoutMs: lifecycle.budget("targetConfigConvergence"), |
| onWarnings: onDoctorWarnings, |
| }); |
| await persistValidatedDowngradeConfig(result.configSnapshot); |
| return result; |
| }, |
| (result) => pluginOutcome(result.pluginUpdate), |
| ); |
| const pluginUpdate = completedPluginUpdate.pluginUpdate; |
| lifecycle.recordWarnings( |
| (pluginUpdate.warnings ?? []) |
| .filter( |
| (warning) => |
| warning.reason === "plugin-target-unavailable" || warning.reason === "doctor-advisory", |
| ) |
| .map((warning) => warning.message), |
| "plugins", |
| ); |
| configSnapshot = completedPluginUpdate.configSnapshot; |
| const completionBudget = lifecycle.budget("completionCache"); |
| |
| const completionTimeout = completionBudget - Math.min(1_000, completionBudget / 2); |
| await lifecycle.run( |
| "completionCache", |
| async () => |
| opts.deferCompletionCache |
| ? ("deferred" as const) |
| : await tryWriteCompletionCache(root, Boolean(opts.json), completionTimeout), |
| (result) => result, |
| ); |
|
|
| const reconciledRuns: string[] = []; |
| const result = { |
| status: |
| pluginUpdate.status === "error" |
| ? "error" |
| : pluginUpdate.status === "warning" || doctorWarnings.length > 0 |
| ? "warning" |
| : "ok", |
| mode: "finalize", |
| root, |
| channel: |
| requestedChannel ?? |
| (configSnapshot.valid |
| ? normalizeUpdateChannel(configSnapshot.config.update?.channel) |
| : null) ?? |
| channel, |
| restart: false, |
| ...(recoveryRunIds.length ? { reconciledRuns } : {}), |
| phaseTimings: lifecycle.phaseTimings, |
| postUpdate: { |
| doctor: { |
| status: doctorWarnings.length > 0 ? "warning" : "ok", |
| ...(doctorWarnings.length > 0 ? { warnings: doctorWarnings } : {}), |
| }, |
| plugins: pluginUpdate, |
| }, |
| }; |
| if (result.status !== "error" && recoveryRunIds.length) { |
| |
| |
| reconciledRuns.push( |
| ...reconcileAbandonedUpdateRuns({ explicit: true, runIds: recoveryRunIds }).map( |
| (run) => run.runId, |
| ), |
| ); |
| if (recoveryRunIds.some((runId) => getUpdateRun(runId)?.status === "running")) { |
| throw new Error( |
| "An update resumed while repair was running; wait for that update before retrying repair.", |
| ); |
| } |
| for (const runId of recoveryRunIds) { |
| acknowledgeAbandonedUpdateRun(runId); |
| } |
| } |
| if (opts.json) { |
| defaultRuntime.writeJson(result); |
| } else if (result.status === "ok") { |
| defaultRuntime.log(theme.muted("Update finalization completed.")); |
| } else if (result.status === "warning") { |
| defaultRuntime.log(theme.warn("Update finalization completed with warnings.")); |
| } else { |
| defaultRuntime.log(theme.error("Update finalization failed.")); |
| } |
| lifecycle.complete(result.status === "error" ? 1 : 0); |
| if (result.status === "error") { |
| throw new UpdateCommandFailure({ |
| status: "error", |
| mode: "unknown", |
| root, |
| reason: "post-update-plugins", |
| postUpdate: { plugins: pluginUpdate }, |
| steps: [], |
| durationMs: Math.round(performance.now() - lifecycle.startedAt), |
| }); |
| } |
| } |
|
|
| function pluginOutcome(result: PostCorePluginUpdateResult): { |
| outcome: "failed" | "warning" | "completed"; |
| failureFacts?: PostCorePluginUpdateResult["failureFacts"]; |
| } { |
| return { |
| outcome: |
| result.status === "error" ? "failed" : result.status === "warning" ? "warning" : "completed", |
| ...(result.status === "error" |
| ? { failureFacts: collectPostCorePluginFailureFacts(result) } |
| : {}), |
| }; |
| } |
|
|