mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-21 18:08:05 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user