refactor: mechanical dedup batch (protocol types, update-cli bridge, talk fallback) (#114432)

* refactor(onboarding): remove search setup barrel

* refactor(plugins): reuse detected package manifest

* test(update): replace global helper bridges

* refactor(talk): remove dead legacy response fallback

* refactor(protocol): derive root types from schema

The maintainer approved broadening the additive schema-backed type surface without a protocol version bump.

* fix(plugins): drop stale package path import

* test(protocol): type dynamic registry lookups

* docs(talk): explain canonical response boundary

* refactor(talk): enforce canonical response input

* fix(protocol): keep root type exports registry-free

* refactor(update): expose helpers through test facades

* fix(protocol): keep result types on leaf schema modules
This commit is contained in:
Peter Steinberger
2026-07-27 06:55:07 -04:00
committed by GitHub
parent 0f879595d4
commit c6b2ec28c8
27 changed files with 582 additions and 2198 deletions
@@ -0,0 +1,57 @@
import {
readGatewayServiceState,
resolveGatewayService,
type GatewayService,
} from "../../daemon/service.js";
import { recoverInstalledLaunchAgent } from "../daemon-cli/launchd-recovery.js";
export type PostUpdateLaunchAgentRecoveryResult =
| { attempted: false; recovered: false }
| { attempted: true; recovered: true; message: string }
| { attempted: true; recovered: false; detail: string };
type PostUpdateLaunchAgentRecoveryDeps = {
platform?: NodeJS.Platform;
readState?: typeof readGatewayServiceState;
recover?: typeof recoverInstalledLaunchAgent;
};
export async function recoverInstalledLaunchAgentAfterUpdate(params: {
service?: GatewayService;
env?: NodeJS.ProcessEnv;
deps?: PostUpdateLaunchAgentRecoveryDeps;
}): Promise<PostUpdateLaunchAgentRecoveryResult> {
const platform = params.deps?.platform ?? process.platform;
if (platform !== "darwin") {
return { attempted: false, recovered: false };
}
const service = params.service ?? resolveGatewayService();
const readState = params.deps?.readState ?? readGatewayServiceState;
const recover = params.deps?.recover ?? recoverInstalledLaunchAgent;
const state = await readState(service, { env: params.env }).catch(() => null);
if (state?.loaded) {
return { attempted: false, recovered: false };
}
if (state && !state.installed && !state.runtime?.missingSupervision) {
return { attempted: false, recovered: false };
}
const recovered = await recover({ result: "restarted", env: state?.env ?? params.env }).catch(
() => null,
);
if (!recovered) {
return {
attempted: true,
recovered: false,
detail:
"LaunchAgent was installed but not loaded; automatic bootstrap/kickstart recovery failed.",
};
}
return {
attempted: true,
recovered: true,
message: recovered.message,
};
}
@@ -0,0 +1,160 @@
import path from "node:path";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import { pathExists } from "../../infra/fs-safe.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
import {
resolveTrustedSourceLinkedOfficialClawHubSpec,
resolveTrustedSourceLinkedOfficialNpmSpec,
} from "../../plugins/official-external-install-records.js";
import { resolveUserPath } from "../../utils.js";
import {
hasNativePackageInstallPayload,
resolveBundleInstallRecordPayload,
validateBundleInstallRecordPayload,
} from "./plugin-payload-validation.js";
export type PostCorePluginUpdateResult = NonNullable<
NonNullable<UpdateRunResult["postUpdate"]>["plugins"]
>;
export type MissingPluginInstallPayload = {
pluginId: string;
installPath?: string;
reason: "missing-install-path" | "missing-package-dir" | "missing-package-json";
};
export function resolvePostSyncPluginUpdateSkipIds(params: {
switchedToClawHub: readonly string[];
switchedToNpm: readonly string[];
repairedMissingPayloadIds: ReadonlySet<string>;
}): Set<string> {
return new Set([
...params.switchedToClawHub,
...params.switchedToNpm,
...params.repairedMissingPayloadIds,
]);
}
function isTrackedPackageInstallRecord(record: PluginInstallRecord): boolean {
return (
record.source === "npm" ||
record.source === "clawhub" ||
record.source === "git" ||
record.source === "marketplace"
);
}
export async function collectMissingPluginInstallPayloads(params: {
records: Record<string, PluginInstallRecord>;
config?: OpenClawConfig;
skipDisabledPlugins?: boolean;
syncOfficialPluginInstalls?: boolean;
env?: NodeJS.ProcessEnv;
}): Promise<MissingPluginInstallPayload[]> {
const env = params.env ?? process.env;
const normalizedPluginConfig =
params.skipDisabledPlugins && params.config
? normalizePluginsConfig(params.config.plugins)
: undefined;
const missing: MissingPluginInstallPayload[] = [];
for (const [pluginId, record] of Object.entries(params.records).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
if (!isTrackedPackageInstallRecord(record)) {
continue;
}
const officialNpmSpec = params.syncOfficialPluginInstalls
? resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record })
: undefined;
const officialClawHubSpec = params.syncOfficialPluginInstalls
? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record })
: undefined;
if (normalizedPluginConfig && params.config) {
const enableState = resolveEffectiveEnableState({
id: pluginId,
origin: "global",
config: normalizedPluginConfig,
rootConfig: params.config,
});
if (!enableState.enabled && !officialNpmSpec && !officialClawHubSpec) {
continue;
}
}
const rawInstallPath = normalizeOptionalString(record.installPath);
if (!rawInstallPath) {
missing.push({ pluginId, reason: "missing-install-path" });
continue;
}
const installPath = resolveUserPath(rawInstallPath, env);
if (!(await pathExists(installPath))) {
missing.push({ pluginId, installPath, reason: "missing-package-dir" });
continue;
}
const bundlePayload = resolveBundleInstallRecordPayload({ record, installPath });
if (bundlePayload.isBundlePayload) {
if (await hasNativePackageInstallPayload(installPath)) {
continue;
}
const bundleFailure = validateBundleInstallRecordPayload({
pluginId,
installPath,
record,
bundleFormat: bundlePayload.bundleFormat,
});
if (bundleFailure) {
missing.push({ pluginId, installPath, reason: "missing-package-json" });
}
continue;
}
const packageJsonPath = path.join(installPath, "package.json");
if (!(await pathExists(packageJsonPath))) {
missing.push({ pluginId, installPath, reason: "missing-package-json" });
}
}
return missing;
}
/**
* Build the post-core-update result we return when the active config cannot
* even be parsed. Mandatory post-core convergence requires a parseable
* config to know which plugins are configured; if one isn't available, we
* refuse to restart the gateway and surface this as a hard error so the
* existing `status === "error"` => `exit 1` pre-restart gate fires.
*/
export function buildInvalidConfigPostCoreUpdateResult(): {
message: string;
guidance: string[];
result: PostCorePluginUpdateResult;
} {
const guidance = [
"Run `openclaw doctor` to inspect the config validation errors.",
"Once the config parses, rerun `openclaw update repair`.",
];
const message =
"Plugin post-update convergence skipped because the config is invalid; refusing to restart the gateway with an unverified plugin set.";
return {
message,
guidance,
result: {
status: "error",
reason: "invalid-config",
changed: false,
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: {
changed: false,
outcomes: [],
},
integrityDrifts: [],
warnings: [{ reason: "invalid-config", message, guidance }],
},
};
}
@@ -1,52 +1,11 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import type { PostCorePluginUpdateResult } from "./update-command-plugins.js";
import "./update-command-plugins.js";
import {
buildInvalidConfigPostCoreUpdateResult,
collectMissingPluginInstallPayloads,
resolvePostSyncPluginUpdateSkipIds,
} from "./update-command-plugins-internals.js";
type MissingPluginInstallPayload = {
pluginId: string;
installPath?: string;
reason: "missing-install-path" | "missing-package-dir" | "missing-package-json";
export const testing = {
buildInvalidConfigPostCoreUpdateResult,
collectMissingPluginInstallPayloads,
resolvePostSyncPluginUpdateSkipIds,
};
type UpdateCommandPluginsTestApi = {
buildInvalidConfigPostCoreUpdateResult(): {
message: string;
guidance: string[];
result: PostCorePluginUpdateResult;
};
collectMissingPluginInstallPayloads(params: {
records: Record<string, PluginInstallRecord>;
config?: OpenClawConfig;
skipDisabledPlugins?: boolean;
syncOfficialPluginInstalls?: boolean;
env?: NodeJS.ProcessEnv;
}): Promise<MissingPluginInstallPayload[]>;
resolvePostSyncPluginUpdateSkipIds(params: {
switchedToClawHub: readonly string[];
switchedToNpm: readonly string[];
repairedMissingPayloadIds: ReadonlySet<string>;
}): Set<string>;
};
function getTestApi(): UpdateCommandPluginsTestApi {
return (globalThis as Record<PropertyKey, unknown>)[
Symbol.for("openclaw.updateCommandPluginsTestApi")
] as UpdateCommandPluginsTestApi;
}
export function buildInvalidConfigPostCoreUpdateResult() {
return getTestApi().buildInvalidConfigPostCoreUpdateResult();
}
export async function collectMissingPluginInstallPayloads(
params: Parameters<UpdateCommandPluginsTestApi["collectMissingPluginInstallPayloads"]>[0],
): Promise<MissingPluginInstallPayload[]> {
return await getTestApi().collectMissingPluginInstallPayloads(params);
}
export function resolvePostSyncPluginUpdateSkipIds(
params: Parameters<UpdateCommandPluginsTestApi["resolvePostSyncPluginUpdateSkipIds"]>[0],
): Set<string> {
return getTestApi().resolvePostSyncPluginUpdateSkipIds(params);
}
+9 -168
View File
@@ -1,7 +1,5 @@
// Plugin synchronization and convergence after the core update.
import path from "node:path";
import { confirm, isCancel, text } from "@clack/prompts";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { stripAnsi } from "../../../packages/terminal-core/src/ansi.js";
import { stylePromptMessage } from "../../../packages/terminal-core/src/prompt-style.js";
import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js";
@@ -10,20 +8,13 @@ import { readConfigFileSnapshot } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import type { ClawHubRiskAcknowledgementRequest } from "../../infra/clawhub-install-trust.js";
import { pathExists } from "../../infra/fs-safe.js";
import type { UpdateChannel } from "../../infra/update-channels.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../../plugins/config-state.js";
import { commitPluginInstallRecordsWithConfig } from "../../plugins/install-record-commit.js";
import {
loadInstalledPluginIndexInstallRecords,
withoutPluginInstallRecords,
withPluginInstallRecords,
} from "../../plugins/installed-plugin-index-records.js";
import {
resolveTrustedSourceLinkedOfficialClawHubSpec,
resolveTrustedSourceLinkedOfficialNpmSpec,
} from "../../plugins/official-external-install-records.js";
import { refreshPluginRegistryAfterConfigMutation } from "../../plugins/registry-refresh.js";
import {
isClawHubTrustSkippedOutcome,
@@ -33,46 +24,27 @@ import {
type PluginUpdateOutcome,
} from "../../plugins/update.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveUserPath } from "../../utils.js";
import { listPersistedBundledPluginLocationBridges } from "../plugins-location-bridges.js";
import {
hasNativePackageInstallPayload,
resolveBundleInstallRecordPayload,
validateBundleInstallRecordPayload,
} from "./plugin-payload-validation.js";
import {
convergenceWarningsToOutcomes,
runPostCorePluginConvergence,
} from "./post-core-plugin-convergence.js";
import { readPackageVersion, type UpdateCommandOptions } from "./shared.js";
import {
buildInvalidConfigPostCoreUpdateResult,
collectMissingPluginInstallPayloads,
resolvePostSyncPluginUpdateSkipIds,
type MissingPluginInstallPayload,
type PostCorePluginUpdateResult,
} from "./update-command-plugins-internals.js";
export type { PostCorePluginUpdateResult } from "./update-command-plugins-internals.js";
const POST_UPDATE_PLUGIN_REPAIR_GUIDANCE =
"Run openclaw update repair to retry post-update plugin repair.";
export type PostCorePluginUpdateResult = NonNullable<
NonNullable<UpdateRunResult["postUpdate"]>["plugins"]
>;
type MissingPluginInstallPayload = {
pluginId: string;
installPath?: string;
reason: "missing-install-path" | "missing-package-dir" | "missing-package-json";
};
type PostUpdatePluginWarning = NonNullable<PostCorePluginUpdateResult["warnings"]>[number];
function resolvePostSyncPluginUpdateSkipIds(params: {
switchedToClawHub: readonly string[];
switchedToNpm: readonly string[];
repairedMissingPayloadIds: ReadonlySet<string>;
}): Set<string> {
return new Set([
...params.switchedToClawHub,
...params.switchedToNpm,
...params.repairedMissingPayloadIds,
]);
}
function isClawHubTrustNotice(message: string): boolean {
const trimmed = stripAnsi(message).trimStart();
return (
@@ -133,85 +105,6 @@ function resolveUpdateClawHubRiskAcknowledgementOptions(
};
}
function isTrackedPackageInstallRecord(record: PluginInstallRecord): boolean {
return (
record.source === "npm" ||
record.source === "clawhub" ||
record.source === "git" ||
record.source === "marketplace"
);
}
async function collectMissingPluginInstallPayloads(params: {
records: Record<string, PluginInstallRecord>;
config?: OpenClawConfig;
skipDisabledPlugins?: boolean;
syncOfficialPluginInstalls?: boolean;
env?: NodeJS.ProcessEnv;
}): Promise<MissingPluginInstallPayload[]> {
const env = params.env ?? process.env;
const normalizedPluginConfig =
params.skipDisabledPlugins && params.config
? normalizePluginsConfig(params.config.plugins)
: undefined;
const missing: MissingPluginInstallPayload[] = [];
for (const [pluginId, record] of Object.entries(params.records).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
if (!isTrackedPackageInstallRecord(record)) {
continue;
}
const officialNpmSpec = params.syncOfficialPluginInstalls
? resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record })
: undefined;
const officialClawHubSpec = params.syncOfficialPluginInstalls
? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record })
: undefined;
if (normalizedPluginConfig && params.config) {
const enableState = resolveEffectiveEnableState({
id: pluginId,
origin: "global",
config: normalizedPluginConfig,
rootConfig: params.config,
});
if (!enableState.enabled && !officialNpmSpec && !officialClawHubSpec) {
continue;
}
}
const rawInstallPath = normalizeOptionalString(record.installPath);
if (!rawInstallPath) {
missing.push({ pluginId, reason: "missing-install-path" });
continue;
}
const installPath = resolveUserPath(rawInstallPath, env);
if (!(await pathExists(installPath))) {
missing.push({ pluginId, installPath, reason: "missing-package-dir" });
continue;
}
const bundlePayload = resolveBundleInstallRecordPayload({ record, installPath });
if (bundlePayload.isBundlePayload) {
if (await hasNativePackageInstallPayload(installPath)) {
continue;
}
const bundleFailure = validateBundleInstallRecordPayload({
pluginId,
installPath,
record,
bundleFormat: bundlePayload.bundleFormat,
});
if (bundleFailure) {
missing.push({ pluginId, installPath, reason: "missing-package-json" });
}
continue;
}
const packageJsonPath = path.join(installPath, "package.json");
if (!(await pathExists(packageJsonPath))) {
missing.push({ pluginId, installPath, reason: "missing-package-json" });
}
}
return missing;
}
function formatMissingPluginPayloadReason(entry: MissingPluginInstallPayload): string {
if (entry.reason === "missing-install-path") {
return "installPath is missing";
@@ -295,58 +188,6 @@ function isActionableSkippedPostUpdateOutcome(outcome: PluginUpdateOutcome): boo
return isDisabledAfterFailureOutcome(outcome) || isClawHubTrustSkippedOutcome(outcome);
}
/**
* Build the post-core-update result we return when the active config cannot
* even be parsed. Mandatory post-core convergence requires a parseable
* config to know which plugins are configured; if one isn't available, we
* refuse to restart the gateway and surface this as a hard error so the
* existing `status === "error"` ⇒ `exit 1` pre-restart gate fires.
*
*/
function buildInvalidConfigPostCoreUpdateResult(): {
message: string;
guidance: string[];
result: PostCorePluginUpdateResult;
} {
const guidance = [
"Run `openclaw doctor` to inspect the config validation errors.",
"Once the config parses, rerun `openclaw update repair`.",
];
const message =
"Plugin post-update convergence skipped because the config is invalid; refusing to restart the gateway with an unverified plugin set.";
return {
message,
guidance,
result: {
status: "error",
reason: "invalid-config",
changed: false,
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: {
changed: false,
outcomes: [],
},
integrityDrifts: [],
warnings: [{ reason: "invalid-config", message, guidance }],
},
};
}
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.updateCommandPluginsTestApi")] =
{
buildInvalidConfigPostCoreUpdateResult,
collectMissingPluginInstallPayloads,
resolvePostSyncPluginUpdateSkipIds,
};
}
export async function updatePluginsAfterCoreUpdate(params: {
root: string;
channel: UpdateChannel;
@@ -0,0 +1,116 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { GatewayService } from "../../daemon/service.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import { replaceCliName, resolveCliName } from "../cli-name.js";
import { formatCliCommand } from "../command-format.js";
import {
waitForGatewayHealthyRestart,
type GatewayRestartSnapshot,
} from "../daemon-cli/restart-health.js";
import {
recoverInstalledLaunchAgentAfterUpdate,
type PostUpdateLaunchAgentRecoveryResult,
} from "./update-command-launch-agent-recovery.js";
const CLI_NAME = resolveCliName();
export function isPackageManagerUpdateMode(
mode: UpdateRunResult["mode"],
): mode is "npm" | "pnpm" | "bun" {
return mode === "npm" || mode === "pnpm" || mode === "bun";
}
export function shouldUseLegacyProcessRestartAfterUpdate(params: {
updateMode: UpdateRunResult["mode"];
}): boolean {
return !isPackageManagerUpdateMode(params.updateMode);
}
type PostUpdateGatewayHealthRecoveryDeps = {
recoverLaunchAgent?: typeof recoverInstalledLaunchAgentAfterUpdate;
waitForHealthy?: typeof waitForGatewayHealthyRestart;
};
export async function recoverLaunchAgentAndRecheckGatewayHealth(params: {
health: GatewayRestartSnapshot;
service: GatewayService;
port: number;
expectedVersion?: string;
env?: NodeJS.ProcessEnv;
deps?: PostUpdateGatewayHealthRecoveryDeps;
}): Promise<{
health: GatewayRestartSnapshot;
launchAgentRecovery: PostUpdateLaunchAgentRecoveryResult | null;
}> {
if (params.health.healthy) {
return { health: params.health, launchAgentRecovery: null };
}
const recoverLaunchAgent =
params.deps?.recoverLaunchAgent ?? recoverInstalledLaunchAgentAfterUpdate;
const launchAgentRecovery = await recoverLaunchAgent({
service: params.service,
env: params.env,
});
if (!launchAgentRecovery.recovered) {
return { health: params.health, launchAgentRecovery };
}
const waitForHealthy = params.deps?.waitForHealthy ?? waitForGatewayHealthyRestart;
const health = await waitForHealthy({
service: params.service,
port: params.port,
expectedVersion: params.expectedVersion,
env: params.env,
supervisorKeepsAlive: true,
});
return { health, launchAgentRecovery };
}
export async function hasLoadedLaunchdKeepAliveSupervisor(params: {
service: GatewayService;
env?: NodeJS.ProcessEnv;
}): Promise<boolean> {
if (process.platform !== "darwin") {
return false;
}
// OpenClaw's loaded LaunchAgent has canonical KeepAlive policy. Read this once before
// polling so an unloaded agent can still reach the existing recovery path promptly.
return await params.service.isLoaded({ env: params.env }).catch(() => false);
}
function formatPostUpdateGatewayRecoveryLine(platform: NodeJS.Platform): string {
const restartCommand = replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME);
const installCommand = replaceCliName(
formatCliCommand("openclaw gateway install --force"),
CLI_NAME,
);
const statusCommand = replaceCliName(
formatCliCommand("openclaw gateway status --deep"),
CLI_NAME,
);
if (platform === "darwin") {
return `Recovery: run \`${restartCommand}\`; if the LaunchAgent is installed but not loaded, run \`${installCommand}\` from the logged-in macOS user session, then rerun \`${statusCommand}\`.`;
}
if (platform === "linux") {
return `Recovery: run \`${restartCommand}\`; if the systemd user service is missing, stale, or not active, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
if (platform === "win32") {
return `Recovery: run \`${restartCommand}\`; if the gateway Scheduled Task or Windows login item is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
return `Recovery: run \`${restartCommand}\`; if the local service manager reports the gateway service is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
export function formatPostUpdateGatewayRecoveryInstructions(
result: UpdateRunResult,
platform: NodeJS.Platform = process.platform,
): string[] {
const lines = [formatPostUpdateGatewayRecoveryLine(platform)];
const beforeVersion = normalizeOptionalString(result.before?.version);
if (isPackageManagerUpdateMode(result.mode) && beforeVersion) {
lines.push(
`Rollback: reinstall OpenClaw ${beforeVersion} with the same package manager, then rerun \`${replaceCliName(formatCliCommand("openclaw gateway install --force"), CLI_NAME)}\`.`,
);
}
return lines;
}
@@ -1,98 +1,15 @@
import type { GatewayServiceState } from "../../daemon/service-types.js";
import type { GatewayService } from "../../daemon/service.js";
import type { UpdateRunResult } from "../../infra/update-runner.js";
import type { GatewayRestartSnapshot } from "../daemon-cli/restart-health.js";
import "./update-command-service.js";
import { recoverInstalledLaunchAgentAfterUpdate } from "./update-command-launch-agent-recovery.js";
import {
formatPostUpdateGatewayRecoveryInstructions,
hasLoadedLaunchdKeepAliveSupervisor,
recoverLaunchAgentAndRecheckGatewayHealth,
shouldUseLegacyProcessRestartAfterUpdate,
} from "./update-command-service-recovery.js";
type PostUpdateLaunchAgentRecoveryResult =
| { attempted: false; recovered: false }
| { attempted: true; recovered: true; message: string }
| { attempted: true; recovered: false; detail: string };
type UpdateCommandServiceTestApi = {
formatPostUpdateGatewayRecoveryInstructions(
result: UpdateRunResult,
platform?: NodeJS.Platform,
): string[];
recoverInstalledLaunchAgentAfterUpdate(params: {
service?: GatewayService;
env?: NodeJS.ProcessEnv;
deps?: {
platform?: NodeJS.Platform;
readState?: (
service: GatewayService,
args: { env?: NodeJS.ProcessEnv },
) => Promise<GatewayServiceState>;
recover?: (params: {
result: "restarted";
env?: Record<string, string | undefined>;
}) => Promise<{ result: "restarted"; loaded: true; message: string } | null>;
};
}): Promise<PostUpdateLaunchAgentRecoveryResult>;
recoverLaunchAgentAndRecheckGatewayHealth(params: {
health: GatewayRestartSnapshot;
service: GatewayService;
port: number;
expectedVersion?: string;
env?: NodeJS.ProcessEnv;
deps?: {
recoverLaunchAgent?: (params: {
service?: GatewayService;
env?: NodeJS.ProcessEnv;
}) => Promise<PostUpdateLaunchAgentRecoveryResult>;
waitForHealthy?: (params: {
service: GatewayService;
port: number;
expectedVersion?: string;
env?: NodeJS.ProcessEnv;
}) => Promise<GatewayRestartSnapshot>;
};
}): Promise<{
health: GatewayRestartSnapshot;
launchAgentRecovery: PostUpdateLaunchAgentRecoveryResult | null;
}>;
hasLoadedLaunchdKeepAliveSupervisor(params: {
service: GatewayService;
env?: NodeJS.ProcessEnv;
}): Promise<boolean>;
shouldUseLegacyProcessRestartAfterUpdate(params: {
updateMode: UpdateRunResult["mode"];
}): boolean;
export const testing = {
formatPostUpdateGatewayRecoveryInstructions,
recoverInstalledLaunchAgentAfterUpdate,
recoverLaunchAgentAndRecheckGatewayHealth,
hasLoadedLaunchdKeepAliveSupervisor,
shouldUseLegacyProcessRestartAfterUpdate,
};
function getTestApi(): UpdateCommandServiceTestApi {
return (globalThis as Record<PropertyKey, unknown>)[
Symbol.for("openclaw.updateCommandServiceTestApi")
] as UpdateCommandServiceTestApi;
}
export function formatPostUpdateGatewayRecoveryInstructions(
result: UpdateRunResult,
platform?: NodeJS.Platform,
): string[] {
return getTestApi().formatPostUpdateGatewayRecoveryInstructions(result, platform);
}
export async function recoverInstalledLaunchAgentAfterUpdate(
params: Parameters<UpdateCommandServiceTestApi["recoverInstalledLaunchAgentAfterUpdate"]>[0],
): Promise<PostUpdateLaunchAgentRecoveryResult> {
return await getTestApi().recoverInstalledLaunchAgentAfterUpdate(params);
}
export async function recoverLaunchAgentAndRecheckGatewayHealth(
params: Parameters<UpdateCommandServiceTestApi["recoverLaunchAgentAndRecheckGatewayHealth"]>[0],
) {
return await getTestApi().recoverLaunchAgentAndRecheckGatewayHealth(params);
}
export async function hasLoadedLaunchdKeepAliveSupervisor(
params: Parameters<UpdateCommandServiceTestApi["hasLoadedLaunchdKeepAliveSupervisor"]>[0],
): Promise<boolean> {
return await getTestApi().hasLoadedLaunchdKeepAliveSupervisor(params);
}
export function shouldUseLegacyProcessRestartAfterUpdate(
params: Parameters<UpdateCommandServiceTestApi["shouldUseLegacyProcessRestartAfterUpdate"]>[0],
): boolean {
return getTestApi().shouldUseLegacyProcessRestartAfterUpdate(params);
}
+10 -170
View File
@@ -28,11 +28,7 @@ import {
} from "../../daemon/schtasks.js";
import { summarizeGatewayServiceLayout } from "../../daemon/service-layout.js";
import type { GatewayServiceCommandConfig } from "../../daemon/service-types.js";
import {
readGatewayServiceState,
resolveGatewayService,
type GatewayService,
} from "../../daemon/service.js";
import { readGatewayServiceState, resolveGatewayService } from "../../daemon/service.js";
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
import { getSelfAndAncestorPidsSync } from "../../infra/restart-stale-pids.js";
import { nodeVersionSatisfiesEngine } from "../../infra/runtime-guard.js";
@@ -45,12 +41,10 @@ import { replaceCliName, resolveCliName } from "../cli-name.js";
import { formatCliCommand } from "../command-format.js";
import { installCompletion } from "../completion-runtime.js";
import { runDaemonInstall, runDaemonRestart } from "../daemon-cli.js";
import { recoverInstalledLaunchAgent } from "../daemon-cli/launchd-recovery.js";
import {
renderRestartDiagnostics,
terminateStaleGatewayPids,
waitForGatewayHealthyRestart,
type GatewayRestartSnapshot,
} from "../daemon-cli/restart-health.js";
import {
registerSignalExitBarrier,
@@ -60,6 +54,15 @@ import {
import { runRestartScript } from "./restart-helper.js";
import { resolveNodeRunner, type UpdateCommandOptions } from "./shared.js";
import { createUpdateConfigSnapshot } from "./update-command-config.js";
import {
formatPostUpdateGatewayRecoveryInstructions,
hasLoadedLaunchdKeepAliveSupervisor,
isPackageManagerUpdateMode,
recoverLaunchAgentAndRecheckGatewayHealth,
shouldUseLegacyProcessRestartAfterUpdate,
} from "./update-command-service-recovery.js";
export { isPackageManagerUpdateMode } from "./update-command-service-recovery.js";
const CLI_NAME = resolveCliName();
const SERVICE_REFRESH_TIMEOUT_MS = 60_000;
@@ -80,12 +83,6 @@ const JSON_MODE_SERVICE_STDOUT = new Writable({
},
});
export function isPackageManagerUpdateMode(
mode: UpdateRunResult["mode"],
): mode is "npm" | "pnpm" | "bun" {
return mode === "npm" || mode === "pnpm" || mode === "bun";
}
export function shouldPrepareUpdatedInstallRestart(params: {
updateMode: UpdateRunResult["mode"];
serviceInstalled: boolean;
@@ -109,163 +106,6 @@ export function shouldPrepareUpdatedInstallRestart(params: {
return params.serviceLoaded;
}
function shouldUseLegacyProcessRestartAfterUpdate(params: {
updateMode: UpdateRunResult["mode"];
}): boolean {
return !isPackageManagerUpdateMode(params.updateMode);
}
type PostUpdateLaunchAgentRecoveryResult =
| { attempted: false; recovered: false }
| { attempted: true; recovered: true; message: string }
| { attempted: true; recovered: false; detail: string };
type PostUpdateLaunchAgentRecoveryDeps = {
platform?: NodeJS.Platform;
readState?: typeof readGatewayServiceState;
recover?: typeof recoverInstalledLaunchAgent;
};
async function recoverInstalledLaunchAgentAfterUpdate(params: {
service?: GatewayService;
env?: NodeJS.ProcessEnv;
deps?: PostUpdateLaunchAgentRecoveryDeps;
}): Promise<PostUpdateLaunchAgentRecoveryResult> {
const platform = params.deps?.platform ?? process.platform;
if (platform !== "darwin") {
return { attempted: false, recovered: false };
}
const service = params.service ?? resolveGatewayService();
const readState = params.deps?.readState ?? readGatewayServiceState;
const recover = params.deps?.recover ?? recoverInstalledLaunchAgent;
const state = await readState(service, { env: params.env }).catch(() => null);
if (state?.loaded) {
return { attempted: false, recovered: false };
}
if (state && !state.installed && !state.runtime?.missingSupervision) {
return { attempted: false, recovered: false };
}
const recovered = await recover({ result: "restarted", env: state?.env ?? params.env }).catch(
() => null,
);
if (!recovered) {
return {
attempted: true,
recovered: false,
detail:
"LaunchAgent was installed but not loaded; automatic bootstrap/kickstart recovery failed.",
};
}
return {
attempted: true,
recovered: true,
message: recovered.message,
};
}
type PostUpdateGatewayHealthRecoveryDeps = {
recoverLaunchAgent?: typeof recoverInstalledLaunchAgentAfterUpdate;
waitForHealthy?: typeof waitForGatewayHealthyRestart;
};
async function recoverLaunchAgentAndRecheckGatewayHealth(params: {
health: GatewayRestartSnapshot;
service: GatewayService;
port: number;
expectedVersion?: string;
env?: NodeJS.ProcessEnv;
deps?: PostUpdateGatewayHealthRecoveryDeps;
}): Promise<{
health: GatewayRestartSnapshot;
launchAgentRecovery: PostUpdateLaunchAgentRecoveryResult | null;
}> {
if (params.health.healthy) {
return { health: params.health, launchAgentRecovery: null };
}
const recoverLaunchAgent =
params.deps?.recoverLaunchAgent ?? recoverInstalledLaunchAgentAfterUpdate;
const launchAgentRecovery = await recoverLaunchAgent({
service: params.service,
env: params.env,
});
if (!launchAgentRecovery.recovered) {
return { health: params.health, launchAgentRecovery };
}
const waitForHealthy = params.deps?.waitForHealthy ?? waitForGatewayHealthyRestart;
const health = await waitForHealthy({
service: params.service,
port: params.port,
expectedVersion: params.expectedVersion,
env: params.env,
supervisorKeepsAlive: true,
});
return { health, launchAgentRecovery };
}
async function hasLoadedLaunchdKeepAliveSupervisor(params: {
service: GatewayService;
env?: NodeJS.ProcessEnv;
}): Promise<boolean> {
if (process.platform !== "darwin") {
return false;
}
// OpenClaw's loaded LaunchAgent has canonical KeepAlive policy. Read this once before
// polling so an unloaded agent can still reach the existing recovery path promptly.
return await params.service.isLoaded({ env: params.env }).catch(() => false);
}
function formatPostUpdateGatewayRecoveryLine(platform: NodeJS.Platform): string {
const restartCommand = replaceCliName(formatCliCommand("openclaw gateway restart"), CLI_NAME);
const installCommand = replaceCliName(
formatCliCommand("openclaw gateway install --force"),
CLI_NAME,
);
const statusCommand = replaceCliName(
formatCliCommand("openclaw gateway status --deep"),
CLI_NAME,
);
if (platform === "darwin") {
return `Recovery: run \`${restartCommand}\`; if the LaunchAgent is installed but not loaded, run \`${installCommand}\` from the logged-in macOS user session, then rerun \`${statusCommand}\`.`;
}
if (platform === "linux") {
return `Recovery: run \`${restartCommand}\`; if the systemd user service is missing, stale, or not active, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
if (platform === "win32") {
return `Recovery: run \`${restartCommand}\`; if the gateway Scheduled Task or Windows login item is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
return `Recovery: run \`${restartCommand}\`; if the local service manager reports the gateway service is missing, stale, or not running, run \`${installCommand}\` from the same user account, then rerun \`${statusCommand}\`.`;
}
function formatPostUpdateGatewayRecoveryInstructions(
result: UpdateRunResult,
platform: NodeJS.Platform = process.platform,
): string[] {
const lines = [formatPostUpdateGatewayRecoveryLine(platform)];
const beforeVersion = normalizeOptionalString(result.before?.version);
if (isPackageManagerUpdateMode(result.mode) && beforeVersion) {
lines.push(
`Rollback: reinstall OpenClaw ${beforeVersion} with the same package manager, then rerun \`${replaceCliName(formatCliCommand("openclaw gateway install --force"), CLI_NAME)}\`.`,
);
}
return lines;
}
if (process.env.VITEST || process.env.NODE_ENV === "test") {
(globalThis as Record<PropertyKey, unknown>)[Symbol.for("openclaw.updateCommandServiceTestApi")] =
{
formatPostUpdateGatewayRecoveryInstructions,
recoverInstalledLaunchAgentAfterUpdate,
recoverLaunchAgentAndRecheckGatewayHealth,
hasLoadedLaunchdKeepAliveSupervisor,
shouldUseLegacyProcessRestartAfterUpdate,
};
}
export type PreManagedServiceStop = {
stopped: boolean;
inspected: boolean;
+60 -41
View File
@@ -10,11 +10,7 @@ import {
updatePluginsAfterCoreUpdate,
type PostCorePluginUpdateResult,
} from "./update-command-plugins.js";
import {
buildInvalidConfigPostCoreUpdateResult,
collectMissingPluginInstallPayloads,
resolvePostSyncPluginUpdateSkipIds,
} from "./update-command-plugins.test-support.js";
import { testing as updateCommandPluginsTesting } from "./update-command-plugins.test-support.js";
import { resolvePostCoreUpdateChildStdio } from "./update-command-post-core.js";
import { applyPostPluginConfigValidation } from "./update-command-post-plugin-validation.js";
import {
@@ -23,13 +19,7 @@ import {
resolveUpdatedGatewayRestartPort,
shouldPrepareUpdatedInstallRestart,
} from "./update-command-service.js";
import {
formatPostUpdateGatewayRecoveryInstructions,
hasLoadedLaunchdKeepAliveSupervisor,
recoverInstalledLaunchAgentAfterUpdate,
recoverLaunchAgentAndRecheckGatewayHealth,
shouldUseLegacyProcessRestartAfterUpdate,
} from "./update-command-service.test-support.js";
import { testing as updateCommandServiceTesting } from "./update-command-service.test-support.js";
describe("resolveGatewayInstallEntrypoint", () => {
it("prefers dist/index.js over dist/entry.js when both exist", async () => {
@@ -294,7 +284,7 @@ describe("collectMissingPluginInstallPayloads", () => {
await fs.mkdir(noPackageJsonDir, { recursive: true });
await expect(
collectMissingPluginInstallPayloads({
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
records: {
present: {
@@ -355,7 +345,7 @@ describe("collectMissingPluginInstallPayloads", () => {
"utf8",
);
await expect(
collectMissingPluginInstallPayloads({
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
records: {
"cursor-bundle": {
@@ -382,7 +372,7 @@ describe("collectMissingPluginInstallPayloads", () => {
"utf8",
);
await expect(
collectMissingPluginInstallPayloads({
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
records: {
"cursor-bundle": {
@@ -419,7 +409,7 @@ describe("collectMissingPluginInstallPayloads", () => {
"utf8",
);
await expect(
collectMissingPluginInstallPayloads({
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
records: {
"dual-format-bundle": {
@@ -442,7 +432,7 @@ describe("collectMissingPluginInstallPayloads", () => {
await fs.mkdir(path.join(bundleDir, ".codex-plugin"), { recursive: true });
await fs.writeFile(path.join(bundleDir, ".codex-plugin", "plugin.json"), "[]", "utf8");
await expect(
collectMissingPluginInstallPayloads({
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
records: {
"bad-bundle": {
@@ -469,7 +459,7 @@ describe("collectMissingPluginInstallPayloads", () => {
const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "missing");
try {
await expect(
collectMissingPluginInstallPayloads({
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
skipDisabledPlugins: true,
config: {
@@ -500,7 +490,7 @@ describe("collectMissingPluginInstallPayloads", () => {
const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "codex");
try {
await expect(
collectMissingPluginInstallPayloads({
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
skipDisabledPlugins: true,
syncOfficialPluginInstalls: true,
@@ -540,7 +530,7 @@ describe("collectMissingPluginInstallPayloads", () => {
const missingDir = path.join(tmpDir, "state", "clawhub", "diagnostics-otel");
try {
await expect(
collectMissingPluginInstallPayloads({
updateCommandPluginsTesting.collectMissingPluginInstallPayloads({
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
skipDisabledPlugins: true,
syncOfficialPluginInstalls: true,
@@ -577,7 +567,7 @@ describe("collectMissingPluginInstallPayloads", () => {
describe("resolvePostSyncPluginUpdateSkipIds", () => {
it("skips plugins already switched through ClawHub or npm and repaired payloads", () => {
expect(
resolvePostSyncPluginUpdateSkipIds({
updateCommandPluginsTesting.resolvePostSyncPluginUpdateSkipIds({
switchedToClawHub: ["whatsapp"],
switchedToNpm: ["voice-call"],
repairedMissingPayloadIds: new Set(["telegram"]),
@@ -588,14 +578,26 @@ describe("resolvePostSyncPluginUpdateSkipIds", () => {
describe("shouldUseLegacyProcessRestartAfterUpdate", () => {
it("never restarts package updates through the pre-update process", () => {
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "npm" })).toBe(false);
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "pnpm" })).toBe(false);
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "bun" })).toBe(false);
expect(
updateCommandServiceTesting.shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "npm" }),
).toBe(false);
expect(
updateCommandServiceTesting.shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "pnpm" }),
).toBe(false);
expect(
updateCommandServiceTesting.shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "bun" }),
).toBe(false);
});
it("keeps the in-process restart path for non-package updates", () => {
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "git" })).toBe(true);
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "unknown" })).toBe(true);
expect(
updateCommandServiceTesting.shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "git" }),
).toBe(true);
expect(
updateCommandServiceTesting.shouldUseLegacyProcessRestartAfterUpdate({
updateMode: "unknown",
}),
).toBe(true);
});
});
@@ -608,7 +610,10 @@ describe("formatPostUpdateGatewayRecoveryInstructions", () => {
};
it("uses systemd wording on Linux instead of macOS LaunchAgent instructions", () => {
const [line] = formatPostUpdateGatewayRecoveryInstructions(result, "linux");
const [line] = updateCommandServiceTesting.formatPostUpdateGatewayRecoveryInstructions(
result,
"linux",
);
expect(line).toContain("the systemd user service");
expect(line).toContain("openclaw gateway restart");
@@ -620,14 +625,20 @@ describe("formatPostUpdateGatewayRecoveryInstructions", () => {
});
it("keeps LaunchAgent recovery wording on macOS", () => {
const [line] = formatPostUpdateGatewayRecoveryInstructions(result, "darwin");
const [line] = updateCommandServiceTesting.formatPostUpdateGatewayRecoveryInstructions(
result,
"darwin",
);
expect(line).toContain("the LaunchAgent is installed but not loaded");
expect(line).toContain("logged-in macOS user session");
});
it("uses Windows service-manager wording on Windows", () => {
const [line] = formatPostUpdateGatewayRecoveryInstructions(result, "win32");
const [line] = updateCommandServiceTesting.formatPostUpdateGatewayRecoveryInstructions(
result,
"win32",
);
expect(line).toContain("the gateway Scheduled Task or Windows login item");
expect(line).not.toContain("LaunchAgent");
@@ -635,7 +646,10 @@ describe("formatPostUpdateGatewayRecoveryInstructions", () => {
});
it("uses generic service-manager wording for unsupported Node platforms", () => {
const [line] = formatPostUpdateGatewayRecoveryInstructions(result, "freebsd");
const [line] = updateCommandServiceTesting.formatPostUpdateGatewayRecoveryInstructions(
result,
"freebsd",
);
expect(line).toContain("local service manager");
expect(line).not.toContain("systemd");
@@ -664,7 +678,7 @@ describe("recoverInstalledLaunchAgentAfterUpdate", () => {
}));
await expect(
recoverInstalledLaunchAgentAfterUpdate({
updateCommandServiceTesting.recoverInstalledLaunchAgentAfterUpdate({
service,
env: serviceEnv,
deps: {
@@ -688,7 +702,7 @@ describe("recoverInstalledLaunchAgentAfterUpdate", () => {
const recover = vi.fn();
await expect(
recoverInstalledLaunchAgentAfterUpdate({
updateCommandServiceTesting.recoverInstalledLaunchAgentAfterUpdate({
service: {} as never,
deps: {
platform: "linux",
@@ -714,7 +728,7 @@ describe("recoverInstalledLaunchAgentAfterUpdate", () => {
const recover = vi.fn();
await expect(
recoverInstalledLaunchAgentAfterUpdate({
updateCommandServiceTesting.recoverInstalledLaunchAgentAfterUpdate({
service: {} as never,
deps: {
platform: "darwin",
@@ -739,7 +753,7 @@ describe("recoverInstalledLaunchAgentAfterUpdate", () => {
const recover = vi.fn(async () => null);
await expect(
recoverInstalledLaunchAgentAfterUpdate({
updateCommandServiceTesting.recoverInstalledLaunchAgentAfterUpdate({
service: {} as never,
deps: {
platform: "darwin",
@@ -782,7 +796,7 @@ describe("recoverLaunchAgentAndRecheckGatewayHealth", () => {
const waitForHealthy = vi.fn(async () => healthy);
await expect(
recoverLaunchAgentAndRecheckGatewayHealth({
updateCommandServiceTesting.recoverLaunchAgentAndRecheckGatewayHealth({
health: unhealthy,
service,
port: 18790,
@@ -830,7 +844,7 @@ describe("recoverLaunchAgentAndRecheckGatewayHealth", () => {
}));
const waitForHealthy = vi.fn(async () => stillUnhealthy);
const result = await recoverLaunchAgentAndRecheckGatewayHealth({
const result = await updateCommandServiceTesting.recoverLaunchAgentAndRecheckGatewayHealth({
health: unhealthy,
service,
port: 18790,
@@ -851,10 +865,15 @@ describe("hasLoadedLaunchdKeepAliveSupervisor", () => {
const service = { isLoaded } as unknown as GatewayService;
await expect(
hasLoadedLaunchdKeepAliveSupervisor({ service, env: { OPENCLAW_PROFILE: "work" } }),
updateCommandServiceTesting.hasLoadedLaunchdKeepAliveSupervisor({
service,
env: { OPENCLAW_PROFILE: "work" },
}),
).resolves.toBe(false);
isLoaded.mockResolvedValue(true);
await expect(hasLoadedLaunchdKeepAliveSupervisor({ service })).resolves.toBe(true);
await expect(
updateCommandServiceTesting.hasLoadedLaunchdKeepAliveSupervisor({ service }),
).resolves.toBe(true);
platformSpy.mockRestore();
});
@@ -864,7 +883,7 @@ describe("hasLoadedLaunchdKeepAliveSupervisor", () => {
const isLoaded = vi.fn().mockResolvedValue(true);
await expect(
hasLoadedLaunchdKeepAliveSupervisor({
updateCommandServiceTesting.hasLoadedLaunchdKeepAliveSupervisor({
service: { isLoaded } as unknown as GatewayService,
}),
).resolves.toBe(false);
@@ -932,14 +951,14 @@ describe("updatePluginsAfterCoreUpdate (invalid config end-to-end)", () => {
describe("buildInvalidConfigPostCoreUpdateResult", () => {
it("returns status:error so the existing pre-restart gate exits 1 instead of restarting on invalid config", () => {
const built = buildInvalidConfigPostCoreUpdateResult();
const built = updateCommandPluginsTesting.buildInvalidConfigPostCoreUpdateResult();
expect(built.result.status).toBe("error");
expect(built.result.reason).toBe("invalid-config");
expect(built.result.changed).toBe(false);
});
it("surfaces actionable repair guidance in both the structural warnings and the message string", () => {
const built = buildInvalidConfigPostCoreUpdateResult();
const built = updateCommandPluginsTesting.buildInvalidConfigPostCoreUpdateResult();
expect(built.guidance).toStrictEqual([
"Run `openclaw doctor` to inspect the config validation errors.",
"Once the config parses, rerun `openclaw update repair`.",