fix(update): surface plugin channel fallbacks (#81422)

* fix: surface plugin update channel fallbacks

* fix: clarify dry-run plugin fallback output

* fix: preserve failed plugin fallback metadata

* chore: mark compatibility aliases deprecated

* chore: fix channel runtime lint directive

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
B.K.
2026-06-04 02:00:40 +03:00
committed by GitHub
parent 28a2e795da
commit c96a12d3c8
10 changed files with 281 additions and 10 deletions

View File

@@ -223,7 +223,11 @@ export function estimateRenderedLlmBoundaryTokenPressure(params: {
return Math.max(0, Math.ceil((systemTokens + promptTokens) * SAFETY_MARGIN));
}
/** Backward-compatible alias for callers that still name this a pre-prompt estimate. */
/**
* Backward-compatible alias for callers that still name this a pre-prompt estimate.
*
* @deprecated Use estimateLlmBoundaryTokenPressure.
*/
export function estimatePrePromptTokens(params: {
messages: AgentMessage[];
systemPrompt?: string;

View File

@@ -4,6 +4,9 @@ import { theme } from "../../packages/terminal-core/src/theme.js";
type PluginUpdateCliOutcome = {
status: string;
message: string;
channelFallback?: {
message: string;
};
};
/** Log update outcomes with severity styling and report whether any errors occurred. */
@@ -16,13 +19,22 @@ export function logPluginUpdateOutcomes(params: {
if (outcome.status === "error") {
hasErrors = true;
params.log(theme.error(outcome.message));
if (outcome.channelFallback) {
params.log(theme.warn(outcome.channelFallback.message));
}
continue;
}
if (outcome.status === "skipped") {
params.log(theme.warn(outcome.message));
if (outcome.channelFallback) {
params.log(theme.warn(outcome.channelFallback.message));
}
continue;
}
params.log(outcome.message);
if (outcome.channelFallback) {
params.log(theme.warn(outcome.channelFallback.message));
}
}
return { hasErrors };
}

View File

@@ -1580,6 +1580,49 @@ describe("update-cli", () => {
expect(npmPluginUpdateCall()?.timeoutMs).toBe(1_800_000);
});
it("prints plugin channel fallbacks near the post-core plugin summary", async () => {
updateNpmInstalledPlugins.mockResolvedValueOnce({
changed: false,
config: baseConfig,
outcomes: [
{
pluginId: "lossless-claw",
status: "updated",
message: "Updated lossless-claw: 1.0.0 -> 1.0.1.",
channelFallback: {
requestedSpec: "lossless-claw@beta",
usedSpec: "lossless-claw",
requestedLabel: "@beta",
usedLabel: "@latest",
reason: "unavailable",
message:
"plugin channel fallback: lossless-claw used @latest because @beta was unavailable",
},
},
],
});
await withEnvAsync(
{
OPENCLAW_UPDATE_POST_CORE: "1",
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "beta",
},
async () => {
await updateCommand({ restart: false });
},
);
const logs = vi.mocked(runtimeCapture.log).mock.calls.map((call) => String(call[0]));
expect(logs.some((line) => line.includes("npm plugins: 1 updated, 0 unchanged."))).toBe(true);
expect(
logs.some((line) =>
line.includes(
"plugin channel fallback: lossless-claw used @latest because @beta was unavailable",
),
),
).toBe(true);
});
it("uses a fail-closed integrity policy for post-core plugin updates", async () => {
await withEnvAsync(
{

View File

@@ -560,6 +560,20 @@ function createGuidedPostUpdatePluginOutcome(outcome: PluginUpdateOutcome): {
};
}
function collectPluginChannelFallbackMessages(outcomes: readonly PluginUpdateOutcome[]): string[] {
const seen = new Set<string>();
const messages: string[] = [];
for (const outcome of outcomes) {
const message = outcome.channelFallback?.message;
if (!message || seen.has(message)) {
continue;
}
seen.add(message);
messages.push(message);
}
return messages;
}
function isDisabledAfterFailureOutcome(outcome: PluginUpdateOutcome): boolean {
return outcome.status === "skipped" && outcome.message.includes("after plugin update failure");
}
@@ -1923,6 +1937,10 @@ export async function updatePluginsAfterCoreUpdate(params: {
defaultRuntime.log(theme.muted(`npm plugins: ${parts.join(", ")}.`));
}
for (const message of collectPluginChannelFallbackMessages(pluginUpdateOutcomes)) {
defaultRuntime.log(theme.warn(message));
}
for (const outcome of pluginUpdateOutcomes) {
if (outcome.status !== "error") {
continue;

View File

@@ -49,8 +49,8 @@ export function registerChannelRuntimeContext(
});
}
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Runtime context values are caller-typed by key.
/** Reads a channel-scoped runtime context from the current runtime registry. */
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Runtime context values are caller-typed by key.
export function getChannelRuntimeContext<T = unknown>(
params: ChannelRuntimeContextKey & {
channelRuntime?: ChannelRuntimeSurface;

View File

@@ -129,7 +129,11 @@ export function resolveHomeRelativePath(
return path.resolve(trimmed);
}
/** Backward-compatible alias for resolving user paths against the effective home. */
/**
* Backward-compatible alias for resolving user paths against the effective home.
*
* @deprecated Use resolveHomeRelativePath.
*/
export function resolveUserPath(
input: string,
env: NodeJS.ProcessEnv = process.env,

View File

@@ -86,6 +86,14 @@ export type UpdateRunResult = {
message: string;
currentVersion?: string;
nextVersion?: string;
channelFallback?: {
requestedSpec: string;
usedSpec: string;
requestedLabel: string;
usedLabel: string;
reason: "unavailable" | "failed";
message: string;
};
}>;
};
integrityDrifts: Array<{

View File

@@ -35,7 +35,11 @@ export function isVoiceMessageCompatibleAudio(opts: {
return VOICE_MESSAGE_AUDIO_EXTENSIONS.has(ext);
}
/** Backward-compatible alias for voice-message audio compatibility checks. */
/**
* Backward-compatible alias for voice-message audio compatibility checks.
*
* @deprecated Use isVoiceMessageCompatibleAudio.
*/
export function isVoiceCompatibleAudio(opts: {
contentType?: string | null;
fileName?: string | null;

View File

@@ -2188,6 +2188,52 @@ describe("updateNpmInstalledPlugins", () => {
expect(result.outcomes[0]?.message).toBe(
"Updated openclaw-codex-app-server: unknown -> 0.2.6. (warning: beta channel fallback used openclaw-codex-app-server because openclaw-codex-app-server@beta could not be used).",
);
expect(result.outcomes[0]?.channelFallback).toEqual({
requestedSpec: "openclaw-codex-app-server@beta",
usedSpec: "openclaw-codex-app-server",
requestedLabel: "@beta",
usedLabel: "@latest",
reason: "unavailable",
message:
"plugin channel fallback: openclaw-codex-app-server used @latest because @beta was unavailable",
});
});
it("reports npm beta fallback as tentative during dry-run checks", async () => {
installPluginFromNpmSpecMock
.mockResolvedValueOnce({
ok: false,
error:
"npm ERR! code ETARGET\nnpm ERR! No matching version found for openclaw-codex-app-server@beta.",
})
.mockResolvedValueOnce(
createSuccessfulNpmUpdateResult({
pluginId: "openclaw-codex-app-server",
targetDir: "/tmp/openclaw-codex-app-server",
version: "0.2.6",
npmResolution: {
name: "openclaw-codex-app-server",
version: "0.2.6",
resolvedSpec: "openclaw-codex-app-server@0.2.6",
},
}),
);
const result = await updateNpmInstalledPlugins({
config: createCodexAppServerInstallConfig({
spec: "openclaw-codex-app-server",
}),
pluginIds: ["openclaw-codex-app-server"],
updateChannel: "beta",
dryRun: true,
});
expect(result.outcomes[0]?.message).toBe(
"Would update openclaw-codex-app-server: unknown -> 0.2.6. (warning: beta channel fallback would use openclaw-codex-app-server because openclaw-codex-app-server@beta could not be used).",
);
expect(result.outcomes[0]?.channelFallback?.message).toBe(
"plugin channel fallback: openclaw-codex-app-server would use @latest because @beta was unavailable",
);
});
it("falls back to the default npm spec when the beta package exists but is invalid", async () => {
@@ -2233,6 +2279,12 @@ describe("updateNpmInstalledPlugins", () => {
expect(result.outcomes[0]?.message).toBe(
"Updated openclaw-codex-app-server: unknown -> 0.2.6. (warning: beta channel fallback used openclaw-codex-app-server because openclaw-codex-app-server@beta could not be used).",
);
expect(result.outcomes[0]?.channelFallback).toMatchObject({
requestedLabel: "@beta",
usedLabel: "@latest",
reason: "failed",
message: "plugin channel fallback: openclaw-codex-app-server used @latest after @beta failed",
});
});
it("reports the fallback npm spec when beta fallback also fails", async () => {
@@ -2262,6 +2314,55 @@ describe("updateNpmInstalledPlugins", () => {
status: "error",
message:
"Failed to update openclaw-codex-app-server: npm package not found for openclaw-codex-app-server.",
channelFallback: {
requestedSpec: "openclaw-codex-app-server@beta",
usedSpec: "openclaw-codex-app-server",
requestedLabel: "@beta",
usedLabel: "@latest",
reason: "failed",
message:
"plugin channel fallback: openclaw-codex-app-server used @latest after @beta failed",
},
},
]);
});
it("keeps fallback metadata when a dry-run beta fallback also fails", async () => {
installPluginFromNpmSpecMock
.mockResolvedValueOnce({
ok: false,
error: "Installed plugin package uses a TypeScript entry without compiled runtime output.",
})
.mockResolvedValueOnce({
ok: false,
code: "npm_package_not_found",
error: "npm package not found",
});
const result = await updateNpmInstalledPlugins({
config: createCodexAppServerInstallConfig({
spec: "openclaw-codex-app-server",
}),
pluginIds: ["openclaw-codex-app-server"],
updateChannel: "beta",
dryRun: true,
});
expect(result.outcomes).toEqual([
{
pluginId: "openclaw-codex-app-server",
status: "error",
message:
"Failed to check openclaw-codex-app-server: npm package not found for openclaw-codex-app-server.",
channelFallback: {
requestedSpec: "openclaw-codex-app-server@beta",
usedSpec: "openclaw-codex-app-server",
requestedLabel: "@beta",
usedLabel: "@latest",
reason: "failed",
message:
"plugin channel fallback: openclaw-codex-app-server would use @latest after @beta failed",
},
},
]);
});

View File

@@ -68,12 +68,22 @@ export type PluginUpdateLogger = {
export type PluginUpdateStatus = "updated" | "unchanged" | "skipped" | "error";
export type PluginUpdateChannelFallback = {
requestedSpec: string;
usedSpec: string;
requestedLabel: string;
usedLabel: string;
reason: "unavailable" | "failed";
message: string;
};
export type PluginUpdateOutcome = {
pluginId: string;
status: PluginUpdateStatus;
message: string;
currentVersion?: string;
nextVersion?: string;
channelFallback?: PluginUpdateChannelFallback;
};
export type PluginUpdateSummary = {
@@ -485,6 +495,13 @@ function shouldFallbackBetaClawHubUpdate(result: { ok: false; code?: string }):
return shouldFallbackClawHubToDefault(result);
}
function isUnavailableNpmTarget(result: { ok: false; code?: string; error: string }): boolean {
return (
result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND ||
/\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test(result.error)
);
}
function describeBetaNpmFallback(params: {
pluginId: string;
betaSpec: string | undefined;
@@ -492,15 +509,47 @@ function describeBetaNpmFallback(params: {
result: { ok: false; code?: string; error: string };
}): string {
const betaSpec = params.betaSpec ?? "the beta npm release";
const missingBeta =
params.result.code === PLUGIN_INSTALL_ERROR_CODE.NPM_PACKAGE_NOT_FOUND ||
/\b(ETARGET|notarget)\b|No matching version found|dist-tag|tag .*not found/i.test(
params.result.error,
);
const missingBeta = isUnavailableNpmTarget(params.result);
const reason = missingBeta ? "has no beta npm release" : "failed beta npm update";
return `Plugin "${params.pluginId}" ${reason} for ${betaSpec}; using ${params.fallbackSpec} instead. Core update can still complete.`;
}
function formatNpmSpecSelectorLabel(spec: string | undefined): string {
const parsed = spec ? parseRegistryNpmSpec(spec) : undefined;
if (!parsed) {
return spec ?? "unknown";
}
if (parsed.selectorKind === "none") {
return "@latest";
}
return `@${parsed.selector}`;
}
function describeNpmChannelFallback(params: {
pluginId: string;
requestedSpec: string | undefined;
usedSpec: string;
result: { ok: false; code?: string; error: string };
verb: "used" | "would use";
}): PluginUpdateChannelFallback {
const requestedSpec = params.requestedSpec ?? "unknown";
const requestedLabel = formatNpmSpecSelectorLabel(params.requestedSpec);
const usedLabel = formatNpmSpecSelectorLabel(params.usedSpec);
const reason = isUnavailableNpmTarget(params.result) ? "unavailable" : "failed";
const message =
reason === "unavailable"
? `plugin channel fallback: ${params.pluginId} ${params.verb} ${usedLabel} because ${requestedLabel} was unavailable`
: `plugin channel fallback: ${params.pluginId} ${params.verb} ${usedLabel} after ${requestedLabel} failed`;
return {
requestedSpec,
usedSpec: params.usedSpec,
requestedLabel,
usedLabel,
reason,
message,
};
}
function formatBetaChannelFallbackOutcomeSuffix(params: {
fallbackLabel: string | undefined;
fallbackSpec: string | undefined;
@@ -990,7 +1039,11 @@ export async function updateNpmInstalledPlugins(params: {
return await installPluginFromNpmSpec(installParams);
};
const recordFailure = (pluginId: string, message: string) => {
const recordFailure = (
pluginId: string,
message: string,
channelFallback?: PluginUpdateChannelFallback,
) => {
if (params.disableOnFailure && !params.dryRun) {
const disabledMessage =
`Disabled "${pluginId}" after plugin update failure; OpenClaw will continue without it. ` +
@@ -1002,6 +1055,7 @@ export async function updateNpmInstalledPlugins(params: {
pluginId,
status: "skipped",
message: disabledMessage,
...(channelFallback ? { channelFallback } : {}),
});
return;
}
@@ -1009,6 +1063,7 @@ export async function updateNpmInstalledPlugins(params: {
pluginId,
status: "error",
message,
...(channelFallback ? { channelFallback } : {}),
});
};
@@ -1317,6 +1372,7 @@ export async function updateNpmInstalledPlugins(params: {
let usedNpmFallback = false;
let usedOfficialNpmFallback = false;
let channelFallbackSuffix = "";
let npmChannelFallback: PluginUpdateChannelFallback | undefined;
if (!probe.ok && record.source === "npm" && npmSpecs?.fallbackSpec) {
logger.warn?.(
describeBetaNpmFallback({
@@ -1327,6 +1383,13 @@ export async function updateNpmInstalledPlugins(params: {
}),
);
usedNpmFallback = true;
npmChannelFallback = describeNpmChannelFallback({
pluginId,
requestedSpec: npmSpecs.fallbackLabel ?? effectiveSpec,
usedSpec: npmSpecs.fallbackSpec,
result: probe,
verb: "would use",
});
channelFallbackSuffix = formatBetaChannelFallbackOutcomeSuffix({
fallbackLabel: npmSpecs.fallbackLabel ?? effectiveSpec,
fallbackSpec: npmSpecs.fallbackSpec,
@@ -1448,6 +1511,7 @@ export async function updateNpmInstalledPlugins(params: {
phase: "check",
error: probe.error,
}),
npmChannelFallback,
);
continue;
}
@@ -1485,6 +1549,7 @@ export async function updateNpmInstalledPlugins(params: {
currentVersion: currentVersion ?? undefined,
nextVersion: resolvedProbeVersion,
message: `${pluginId} is up to date (${currentLabel}).${channelFallbackSuffix}`,
...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}),
});
} else {
outcomes.push({
@@ -1493,6 +1558,7 @@ export async function updateNpmInstalledPlugins(params: {
currentVersion: currentVersion ?? undefined,
nextVersion: resolvedProbeVersion,
message: `Would update ${pluginId}: ${currentLabel} -> ${nextVersion}.${channelFallbackSuffix}`,
...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}),
});
}
continue;
@@ -1567,6 +1633,7 @@ export async function updateNpmInstalledPlugins(params: {
let channelFallbackSuffix = "";
let resultSource = record.source;
activeClawHubInstallSpec = effectiveSpec;
let npmChannelFallback: PluginUpdateChannelFallback | undefined;
if (!result.ok && record.source === "npm" && npmSpecs?.fallbackSpec) {
logger.warn?.(
describeBetaNpmFallback({
@@ -1577,6 +1644,13 @@ export async function updateNpmInstalledPlugins(params: {
}),
);
usedNpmFallback = true;
npmChannelFallback = describeNpmChannelFallback({
pluginId,
requestedSpec: npmSpecs.fallbackLabel ?? effectiveSpec,
usedSpec: npmSpecs.fallbackSpec,
result,
verb: "used",
});
channelFallbackSuffix = formatBetaChannelFallbackOutcomeSuffix({
fallbackLabel: npmSpecs.fallbackLabel ?? effectiveSpec,
fallbackSpec: npmSpecs.fallbackSpec,
@@ -1696,6 +1770,7 @@ export async function updateNpmInstalledPlugins(params: {
phase: "update",
error: result.error,
}),
npmChannelFallback,
);
continue;
}
@@ -1776,6 +1851,7 @@ export async function updateNpmInstalledPlugins(params: {
currentVersion: currentVersion ?? undefined,
nextVersion: nextVersion ?? undefined,
message: `${pluginId} already at ${currentLabel}.${channelFallbackSuffix}`,
...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}),
});
} else {
outcomes.push({
@@ -1784,6 +1860,7 @@ export async function updateNpmInstalledPlugins(params: {
currentVersion: currentVersion ?? undefined,
nextVersion: nextVersion ?? undefined,
message: `Updated ${pluginId}: ${currentLabel} -> ${nextLabel}.${channelFallbackSuffix}`,
...(npmChannelFallback ? { channelFallback: npmChannelFallback } : {}),
});
}
}