From dc4732cfed712b004d3d3d94a414d3ff127a93cc Mon Sep 17 00:00:00 2001 From: CaIon Date: Sun, 30 Aug 2026 20:35:38 +0800 Subject: [PATCH] feat(web): factory task plugins update only with the system Marketplace install/upgrade on a factory-served plugin actually created a permanent override shadowing every future built-in release. The card now shows an informational "Updates with the system" badge instead of the action, while keeping the built-in vs marketplace version line and the upgradable state badge visible. Deliberate overrides are untouched: upload and marketplace actions on overridden or third-party plugins behave as before, and the plugins table now hints when an override lags behind the shipped built-in version so operators know deleting it restores the newer factory plugin. --- .../__tests__/marketplace.test.ts | 118 +++++++++++++++++- .../components/marketplace-plugin-card.tsx | 39 +++--- .../task-plugins/components/plugins-table.tsx | 25 +++- .../features/task-plugins/lib/marketplace.ts | 40 ++++++ web/src/i18n/locales/en.json | 2 + web/src/i18n/locales/fr.json | 2 + web/src/i18n/locales/ja.json | 2 + web/src/i18n/locales/ru.json | 2 + web/src/i18n/locales/vi.json | 2 + web/src/i18n/locales/zh-TW.json | 2 + web/src/i18n/locales/zh.json | 2 + 11 files changed, 216 insertions(+), 20 deletions(-) diff --git a/web/src/features/task-plugins/__tests__/marketplace.test.ts b/web/src/features/task-plugins/__tests__/marketplace.test.ts index 852a228dfa..bfc0a4c6d5 100644 --- a/web/src/features/task-plugins/__tests__/marketplace.test.ts +++ b/web/src/features/task-plugins/__tests__/marketplace.test.ts @@ -26,7 +26,10 @@ import { GITHUB_MARKETPLACE_INDEX_URL, indexHasIntegrityHashes, isDefaultMarketplaceSource, + isStaleFactoryOverride, + marketplaceBuiltInVersion, parseMarketplaceIndex, + resolveMarketplaceActionPolicy, resolvePluginSourceUrl, } from '../lib/marketplace' import type { @@ -52,7 +55,23 @@ function marketplacePlugin( } } -function installedPlugin(key: string, version: string): TaskPluginListItem { +function factoryMeta(key: string, version: string) { + return { + apiVersion: 1, + key, + name: key, + version, + author: { name: 'test' as const }, + models: null, + fetchMode: 'poll', + } +} + +function installedPlugin( + key: string, + version: string, + overrides: Partial = {} +): TaskPluginListItem { return { meta: { apiVersion: 1, @@ -71,6 +90,7 @@ function installedPlugin(key: string, version: string): TaskPluginListItem { runtime_status: 'registered', channel_count: 0, in_flight_count: 0, + ...overrides, } } @@ -399,6 +419,102 @@ describe('install state derivation', () => { }) }) +describe('marketplace action policy', () => { + test('factory-served plugin returns the informational system-update state', () => { + assert.deepEqual( + resolveMarketplaceActionPolicy( + installedPlugin('doubao', '1.0.0', { source: 'factory' }) + ), + { kind: 'system_update' } + ) + }) + + test('overridden factory plugin still allows marketplace install', () => { + assert.deepEqual( + resolveMarketplaceActionPolicy( + installedPlugin('doubao', '1.2.0', { + source: 'override_over_factory', + factory_meta: factoryMeta('doubao', '1.0.0'), + }) + ), + { kind: 'install' } + ) + }) + + test('third-party plugin still allows marketplace install', () => { + assert.deepEqual( + resolveMarketplaceActionPolicy(installedPlugin('doubao', '1.0.0')), + { kind: 'install' } + ) + }) + + test('uninstalled plugin still allows marketplace install', () => { + assert.deepEqual(resolveMarketplaceActionPolicy(undefined), { + kind: 'install', + }) + }) + + test('factory-served built-in version is the installed meta version', () => { + assert.equal( + marketplaceBuiltInVersion( + installedPlugin('doubao', '1.0.0', { source: 'factory' }) + ), + '1.0.0' + ) + }) + + test('overridden factory built-in version comes from factory_meta', () => { + assert.equal( + marketplaceBuiltInVersion( + installedPlugin('doubao', '1.2.0', { + source: 'override_over_factory', + factory_meta: factoryMeta('doubao', '1.0.0'), + }) + ), + '1.0.0' + ) + }) +}) + +describe('stale factory override', () => { + test('is stale when override version differs from built-in', () => { + assert.equal( + isStaleFactoryOverride( + installedPlugin('doubao', '1.2.0', { + source: 'override_over_factory', + factory_meta: factoryMeta('doubao', '1.0.0'), + }) + ), + true + ) + }) + + test('is not stale when override version matches built-in', () => { + assert.equal( + isStaleFactoryOverride( + installedPlugin('doubao', '1.0.0', { + source: 'override_over_factory', + factory_meta: factoryMeta('doubao', '1.0.0'), + }) + ), + false + ) + }) + + test('is not stale for factory-served or third-party plugins', () => { + assert.equal( + isStaleFactoryOverride( + installedPlugin('doubao', '1.0.0', { source: 'factory' }) + ), + false + ) + assert.equal( + isStaleFactoryOverride(installedPlugin('doubao', '1.0.0')), + false + ) + }) +}) + describe('marketplace version lookup', () => { test('finds the entry matching a version', () => { assert.equal( diff --git a/web/src/features/task-plugins/components/marketplace-plugin-card.tsx b/web/src/features/task-plugins/components/marketplace-plugin-card.tsx index 8361417ddf..a3967c3664 100644 --- a/web/src/features/task-plugins/components/marketplace-plugin-card.tsx +++ b/web/src/features/task-plugins/components/marketplace-plugin-card.tsx @@ -29,7 +29,12 @@ import { Button } from '@/components/ui/button' import { getChannelTypeLabel } from '@/features/channels/lib' import { resolveLocalizedText } from '@/lib/localized-text' -import { findMarketplaceVersion, type InstallState } from '../lib/marketplace' +import { + findMarketplaceVersion, + marketplaceBuiltInVersion, + resolveMarketplaceActionPolicy, + type InstallState, +} from '../lib/marketplace' import type { MarketplacePlugin, TaskPluginListItem } from '../types' import { PluginIcon } from './plugin-icon' @@ -47,6 +52,8 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) { const channelTypes = plugin.channelTypes ?? [] const latestEntry = findMarketplaceVersion(plugin, plugin.latest) const labelClass = 'text-muted-foreground text-[11px] font-medium select-none' + const actionPolicy = resolveMarketplaceActionPolicy(props.installed) + const builtInVersion = marketplaceBuiltInVersion(props.installed) return (
@@ -90,12 +97,12 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) {
- {props.installed?.factory_meta && ( + {builtInVersion && (
{t('Versions')}{' '} {t('Built-in v{{factory}} / marketplace v{{market}}', { - factory: props.installed.factory_meta.version, + factory: builtInVersion, market: plugin.latest, })} @@ -110,17 +117,21 @@ export function MarketplacePluginCard(props: MarketplacePluginCardProps) { )}
- + {actionPolicy.kind === 'system_update' ? ( + {t('Updates with the system')} + ) : ( + + )}
) diff --git a/web/src/features/task-plugins/components/plugins-table.tsx b/web/src/features/task-plugins/components/plugins-table.tsx index 5edfa5481c..5d0c579d3e 100644 --- a/web/src/features/task-plugins/components/plugins-table.tsx +++ b/web/src/features/task-plugins/components/plugins-table.tsx @@ -43,6 +43,7 @@ import { setTaskPluginStatus, TaskPluginUsageError, } from '../api' +import { isStaleFactoryOverride } from '../lib/marketplace' import type { TaskPluginListItem, TaskPluginUsage } from '../types' import { PluginCard } from './plugin-card' import { PluginIcon } from './plugin-icon' @@ -158,12 +159,26 @@ export function PluginsTable(props: PluginsTableProps) { return {t('Factory')} } if (row.original.source === 'override_over_factory') { + const factoryVersion = row.original.factory_meta?.version + const staleHint = isStaleFactoryOverride(row.original) + ? t( + 'Built-in is v{{factory}}; delete the custom version to return to it', + { factory: factoryVersion } + ) + : undefined return ( - - {t('Custom (overrides factory {{version}})', { - version: row.original.factory_meta?.version, - })} - +
+ + {t('Custom (overrides factory {{version}})', { + version: factoryVersion, + })} + + {staleHint ? ( + + {staleHint} + + ) : null} +
) } return {t('Third-party')} diff --git a/web/src/features/task-plugins/lib/marketplace.ts b/web/src/features/task-plugins/lib/marketplace.ts index 8777a86c28..c250f7e54a 100644 --- a/web/src/features/task-plugins/lib/marketplace.ts +++ b/web/src/features/task-plugins/lib/marketplace.ts @@ -234,6 +234,46 @@ export function deriveInstallState( } } +export type MarketplaceActionPolicy = + | { kind: 'install' } + | { kind: 'system_update' } + +/** + * Factory-served plugins are compiled into the binary and must only update + * with a system release. Marketplace install would create a permanent override + * that shadows every future built-in update — that action is suppressed. + * Overrides and third-party plugins still install/upgrade normally. + */ +export function resolveMarketplaceActionPolicy( + installed?: TaskPluginListItem +): MarketplaceActionPolicy { + if (installed?.source === 'factory') { + return { kind: 'system_update' } + } + return { kind: 'install' } +} + +/** + * Built-in version shown next to the marketplace latest. Factory-served items + * do not carry `factory_meta` (their `meta` *is* the factory meta); overridden + * factory plugins expose the shadowed built-in on `factory_meta`. + */ +export function marketplaceBuiltInVersion( + installed?: TaskPluginListItem +): string | undefined { + if (!installed) return undefined + if (installed.source === 'factory') return installed.meta.version + return installed.factory_meta?.version +} + +export function isStaleFactoryOverride(item: TaskPluginListItem): boolean { + return ( + item.source === 'override_over_factory' && + item.factory_meta != null && + item.factory_meta.version !== item.meta.version + ) +} + /** * A source is only integrity-checked when every listed version carries a * sha256. Anything less and installs from it cannot be pinned, so the UI warns. diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index a65405c6be..f1a410f7e7 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -714,6 +714,8 @@ "Built-in": "Built-in", "Built-in Device": "Built-in Device", "Built-in v{{factory}} / marketplace v{{market}}": "Built-in v{{factory}} / marketplace v{{market}}", + "Updates with the system": "Updates with the system", + "Built-in is v{{factory}}; delete the custom version to return to it": "Built-in is v{{factory}}; delete the custom version to return to it", "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key", "by": "by", "By category": "By category", diff --git a/web/src/i18n/locales/fr.json b/web/src/i18n/locales/fr.json index 901736b038..e81b95fcee 100644 --- a/web/src/i18n/locales/fr.json +++ b/web/src/i18n/locales/fr.json @@ -714,6 +714,8 @@ "Built-in": "Intégré", "Built-in Device": "Appareil intégré", "Built-in v{{factory}} / marketplace v{{market}}": "Intégré v{{factory}} / marché v{{market}}", + "Updates with the system": "Mise à jour système", + "Built-in is v{{factory}}; delete the custom version to return to it": "La version intégrée est v{{factory}} ; supprimez la version personnalisée pour y revenir", "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Intégré : empreinte digitale/visage du téléphone, ou Windows Hello ; Externe : clé de sécurité USB", "by": "par", "By category": "Par catégorie", diff --git a/web/src/i18n/locales/ja.json b/web/src/i18n/locales/ja.json index 3cf134dae1..5a9a00a665 100644 --- a/web/src/i18n/locales/ja.json +++ b/web/src/i18n/locales/ja.json @@ -714,6 +714,8 @@ "Built-in": "組み込み", "Built-in Device": "内蔵デバイス", "Built-in v{{factory}} / marketplace v{{market}}": "組み込み v{{factory}} / マーケット v{{market}}", + "Updates with the system": "システムとともに更新", + "Built-in is v{{factory}}; delete the custom version to return to it": "組み込みは v{{factory}} です。カスタム版を削除すると戻ります", "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内蔵: 電話の指紋/顔認証、またはWindows Hello。外部: USBセキュリティキー", "by": "によって", "By category": "カテゴリ別", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 97932b5891..2575247833 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -714,6 +714,8 @@ "Built-in": "Встроенный", "Built-in Device": "Встроенное устройство", "Built-in v{{factory}} / marketplace v{{market}}": "Встроенный v{{factory}} / магазин v{{market}}", + "Updates with the system": "Обновляется с системой", + "Built-in is v{{factory}}; delete the custom version to return to it": "Встроенная версия — v{{factory}}; удалите свою, чтобы вернуться к ней", "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Встроенное: отпечаток пальца/лицо телефона или Windows Hello; Внешнее: USB-ключ безопасности", "by": "от", "By category": "По категориям", diff --git a/web/src/i18n/locales/vi.json b/web/src/i18n/locales/vi.json index 7e1bc1ea31..f558290194 100644 --- a/web/src/i18n/locales/vi.json +++ b/web/src/i18n/locales/vi.json @@ -714,6 +714,8 @@ "Built-in": "Tích hợp sẵn", "Built-in Device": "Thiết bị tích hợp", "Built-in v{{factory}} / marketplace v{{market}}": "Tích hợp v{{factory}} / chợ v{{market}}", + "Updates with the system": "Cập nhật cùng hệ thống", + "Built-in is v{{factory}}; delete the custom version to return to it": "Bản tích hợp là v{{factory}}; xóa bản tùy chỉnh để trở lại", "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "Tích hợp sẵn: vân tay/khuôn mặt điện thoại, hoặc Windows Hello; Bên ngoài: khóa bảo mật USB", "by": "by", "By category": "Theo danh mục", diff --git a/web/src/i18n/locales/zh-TW.json b/web/src/i18n/locales/zh-TW.json index 6cc8c1d6b4..94899c98cc 100644 --- a/web/src/i18n/locales/zh-TW.json +++ b/web/src/i18n/locales/zh-TW.json @@ -714,6 +714,8 @@ "Built-in": "內置", "Built-in Device": "內置設備", "Built-in v{{factory}} / marketplace v{{market}}": "內建 v{{factory}} / 市集 v{{market}}", + "Updates with the system": "隨系統更新", + "Built-in is v{{factory}}; delete the custom version to return to it": "內建版本為 v{{factory}};刪除自訂版本即可恢復", "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "內置:手機指紋/面部,或 Windows Hello;外部:USB 安全金鑰", "by": "由", "By category": "按行業", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 90dec7d69a..85278c6308 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -714,6 +714,8 @@ "Built-in": "内置", "Built-in Device": "内置设备", "Built-in v{{factory}} / marketplace v{{market}}": "内置 v{{factory}} / 市场 v{{market}}", + "Updates with the system": "随系统更新", + "Built-in is v{{factory}}; delete the custom version to return to it": "内置版本为 v{{factory}};删除自定义版本即可恢复", "Built-in: phone fingerprint/face, or Windows Hello; External: USB security key": "内置:手机指纹/面部,或 Windows Hello;外部:USB 安全密钥", "by": "由", "By category": "按行业",