From 982d6602363dd19068565763709fea2b2fda662f Mon Sep 17 00:00:00 2001 From: Hyu Date: Tue, 1 Sep 2026 12:46:16 +0800 Subject: [PATCH] fix(web): harden agent runner marketplace flows --- .../home/agents/agent-runner-marketplace.ts | 140 +++++++++++++++++- .../agents/components/AgentFormComponent.tsx | 90 ++++++++++- .../agents/components/AgentRunnerSelect.tsx | 72 ++++++--- .../pipeline-form/PipelineFormComponent.tsx | 83 ++++++++++- web/src/app/wizard/page.tsx | 47 +++++- web/src/i18n/locales/en-US.ts | 5 +- web/src/i18n/locales/ja-JP.ts | 5 +- web/src/i18n/locales/zh-Hans.ts | 4 +- web/tests/unit/wizard-page-bot.test.mjs | 10 +- 9 files changed, 426 insertions(+), 30 deletions(-) diff --git a/web/src/app/home/agents/agent-runner-marketplace.ts b/web/src/app/home/agents/agent-runner-marketplace.ts index 37b174914..477b36b9f 100644 --- a/web/src/app/home/agents/agent-runner-marketplace.ts +++ b/web/src/app/home/agents/agent-runner-marketplace.ts @@ -1,5 +1,6 @@ import { httpClient } from '@/app/infra/http/HttpClient'; import { getCloudServiceClient } from '@/app/infra/http'; +import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext'; import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic'; import type { PipelineConfigTab } from '@/app/infra/entities/pipeline'; import type { PluginV4 } from '@/app/infra/entities/plugin'; @@ -9,6 +10,8 @@ export const RUNNER_COMPONENT_FILTER = 'AgentRunner'; const RUNNER_CATALOG_PAGE_SIZE = 100; const RUNNER_INSTALL_TIMEOUT_MS = 120_000; const RUNNER_REGISTRATION_TIMEOUT_MS = 60_000; +const RUNNER_INSTALL_INTENT_KEY_PREFIX = 'langbot-agent-runner-install'; +const RUNNER_INSTALL_INTENT_EVENT = 'langbot-agent-runner-install-change'; export type AgentRunnerMarketplaceErrorCode = | 'version-unavailable' @@ -32,6 +35,21 @@ export interface InstalledAgentRunner { runner: IDynamicFormItemOption; } +export interface PendingAgentRunnerInstall { + taskId: number; + pluginId: string; + pluginAuthor: string; + pluginName: string; + pluginLabel: string; + scope: string; + startedAt: number; +} + +interface InstallAgentRunnerOptions { + scope: string; + onTaskCreated?: (taskId: number) => void; +} + function wait(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -52,6 +70,74 @@ export function runnerPluginPrefix(plugin: Pick) { return `plugin:${plugin.author}/${plugin.name}/`; } +function installIntentStorageKey(scope: string) { + return `${RUNNER_INSTALL_INTENT_KEY_PREFIX}:${getActiveWorkspaceUuid() || 'default'}:${scope}`; +} + +function emitInstallIntentChange(scope: string) { + if (typeof window === 'undefined') return; + window.dispatchEvent( + new CustomEvent(RUNNER_INSTALL_INTENT_EVENT, { detail: { scope } }), + ); +} + +export function readPendingAgentRunnerInstall( + scope: string, +): PendingAgentRunnerInstall | null { + if (typeof window === 'undefined') return null; + try { + const raw = sessionStorage.getItem(installIntentStorageKey(scope)); + if (!raw) return null; + const value = JSON.parse(raw) as Partial; + if ( + value.scope !== scope || + typeof value.taskId !== 'number' || + typeof value.pluginId !== 'string' || + typeof value.pluginAuthor !== 'string' || + typeof value.pluginName !== 'string' || + typeof value.pluginLabel !== 'string' || + typeof value.startedAt !== 'number' + ) { + sessionStorage.removeItem(installIntentStorageKey(scope)); + return null; + } + return value as PendingAgentRunnerInstall; + } catch { + return null; + } +} + +function writePendingAgentRunnerInstall(intent: PendingAgentRunnerInstall) { + if (typeof window === 'undefined') return; + sessionStorage.setItem( + installIntentStorageKey(intent.scope), + JSON.stringify(intent), + ); + emitInstallIntentChange(intent.scope); +} + +export function clearPendingAgentRunnerInstall(scope: string, taskId?: number) { + if (typeof window === 'undefined') return; + const current = readPendingAgentRunnerInstall(scope); + if (taskId !== undefined && current?.taskId !== taskId) return; + sessionStorage.removeItem(installIntentStorageKey(scope)); + emitInstallIntentChange(scope); +} + +export function subscribePendingAgentRunnerInstall( + scope: string, + listener: () => void, +) { + if (typeof window === 'undefined') return () => undefined; + const handleChange = (event: Event) => { + const detail = (event as CustomEvent<{ scope?: string }>).detail; + if (detail?.scope === scope) listener(); + }; + window.addEventListener(RUNNER_INSTALL_INTENT_EVENT, handleChange); + return () => + window.removeEventListener(RUNNER_INSTALL_INTENT_EVENT, handleChange); +} + export async function loadAgentRunnerCatalog(): Promise { const cloudClient = await getCloudServiceClient(); const [firstSearchResult, recommendationResult, installedResult] = @@ -124,6 +210,7 @@ export async function loadAgentRunnerCatalog(): Promise { export async function installMarketplaceAgentRunner( plugin: PluginV4, + options: InstallAgentRunnerOptions, ): Promise { if (!plugin.latest_version) { throw new AgentRunnerMarketplaceError('version-unavailable'); @@ -134,17 +221,51 @@ export async function installMarketplaceAgentRunner( plugin.name, plugin.latest_version, ); + const pending: PendingAgentRunnerInstall = { + taskId, + pluginId: marketplacePluginId(plugin), + pluginAuthor: plugin.author, + pluginName: plugin.name, + pluginLabel: extractPluginLabel(plugin), + scope: options.scope, + startedAt: Date.now(), + }; + writePendingAgentRunnerInstall(pending); + options.onTaskCreated?.(taskId); + return finishAgentRunnerInstall(pending); +} + +function extractPluginLabel(plugin: PluginV4) { + const label = plugin.label; + if (typeof label === 'string') return label || plugin.name; + if (label && typeof label === 'object') { + const localized = Object.values(label).find( + (value): value is string => typeof value === 'string' && value.length > 0, + ); + if (localized) return localized; + } + return plugin.name; +} + +async function finishAgentRunnerInstall( + pending: PendingAgentRunnerInstall, +): Promise { + // A refreshed page receives a fresh observation window. The backend task is + // authoritative; `startedAt` is display metadata, not a reason to abandon a + // still-running installation immediately after recovery. const installDeadline = Date.now() + RUNNER_INSTALL_TIMEOUT_MS; let installCompleted = false; - while (Date.now() < installDeadline) { - const task = await httpClient.getAsyncTask(taskId); + while (true) { + const task = await httpClient.getAsyncTask(pending.taskId); if (task.runtime.done) { if (task.runtime.exception) { + clearPendingAgentRunnerInstall(pending.scope, pending.taskId); throw new Error(task.runtime.exception); } installCompleted = true; break; } + if (Date.now() >= installDeadline) break; await wait(1000); } if (!installCompleted) { @@ -152,7 +273,10 @@ export async function installMarketplaceAgentRunner( } const registrationDeadline = Date.now() + RUNNER_REGISTRATION_TIMEOUT_MS; - const prefix = runnerPluginPrefix(plugin); + const prefix = runnerPluginPrefix({ + author: pending.pluginAuthor, + name: pending.pluginName, + }); while (Date.now() < registrationDeadline) { const metadata = await httpClient.getGeneralPipelineMetadata(); const configTab = metadata.configs.find((config) => config.name === 'ai'); @@ -169,10 +293,20 @@ export async function installMarketplaceAgentRunner( pluginRunnerOptions[0]; if (configTab && runner) { + clearPendingAgentRunnerInstall(pending.scope, pending.taskId); return { configTab, runner }; } await wait(1000); } + clearPendingAgentRunnerInstall(pending.scope, pending.taskId); throw new AgentRunnerMarketplaceError('registration-timeout'); } + +export async function resumePendingAgentRunnerInstall( + scope: string, +): Promise { + const pending = readPendingAgentRunnerInstall(scope); + if (!pending) return null; + return finishAgentRunnerInstall(pending); +} diff --git a/web/src/app/home/agents/components/AgentFormComponent.tsx b/web/src/app/home/agents/components/AgentFormComponent.tsx index 3486ae304..ec9c4aac7 100644 --- a/web/src/app/home/agents/components/AgentFormComponent.tsx +++ b/web/src/app/home/agents/components/AgentFormComponent.tsx @@ -13,7 +13,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; -import { Bot, SlidersHorizontal, Zap } from 'lucide-react'; +import { Bot, Loader2, SlidersHorizontal, Zap } from 'lucide-react'; import { httpClient } from '@/app/infra/http/HttpClient'; import { Agent, ApiRespPluginSystemStatus } from '@/app/infra/entities/api'; import { @@ -21,6 +21,13 @@ import { PipelineConfigTab, } from '@/app/infra/entities/pipeline'; import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; +import { getDefaultValues } from '@/app/home/components/dynamic-form/DynamicFormItemConfig'; +import { + getErrorMessage, + readPendingAgentRunnerInstall, + resumePendingAgentRunnerInstall, + type InstalledAgentRunner, +} from '@/app/home/agents/agent-runner-marketplace'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { @@ -117,6 +124,8 @@ function AgentFormComponent( useState(null); const [pluginStatusLoading, setPluginStatusLoading] = useState(true); const [pluginStatusError, setPluginStatusError] = useState(false); + const [initialDataLoaded, setInitialDataLoaded] = useState(false); + const [runnerInstallRecovering, setRunnerInstallRecovering] = useState(false); const [activeSection, setActiveSection] = useState('runner'); const isSavingRef = useRef(false); @@ -147,6 +156,35 @@ function AgentFormComponent( supported_event_patterns: ['*'], }, }); + const runnerInstallScope = `agent:${agentId}`; + + const applyInstalledRunner = useCallback( + (installed: InstalledAgentRunner) => { + setRunnerConfigSchema(installed.configTab); + const currentRunner = form.getValues('runner') || {}; + const currentConfigs = form.getValues('runner_config') || {}; + const runnerName = installed.runner.name; + const runnerStage = installed.configTab.stages.find( + (stage) => stage.name === runnerName, + ); + form.setValue( + 'runner', + { ...currentRunner, id: runnerName }, + { shouldDirty: true }, + ); + if (!(runnerName in currentConfigs) && runnerStage) { + form.setValue( + 'runner_config', + { + ...currentConfigs, + [runnerName]: getDefaultValues(runnerStage.config), + }, + { shouldDirty: true }, + ); + } + }, + [form], + ); const savedSnapshotRef = useRef(''); const initializedStagesRef = useRef>(new Set()); @@ -189,6 +227,7 @@ function AgentFormComponent( form.reset(loadedValues); savedSnapshotRef.current = JSON.stringify(loadedValues); initializedStagesRef.current.clear(); + setInitialDataLoaded(true); }) .catch((err) => { toast.error(t('agents.loadError') + err.msg); @@ -198,6 +237,40 @@ function AgentFormComponent( }; }, [agentId, form, t]); + useEffect(() => { + if ( + !initialDataLoaded || + !readPendingAgentRunnerInstall(runnerInstallScope) + ) { + return; + } + let cancelled = false; + setRunnerInstallRecovering(true); + void resumePendingAgentRunnerInstall(runnerInstallScope) + .then((installed) => { + if (cancelled || !installed) return; + applyInstalledRunner(installed); + toast.success( + t('wizard.aiEngine.installSuccess', { + runner: extractI18nObject(installed.runner.label), + }), + ); + }) + .catch((error) => { + if (!cancelled) { + toast.error( + getErrorMessage(error) || t('wizard.aiEngine.installFailed'), + ); + } + }) + .finally(() => { + if (!cancelled) setRunnerInstallRecovering(false); + }); + return () => { + cancelled = true; + }; + }, [applyInstalledRunner, initialDataLoaded, runnerInstallScope, t]); + const loadPluginSystemStatus = useCallback(async () => { setPluginStatusLoading(true); setPluginStatusError(false); @@ -425,7 +498,8 @@ function AgentFormComponent( label={extractI18nObject(config.label)} value={String(field.value ?? '')} onValueChange={field.onChange} - onMetadataRefresh={setRunnerConfigSchema} + installScope={runnerInstallScope} + onInstalled={applyInstalledRunner} /> ) : undefined : undefined @@ -576,7 +650,17 @@ function AgentFormComponent( {activeSection === 'runner_config' && (
- {activeRunnerStage ? ( + {runnerInstallRecovering ? ( + + + {t('agents.runnerSettings')} + + + {t('agents.restoringRunnerInstall')} + + + + ) : activeRunnerStage ? ( renderDynamicStage(activeRunnerStage) ) : ( diff --git a/web/src/app/home/agents/components/AgentRunnerSelect.tsx b/web/src/app/home/agents/components/AgentRunnerSelect.tsx index da9b2f04f..d28a00215 100644 --- a/web/src/app/home/agents/components/AgentRunnerSelect.tsx +++ b/web/src/app/home/agents/components/AgentRunnerSelect.tsx @@ -1,11 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Bot, Download, Loader2, Store } from 'lucide-react'; +import { Bot, Download, ExternalLink, Loader2, Store } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { getCloudServiceClientSync, httpClient } from '@/app/infra/http'; import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic'; -import type { PipelineConfigTab } from '@/app/infra/entities/pipeline'; import type { PluginV4 } from '@/app/infra/entities/plugin'; import { AgentRunnerMarketplaceError, @@ -14,7 +13,11 @@ import { loadAgentRunnerCatalog, marketplacePluginId, runnerPluginPrefix, + readPendingAgentRunnerInstall, + subscribePendingAgentRunnerInstall, + type InstalledAgentRunner, } from '@/app/home/agents/agent-runner-marketplace'; +import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task'; import { extractI18nObject } from '@/i18n/I18nProvider'; import { Select, @@ -109,21 +112,24 @@ export default function AgentRunnerSelect({ label, value, onValueChange, - onMetadataRefresh, + installScope, + onInstalled, }: { options: IDynamicFormItemOption[]; label: string; value: string; onValueChange: (value: string) => void; - onMetadataRefresh: (configTab: PipelineConfigTab) => void; + installScope: string; + onInstalled: (installed: InstalledAgentRunner) => void; }) { const { t } = useTranslation(); + const { addTask } = usePluginInstallTasks(); const [marketplaceRunners, setMarketplaceRunners] = useState([]); const [installedPluginIds, setInstalledPluginIds] = useState([]); const [catalogLoading, setCatalogLoading] = useState(true); const [catalogError, setCatalogError] = useState(false); - const [installingPlugin, setInstallingPlugin] = useState( - null, + const [pendingInstall, setPendingInstall] = useState(() => + readPendingAgentRunnerInstall(installScope), ); const [installError, setInstallError] = useState(null); @@ -146,6 +152,13 @@ export default function AgentRunnerSelect({ void loadCatalog(); }, [loadCatalog]); + useEffect(() => { + const syncPendingInstall = () => + setPendingInstall(readPendingAgentRunnerInstall(installScope)); + syncPendingInstall(); + return subscribePendingAgentRunnerInstall(installScope, syncPendingInstall); + }, [installScope]); + const marketplaceOptions = useMemo( () => marketplaceRunners.filter((plugin) => { @@ -159,6 +172,11 @@ export default function AgentRunnerSelect({ ); const selectedOption = options.find((option) => option.name === value); + const installingPlugin = pendingInstall + ? (marketplaceRunners.find( + (plugin) => marketplacePluginId(plugin) === pendingInstall.pluginId, + ) ?? null) + : null; const handleValueChange = useCallback( async (nextValue: string) => { @@ -172,14 +190,21 @@ export default function AgentRunnerSelect({ const plugin = marketplaceRunners.find( (candidate) => marketplacePluginId(candidate) === pluginId, ); - if (!plugin || installingPlugin) return; + if (!plugin || pendingInstall) return; - setInstallingPlugin(plugin); setInstallError(null); try { - const installed = await installMarketplaceAgentRunner(plugin); - onMetadataRefresh(installed.configTab); - onValueChange(installed.runner.name); + const installed = await installMarketplaceAgentRunner(plugin, { + scope: installScope, + onTaskCreated: (taskId) => + addTask({ + taskId, + pluginName: marketplacePluginId(plugin), + source: 'marketplace', + extensionType: 'plugin', + }), + }); + onInstalled(installed); await loadCatalog(); toast.success( t('wizard.aiEngine.installSuccess', { @@ -191,15 +216,17 @@ export default function AgentRunnerSelect({ setInstallError(message); toast.error(message); } finally { - setInstallingPlugin(null); + setPendingInstall(readPendingAgentRunnerInstall(installScope)); } }, [ - installingPlugin, + addTask, + installScope, loadCatalog, marketplaceRunners, - onMetadataRefresh, + onInstalled, onValueChange, + pendingInstall, t, ], ); @@ -208,7 +235,7 @@ export default function AgentRunnerSelect({