mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
feat(channels): add channel-owned setup contracts (#112176)
* feat(channels): add channel-owned setup contracts * test(channels): align legacy setup fixtures * chore(channels): regenerate config and SDK baselines after rebase * fix(update): run fresh doctor after current-process core changes * fix(channels): align add pre-scan with execution precedence * style(cli): format channels-cli test additions * fix(channels): restore option-before-positional channel resolution via metadata arity scan * fix(channels): keep help flags out of metadata arity escalation * test(update): mock fresh post-update doctor in current-process suites * style: format review fixes and correct entrypoint mock type * fix(channels): register only modern contract options for dual-publishing plugins * test(update): align downgrade suites with fresh-doctor child invocation * docs(channels): record empty-contract and input-forwarding invariants * fix(line): keep the shipped --token switch as a channel access token alias * fix(signal): stop treating exact cross-family loopback endpoints as bind-aligned * chore(config): regenerate docs config baselines after second rebase * style: format rebased channels add tests * fix(channels): enforce field-key and flag-name agreement in setup contracts * fix(signal): detect container endpoints for bare --http-url setup * fix(signal): ignore unconfigured accounts in transport collision checks * fix(channels): validate negated setup flags in contract and normalizer * fix(signal): preserve existing transport kind when setup detection is unreachable * style(signal): use direct boolean check in collision guard * style(signal): type test config literals * docs(update): record two-read design of fresh-doctor validation gate * fix(channels): satisfy post-rebase architecture gates * docs: refresh channel setup map --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
// Runs the post-plugin migration pass without retaining pre-update plugin modules.
|
||||
import {
|
||||
UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV,
|
||||
UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV,
|
||||
} from "../../commands/doctor/shared/update-phase.js";
|
||||
import { readConfigFileSnapshot } from "../../config/config.js";
|
||||
import type { ConfigFileSnapshot } from "../../config/types.openclaw.js";
|
||||
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
|
||||
import { runExec } from "../../process/exec.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { resolveNodeRunner } from "./shared.js";
|
||||
import type { PostCorePluginUpdateResult } from "./update-command-plugins.js";
|
||||
import {
|
||||
applyPostPluginConfigValidation,
|
||||
POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON,
|
||||
} from "./update-command-post-plugin-validation.js";
|
||||
import {
|
||||
disableUpdatedPackageCompileCacheEnv,
|
||||
stripGatewayServiceMarkerEnv,
|
||||
} from "./update-command-service.js";
|
||||
|
||||
export function withUpdateFinalizationEnv<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
|
||||
const previousDeferConfiguredPluginInstallRepair =
|
||||
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV];
|
||||
const previousParentSupportsDoctorConfigWrite =
|
||||
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV];
|
||||
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";
|
||||
return run().finally(() => {
|
||||
if (previousUpdateInProgress === undefined) {
|
||||
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
|
||||
} else {
|
||||
process.env.OPENCLAW_UPDATE_IN_PROGRESS = previousUpdateInProgress;
|
||||
}
|
||||
if (previousDeferConfiguredPluginInstallRepair === undefined) {
|
||||
delete process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV];
|
||||
} else {
|
||||
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] =
|
||||
previousDeferConfiguredPluginInstallRepair;
|
||||
}
|
||||
if (previousParentSupportsDoctorConfigWrite === undefined) {
|
||||
delete process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV];
|
||||
} else {
|
||||
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] =
|
||||
previousParentSupportsDoctorConfigWrite;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
): PostCorePluginUpdateResult {
|
||||
return {
|
||||
...pluginUpdate,
|
||||
status: "error",
|
||||
reason: POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON,
|
||||
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."],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function runPostPluginDoctorInFreshProcess(params: {
|
||||
root: string;
|
||||
yes: boolean;
|
||||
json: boolean;
|
||||
timeoutMs: number;
|
||||
nodeRunner?: string;
|
||||
entryPath?: string;
|
||||
}): 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",
|
||||
"--no-workspace-suggestions",
|
||||
...(params.yes ? ["--yes"] : []),
|
||||
];
|
||||
const result = await runExec(params.nodeRunner ?? resolveNodeRunner(), args, {
|
||||
cwd: params.root,
|
||||
timeoutMs: params.timeoutMs,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
logOutput: false,
|
||||
baseEnv: stripGatewayServiceMarkerEnv(disableUpdatedPackageCompileCacheEnv(process.env)),
|
||||
env: {
|
||||
OPENCLAW_UPDATE_IN_PROGRESS: "1",
|
||||
[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV]: "1",
|
||||
[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV]: "1",
|
||||
},
|
||||
});
|
||||
if (!params.json) {
|
||||
if (result.stdout.trim()) {
|
||||
defaultRuntime.log(result.stdout.trimEnd());
|
||||
}
|
||||
if (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;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyFreshPostPluginDoctor(params: {
|
||||
root: string;
|
||||
pluginUpdate: PostCorePluginUpdateResult;
|
||||
yes: boolean;
|
||||
json: boolean;
|
||||
timeoutMs: number;
|
||||
nodeRunner?: string;
|
||||
}): Promise<{ pluginUpdate: PostCorePluginUpdateResult; configValid: boolean }> {
|
||||
let entryPath: string | undefined;
|
||||
try {
|
||||
entryPath = await resolveGatewayInstallEntrypoint(params.root);
|
||||
} catch (err) {
|
||||
return {
|
||||
pluginUpdate: createPostPluginDoctorExecutionFailure(params.pluginUpdate, String(err)),
|
||||
configValid: false,
|
||||
};
|
||||
}
|
||||
if (!entryPath) {
|
||||
return {
|
||||
pluginUpdate: createPostPluginDoctorExecutionFailure(
|
||||
params.pluginUpdate,
|
||||
"Updated OpenClaw entrypoint not found for post-plugin doctor",
|
||||
),
|
||||
configValid: false,
|
||||
};
|
||||
}
|
||||
let pluginUpdate = params.pluginUpdate;
|
||||
try {
|
||||
await runPostPluginDoctorInFreshProcess({ ...params, entryPath });
|
||||
} catch (err) {
|
||||
pluginUpdate = createPostPluginDoctorExecutionFailure(params.pluginUpdate, String(err));
|
||||
}
|
||||
const configValid = await validatePostPluginConfigInFreshProcess({ ...params, entryPath });
|
||||
return { pluginUpdate, configValid };
|
||||
}
|
||||
|
||||
export async function completePostCorePluginUpdate(params: {
|
||||
root: string;
|
||||
pluginUpdate: PostCorePluginUpdateResult;
|
||||
freshDoctorRequired: boolean;
|
||||
yes: boolean;
|
||||
json: boolean;
|
||||
timeoutMs: number;
|
||||
nodeRunner?: string;
|
||||
}): Promise<{
|
||||
pluginUpdate: PostCorePluginUpdateResult;
|
||||
configSnapshot: ConfigFileSnapshot;
|
||||
}> {
|
||||
let pluginUpdate = params.pluginUpdate;
|
||||
let freshConfigValid: boolean | undefined;
|
||||
if (pluginUpdate.status !== "error" && params.freshDoctorRequired) {
|
||||
// The current process can still hold the pre-update plugin and schema. Reload the updated
|
||||
// migration owner before trusting strict validation or restarting the gateway.
|
||||
const freshResult = await applyFreshPostPluginDoctor({
|
||||
root: params.root,
|
||||
pluginUpdate,
|
||||
yes: params.yes,
|
||||
json: params.json,
|
||||
timeoutMs: params.timeoutMs,
|
||||
...(params.nodeRunner ? { nodeRunner: params.nodeRunner } : {}),
|
||||
});
|
||||
pluginUpdate = freshResult.pluginUpdate;
|
||||
freshConfigValid = freshResult.configValid;
|
||||
}
|
||||
|
||||
const configSnapshot = await withNormalConfigValidation(() => readConfigFileSnapshot());
|
||||
// A plugin migration that did not converge must fail finalization instead of letting legacy
|
||||
// config reach the restarted gateway.
|
||||
// Two reads by design: the fresh child is the only process able to validate under the
|
||||
// UPDATED schema, so its verdict gates the restart; this parent snapshot is best-effort
|
||||
// state under the stale in-memory schema and the restarted gateway re-reads config anyway.
|
||||
pluginUpdate = applyPostPluginConfigValidation(
|
||||
pluginUpdate,
|
||||
freshConfigValid ?? configSnapshot.valid,
|
||||
);
|
||||
return { pluginUpdate, configSnapshot };
|
||||
}
|
||||
@@ -7,10 +7,6 @@ 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 {
|
||||
UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV,
|
||||
UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV,
|
||||
} from "../../commands/doctor/shared/update-phase.js";
|
||||
import {
|
||||
assertConfigWriteAllowedInCurrentMode,
|
||||
readConfigFileSnapshot,
|
||||
@@ -70,6 +66,10 @@ import {
|
||||
restoreDroppedPreUpdateChannels,
|
||||
writePostCoreSourceConfigFile,
|
||||
} from "./update-command-config.js";
|
||||
import {
|
||||
completePostCorePluginUpdate,
|
||||
withUpdateFinalizationEnv,
|
||||
} from "./update-command-fresh-doctor.js";
|
||||
import {
|
||||
updatePluginsAfterCoreUpdate,
|
||||
type PostCorePluginUpdateResult,
|
||||
@@ -128,36 +128,6 @@ type UpdateFinalizeResult = {
|
||||
};
|
||||
};
|
||||
|
||||
function withUpdateFinalizationEnv<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previousUpdateInProgress = process.env.OPENCLAW_UPDATE_IN_PROGRESS;
|
||||
const previousDeferConfiguredPluginInstallRepair =
|
||||
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV];
|
||||
const previousParentSupportsDoctorConfigWrite =
|
||||
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV];
|
||||
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";
|
||||
return run().finally(() => {
|
||||
if (previousUpdateInProgress === undefined) {
|
||||
delete process.env.OPENCLAW_UPDATE_IN_PROGRESS;
|
||||
} else {
|
||||
process.env.OPENCLAW_UPDATE_IN_PROGRESS = previousUpdateInProgress;
|
||||
}
|
||||
if (previousDeferConfiguredPluginInstallRepair === undefined) {
|
||||
delete process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV];
|
||||
} else {
|
||||
process.env[UPDATE_DEFER_CONFIGURED_PLUGIN_INSTALL_REPAIR_ENV] =
|
||||
previousDeferConfiguredPluginInstallRepair;
|
||||
}
|
||||
if (previousParentSupportsDoctorConfigWrite === undefined) {
|
||||
delete process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV];
|
||||
} else {
|
||||
process.env[UPDATE_PARENT_SUPPORTS_DOCTOR_CONFIG_WRITE_ENV] =
|
||||
previousParentSupportsDoctorConfigWrite;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promise<void> {
|
||||
suppressDeprecations();
|
||||
const timeoutMs = parseTimeoutMsOrExit(opts.timeout);
|
||||
@@ -225,7 +195,7 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
|
||||
});
|
||||
}
|
||||
|
||||
const pluginUpdate = await withUpdateFinalizationEnv(async () => {
|
||||
const initialPluginUpdate = await withUpdateFinalizationEnv(async () => {
|
||||
await createUpdateConfigSnapshot();
|
||||
await doctorCommand(defaultRuntime, {
|
||||
nonInteractive: true,
|
||||
@@ -268,6 +238,16 @@ export async function updateFinalizeCommand(opts: UpdateFinalizeOptions): Promis
|
||||
pluginInstallRecords,
|
||||
});
|
||||
});
|
||||
const completedPluginUpdate = 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:
|
||||
@@ -627,27 +607,28 @@ export async function continuePostCoreUpdateInFreshProcess(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export function didCoreUpdateChangeInstall(result: UpdateRunResult): boolean {
|
||||
if (isPackageManagerUpdateMode(result.mode)) {
|
||||
return true;
|
||||
}
|
||||
if (result.mode !== "git") {
|
||||
return false;
|
||||
}
|
||||
const beforeSha = normalizeOptionalString(result.before?.sha);
|
||||
const afterSha = normalizeOptionalString(result.after?.sha);
|
||||
if (beforeSha && afterSha && beforeSha !== afterSha) {
|
||||
return true;
|
||||
}
|
||||
const beforeVersion = normalizeOptionalString(result.before?.version);
|
||||
const afterVersion = normalizeOptionalString(result.after?.version);
|
||||
return Boolean(beforeVersion && afterVersion && beforeVersion !== afterVersion);
|
||||
}
|
||||
|
||||
export function shouldResumePostCoreUpdateInFreshProcess(params: {
|
||||
result: UpdateRunResult;
|
||||
downgradeRisk: boolean;
|
||||
}): boolean {
|
||||
if (params.downgradeRisk) {
|
||||
return false;
|
||||
}
|
||||
if (isPackageManagerUpdateMode(params.result.mode)) {
|
||||
return true;
|
||||
}
|
||||
if (params.result.mode !== "git") {
|
||||
return false;
|
||||
}
|
||||
const beforeSha = normalizeOptionalString(params.result.before?.sha);
|
||||
const afterSha = normalizeOptionalString(params.result.after?.sha);
|
||||
if (beforeSha && afterSha && beforeSha !== afterSha) {
|
||||
return true;
|
||||
}
|
||||
const beforeVersion = normalizeOptionalString(params.result.before?.version);
|
||||
const afterVersion = normalizeOptionalString(params.result.after?.version);
|
||||
return Boolean(beforeVersion && afterVersion && beforeVersion !== afterVersion);
|
||||
return !params.downgradeRisk && didCoreUpdateChangeInstall(params.result);
|
||||
}
|
||||
|
||||
export async function writeControlPlaneUpdateRestartSentinelBestEffort(params: {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { PostCorePluginUpdateResult } from "./update-command-plugins.js";
|
||||
|
||||
export const POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON = "post-plugin-doctor-execution-failed";
|
||||
|
||||
export function applyPostPluginConfigValidation(
|
||||
pluginUpdate: PostCorePluginUpdateResult,
|
||||
configValid: boolean,
|
||||
): PostCorePluginUpdateResult {
|
||||
if (
|
||||
configValid ||
|
||||
(pluginUpdate.status === "error" &&
|
||||
pluginUpdate.reason !== POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON)
|
||||
) {
|
||||
return pluginUpdate;
|
||||
}
|
||||
return {
|
||||
...pluginUpdate,
|
||||
status: "error",
|
||||
reason: "post-plugin-doctor-invalid-config",
|
||||
warnings: [
|
||||
...(pluginUpdate.warnings ?? []),
|
||||
{
|
||||
reason: "Config remained invalid after updated plugin migrations.",
|
||||
message:
|
||||
"Post-update plugin migration did not produce a valid config; refusing to restart.",
|
||||
guidance: ["Run `openclaw doctor --fix`, then rerun `openclaw update repair`."],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -26,13 +26,16 @@ import {
|
||||
persistRequestedUpdateChannel,
|
||||
restoreDroppedPreUpdateChannels,
|
||||
} from "./update-command-config.js";
|
||||
import { completePostCorePluginUpdate } from "./update-command-fresh-doctor.js";
|
||||
import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js";
|
||||
import {
|
||||
continuePostCoreUpdateInFreshProcess,
|
||||
didCoreUpdateChangeInstall,
|
||||
markControlPlaneUpdateRestartSentinelFailureBestEffort,
|
||||
shouldResumePostCoreUpdateInFreshProcess,
|
||||
writeControlPlaneUpdateRestartSentinelBestEffort,
|
||||
} from "./update-command-post-core.js";
|
||||
import { POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON } from "./update-command-post-plugin-validation.js";
|
||||
import {
|
||||
gatewayServiceCommandUsesRoot,
|
||||
maybeRestartService,
|
||||
@@ -254,7 +257,7 @@ export async function finishUpdate(params: {
|
||||
process.env.OPENCLAW_COMPATIBILITY_HOST_VERSION = compatibilityDowngradeTarget;
|
||||
}
|
||||
try {
|
||||
postCorePluginUpdate = await updatePluginsAfterCoreUpdate({
|
||||
const initialPluginUpdate = await updatePluginsAfterCoreUpdate({
|
||||
root: postUpdateRoot,
|
||||
channel: params.channel,
|
||||
configSnapshot: postUpdateConfigSnapshot,
|
||||
@@ -264,6 +267,22 @@ export async function finishUpdate(params: {
|
||||
timeoutMs: params.updateStepTimeoutMs,
|
||||
pluginInstallRecords: params.preUpdatePluginInstallRecords,
|
||||
});
|
||||
const completedPluginUpdate = await completePostCorePluginUpdate({
|
||||
root: postUpdateRoot,
|
||||
pluginUpdate: initialPluginUpdate,
|
||||
// A plugin-only update can replace its migration owner without replacing core.
|
||||
// Downgrades and resume fallbacks can also leave an updated core on disk in this process.
|
||||
freshDoctorRequired:
|
||||
didCoreUpdateChangeInstall(params.result) ||
|
||||
initialPluginUpdate.sync.changed ||
|
||||
initialPluginUpdate.npm.changed,
|
||||
yes: params.opts.yes === true,
|
||||
json: params.opts.json === true,
|
||||
timeoutMs: params.updateStepTimeoutMs,
|
||||
...(params.packageUpdateNodeRunner ? { nodeRunner: params.packageUpdateNodeRunner } : {}),
|
||||
});
|
||||
postCorePluginUpdate = completedPluginUpdate.pluginUpdate;
|
||||
postUpdateConfigSnapshot = completedPluginUpdate.configSnapshot;
|
||||
} finally {
|
||||
if (compatibilityDowngradeTarget) {
|
||||
if (previousCompatibilityHostVersion === undefined) {
|
||||
@@ -296,6 +315,14 @@ export async function finishUpdate(params: {
|
||||
result: resultWithPostUpdate,
|
||||
jsonMode: Boolean(params.opts.json),
|
||||
});
|
||||
// If strict config became valid despite a fresh-doctor process failure, restore the service
|
||||
// stopped by this update. Invalid post-migration config intentionally remains stopped.
|
||||
if (postCorePluginUpdate.reason === POST_PLUGIN_DOCTOR_EXECUTION_FAILED_REASON) {
|
||||
await maybeRestartServiceAfterFailedMutableUpdate({
|
||||
preManagedServiceStop: params.preManagedServiceStop,
|
||||
jsonMode: Boolean(params.opts.json),
|
||||
});
|
||||
}
|
||||
if (params.opts.json) {
|
||||
defaultRuntime.writeJson(resultWithPostUpdate);
|
||||
} else {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
readPostCorePreUpdateSourceConfig,
|
||||
restoreDroppedPreUpdateChannels,
|
||||
} from "./update-command-config.js";
|
||||
import { completePostCorePluginUpdate } from "./update-command-fresh-doctor.js";
|
||||
import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js";
|
||||
import {
|
||||
POST_CORE_UPDATE_INSTALL_RECORDS_PATH_ENV,
|
||||
@@ -75,7 +76,7 @@ export async function resumePostCoreUpdate(params: {
|
||||
? currentPluginInstallRecords
|
||||
: parentPluginInstallRecords;
|
||||
|
||||
const pluginUpdate = await updatePluginsAfterCoreUpdate({
|
||||
const initialPluginUpdate = await updatePluginsAfterCoreUpdate({
|
||||
root: params.root,
|
||||
channel: params.channel,
|
||||
configSnapshot: restoredConfig.snapshot,
|
||||
@@ -85,6 +86,15 @@ export async function resumePostCoreUpdate(params: {
|
||||
timeoutMs: params.timeoutMs,
|
||||
pluginInstallRecords,
|
||||
});
|
||||
const { pluginUpdate } = await completePostCorePluginUpdate({
|
||||
root: params.root,
|
||||
pluginUpdate: initialPluginUpdate,
|
||||
// Only package/channel sync can replace the migration owner loaded by this process.
|
||||
freshDoctorRequired: initialPluginUpdate.sync.changed || initialPluginUpdate.npm.changed,
|
||||
yes: params.opts.yes === true,
|
||||
json: params.opts.json === true,
|
||||
timeoutMs: params.timeoutMs,
|
||||
});
|
||||
if (process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) {
|
||||
await writePostCorePluginUpdateResultFile(
|
||||
process.env[POST_CORE_UPDATE_RESULT_PATH_ENV],
|
||||
|
||||
@@ -6,13 +6,17 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
|
||||
import type { GatewayService } from "../../daemon/service.js";
|
||||
import type { UpdateRunResult } from "../../infra/update-runner.js";
|
||||
import { updatePluginsAfterCoreUpdate } from "./update-command-plugins.js";
|
||||
import {
|
||||
updatePluginsAfterCoreUpdate,
|
||||
type PostCorePluginUpdateResult,
|
||||
} from "./update-command-plugins.js";
|
||||
import {
|
||||
buildInvalidConfigPostCoreUpdateResult,
|
||||
collectMissingPluginInstallPayloads,
|
||||
resolvePostSyncPluginUpdateSkipIds,
|
||||
} 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 {
|
||||
resolvePostInstallDoctorEnv,
|
||||
resolvePostUpdateServiceStateReadEnv,
|
||||
@@ -51,6 +55,45 @@ describe("resolveGatewayInstallEntrypoint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyPostPluginConfigValidation", () => {
|
||||
const pluginUpdate = {
|
||||
status: "ok",
|
||||
changed: true,
|
||||
sync: {
|
||||
changed: true,
|
||||
switchedToBundled: [],
|
||||
switchedToNpm: [],
|
||||
warnings: [],
|
||||
errors: [],
|
||||
},
|
||||
npm: { changed: true, outcomes: [] },
|
||||
integrityDrifts: [],
|
||||
warnings: [],
|
||||
} satisfies PostCorePluginUpdateResult;
|
||||
|
||||
it("fails closed when updated plugin migrations leave config invalid", () => {
|
||||
expect(applyPostPluginConfigValidation(pluginUpdate, false)).toMatchObject({
|
||||
status: "error",
|
||||
reason: "post-plugin-doctor-invalid-config",
|
||||
warnings: [
|
||||
{
|
||||
guidance: ["Run `openclaw doctor --fix`, then rerun `openclaw update repair`."],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an earlier plugin update error", () => {
|
||||
const failed = {
|
||||
...pluginUpdate,
|
||||
status: "error" as const,
|
||||
reason: "plugin-sync-failed",
|
||||
};
|
||||
|
||||
expect(applyPostPluginConfigValidation(failed, false)).toBe(failed);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldPrepareUpdatedInstallRestart", () => {
|
||||
it("prepares package update restarts when the service is installed but stopped", () => {
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user