openclaw / src /cli /update-cli /update-command-fresh-doctor.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw History Blame Contribute Delete
13.6 kB
// Runs post-plugin convergence checks without retaining pre-update plugin modules.
import os from "node:os";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import {
UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV,
UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV,
UPDATE_POST_CORE_CONVERGENCE_ENV,
} from "../../commands/doctor/shared/update-phase.js";
import { readConfigFileSnapshot } from "../../config/config.js";
import { resolveStateDir } from "../../config/paths.js";
import type { ConfigFileSnapshot } from "../../config/types.openclaw.js";
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import { resolveAggregateSqliteInspectionTimeoutMs } from "../../infra/sqlite-readonly-worker.js";
import { collectStateDatabasePaths } from "../../infra/update-candidate-state.js";
import { readUpdateStateDatabaseSizes } from "../../infra/update-candidate-state.sizes.js";
import { UPDATE_RUN_ID_ENV } from "../../infra/update-control-plane-sentinel.js";
import { hasDeferredUpdateModelRetirement } from "../../infra/update-deferred-model-retirement.js";
import {
consumeUpdatePostInstallDoctorResult,
createUpdatePostInstallDoctorResultPath,
UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE,
UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV,
UpdateDoctorError,
type UpdatePostInstallDoctorResult,
} from "../../infra/update-doctor-result.js";
import {
createUpdateFailureFact,
type UpdateFailureFact,
} from "../../infra/update-failure-facts.js";
import { buildUpdateDoctorEnv } from "../../infra/update-runner-doctor.js";
import { redactSupportString } from "../../logging/diagnostic-support-redaction.js";
import { formatCommandOutput } from "../../process/command-error.js";
import { isPlainCommandExitFailure, runExec } from "../../process/exec.js";
import { defaultRuntime } from "../../runtime.js";
import { truncateUtf8Prefix, truncateUtf8Suffix } from "../../utils/utf8-truncate.js";
import { resolveNodeRunner } from "./shared.js";
import type { PostCorePluginUpdateResult } from "./update-command-plugins.js";
import { applyPostPluginUpdateReadiness } from "./update-command-post-plugin-readiness.js";
import {
applyPostPluginConfigValidation,
POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON,
} from "./update-command-post-plugin-validation.js";
import {
disableUpdatedPackageCompileCacheEnv,
stripGatewayServiceMarkerEnv,
} from "./update-command-service-env.js";
import { captureUpdateFinalizationDoctorOutput } from "./update-finalization-output.js";
type UpdateDoctorPhase = "pre-plugin" | "post-plugin";
export async function withPrePluginUpdateDoctorEnv<T>(run: () => Promise<T>): Promise<T> {
const previousValues = [
"OPENCLAW_UPDATE_IN_PROGRESS",
UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV,
UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV,
UPDATE_POST_CORE_CONVERGENCE_ENV,
].map((key) => [key, process.env[key]] as const);
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "1";
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] = "1";
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] = "1";
delete process.env[UPDATE_POST_CORE_CONVERGENCE_ENV];
try {
return await run();
} finally {
for (const [key, value] of previousValues) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
async function withNormalConfigValidation<T>(run: () => Promise<T>): Promise<T> {
const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
process.env.OPENCLAW_UPDATE_IN_PROGRESS = "0";
try {
return await run();
} finally {
if (previousUpdateInProgress === undefined) {
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
} else {
process.env.OPENCLAW_UPDATE_IN_PROGRESS = previousUpdateInProgress;
}
}
}
function createPostPluginDoctorExecutionFailure(
pluginUpdate: PostCorePluginUpdateResult,
reason: string,
failureFacts?: UpdateFailureFact[],
): PostCorePluginUpdateResult {
return {
...pluginUpdate,
status: "error",
reason: POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON,
...(failureFacts?.length ? { failureFacts } : {}),
warnings: [
...(pluginUpdate.warnings ?? []),
{
reason,
message: "Updated plugin migrations could not be run in a fresh process.",
guidance: ["Run `openclaw update repair` to retry post-update plugin repair."],
},
],
};
}
export async function runUpdateFinalizationDoctorInFreshProcess(params: {
phase: UpdateDoctorPhase;
root: string;
runId?: string;
yes: boolean;
json: boolean;
workspaceSuggestions?: boolean;
timeoutMs?: number;
nodeRunner?: string;
entryPath?: string;
onWarnings?: (warnings: string[]) => void;
}): Promise<void> {
const entryPath = params.entryPath ?? (await resolveGatewayInstallEntrypoint(params.root));
if (!entryPath) {
throw new Error("Updated OpenClaw entrypoint not found for post-plugin doctor");
}
const args = [
entryPath,
"doctor",
"--repair",
"--non-interactive",
...(params.workspaceSuggestions ? [] : ["--no-workspace-suggestions"]),
...(params.yes ? ["--yes"] : []),
];
const baseEnv = stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env));
delete baseEnv[UPDATE_POST_CORE_CONVERGENCE_ENV];
const doctorResultPath = createUpdatePostInstallDoctorResultPath();
let doctorResult: UpdatePostInstallDoctorResult | null = null;
let result: { stdout?: unknown; stderr?: unknown } | undefined;
try {
result = await runExec(params.nodeRunner ?? resolveNodeRunner(), args, {
cwd: params.root,
timeoutMs: params.timeoutMs,
maxBuffer: 4 * 1024 * 1024,
logOutput: false,
onOutputChunk: captureUpdateFinalizationDoctorOutput(params.phase),
baseEnv,
env: {
[UPDATE_POST_INSTALL_DOCTOR_RESULT_PATH_ENV]: doctorResultPath,
...(params.runId ? { [UPDATE_RUN_ID_ENV]: params.runId } : {}),
// The outer updater owns service refresh and activation after every
// migration finishes; a fresh Doctor must not resume its parked service.
...buildUpdateDoctorEnv({
allowGatewayServiceRepair: false,
allowGatewayActivation: false,
deferConfiguredPluginInstallRepair: true,
}),
...(params.phase === "post-plugin" ? { [UPDATE_POST_CORE_CONVERGENCE_ENV]: "1" } : {}),
},
});
} catch (error) {
doctorResult = await consumeUpdatePostInstallDoctorResult(doctorResultPath);
if (isRecord(error)) {
result = error;
// Enabling the existing result channel gives deferred plugin repair its
// advisory exit code. Convergence below still owns that repair.
if (
error.exitCode === UPDATE_POST_INSTALL_DOCTOR_ADVISORY_EXIT_CODE &&
isPlainCommandExitFailure({ ...error, failed: error.failed === true }) &&
doctorResult?.status === "advisory"
) {
return;
}
}
const exitCode = isRecord(error) && typeof error.exitCode === "number" ? error.exitCode : null;
const redaction = { env: process.env, stateDir: resolveStateDir() };
const failureFacts = doctorResult?.failureFacts?.length
? doctorResult.failureFacts
: [
createUpdateFailureFact({
check: "doctor",
code: "doctor-failed",
message:
typeof result?.stderr === "string" && result.stderr.trim()
? result.stderr
: error instanceof Error
? error.message
: String(error),
}),
];
const details = (["stderr", "stdout"] as const).flatMap((stream) => {
const output = result?.[stream];
if (typeof output !== "string" || !output.trim()) {
return [];
}
// Execa's message starts with full argv. Keep both actual diagnostics before
// the bounded update handoff, without cutting a credential before redaction.
const redacted = redactSupportString(output, redaction, {
maxLength: Number.MAX_SAFE_INTEGER,
});
const formatted = formatCommandOutput(redacted, 384);
let excerpt = formatted;
if (Buffer.byteLength(redacted) > 384 || Buffer.byteLength(formatted) > 384) {
const beginning = formatCommandOutput(truncateUtf8Prefix(redacted, 256), 256);
excerpt = `${truncateUtf8Prefix(beginning, 256)}\n...\n${truncateUtf8Suffix(formatted, 123)}`;
}
return excerpt ? [`${stream}: ${excerpt}`] : [];
});
if (details.length > 0) {
throw new UpdateDoctorError(
`Updated ${params.phase} Doctor failed:\n${details.join("\n")}`,
failureFacts,
{ cause: error, exitCode },
);
}
throw new UpdateDoctorError(
error instanceof Error ? error.message : String(error),
failureFacts,
{ cause: error, exitCode },
);
} finally {
doctorResult ??= await consumeUpdatePostInstallDoctorResult(doctorResultPath);
if (doctorResult?.warnings?.length) {
params.onWarnings?.(doctorResult.warnings);
}
// Clack writes directly to the child's stdout. Preserve diagnostics on either
// exit path without letting them share the parent's JSON result stream.
if (typeof result?.stdout === "string" && result.stdout.trim()) {
defaultRuntime[params.json ? "error" : "log"](result.stdout.trimEnd());
}
if (typeof result?.stderr === "string" && result.stderr.trim()) {
defaultRuntime.error(result.stderr.trimEnd());
}
}
}
async function validatePostPluginConfigInFreshProcess(params: {
root: string;
timeoutMs: number;
entryPath: string;
nodeRunner?: string;
}): Promise<boolean> {
try {
await runExec(
params.nodeRunner ?? resolveNodeRunner(),
[params.entryPath, "config", "validate", "--json"],
{
cwd: params.root,
timeoutMs: params.timeoutMs,
maxBuffer: 4 * 1024 * 1024,
logOutput: false,
baseEnv: stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
env: { OPENCLAW_UPDATE_IN_PROGRESS: "0" },
},
);
return true;
} catch {
return false;
}
}
export async function completePostCorePluginUpdate(params: {
root: string;
runId?: string;
pluginUpdate: PostCorePluginUpdateResult;
freshDoctorRequired: boolean;
yes: boolean;
json: boolean;
timeoutMs?: number;
nodeRunner?: string;
beforeDoctor?: () => Promise<void>;
onWarnings?: (warnings: string[]) => void;
}): Promise<{
pluginUpdate: PostCorePluginUpdateResult;
configSnapshot: ConfigFileSnapshot;
}> {
let pluginUpdate = params.pluginUpdate;
let entryPath: string | undefined;
let freshConfigValid: boolean | undefined;
if (pluginUpdate.status !== "error") {
try {
entryPath = await resolveGatewayInstallEntrypoint(params.root);
if (!entryPath) {
throw new Error("Updated OpenClaw entrypoint not found for post-plugin doctor");
}
if (params.freshDoctorRequired || hasDeferredUpdateModelRetirement()) {
await params.beforeDoctor?.();
await runUpdateFinalizationDoctorInFreshProcess({
...params,
entryPath,
phase: "post-plugin",
});
}
} catch (err) {
pluginUpdate = createPostPluginDoctorExecutionFailure(
params.pluginUpdate,
String(err),
err instanceof UpdateDoctorError ? err.failureFacts : undefined,
);
freshConfigValid = false;
}
}
// Only the target runtime may write state after a version switch: observing
// config here could migrate its database back to the parent's newer schema.
const configSnapshot = await withNormalConfigValidation(() =>
readConfigFileSnapshot({ observe: false }),
);
if (entryPath) {
let checkTimeoutMs = params.timeoutMs;
if (checkTimeoutMs === undefined) {
// Doctor can grow shared and agent stores. Measure once after its writes settle.
const env = { ...process.env };
const databases = await collectStateDatabasePaths(
{ stateDir: resolveStateDir(env), config: configSnapshot.sourceConfig, env },
{ includeUnconfiguredAgents: false },
);
checkTimeoutMs = resolveAggregateSqliteInspectionTimeoutMs(
"post-plugin checks",
await readUpdateStateDatabaseSizes(
Array.from(databases.values(), (database) => database.spellings[0]),
{ nodeRunner: process.execPath, sourceEnv: env, stagingRoot: os.tmpdir() },
),
);
}
// No authored file is a valid unconfigured install, not an invalid config.
// Existing files still need the target schema; every install needs readiness.
freshConfigValid =
(!configSnapshot.exists && configSnapshot.valid) ||
(await validatePostPluginConfigInFreshProcess({
...params,
entryPath,
timeoutMs: checkTimeoutMs,
}));
if (freshConfigValid) {
pluginUpdate = await applyPostPluginUpdateReadiness({
root: params.root,
entryPath,
pluginUpdate,
timeoutMs: checkTimeoutMs,
...(params.nodeRunner ? { nodeRunner: params.nodeRunner } : {}),
});
}
}
// Strict validity belongs to the target runtime even when no plugin changed.
// The parent may retain the previous schema; its snapshot is best-effort context.
pluginUpdate = applyPostPluginConfigValidation(
pluginUpdate,
freshConfigValid ?? configSnapshot.valid,
);
return { pluginUpdate, configSnapshot };
}