mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): share plugin install operation identity
This commit is contained in:
@@ -23,6 +23,26 @@ export type PluginInstallRequest = PluginsInstallParams;
|
||||
export type PluginMutationResult = PluginsInstallResult | PluginsSetEnabledResult;
|
||||
type PluginUninstallResult = PluginsUninstallResult;
|
||||
|
||||
export function resolvePluginInstallIdentity(
|
||||
request: PluginInstallRequest,
|
||||
plugins: readonly PluginCatalogItem[],
|
||||
runtimeId?: string,
|
||||
): string {
|
||||
if (request.source === "official") {
|
||||
return `plugin:${request.pluginId}`;
|
||||
}
|
||||
const catalogEntry =
|
||||
plugins.find(
|
||||
(plugin) =>
|
||||
plugin.packageName === request.packageName ||
|
||||
(plugin.install?.source === "clawhub" &&
|
||||
plugin.install.packageName === request.packageName),
|
||||
) ?? (runtimeId ? plugins.find((plugin) => plugin.id === runtimeId) : undefined);
|
||||
return catalogEntry || runtimeId
|
||||
? `plugin:${catalogEntry?.id ?? runtimeId}`
|
||||
: `clawhub:${request.packageName}`;
|
||||
}
|
||||
|
||||
export const CLAWHUB_BROWSE_URL = "https://clawhub.ai/plugins";
|
||||
|
||||
export function loadPluginCatalog(client: GatewayBrowserClient): Promise<PluginListResult> {
|
||||
|
||||
@@ -230,11 +230,19 @@ describe("PluginsPage", () => {
|
||||
throw new Error(`Unexpected method ${method}`);
|
||||
});
|
||||
const harness = createGateway(client);
|
||||
const lobsterCatalog = createResult(
|
||||
createPlugin({
|
||||
id: "lobster",
|
||||
name: "Lobster",
|
||||
installed: false,
|
||||
install: { source: "clawhub", packageName: "@openclaw/lobster" },
|
||||
}),
|
||||
);
|
||||
const { page } = await mountPage(
|
||||
createContext(harness.gateway),
|
||||
createPluginsRouteData(harness.gateway),
|
||||
createPluginsRouteData(harness.gateway, lobsterCatalog),
|
||||
);
|
||||
const rowKey = "plugin:@openclaw/lobster";
|
||||
const rowKey = "plugin:lobster";
|
||||
const installRequest = {
|
||||
source: "clawhub",
|
||||
packageName: "@openclaw/lobster",
|
||||
@@ -321,11 +329,19 @@ describe("PluginsPage", () => {
|
||||
throw new Error(`Unexpected method ${method}`);
|
||||
});
|
||||
const harness = createGateway(client);
|
||||
const lobsterCatalog = createResult(
|
||||
createPlugin({
|
||||
id: "lobster",
|
||||
name: "Lobster",
|
||||
installed: false,
|
||||
install: { source: "clawhub", packageName: "@openclaw/lobster" },
|
||||
}),
|
||||
);
|
||||
const { page } = await mountPage(
|
||||
createContext(harness.gateway),
|
||||
createPluginsRouteData(harness.gateway),
|
||||
createPluginsRouteData(harness.gateway, lobsterCatalog),
|
||||
);
|
||||
const rowKey = "plugin:@openclaw/lobster";
|
||||
const rowKey = "plugin:lobster";
|
||||
const installRequest = {
|
||||
source: "clawhub",
|
||||
packageName: "@openclaw/lobster",
|
||||
@@ -353,6 +369,50 @@ describe("PluginsPage", () => {
|
||||
expect(page.result?.plugins[0]?.installed).toBe(false);
|
||||
});
|
||||
|
||||
it("deduplicates an active install across catalog and ClawHub search rows", async () => {
|
||||
const pendingInstall = deferred<PluginMutationResult>();
|
||||
const { client, request } = createClient(async (method) => {
|
||||
if (method === "plugins.install") {
|
||||
return pendingInstall.promise;
|
||||
}
|
||||
if (method === "plugins.list") {
|
||||
return createResult(
|
||||
createPlugin({ id: "lobster", name: "Lobster", installed: true, enabled: true }),
|
||||
);
|
||||
}
|
||||
throw new Error(`Unexpected method ${method}`);
|
||||
});
|
||||
const harness = createGateway(client);
|
||||
const catalog = createResult(
|
||||
createPlugin({
|
||||
id: "lobster",
|
||||
name: "Lobster",
|
||||
installed: false,
|
||||
install: { source: "clawhub", packageName: "@openclaw/lobster" },
|
||||
}),
|
||||
);
|
||||
const { page } = await mountPage(
|
||||
createContext(harness.gateway),
|
||||
createPluginsRouteData(harness.gateway, catalog),
|
||||
);
|
||||
const installRequest = {
|
||||
source: "clawhub",
|
||||
packageName: "@openclaw/lobster",
|
||||
} satisfies PluginInstallRequest;
|
||||
|
||||
const catalogInstall = page.install("plugin:lobster", installRequest);
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledOnce());
|
||||
await page.install("clawhub:@openclaw/lobster", installRequest);
|
||||
expect(request).toHaveBeenCalledOnce();
|
||||
|
||||
pendingInstall.resolve({
|
||||
ok: true,
|
||||
plugin: createPlugin({ id: "lobster", name: "Lobster", installed: true, enabled: true }),
|
||||
restartRequired: true,
|
||||
});
|
||||
await catalogInstall;
|
||||
});
|
||||
|
||||
it("debounces two-character ClawHub searches and cancels stale input", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { client, request } = createClient(async (method) => {
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
installPlugin,
|
||||
pluginInstallNeedsRiskAcknowledgement,
|
||||
readPluginInstallTrustError,
|
||||
resolvePluginInstallIdentity,
|
||||
runPluginConfigMutation,
|
||||
setPluginEnabled,
|
||||
uninstallPlugin,
|
||||
@@ -146,6 +147,7 @@ class PluginsPage extends OpenClawLightDomElement {
|
||||
private searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private mutationToken = 0;
|
||||
private readonly mutationTokens = new Map<string, number>();
|
||||
private readonly pendingInstallTargets = new Set<string>();
|
||||
private readonly iconMisses = new Set<string>();
|
||||
private readonly iconRequests = new Map<
|
||||
string,
|
||||
@@ -372,17 +374,17 @@ class PluginsPage extends OpenClawLightDomElement {
|
||||
}
|
||||
|
||||
private captureUnknownInstallOutcomes() {
|
||||
// Once an acknowledged request crosses a connection epoch, only a fresh catalog can
|
||||
// Once an install 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 identity of this.pendingInstallTargets) {
|
||||
nextReconciliations[identity] = "checking";
|
||||
}
|
||||
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;
|
||||
@@ -783,17 +785,25 @@ class PluginsPage extends OpenClawLightDomElement {
|
||||
text: formatUiError(error),
|
||||
});
|
||||
},
|
||||
options: { preserveMessageWhilePending?: boolean } = {},
|
||||
options: {
|
||||
operationKey?: string;
|
||||
pendingInstallTarget?: string;
|
||||
preserveMessageWhilePending?: boolean;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const scope = this.gateway.capture();
|
||||
if (!scope || !this.canMutate() || this.busy[rowKey]) {
|
||||
const operationKey = options.operationKey ?? rowKey;
|
||||
if (!scope || !this.canMutate() || this.busy[operationKey]) {
|
||||
return;
|
||||
}
|
||||
const mutationToken = ++this.mutationToken;
|
||||
this.mutationTokens.set(rowKey, mutationToken);
|
||||
this.mutationTokens.set(operationKey, mutationToken);
|
||||
if (options.pendingInstallTarget) {
|
||||
this.pendingInstallTargets.add(options.pendingInstallTarget);
|
||||
}
|
||||
const isCurrent = () =>
|
||||
this.gateway.isCurrent(scope) && this.mutationTokens.get(rowKey) === mutationToken;
|
||||
this.setBusy(rowKey, true);
|
||||
this.gateway.isCurrent(scope) && this.mutationTokens.get(operationKey) === mutationToken;
|
||||
this.setBusy(operationKey, true);
|
||||
if (!options.preserveMessageWhilePending) {
|
||||
this.setMessage(rowKey, null);
|
||||
}
|
||||
@@ -812,14 +822,24 @@ class PluginsPage extends OpenClawLightDomElement {
|
||||
onError(error);
|
||||
}
|
||||
} finally {
|
||||
if (this.mutationTokens.get(rowKey) === mutationToken) {
|
||||
this.mutationTokens.delete(rowKey);
|
||||
this.setBusy(rowKey, false);
|
||||
if (this.mutationTokens.get(operationKey) === mutationToken) {
|
||||
this.mutationTokens.delete(operationKey);
|
||||
this.setBusy(operationKey, false);
|
||||
}
|
||||
if (options.pendingInstallTarget) {
|
||||
this.pendingInstallTargets.delete(options.pendingInstallTarget);
|
||||
if (!this.gateway.isCurrent(scope)) {
|
||||
this.installOutcomeReconciliations = {
|
||||
...this.installOutcomeReconciliations,
|
||||
[options.pendingInstallTarget]: "checking",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async install(rowKey: string, request: PluginInstallRequest): Promise<void> {
|
||||
const operationKey = resolvePluginInstallIdentity(request, this.result?.plugins ?? []);
|
||||
await this.runPluginMutation(
|
||||
rowKey,
|
||||
(client) => installPlugin(client, request),
|
||||
@@ -860,6 +880,8 @@ class PluginsPage extends OpenClawLightDomElement {
|
||||
});
|
||||
},
|
||||
{
|
||||
operationKey,
|
||||
pendingInstallTarget: operationKey,
|
||||
preserveMessageWhilePending: request.installPolicyWarningAcknowledgement !== undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -148,6 +148,22 @@ const calendarSearchResponse = {
|
||||
],
|
||||
} satisfies PluginsSearchResult;
|
||||
|
||||
const lobsterSearchResponse = {
|
||||
results: [
|
||||
{
|
||||
score: 1,
|
||||
package: {
|
||||
name: "@openclaw/lobster",
|
||||
displayName: "Lobster",
|
||||
family: "code-plugin",
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
runtimeId: "lobster",
|
||||
},
|
||||
},
|
||||
],
|
||||
} satisfies PluginsSearchResult;
|
||||
|
||||
const uninstallResult = {
|
||||
ok: true,
|
||||
pluginId: "calendar-plus",
|
||||
@@ -860,6 +876,17 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => {
|
||||
);
|
||||
expect((await gateway.getRequests("plugins.install")).length).toBe(2);
|
||||
|
||||
await gateway.setMethodResponse("plugins.search", lobsterSearchResponse);
|
||||
await page.getByRole("searchbox", { name: "Search plugins" }).fill("lobster");
|
||||
await gateway.waitForRequest("plugins.search");
|
||||
const searchRow = page.locator('[data-package-name="@openclaw/lobster"]');
|
||||
await searchRow.waitFor({ state: "visible" });
|
||||
await searchRow.getByText("Checking plugin status…", { exact: false }).waitFor();
|
||||
expect(
|
||||
await searchRow.getByRole("button", { name: "Install Lobster", exact: true }).isDisabled(),
|
||||
).toBe(true);
|
||||
expect((await gateway.getRequests("plugins.install")).length).toBe(2);
|
||||
|
||||
await gateway.resolveDeferred("plugins.install", {
|
||||
ok: true,
|
||||
plugin: installedLobsterPlugin,
|
||||
|
||||
@@ -770,18 +770,33 @@ describe("renderPlugins", () => {
|
||||
const plugin = createPlugin({
|
||||
id: "lobster",
|
||||
name: "Lobster",
|
||||
packageName: "@openclaw/lobster",
|
||||
installed: false,
|
||||
enabled: false,
|
||||
state: "disabled",
|
||||
install: { source: "clawhub", packageName: "@openclaw/lobster" },
|
||||
install: { source: "official", pluginId: "lobster" },
|
||||
});
|
||||
const key = pluginRowKey(plugin.id);
|
||||
const onRetryInstallOutcome = vi.fn();
|
||||
const container = mount(
|
||||
createProps({
|
||||
activeTab: "discover",
|
||||
query: "lobster",
|
||||
result: createResult([plugin]),
|
||||
installOutcomeReconciliations: { [key]: "failed" },
|
||||
searchResults: [
|
||||
{
|
||||
score: 1,
|
||||
package: {
|
||||
name: "@openclaw/lobster",
|
||||
displayName: "Lobster",
|
||||
family: "code-plugin",
|
||||
channel: "official",
|
||||
isOfficial: true,
|
||||
runtimeId: "lobster",
|
||||
},
|
||||
},
|
||||
],
|
||||
onRetryInstallOutcome,
|
||||
}),
|
||||
);
|
||||
@@ -793,6 +808,15 @@ describe("renderPlugins", () => {
|
||||
const install = actionButton(row ?? container, "Install Lobster");
|
||||
expect(install?.disabled).toBe(true);
|
||||
expect(normalizedText(install)).toBe("Checking…");
|
||||
const searchRow = container.querySelector<HTMLElement>(
|
||||
'[data-package-name="@openclaw/lobster"]',
|
||||
);
|
||||
const searchInstall = actionButton(searchRow ?? container, "Install Lobster");
|
||||
expect(searchInstall?.disabled).toBe(true);
|
||||
expect(normalizedText(searchInstall)).toBe("Checking…");
|
||||
expect(normalizedText(searchRow?.querySelector('[role="alert"]') ?? null)).toContain(
|
||||
"couldn’t confirm whether this plugin was installed",
|
||||
);
|
||||
row?.querySelector<HTMLButtonElement>('[role="alert"] button')?.click();
|
||||
expect(onRetryInstallOutcome).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import { EXTERNAL_LINK_TARGET, buildExternalLinkRel } from "../../lib/external-l
|
||||
import "../../styles/plugins.css";
|
||||
import {
|
||||
CLAWHUB_BROWSE_URL,
|
||||
resolvePluginInstallIdentity,
|
||||
type PluginCatalogItem,
|
||||
type PluginInstallRequest,
|
||||
type PluginListResult,
|
||||
@@ -159,6 +160,19 @@ function clawHubRowKey(packageName: string): string {
|
||||
return `clawhub:${packageName}`;
|
||||
}
|
||||
|
||||
function installOperationState(
|
||||
props: PluginsViewProps,
|
||||
request: PluginInstallRequest | undefined,
|
||||
runtimeId?: string,
|
||||
): { busy: boolean; outcome?: InstallOutcomeReconciliation } {
|
||||
if (!request) {
|
||||
return { busy: false };
|
||||
}
|
||||
const identity = resolvePluginInstallIdentity(request, props.result?.plugins ?? [], runtimeId);
|
||||
const outcome = props.installOutcomeReconciliations[identity];
|
||||
return { busy: Boolean(props.busy[identity] || outcome), ...(outcome ? { outcome } : {}) };
|
||||
}
|
||||
|
||||
export function connectorRowKey(connectorId: string): string {
|
||||
return `connector:${connectorId}`;
|
||||
}
|
||||
@@ -401,8 +415,8 @@ function renderRowMessage(
|
||||
message: PluginRowMessage | undefined,
|
||||
busy: boolean,
|
||||
props: PluginsViewProps,
|
||||
installOutcome?: InstallOutcomeReconciliation,
|
||||
) {
|
||||
const installOutcome = props.installOutcomeReconciliations[key];
|
||||
if (installOutcome) {
|
||||
return html`
|
||||
<div
|
||||
@@ -622,6 +636,7 @@ function renderInstallButton(
|
||||
name: string,
|
||||
request: PluginInstallRequest,
|
||||
) {
|
||||
const { outcome: installOutcome } = installOperationState(props, request);
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
@@ -635,7 +650,7 @@ function renderInstallButton(
|
||||
}}
|
||||
>
|
||||
${busy
|
||||
? props.installOutcomeReconciliations[key]
|
||||
? installOutcome
|
||||
? t("pluginsPage.checkingInstallOutcome")
|
||||
: t("pluginsPage.installing")
|
||||
: t("pluginsPage.install")}
|
||||
@@ -764,7 +779,8 @@ function renderPluginRow(
|
||||
includePackageName = false,
|
||||
): TemplateResult {
|
||||
const key = pluginRowKey(plugin.id);
|
||||
const busy = Boolean(props.busy[key] || props.installOutcomeReconciliations[key]);
|
||||
const installOperation = installOperationState(props, plugin.install);
|
||||
const busy = Boolean(props.busy[key] || installOperation.busy);
|
||||
return html`
|
||||
<article
|
||||
class="settings-row plugins-item plugins-item--clickable"
|
||||
@@ -813,7 +829,7 @@ function renderPluginRow(
|
||||
${plugin.error}
|
||||
</div>`
|
||||
: nothing}
|
||||
${renderRowMessage(key, props.messages[key], busy, props)}
|
||||
${renderRowMessage(key, props.messages[key], busy, props, installOperation.outcome)}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
@@ -943,7 +959,7 @@ function renderConnectorRow(
|
||||
props: PluginsViewProps,
|
||||
): TemplateResult {
|
||||
const key = connectorRowKey(connector.id);
|
||||
const busy = Boolean(props.busy[key] || props.installOutcomeReconciliations[key]);
|
||||
const busy = Boolean(props.busy[key]);
|
||||
const isMcp = connector.action.kind === "mcp";
|
||||
const installed =
|
||||
isMcp &&
|
||||
@@ -1030,7 +1046,9 @@ function renderClawHubResult(item: PluginSearchResult, props: PluginsViewProps):
|
||||
const pkg = item.package;
|
||||
const installed = findInstalledSearchPlugin(item, props.result?.plugins ?? []);
|
||||
const key = clawHubRowKey(pkg.name);
|
||||
const busy = Boolean(props.busy[key] || props.installOutcomeReconciliations[key]);
|
||||
const installRequest = { source: "clawhub", packageName: pkg.name } as const;
|
||||
const installOperation = installOperationState(props, installRequest, pkg.runtimeId);
|
||||
const busy = Boolean(props.busy[key] || installOperation.busy);
|
||||
const artSlug = pkg.runtimeId ?? pkg.name;
|
||||
return html`
|
||||
<article
|
||||
@@ -1075,12 +1093,9 @@ function renderClawHubResult(item: PluginSearchResult, props: PluginsViewProps):
|
||||
<div class="settings-row__control">
|
||||
${installed
|
||||
? html`${rowStateStatus(installed)}${renderCatalogActions(installed, props, busy, key)}`
|
||||
: renderInstallButton(props, busy, key, pkg.displayName, {
|
||||
source: "clawhub",
|
||||
packageName: pkg.name,
|
||||
})}
|
||||
: renderInstallButton(props, busy, key, pkg.displayName, installRequest)}
|
||||
</div>
|
||||
${renderRowMessage(key, props.messages[key], busy, props)}
|
||||
${renderRowMessage(key, props.messages[key], busy, props, installOperation.outcome)}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
@@ -1203,7 +1218,8 @@ function renderDetailOverlay(props: PluginsViewProps) {
|
||||
return nothing;
|
||||
}
|
||||
const key = pluginRowKey(plugin.id);
|
||||
const busy = Boolean(props.busy[key] || props.installOutcomeReconciliations[key]);
|
||||
const installOperation = installOperationState(props, plugin.install);
|
||||
const busy = Boolean(props.busy[key] || installOperation.busy);
|
||||
return html`
|
||||
<openclaw-modal-dialog
|
||||
label=${plugin.name}
|
||||
@@ -1281,7 +1297,7 @@ function renderDetailOverlay(props: PluginsViewProps) {
|
||||
${plugin.error}
|
||||
</div>`
|
||||
: nothing}
|
||||
${renderRowMessage(key, props.messages[key], busy, props)}
|
||||
${renderRowMessage(key, props.messages[key], busy, props, installOperation.outcome)}
|
||||
<div class="plugins-detail__meta">
|
||||
${plugin.origin
|
||||
? detailMetaRow(t("pluginsPage.detailOrigin"), originLabel(plugin.origin))
|
||||
|
||||
Reference in New Issue
Block a user