openclaw / src /cli /update-cli /update-command-finalize.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw
History Blame Contribute Delete
15.7 kB
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 () => {
// Refused invocations cannot create a ledger or write failure-triage artifacts.
// A missing canonical path can be an interrupted publication, not a
// fresh installation. Only the recovery executor may reconcile it.
await assertUpdateRecoveryAdmission({ env: process.env });
assertConfigWriteAllowedInCurrentMode();
await assertOpenClawStateWriteAllowedAtPath({
databasePath: resolveOpenClawStateSqlitePath(process.env),
recoverOrphanedSidecars: false,
});
await retainCliProcessJobUntilExit();
// Public repair supplies a recovery selection, even when it is empty.
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) // SAFETY: snapshot parser validated this config record.
: 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;
// 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 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,
);
});
// Fresh Doctor acquires this same lease; convergence must run after release.
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");
// Leave shutdown time inside the phase deadline so optional cache failures can settle.
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) {
// Publish successful recovery only after convergence and the ledger's
// transactional inactivity/driver check both finish.
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) }
: {}),
};
}