improve(update): make managed finalization timing auditable (#126107)

* improve(update): report finalizer phase timings

* test(update): return completion cache outcome

* refactor(update): isolate finalize command

* chore(update): document finalize config assertion

* test(update): isolate finalizer capability fixtures
This commit is contained in:
Jason (Json)
2026-08-18 22:34:22 -06:00
committed by GitHub
parent a89c568068
commit d16c2858bf
7 changed files with 426 additions and 231 deletions
-1
View File
@@ -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
+73 -1
View File
@@ -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<typeof updateFinalizeCommand>[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",
+2
View File
@@ -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<boolean>(actionCommand, "yes")),
restart: false,
deferCompletionCache: hidden && process.env[POST_CORE_UPDATE_ENV]?.trim() === "1",
acknowledgeClawHubRisk:
normalizeCommanderClawHubRiskOption(opts) || inheritedUpdateClawHubRisk(actionCommand),
});
+20 -11
View File
@@ -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<void> {
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. */
@@ -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<T>(params: {
finalizationStartedAt: number;
phaseTimings: UpdateFinalizePhaseTiming[];
phase: UpdateFinalizePhase;
run: () => Promise<T>;
outcome?: (result: T) => UpdateFinalizePhaseOutcome;
}): Promise<T> {
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<void> {
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);
}
}
+3 -217
View File
@@ -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<void> {
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,
+1 -1
View File
@@ -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;