fix(ui): reconcile plugin install outcomes

This commit is contained in:
jesse-merhi
2026-08-12 22:14:18 +10:00
parent ab769f167b
commit 90612049c1
7 changed files with 242 additions and 14 deletions
+5
View File
@@ -2794,6 +2794,7 @@ export const en: TranslationMap = {
unavailable: "Unavailable",
install: "Install",
installing: "Installing…",
checkingInstallOutcome: "Checking…",
installNamed: "Install {name}",
acknowledgeRisk: "Acknowledge risk and install",
defaultRiskWarning: "Review the ClawHub warning before installing this plugin.",
@@ -2806,6 +2807,10 @@ export const en: TranslationMap = {
policyReviewSeverityCritical: "Critical",
policyReviewTechnicalDetails: "Details",
installAnyway: "Install anyway",
installOutcomeChecking:
"The Gateway connection changed before OpenClaw confirmed this install. Checking plugin status…",
installOutcomeFailed:
"OpenClaw couldnt confirm whether this plugin was installed. Retry the status check before installing again.",
connectToChange: "Connect to the gateway to change plugins.",
adminRequired: "Browsing only. Plugin changes require operator.admin access.",
changesDisabled: "Browsing only. This gateway does not allow plugin changes.",
@@ -20,6 +20,7 @@ import {
} from "../../test-helpers/application-context.ts";
import type { PluginsRouteData } from "./plugins-page.ts";
import type { PluginRowMessage } from "./view.ts";
import type { InstallOutcomeReconciliation } from "./view.ts";
import "./plugins-page.ts";
type RequestHandler = (method: string, params: unknown) => Promise<unknown>;
@@ -35,11 +36,13 @@ type TestPluginsPage = HTMLElement & {
result: PluginListResult | null;
loading: boolean;
busy: Record<string, boolean>;
installOutcomeReconciliations: Record<string, InstallOutcomeReconciliation>;
messages: Record<string, PluginRowMessage>;
activeTab: "installed" | "discover";
searchResults: PluginSearchResult[] | null;
applyMutationResult: (result: PluginMutationResult) => void;
install: (rowKey: string, request: PluginInstallRequest) => Promise<void>;
refreshCatalog: () => Promise<void>;
updateEnabled: (pluginId: string, enabled: boolean, key?: string) => Promise<void>;
uninstall: (pluginId: string, rowKey: string) => Promise<void>;
};
+84 -3
View File
@@ -201,8 +201,9 @@ describe("PluginsPage", () => {
);
});
it("retires a pending install-policy review across a same-client reconnect", async () => {
it("reconciles a pending install-policy retry before allowing another install", async () => {
const retry = deferred<PluginMutationResult>();
const catalogRefresh = deferred<PluginListResult>();
let installCalls = 0;
const { client, request } = createClient(async (method) => {
if (method === "plugins.install") {
@@ -224,7 +225,7 @@ describe("PluginsPage", () => {
return retry.promise;
}
if (method === "plugins.list") {
return createResult();
return catalogRefresh.promise;
}
throw new Error(`Unexpected method ${method}`);
});
@@ -252,11 +253,24 @@ describe("PluginsPage", () => {
expect(request.mock.calls.filter(([method]) => method === "plugins.install")).toHaveLength(2),
);
expect(page.messages[rowKey]?.installPolicyWarning).toBeDefined();
page.messages["plugin:workboard"] = { kind: "success", text: "Unrelated message." };
harness.emit(client, false);
harness.emit(client, true);
await page.updateComplete;
await waitForFast(() =>
expect(request.mock.calls.filter(([method]) => method === "plugins.list")).toHaveLength(1),
);
expect(page.messages[rowKey]).toBeUndefined();
expect(page.messages["plugin:workboard"]?.text).toBe("Unrelated message.");
expect(page.installOutcomeReconciliations[rowKey]).toBe("checking");
catalogRefresh.resolve(
createResult(
createPlugin({ id: "lobster", name: "Lobster", installed: true, enabled: true }),
),
);
await waitForFast(() => expect(page.installOutcomeReconciliations[rowKey]).toBeUndefined());
expect(page.result?.plugins[0]?.installed).toBe(true);
retry.resolve({
ok: true,
@@ -265,6 +279,73 @@ describe("PluginsPage", () => {
});
await pendingRetry;
expect(page.messages[rowKey]).toBeUndefined();
expect(page.result?.plugins[0]?.installed).toBe(true);
});
it("keeps an unknown install outcome blocked until a failed catalog check is retried", async () => {
const retry = deferred<PluginMutationResult>();
let installCalls = 0;
let listCalls = 0;
const { client } = createClient(async (method) => {
if (method === "plugins.install") {
installCalls += 1;
if (installCalls === 1) {
throw new GatewayRequestError({
code: "INVALID_REQUEST",
message: "install requires review",
details: {
installPolicyCode: "install_policy_warning_acknowledgement_required",
targetName: "@openclaw/lobster",
targetType: "plugin",
requestMode: "install",
reason: "Review this plugin.",
acknowledgementToken: "approval-token",
},
});
}
return retry.promise;
}
if (method === "plugins.list") {
listCalls += 1;
if (listCalls === 1) {
throw new Error("catalog unavailable");
}
return createResult(
createPlugin({
id: "lobster",
name: "Lobster",
installed: false,
install: { source: "clawhub", packageName: "@openclaw/lobster" },
}),
);
}
throw new Error(`Unexpected method ${method}`);
});
const harness = createGateway(client);
const { page } = await mountPage(
createContext(harness.gateway),
createPluginsRouteData(harness.gateway),
);
const rowKey = "plugin:@openclaw/lobster";
const installRequest = {
source: "clawhub",
packageName: "@openclaw/lobster",
} satisfies PluginInstallRequest;
await page.install(rowKey, installRequest);
void page.install(rowKey, {
...installRequest,
installPolicyWarningAcknowledgement: "approval-token",
});
await waitForFast(() => expect(installCalls).toBe(2));
harness.emit(client, false);
harness.emit(client, true);
await waitForFast(() => expect(page.installOutcomeReconciliations[rowKey]).toBe("failed"));
await page.refreshCatalog();
expect(page.installOutcomeReconciliations[rowKey]).toBeUndefined();
expect(page.result?.plugins[0]?.installed).toBe(false);
});
it("debounces two-character ClawHub searches and cancels stale input", async () => {
+47 -4
View File
@@ -61,6 +61,7 @@ import {
pluginRowKey,
renderPlugins,
type InstalledFilter,
type InstallOutcomeReconciliation,
type PluginRowMessage,
type PluginsTab,
} from "./view.ts";
@@ -129,6 +130,7 @@ class PluginsPage extends OpenClawLightDomElement {
@state() private installedFilter: InstalledFilter = "all";
@state() private debouncedSearchQuery = "";
@state() private busy: Record<string, boolean> = {};
@state() private installOutcomeReconciliations: Record<string, InstallOutcomeReconciliation> = {};
@state() private messages: Record<string, PluginRowMessage> = {};
@state() private pendingRemoval: Record<string, boolean> = {};
@state() private detailPluginId: string | null = null;
@@ -153,6 +155,7 @@ class PluginsPage extends OpenClawLightDomElement {
private readonly gateway = new GatewayPageController(this, {
getGateway: () => this.context?.gateway,
onIdentityChange: () => {
this.captureUnknownInstallOutcomes();
this.result = null;
this.error = null;
this.messages = {};
@@ -173,9 +176,11 @@ class PluginsPage extends OpenClawLightDomElement {
client ? client.request<PluginListResult>("plugins.list", {}, { signal }) : initialState,
onComplete: (result) => {
this.replaceResult(result);
this.clearInstallOutcomeReconciliations();
},
onError: (error) => {
this.error = formatUiError(error);
this.failInstallOutcomeReconciliations();
},
});
@@ -363,10 +368,41 @@ class PluginsPage extends OpenClawLightDomElement {
void this.configTask.run([null, this.context.runtimeConfig]);
void this.searchTask.run([null, ""]);
this.mutationTokens.clear();
// A connection-epoch change makes the one-shot approval outcome unknown;
// retire its action so reconnect cannot resend a spent or process-stale token.
this.messages = Object.fromEntries(
Object.entries(this.messages).filter(([, message]) => !message.installPolicyWarning),
this.captureUnknownInstallOutcomes();
}
private captureUnknownInstallOutcomes() {
// Once an acknowledged request crosses a connection epoch, only a fresh catalog can
// prove whether it committed. Keep that safety gate separate from dismissible messages.
const nextMessages = { ...this.messages };
const nextReconciliations = { ...this.installOutcomeReconciliations };
for (const [key, message] of Object.entries(this.messages)) {
if (!message.installPolicyWarning) {
continue;
}
if (this.busy[key]) {
nextReconciliations[key] = "checking";
}
delete nextMessages[key];
}
this.messages = nextMessages;
this.installOutcomeReconciliations = nextReconciliations;
}
private clearInstallOutcomeReconciliations() {
if (Object.keys(this.installOutcomeReconciliations).length === 0) {
return;
}
this.installOutcomeReconciliations = {};
}
private failInstallOutcomeReconciliations() {
const entries = Object.keys(this.installOutcomeReconciliations);
if (entries.length === 0) {
return;
}
this.installOutcomeReconciliations = Object.fromEntries(
entries.map((key) => [key, "failed" as const]),
);
}
@@ -571,6 +607,11 @@ class PluginsPage extends OpenClawLightDomElement {
if (!client || !this.gateway.connected) {
return;
}
if (Object.keys(this.installOutcomeReconciliations).length > 0) {
this.installOutcomeReconciliations = Object.fromEntries(
Object.keys(this.installOutcomeReconciliations).map((key) => [key, "checking" as const]),
);
}
this.error = null;
await this.catalogTask.run([client]);
}
@@ -1027,6 +1068,7 @@ class PluginsPage extends OpenClawLightDomElement {
searchLoading: this.searchLoading,
searchError: this.searchError,
busy: this.busy,
installOutcomeReconciliations: this.installOutcomeReconciliations,
messages: this.messages,
pendingRemoval: this.pendingRemoval,
detailPluginId: this.detailPluginId,
@@ -1052,6 +1094,7 @@ class PluginsPage extends OpenClawLightDomElement {
void this.updateEnabled(pluginId, enabled, rowKey),
onInstall: (rowKey, request) => void this.install(rowKey, request),
onDismissMessage: (rowKey) => this.setMessage(rowKey, null),
onRetryInstallOutcome: () => void this.refreshCatalog(),
onRequestUninstall: (rowKey) => this.setPendingRemoval(rowKey, true),
onCancelUninstall: (rowKey) => this.setPendingRemoval(rowKey, false),
onUninstall: (pluginId, rowKey) => void this.uninstall(pluginId, rowKey),
+35 -2
View File
@@ -813,7 +813,7 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => {
}
});
it("retires a pending install-policy review after reconnect", async () => {
it("reconciles a pending install-policy review before allowing another install", async () => {
const context = await newContext();
const page = await context.newPage();
const gateway = await installMockGateway(page, {
@@ -844,6 +844,7 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => {
await waitForNextRequest(gateway, "plugins.install", installCountBeforeRetry);
await review.getByRole("button", { name: "Installing…", exact: true }).waitFor();
await gateway.deferNext("plugins.list");
const socketsBeforeReconnect = await gateway.getSocketCount();
await gateway.closeLatest(1001, "install-policy reconnect proof");
await expect
@@ -851,10 +852,42 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => {
.toBeGreaterThan(socketsBeforeReconnect);
await review.waitFor({ state: "detached" });
await row.getByRole("button", { name: "Install Lobster", exact: true }).waitFor();
await row.getByText("Checking plugin status…", { exact: false }).waitFor();
const blockedInstall = row.getByRole("button", { name: "Install Lobster", exact: true });
expect(await blockedInstall.isDisabled()).toBe(true);
expect(await row.getByRole("button", { name: "Install anyway", exact: true }).count()).toBe(
0,
);
expect((await gateway.getRequests("plugins.install")).length).toBe(2);
await gateway.rejectDeferred("plugins.list", {
code: "UNAVAILABLE",
message: "catalog unavailable",
});
await row
.getByText("couldnt confirm whether this plugin was installed", {
exact: false,
})
.waitFor();
expect(await blockedInstall.isDisabled()).toBe(true);
const listCountBeforeRetry = (await gateway.getRequests("plugins.list")).length;
await gateway.deferNext("plugins.list");
await row.getByRole("button", { name: "Retry", exact: true }).click();
await waitForNextRequest(gateway, "plugins.list", listCountBeforeRetry);
await gateway.resolveDeferred(
"plugins.list",
inventory([workboardDisabled, installedLobsterPlugin, remoteIconPlugin]),
);
await row.getByRole("button", { name: "Disable", exact: true }).waitFor();
expect(await row.getByText("couldnt confirm", { exact: false }).count()).toBe(0);
await gateway.resolveDeferred("plugins.install", {
ok: true,
plugin: installedLobsterPlugin,
restartRequired: true,
} satisfies PluginMutationResult);
await row.getByRole("button", { name: "Disable", exact: true }).waitFor();
} finally {
await context.close();
}
+33
View File
@@ -46,6 +46,7 @@ function createProps(overrides: Partial<PluginsViewProps> = {}): PluginsViewProp
searchLoading: false,
searchError: null,
busy: {},
installOutcomeReconciliations: {},
messages: {},
pendingRemoval: {},
detailPluginId: null,
@@ -66,6 +67,7 @@ function createProps(overrides: Partial<PluginsViewProps> = {}): PluginsViewProp
onSetEnabled: () => undefined,
onInstall: () => undefined,
onDismissMessage: () => undefined,
onRetryInstallOutcome: () => undefined,
onRequestUninstall: () => undefined,
onCancelUninstall: () => undefined,
onUninstall: () => undefined,
@@ -764,6 +766,37 @@ describe("renderPlugins", () => {
});
});
it("blocks a repeated install while its reconnect outcome is unresolved", () => {
const plugin = createPlugin({
id: "lobster",
name: "Lobster",
installed: false,
enabled: false,
state: "disabled",
install: { source: "clawhub", packageName: "@openclaw/lobster" },
});
const key = pluginRowKey(plugin.id);
const onRetryInstallOutcome = vi.fn();
const container = mount(
createProps({
activeTab: "discover",
result: createResult([plugin]),
installOutcomeReconciliations: { [key]: "failed" },
onRetryInstallOutcome,
}),
);
const row = container.querySelector<HTMLElement>('[data-plugin-id="lobster"]');
expect(normalizedText(row?.querySelector('[role="alert"]') ?? null)).toContain(
"couldnt confirm whether this plugin was installed",
);
const install = actionButton(row ?? container, "Install Lobster");
expect(install?.disabled).toBe(true);
expect(normalizedText(install)).toBe("Checking…");
row?.querySelector<HTMLButtonElement>('[role="alert"] button')?.click();
expect(onRetryInstallOutcome).toHaveBeenCalledOnce();
});
it("keeps the not-installed outcome visible for reason-only policy warnings", () => {
const plugin = createPlugin({
id: "reason-only",
+35 -5
View File
@@ -55,6 +55,8 @@ export type PluginRowMessage = {
};
};
export type InstallOutcomeReconciliation = "checking" | "failed";
type PluginInstallPolicyFinding = NonNullable<
PluginInstallPolicyWarningDetails["findings"]
>[number];
@@ -84,6 +86,7 @@ type PluginsViewProps = {
searchLoading: boolean;
searchError: string | null;
busy: Readonly<Record<string, boolean>>;
installOutcomeReconciliations: Readonly<Record<string, InstallOutcomeReconciliation>>;
messages: Readonly<Record<string, PluginRowMessage>>;
pendingRemoval: Readonly<Record<string, boolean>>;
detailPluginId: string | null;
@@ -104,6 +107,7 @@ type PluginsViewProps = {
onSetEnabled: (pluginId: string, enabled: boolean, rowKey: string) => void;
onInstall: (rowKey: string, request: PluginInstallRequest) => void;
onDismissMessage: (rowKey: string) => void;
onRetryInstallOutcome: () => void;
onRequestUninstall: (rowKey: string) => void;
onCancelUninstall: (rowKey: string) => void;
onUninstall: (pluginId: string, rowKey: string) => void;
@@ -398,6 +402,28 @@ function renderRowMessage(
busy: boolean,
props: PluginsViewProps,
) {
const installOutcome = props.installOutcomeReconciliations[key];
if (installOutcome) {
return html`
<div
class="plugins-row-message plugins-row-message--warning"
role=${installOutcome === "failed" ? "alert" : "status"}
>
<span>
${t(
installOutcome === "failed"
? "pluginsPage.installOutcomeFailed"
: "pluginsPage.installOutcomeChecking",
)}
</span>
${installOutcome === "failed"
? html`<button type="button" class="btn btn--sm" @click=${props.onRetryInstallOutcome}>
${t("common.retry")}
</button>`
: nothing}
</div>
`;
}
if (!message) {
return nothing;
}
@@ -608,7 +634,11 @@ function renderInstallButton(
props.onInstall(key, request);
}}
>
${busy ? t("pluginsPage.installing") : t("pluginsPage.install")}
${busy
? props.installOutcomeReconciliations[key]
? t("pluginsPage.checkingInstallOutcome")
: t("pluginsPage.installing")
: t("pluginsPage.install")}
</button>
`;
}
@@ -734,7 +764,7 @@ function renderPluginRow(
includePackageName = false,
): TemplateResult {
const key = pluginRowKey(plugin.id);
const busy = props.busy[key] ?? false;
const busy = Boolean(props.busy[key] || props.installOutcomeReconciliations[key]);
return html`
<article
class="settings-row plugins-item plugins-item--clickable"
@@ -913,7 +943,7 @@ function renderConnectorRow(
props: PluginsViewProps,
): TemplateResult {
const key = connectorRowKey(connector.id);
const busy = props.busy[key] ?? false;
const busy = Boolean(props.busy[key] || props.installOutcomeReconciliations[key]);
const isMcp = connector.action.kind === "mcp";
const installed =
isMcp &&
@@ -1000,7 +1030,7 @@ function renderClawHubResult(item: PluginSearchResult, props: PluginsViewProps):
const pkg = item.package;
const installed = findInstalledSearchPlugin(item, props.result?.plugins ?? []);
const key = clawHubRowKey(pkg.name);
const busy = props.busy[key] ?? false;
const busy = Boolean(props.busy[key] || props.installOutcomeReconciliations[key]);
const artSlug = pkg.runtimeId ?? pkg.name;
return html`
<article
@@ -1173,7 +1203,7 @@ function renderDetailOverlay(props: PluginsViewProps) {
return nothing;
}
const key = pluginRowKey(plugin.id);
const busy = props.busy[key] ?? false;
const busy = Boolean(props.busy[key] || props.installOutcomeReconciliations[key]);
return html`
<openclaw-modal-dialog
label=${plugin.name}