diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index eb5842edd1a7..910f0b4311ee 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -2505,7 +2505,6 @@ src/cli/update-cli/plugin-payload-validation.ts 2 src/cli/update-cli/shared.ts 1 src/cli/update-cli/update-command-config.ts 6 src/cli/update-cli/update-command-plugins.ts 1 -src/cli/update-cli/update-command-post-core.ts 1 src/cli/update-cli/update-command-post-update.ts 2 src/cli/users-cli.ts 1 src/commands/agent-exec.ts 6 diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 5db534d3f307..33fd4a09220c 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -6941,7 +6941,7 @@ describe("update-cli", () => { const gitRoot = path.join(tempDir, "..", "openclaw"); const completionCacheSpy = vi .spyOn(updateCliShared, "tryWriteCompletionCache") - .mockResolvedValue(undefined); + .mockResolvedValueOnce("completed"); mockPackageInstallStatus(tempDir); vi.mocked(readConfigFileSnapshot).mockResolvedValue({ ...baseSnapshot, @@ -7647,6 +7647,12 @@ describe("update-cli", () => { status?: string; mode?: string; restart?: boolean; + phaseTimings?: Array<{ + phase?: string; + startedOffsetMs?: number; + durationMs?: number; + outcome?: string; + }>; postUpdate?: { doctor?: { status?: string }; plugins?: { status?: string } }; } | undefined; @@ -7655,10 +7661,76 @@ describe("update-cli", () => { expect(output?.restart).toBe(false); expect(output?.postUpdate?.doctor?.status).toBe("ok"); expect(output?.postUpdate?.plugins?.status).toBe("ok"); + expect(output?.phaseTimings?.map((timing) => timing.phase)).toEqual([ + "targetConfigValidation", + "configSnapshot", + "doctor", + "plugins", + "targetConfigConvergence", + "completionCache", + ]); + for (const timing of output?.phaseTimings ?? []) { + expect(timing.startedOffsetMs).toEqual(expect.any(Number)); + expect(timing.durationMs).toEqual(expect.any(Number)); + } + expect(output?.phaseTimings?.map((timing) => timing.outcome)).toEqual([ + "completed", + "completed", + "completed", + "completed", + "completed", + "skipped", + ]); }, ); }); + it("updateFinalizeCommand can defer only the best-effort completion cache", async () => { + pathExists.mockResolvedValue(true); + vi.mocked(spawnSync).mockClear(); + vi.mocked(defaultRuntime.writeJson).mockClear(); + + await updateFinalizeCommand({ + json: true, + yes: true, + restart: false, + deferCompletionCache: true, + } as Parameters[0] & { deferCompletionCache: boolean }); + + expect(spawnSync).not.toHaveBeenCalled(); + const output = lastWriteJsonCall() as + | { phaseTimings?: Array<{ phase?: string; outcome?: string }> } + | undefined; + expect(output?.phaseTimings?.at(-1)).toEqual( + expect.objectContaining({ phase: "completionCache", outcome: "deferred" }), + ); + }); + + it("updateFinalizeCommand capability env applies only to the hidden finalizer", async () => { + pathExists.mockResolvedValue(false); + await withEnvAsync({ OPENCLAW_UPDATE_POST_CORE: "1" }, async () => { + const run = async (command: "repair" | "finalize") => { + vi.mocked(defaultRuntime.writeJson).mockClear(); + const program = new Command(); + program.name("openclaw"); + program.exitOverride(); + registerUpdateCli(program); + await program.parseAsync(["node", "openclaw", "update", command, "--json", "--yes"]); + const output = lastWriteJsonCall() as + | { phaseTimings?: Array<{ phase?: string; outcome?: string }> } + | undefined; + return output?.phaseTimings?.at(-1); + }; + + expect(await run("repair")).toEqual( + expect.objectContaining({ phase: "completionCache", outcome: "skipped" }), + ); + expect(await run("finalize")).toEqual( + expect.objectContaining({ phase: "completionCache", outcome: "deferred" }), + ); + }); + }); + it("updateFinalizeCommand rejects extended-stable on Git before persistence", async () => { await updateFinalizeCommand({ channel: "extended-stable", diff --git a/src/cli/update-cli.ts b/src/cli/update-cli.ts index a07563f6a23e..3ed4ebe5e139 100644 --- a/src/cli/update-cli.ts +++ b/src/cli/update-cli.ts @@ -3,6 +3,7 @@ import type { Command } from "commander"; import { formatDocsLink } from "../../packages/terminal-core/src/links.js"; import { theme } from "../../packages/terminal-core/src/theme.js"; import { formatErrorMessage } from "../infra/errors.js"; +import { POST_CORE_UPDATE_ENV } from "../infra/update-post-core-context.js"; import { defaultRuntime } from "../runtime.js"; import { inheritOptionFromParent } from "./command-options.js"; import { formatHelpExamples } from "./help-format.js"; @@ -125,6 +126,7 @@ function registerUpdateFinalizationCommand(update: Command, name: string, hidden timeout: inheritedUpdateTimeout(opts, actionCommand), yes: Boolean(opts.yes) || Boolean(inheritOptionFromParent(actionCommand, "yes")), restart: false, + deferCompletionCache: hidden && process.env[POST_CORE_UPDATE_ENV]?.trim() === "1", acknowledgeClawHubRisk: normalizeCommanderClawHubRiskOption(opts) || inheritedUpdateClawHubRisk(actionCommand), }); diff --git a/src/cli/update-cli/shared.ts b/src/cli/update-cli/shared.ts index 3401ef9ea936..82eb375b20f3 100644 --- a/src/cli/update-cli/shared.ts +++ b/src/cli/update-cli/shared.ts @@ -52,6 +52,8 @@ export type UpdateFinalizeOptions = { yes?: boolean; restart?: boolean; acknowledgeClawHubRisk?: boolean; + /** Internal external-supervisor handshake; public repair always leaves this false. */ + deferCompletionCache?: boolean; }; export type UpdateWizardOptions = { @@ -414,10 +416,13 @@ const COMPLETION_CACHE_MANUAL_REFRESH_HINT = "Shell tab-completion may be stale; refresh manually with: openclaw completion --write-state"; /** Best-effort refresh of shell completion state after a successful update. */ -export async function tryWriteCompletionCache(root: string, jsonMode: boolean): Promise { +export async function tryWriteCompletionCache( + root: string, + jsonMode: boolean, +): Promise<"completed" | "failed" | "skipped"> { const binPath = path.join(root, "openclaw.mjs"); if (!(await pathExists(binPath))) { - return; + return "skipped"; } const result = spawnSync(resolveNodeRunner(), [binPath, "completion", "--write-state"], { @@ -443,18 +448,22 @@ export async function tryWriteCompletionCache(root: string, jsonMode: boolean): ), ); } - return; + return "failed"; } - if (result.status !== 0 && !jsonMode) { - const stderr = (result.stderr ?? "").trim(); - const detail = stderr ? ` (${stderr})` : ""; - defaultRuntime.log( - theme.warn( - `Completion cache update failed${detail}. ${COMPLETION_CACHE_MANUAL_REFRESH_HINT}`, - ), - ); + if (result.status !== 0) { + if (!jsonMode) { + const stderr = (result.stderr ?? "").trim(); + const detail = stderr ? ` (${stderr})` : ""; + defaultRuntime.log( + theme.warn( + `Completion cache update failed${detail}. ${COMPLETION_CACHE_MANUAL_REFRESH_HINT}`, + ), + ); + } + return "failed"; } + return "completed"; } /** Adapter used by global-install detection helpers to execute bounded subprocess probes. */ diff --git a/src/cli/update-cli/update-command-finalize.ts b/src/cli/update-cli/update-command-finalize.ts new file mode 100644 index 000000000000..d4e58a0338cc --- /dev/null +++ b/src/cli/update-cli/update-command-finalize.ts @@ -0,0 +1,327 @@ +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { theme } from "../../../packages/terminal-core/src/theme.js"; +import { doctorCommand } from "../../commands/doctor.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 { checkUpdateStatus } from "../../infra/update-check.js"; +import { POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV } from "../../infra/update-post-core-context.js"; +import { loadInstalledPluginIndexInstallRecords } from "../../plugins/installed-plugin-index-records.js"; +import { withPluginLifecycleLease } from "../../plugins/plugin-lifecycle-lease.js"; +import { defaultRuntime } from "../../runtime.js"; +import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; +import { assertOpenClawStateWriteAllowedAtPath } from "../../state/openclaw-state-ownership.js"; +import { + parseTimeoutMsOrExit, + resolveUpdateRoot, + tryWriteCompletionCache, + type UpdateFinalizeOptions, +} from "./shared.js"; +import { suppressDeprecations } from "./suppress-deprecations.js"; +import { + createUpdateConfigSnapshot, + persistRequestedUpdateChannel, + readPostCorePreUpdateSourceConfig, + restoreDroppedPreUpdateChannels, +} from "./update-command-config.js"; +import { + completePostCorePluginUpdate, + withPrePluginUpdateDoctorEnv, +} from "./update-command-fresh-doctor.js"; +import { + updatePluginsAfterCoreUpdate, + type PostCorePluginUpdateResult, +} from "./update-command-plugins.js"; +import { reportPreMutationUpdateFailure } from "./update-command-post-core.js"; + +const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 30 * 60_000; + +type UpdateFinalizePhase = + | "configSnapshot" + | "doctor" + | "plugins" + | "targetConfigValidation" + | "targetConfigConvergence" + | "completionCache"; + +type UpdateFinalizePhaseOutcome = "completed" | "failed" | "warning" | "skipped" | "deferred"; + +type UpdateFinalizePhaseTiming = { + phase: UpdateFinalizePhase; + startedOffsetMs: number; + durationMs: number; + outcome: UpdateFinalizePhaseOutcome; +}; + +async function runTimedFinalizePhase(params: { + finalizationStartedAt: number; + phaseTimings: UpdateFinalizePhaseTiming[]; + phase: UpdateFinalizePhase; + run: () => Promise; + outcome?: (result: T) => UpdateFinalizePhaseOutcome; +}): Promise { + const startedAt = performance.now(); + try { + const result = await params.run(); + params.phaseTimings.push({ + phase: params.phase, + startedOffsetMs: Math.max(0, Math.round(startedAt - params.finalizationStartedAt)), + durationMs: Math.max(0, Math.round(performance.now() - startedAt)), + outcome: params.outcome?.(result) ?? "completed", + }); + return result; + } catch (err) { + params.phaseTimings.push({ + phase: params.phase, + startedOffsetMs: Math.max(0, Math.round(startedAt - params.finalizationStartedAt)), + durationMs: Math.max(0, Math.round(performance.now() - startedAt)), + outcome: "failed", + }); + throw err; + } +} + +type UpdateFinalizeResult = { + status: "ok" | "warning" | "error"; + mode: "finalize"; + root: string; + channel: UpdateChannel; + restart: false; + phaseTimings: UpdateFinalizePhaseTiming[]; + postUpdate: { + doctor: { + status: "ok"; + }; + plugins: PostCorePluginUpdateResult; + }; +}; + +export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promise { + suppressDeprecations(); + const finalizationStartedAt = performance.now(); + const phaseTimings: UpdateFinalizePhaseTiming[] = []; + 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; + } + + assertConfigWriteAllowedInCurrentMode(); + await assertOpenClawStateWriteAllowedAtPath({ + databasePath: resolveOpenClawStateSqlitePath(process.env), + }); + + const root = await resolveUpdateRoot(); + let configSnapshot = await runTimedFinalizePhase({ + finalizationStartedAt, + phaseTimings, + phase: "targetConfigValidation", + run: async () => 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) // SAFETY: snapshot parser validated this config record. + : configSnapshot.sourceConfig, + } + : undefined); + if (requestedChannel === "extended-stable") { + const updateStatus = await checkUpdateStatus({ + root, + timeoutMs: timeoutMs ?? 3500, + fetchGit: false, + includeRegistry: false, + }); + if (updateStatus.installKind === "git") { + await reportPreMutationUpdateFailure({ + root, + installKind: updateStatus.installKind, + reason: "unsupported_git_channel", + opts, + controlPlaneUpdateSentinelMeta: null, + }); + return; + } + } + const storedChannel = configSnapshot.valid + ? normalizeUpdateChannel(configSnapshot.config.update?.channel) + : null; + // Effective channel the core update actually ran on (e.g. git/dev for an + // unconfigured source update), passed by the caller via env. Used only as a + // convergence fallback; it is never persisted (that stays gated on + // `requestedChannel`), so a default source update does not write update.channel. + const effectiveChannel = normalizeUpdateChannel( + process.env[UPDATE_EFFECTIVE_CHANNEL_ENV]?.trim(), + ); + const channel = requestedChannel ?? storedChannel ?? effectiveChannel ?? DEFAULT_PACKAGE_CHANNEL; + if (requestedChannel) { + configSnapshot = await persistRequestedUpdateChannel({ + configSnapshot, + requestedChannel, + }); + } + + const completedPluginUpdate = await withPluginLifecycleLease({}, async () => { + const initialPluginUpdate = await withPrePluginUpdateDoctorEnv(async () => { + await runTimedFinalizePhase({ + finalizationStartedAt, + phaseTimings, + phase: "configSnapshot", + run: createUpdateConfigSnapshot, + }); + const doctorPreparation = await runTimedFinalizePhase({ + finalizationStartedAt, + phaseTimings, + phase: "doctor", + run: async () => { + await doctorCommand(defaultRuntime, { + nonInteractive: true, + repair: true, + yes: opts.yes === true, + }); + configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); + if (requestedChannel) { + configSnapshot = await persistRequestedUpdateChannel({ + configSnapshot, + requestedChannel, + }); + } + const restoredConfig = restoreDroppedPreUpdateChannels(configSnapshot, preFinalizeConfig); + configSnapshot = restoredConfig.snapshot; + const postDoctorStoredChannel = configSnapshot.valid + ? normalizeUpdateChannel(configSnapshot.config.update?.channel) + : null; + const postDoctorChannel = + requestedChannel ?? + postDoctorStoredChannel ?? + storedChannel ?? + effectiveChannel ?? + DEFAULT_PACKAGE_CHANNEL; + const pluginInstallRecords = await loadInstalledPluginIndexInstallRecords(); + return { restoredConfig, postDoctorChannel, pluginInstallRecords }; + }, + }); + return await runTimedFinalizePhase({ + finalizationStartedAt, + phaseTimings, + phase: "plugins", + run: async () => + await updatePluginsAfterCoreUpdate({ + root, + channel: doctorPreparation.postDoctorChannel, + configSnapshot, + configChanged: doctorPreparation.restoredConfig.changed, + restoredAuthoredChannels: doctorPreparation.restoredConfig.authoredChannels, + opts: { + json: opts.json, + timeout: opts.timeout, + yes: opts.yes, + restart: false, + acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk, + }, + timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS, + pluginInstallRecords: doctorPreparation.pluginInstallRecords, + }), + outcome: (result) => + result.status === "error" + ? "failed" + : result.status === "warning" + ? "warning" + : "completed", + }); + }); + return await runTimedFinalizePhase({ + finalizationStartedAt, + phaseTimings, + phase: "targetConfigConvergence", + run: async () => + await completePostCorePluginUpdate({ + root, + pluginUpdate: initialPluginUpdate, + freshDoctorRequired: initialPluginUpdate.changed, + yes: opts.yes === true, + json: opts.json === true, + timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS, + }), + outcome: (result) => + result.pluginUpdate.status === "error" + ? "failed" + : result.pluginUpdate.status === "warning" + ? "warning" + : "completed", + }); + }); + const pluginUpdate = completedPluginUpdate.pluginUpdate; + configSnapshot = completedPluginUpdate.configSnapshot; + + if (opts.deferCompletionCache) { + phaseTimings.push({ + phase: "completionCache", + startedOffsetMs: Math.max(0, Math.round(performance.now() - finalizationStartedAt)), + durationMs: 0, + outcome: "deferred", + }); + } else { + await runTimedFinalizePhase({ + finalizationStartedAt, + phaseTimings, + phase: "completionCache", + run: async () => await tryWriteCompletionCache(root, Boolean(opts.json)), + outcome: (result) => result, + }); + } + + const result: UpdateFinalizeResult = { + status: + pluginUpdate.status === "error" + ? "error" + : pluginUpdate.status === "warning" + ? "warning" + : "ok", + mode: "finalize", + root, + channel: + requestedChannel ?? + (configSnapshot.valid + ? normalizeUpdateChannel(configSnapshot.config.update?.channel) + : null) ?? + channel, + restart: false, + phaseTimings, + postUpdate: { + doctor: { + status: "ok", + }, + plugins: pluginUpdate, + }, + }; + if (opts.json) { + defaultRuntime.writeJson(result); + } else if (result.status === "ok") { + defaultRuntime.log(theme.muted("Update finalization completed.")); + } + if (result.status === "error") { + defaultRuntime.exit(1); + } +} diff --git a/src/cli/update-cli/update-command-post-core.ts b/src/cli/update-cli/update-command-post-core.ts index 9addbd454eba..354ccc43aff1 100644 --- a/src/cli/update-cli/update-command-post-core.ts +++ b/src/cli/update-cli/update-command-post-core.ts @@ -4,33 +4,22 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { parseStrictPositiveInteger } from "@openclaw/normalization-core/number-coercion"; -import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { theme } from "../../../packages/terminal-core/src/theme.js"; -import { doctorCommand } from "../../commands/doctor.js"; -import { - assertConfigWriteAllowedInCurrentMode, - readConfigFileSnapshot, -} from "../../config/config.js"; import { createPluginInstallRecordMap, serializePluginInstallRecordMap, setPluginInstallRecordMapEntry, } from "../../config/plugin-install-record-map.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { PluginInstallRecord } from "../../config/types.plugins.js"; import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js"; import { hasErrnoCode } from "../../infra/errors.js"; import { readJsonIfExists, writeJson } from "../../infra/json-files.js"; import { - DEFAULT_PACKAGE_CHANNEL, EXTENDED_STABLE_TAG_UNSUPPORTED_REASON, - normalizeUpdateChannel, type UpdateChannel, - UPDATE_EFFECTIVE_CHANNEL_ENV, } from "../../infra/update-channels.js"; import { - checkUpdateStatus, compareSemverStrings, type ExtendedStableFailureReason, } from "../../infra/update-check.js"; @@ -42,56 +31,29 @@ import { import { buildPostCoreHandoffEnv, POST_CORE_UPDATE_ENV, - POST_CORE_UPDATE_SOURCE_CONFIG_PATH_ENV, type PreUpdateConfigRestoreInput, } from "../../infra/update-post-core-context.js"; import type { UpdateRunResult } from "../../infra/update-runner.js"; import { getWindowsSystem32ExePath } from "../../infra/windows-install-roots.js"; -import { - loadInstalledPluginIndexInstallRecords, - writePersistedInstalledPluginIndexInstallRecordsWithLease, -} from "../../plugins/installed-plugin-index-records.js"; +import { writePersistedInstalledPluginIndexInstallRecordsWithLease } from "../../plugins/installed-plugin-index-records.js"; import { restorePersistedInstalledPluginIndexIfCurrent } from "../../plugins/installed-plugin-index-store.js"; import { withPluginLifecycleLease } from "../../plugins/plugin-lifecycle-lease.js"; import { runExec } from "../../process/exec.js"; import { defaultRuntime } from "../../runtime.js"; -import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; -import { assertOpenClawStateWriteAllowedAtPath } from "../../state/openclaw-state-ownership.js"; import { VERSION } from "../../version.js"; import { printResult } from "./progress.js"; +import { readPackageVersion, resolveNodeRunner, type UpdateCommandOptions } from "./shared.js"; import { - parseTimeoutMsOrExit, - readPackageVersion, - resolveNodeRunner, - resolveUpdateRoot, - tryWriteCompletionCache, - type UpdateCommandOptions, - type UpdateFinalizeOptions, -} from "./shared.js"; -import { suppressDeprecations } from "./suppress-deprecations.js"; -import { - createUpdateConfigSnapshot, normalizePluginInstallRecordMap, - persistRequestedUpdateChannel, - readPostCorePreUpdateSourceConfig, - restoreDroppedPreUpdateChannels, writePostCoreSourceConfigFile, } from "./update-command-config.js"; -import { - completePostCorePluginUpdate, - withPrePluginUpdateDoctorEnv, -} from "./update-command-fresh-doctor.js"; -import { - updatePluginsAfterCoreUpdate, - type PostCorePluginUpdateResult, -} from "./update-command-plugins.js"; +import type { PostCorePluginUpdateResult } from "./update-command-plugins.js"; import { disableUpdatedPackageCompileCacheEnv, isPackageManagerUpdateMode, stripGatewayServiceMarkerEnv, } from "./update-command-service.js"; -const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 30 * 60_000; export { POST_CORE_UPDATE_ENV }; export const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL"; export const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH"; @@ -134,182 +96,6 @@ export async function reportPreMutationUpdateFailure(params: { defaultRuntime.exit(1); } -type UpdateFinalizeResult = { - status: "ok" | "warning" | "error"; - mode: "finalize"; - root: string; - channel: UpdateChannel; - restart: false; - postUpdate: { - doctor: { - status: "ok"; - }; - plugins: PostCorePluginUpdateResult; - }; -}; - -export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promise { - 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; - } - - assertConfigWriteAllowedInCurrentMode(); - await assertOpenClawStateWriteAllowedAtPath({ - databasePath: resolveOpenClawStateSqlitePath(process.env), - }); - - const root = await resolveUpdateRoot(); - 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") { - const updateStatus = await checkUpdateStatus({ - root, - timeoutMs: timeoutMs ?? 3500, - fetchGit: false, - includeRegistry: false, - }); - if (updateStatus.installKind === "git") { - await reportPreMutationUpdateFailure({ - root, - installKind: updateStatus.installKind, - reason: "unsupported_git_channel", - opts, - controlPlaneUpdateSentinelMeta: null, - }); - return; - } - } - const storedChannel = configSnapshot.valid - ? normalizeUpdateChannel(configSnapshot.config.update?.channel) - : null; - // Effective channel the core update actually ran on (e.g. git/dev for an - // unconfigured source update), passed by the caller via env. Used only as a - // convergence fallback; it is never persisted (that stays gated on - // `requestedChannel`), so a default source update does not write update.channel. - const effectiveChannel = normalizeUpdateChannel( - process.env[UPDATE_EFFECTIVE_CHANNEL_ENV]?.trim(), - ); - const channel = requestedChannel ?? storedChannel ?? effectiveChannel ?? DEFAULT_PACKAGE_CHANNEL; - if (requestedChannel) { - configSnapshot = await persistRequestedUpdateChannel({ - configSnapshot, - requestedChannel, - }); - } - - const completedPluginUpdate = await withPluginLifecycleLease({}, async () => { - const initialPluginUpdate = await withPrePluginUpdateDoctorEnv(async () => { - await createUpdateConfigSnapshot(); - await doctorCommand(defaultRuntime, { - nonInteractive: true, - repair: true, - yes: opts.yes === true, - }); - configSnapshot = await readConfigFileSnapshot({ skipPluginValidation: true }); - if (requestedChannel) { - configSnapshot = await persistRequestedUpdateChannel({ - configSnapshot, - requestedChannel, - }); - } - const restoredConfig = restoreDroppedPreUpdateChannels(configSnapshot, preFinalizeConfig); - configSnapshot = restoredConfig.snapshot; - 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, - configSnapshot, - configChanged: restoredConfig.changed, - restoredAuthoredChannels: restoredConfig.authoredChannels, - opts: { - json: opts.json, - timeout: opts.timeout, - yes: opts.yes, - restart: false, - acknowledgeClawHubRisk: opts.acknowledgeClawHubRisk, - }, - timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS, - pluginInstallRecords, - }); - }); - return await completePostCorePluginUpdate({ - root, - pluginUpdate: initialPluginUpdate, - freshDoctorRequired: initialPluginUpdate.changed, - yes: opts.yes === true, - json: opts.json === true, - timeoutMs: timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS, - }); - }); - const pluginUpdate = completedPluginUpdate.pluginUpdate; - configSnapshot = completedPluginUpdate.configSnapshot; - - const result: UpdateFinalizeResult = { - status: - pluginUpdate.status === "error" - ? "error" - : pluginUpdate.status === "warning" - ? "warning" - : "ok", - mode: "finalize", - root, - channel: - requestedChannel ?? - (configSnapshot.valid - ? normalizeUpdateChannel(configSnapshot.config.update?.channel) - : null) ?? - channel, - restart: false, - postUpdate: { - doctor: { - status: "ok", - }, - plugins: pluginUpdate, - }, - }; - - await tryWriteCompletionCache(root, Boolean(opts.json)); - if (opts.json) { - defaultRuntime.writeJson(result); - } else if (result.status === "ok") { - defaultRuntime.log(theme.muted("Update finalization completed.")); - } - if (result.status === "error") { - defaultRuntime.exit(1); - } -} - export async function writePostCorePluginUpdateResultFile( filePath: string | undefined, result: PostCorePluginUpdateResult, diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 4e7f9c50e3cd..847dd03571c1 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -88,7 +88,7 @@ import { type ManagedServiceRootRedirect, type UpdateCommandRecoveryState, } from "./update-command-service.js"; -export { updateFinalizeCommand } from "./update-command-post-core.js"; +export { updateFinalizeCommand } from "./update-command-finalize.js"; const CLI_NAME = resolveCliName(); const DEFAULT_UPDATE_STEP_TIMEOUT_MS = 30 * 60_000;