From cfcf7c486af47d68d17ee40ed0012d5adf60763d Mon Sep 17 00:00:00 2001 From: Zyy <39424122+zyycn@users.noreply.github.com> Date: Sat, 26 Sep 2026 19:00:23 +0800 Subject: [PATCH] fix(plugins): detect latest releases and streamline upgrades (#293) Refresh explicit GitHub release checks while preserving concurrent fetch sharing and fixed-release download caching. Compare verified package versions and offer a direct upgrade with pinned digests, revision checks, permission disclosure, and configuration fallback. Co-authored-by: Codex --- .../src/plugin_distribution/github.rs | 15 +++- .../src/plugin_distribution/mod.rs | 2 +- .../tests/plugin_distribution/github.rs | 63 ++++++++++++++ .../tests/plugin_distribution/lifecycle.rs | 19 +++-- docs/api.md | 2 +- frontend/package.json | 2 + frontend/pnpm-lock.yaml | 11 +++ .../plugins/components/PluginDetailModal.vue | 2 +- .../plugins/components/PluginInstallModal.vue | 19 ++--- .../plugins/components/PluginManagement.vue | 5 +- .../components/PluginUpdateCheckModal.vue | 82 ++++++++++++++----- .../composables/usePluginInstallation.ts | 28 ++++++- .../composables/usePluginManagement.ts | 17 +++- .../composables/usePluginUpdateCheck.ts | 22 ++++- frontend/src/views/plugins/utils/updates.ts | 28 +++++++ 15 files changed, 263 insertions(+), 54 deletions(-) create mode 100644 frontend/src/views/plugins/utils/updates.ts diff --git a/backend/crates/gateway-host/src/plugin_distribution/github.rs b/backend/crates/gateway-host/src/plugin_distribution/github.rs index 14b350193..becf902e9 100644 --- a/backend/crates/gateway-host/src/plugin_distribution/github.rs +++ b/backend/crates/gateway-host/src/plugin_distribution/github.rs @@ -26,6 +26,7 @@ use super::{ pub(super) struct CachedRelease { result: Result, until: Instant, + fetched_at: Instant, } #[derive(Clone)] @@ -57,7 +58,9 @@ impl HttpPluginDistribution { mut query: GithubReleaseQuery, credentials: &[SourceCredential], egress: Option<&PluginDistributionEgress>, + refresh: bool, ) -> Result { + let requested_at = Instant::now(); validate_repository(&query.repository)?; query.repository.make_ascii_lowercase(); let url = self.release_url(&query)?; @@ -91,7 +94,11 @@ impl HttpPluginDistribution { tokio::time::timeout(Duration::from_secs(60), async { // 同一来源共用锁和结果;取消请求释放锁,下一位查询者可重新发起。 let mut cached = slot.lock().await; - if let Some(entry) = cached.as_ref().filter(|entry| entry.until > Instant::now()) { + // 显式查询刷新成功结果;等待同一次请求的调用者仍共享结果,失败保留短缓存限流。 + if let Some(entry) = cached.as_ref().filter(|entry| { + entry.until > Instant::now() + && (!refresh || entry.fetched_at >= requested_at || entry.result.is_err()) + }) { return entry.result.clone(); } let _permit = self @@ -105,9 +112,11 @@ impl HttpPluginDistribution { } else { Duration::from_secs(30) }; + let fetched_at = Instant::now(); *cached = Some(CachedRelease { result: result.clone(), - until: Instant::now() + ttl, + until: fetched_at + ttl, + fetched_at, }); result }) @@ -213,7 +222,7 @@ impl HttpPluginDistribution { credentials: &[SourceCredential], egress: Option<&PluginDistributionEgress>, ) -> Result<(Url, Option, PluginSource, bool), AdminError> { - let release = self.release(query, credentials, egress).await?; + let release = self.release(query, credentials, egress, false).await?; let selected = release .assets .iter() diff --git a/backend/crates/gateway-host/src/plugin_distribution/mod.rs b/backend/crates/gateway-host/src/plugin_distribution/mod.rs index c709d0665..09945c4a7 100644 --- a/backend/crates/gateway-host/src/plugin_distribution/mod.rs +++ b/backend/crates/gateway-host/src/plugin_distribution/mod.rs @@ -153,7 +153,7 @@ impl PluginDistribution for HttpPluginDistribution { egress: Option, ) -> Result { Ok(self - .release(query, &credentials, egress.as_ref()) + .release(query, &credentials, egress.as_ref(), true) .await? .view) } diff --git a/backend/crates/gateway-host/tests/plugin_distribution/github.rs b/backend/crates/gateway-host/tests/plugin_distribution/github.rs index e72861e18..858d7e9a5 100644 --- a/backend/crates/gateway-host/tests/plugin_distribution/github.rs +++ b/backend/crates/gateway-host/tests/plugin_distribution/github.rs @@ -109,6 +109,69 @@ async fn concurrent_queries_share_a_single_fetch_and_return_cache_times() { } } +#[tokio::test] +async fn explicit_queries_refresh_a_new_release_before_the_cache_expires() { + let server = MockServer::start().await; + let calls = std::sync::atomic::AtomicUsize::new(0); + Mock::given(path("/repos/example/plugins/releases/latest")) + .respond_with(move |_: &wiremock::Request| { + let mut metadata = release(); + if calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed) > 0 { + metadata["tag_name"] = json!("model-trace-v0.1.3"); + } + ResponseTemplate::new(200).set_body_json(metadata) + }) + .expect(2) + .mount(&server) + .await; + let distribution = transport(&server); + let previous = distribution + .query_release(query(None), vec![], None) + .await + .unwrap(); + let latest = distribution + .query_release(query(None), vec![], None) + .await + .unwrap(); + assert_eq!(previous.tag, "v1.0.0"); + assert_eq!(latest.tag, "model-trace-v0.1.3"); + assert!(latest.queried_at < previous.expires_at); +} + +#[tokio::test] +async fn artifact_downloads_reuse_the_checked_fixed_release() { + let server = MockServer::start().await; + Mock::given(path("/repos/example/plugins/releases/tags/v1.0.0")) + .respond_with(ResponseTemplate::new(200).set_body_json(release())) + .expect(1) + .mount(&server) + .await; + Mock::given(path("/repos/example/plugins/releases/assets/1")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"package")) + .mount(&server) + .await; + let distribution = transport(&server); + distribution + .query_release(query(Some("v1.0.0")), vec![], None) + .await + .unwrap(); + let downloaded = distribution + .download( + RemotePluginLocation::Github { + repository: "example/plugins".into(), + tag: "v1.0.0".into(), + asset: "example_1.0.0_linux_x86_64.tar.gz".into(), + allow_prerelease: false, + sha256: None, + }, + vec![], + None, + ) + .await + .unwrap(); + assert_eq!(downloaded.sha256, digest(b"package")); +} + #[tokio::test] async fn rate_limit_applies_to_other_repositories_using_the_same_identity_and_egress() { let server = MockServer::start().await; diff --git a/backend/crates/gateway-host/tests/plugin_distribution/lifecycle.rs b/backend/crates/gateway-host/tests/plugin_distribution/lifecycle.rs index fb48f41ab..02d171b37 100644 --- a/backend/crates/gateway-host/tests/plugin_distribution/lifecycle.rs +++ b/backend/crates/gateway-host/tests/plugin_distribution/lifecycle.rs @@ -60,7 +60,7 @@ async fn cancelling_the_fetch_owner_releases_the_shared_slot_for_the_next_query( response } }) - .expect(2) + .expect(3) .mount(&server) .await; let distribution = Arc::new(transport(&server)); @@ -84,12 +84,12 @@ async fn cancelling_the_fetch_owner_releases_the_shared_slot_for_the_next_query( .unwrap(); assert_eq!(recovered.tag, "v1.0.0"); assert_eq!(calls.load(Ordering::SeqCst), 2); - let cached = distribution + let refreshed = distribution .query_release(query("example/plugins"), vec![], None) .await .unwrap(); - assert_eq!(cached.queried_at, recovered.queried_at); - assert_eq!(calls.load(Ordering::SeqCst), 2); + assert!(refreshed.queried_at > recovered.queried_at); + assert_eq!(calls.load(Ordering::SeqCst), 3); } #[tokio::test] @@ -101,7 +101,7 @@ async fn cancelling_a_cache_waiter_does_not_cancel_or_poison_the_fetch_owner() { .set_body_json(release()) .set_delay(Duration::from_millis(500)), ) - .expect(1) + .expect(2) .mount(&server) .await; let distribution = Arc::new(transport(&server)); @@ -124,12 +124,15 @@ async fn cancelling_a_cache_waiter_does_not_cancel_or_poison_the_fetch_owner() { .unwrap() .unwrap() .unwrap(); - let cached = distribution + assert_eq!(first.tag, "v1.0.0"); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + // 等待者取消不影响持有者完成,也不阻止之后的显式查询刷新结果。 + let refreshed = distribution .query_release(query("example/plugins"), vec![], None) .await .unwrap(); - assert_eq!(cached.queried_at, first.queried_at); - assert_eq!(server.received_requests().await.unwrap().len(), 1); + assert!(refreshed.queried_at > first.queried_at); + assert_eq!(server.received_requests().await.unwrap().len(), 2); } #[tokio::test] diff --git a/docs/api.md b/docs/api.md index 1277bfe1b..48bf208b0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1725,7 +1725,7 @@ GitHub 的 `location` 使用 `kind: "github"`、`repository: "owner/repo"`、`ta 查询请求为 `{ "query": { "repository": "owner/repo", "tag": null, "allowPrerelease": false }, "credentialIds": [], "outboundProxyId": null }`。 `tag: null` 查询最新稳定版;预发行版须指定 tag 并允许预发行。响应包含固定 tag、产物列表、`queriedAt` -和 `expiresAt`。成功缓存 1 小时,失败缓存 30 秒,同一查询合并并发;限流返回错误,不转换成空列表。 +和 `expiresAt`。显式查询刷新成功结果,同一批并发查询共享结果;下载固定 tag 的产物可复用 1 小时内的元数据,失败缓存 30 秒;限流返回错误,不转换成空列表。 #### 下载认证与代理 diff --git a/frontend/package.json b/frontend/package.json index 61dcd5218..b07d68ae1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -30,6 +30,7 @@ "gsap": "^3.15.0", "pinia": "^4.0.3", "pinia-plugin-persistedstate": "^4.7.1", + "semver": "^7.8.5", "vue": "^3.5.43", "vue-router": "^5.3.1" }, @@ -38,6 +39,7 @@ "@tailwindcss/vite": "^4.3.3", "@types/css-tree": "^3.2.0", "@types/node": "^26.6.2", + "@types/semver": "^7.8.0", "@vitejs/plugin-vue": "^6.0.9", "@vue/tsconfig": "^0.9.1", "eslint": "^10.11.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 957ac0491..2f49c87c1 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -208,6 +208,9 @@ importers: pinia-plugin-persistedstate: specifier: ^4.7.1 version: 4.7.1(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@6.0.3)(vue@3.5.43(typescript@6.0.3))) + semver: + specifier: ^7.8.5 + version: 7.8.5 vue: specifier: ^3.5.43 version: 3.5.43(typescript@6.0.3) @@ -227,6 +230,9 @@ importers: '@types/node': specifier: ^26.6.2 version: 26.6.2 + '@types/semver': + specifier: ^7.8.0 + version: 7.8.0 '@vitejs/plugin-vue': specifier: ^6.0.9 version: 6.0.9(vite@8.3.0(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.43(typescript@6.0.3)) @@ -900,6 +906,9 @@ packages: '@types/node@26.6.2': resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} + '@types/semver@7.8.0': + resolution: {integrity: sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -3170,6 +3179,8 @@ snapshots: dependencies: undici-types: 8.9.0 + '@types/semver@7.8.0': {} + '@types/trusted-types@2.0.7': optional: true diff --git a/frontend/src/views/plugins/components/PluginDetailModal.vue b/frontend/src/views/plugins/components/PluginDetailModal.vue index e09bc89ea..fd174a59c 100644 --- a/frontend/src/views/plugins/components/PluginDetailModal.vue +++ b/frontend/src/views/plugins/components/PluginDetailModal.vue @@ -68,7 +68,7 @@ watch(() => props.initialSection, value => section.value = value) - + diff --git a/frontend/src/views/plugins/components/PluginInstallModal.vue b/frontend/src/views/plugins/components/PluginInstallModal.vue index 3e9b6d224..a958f0fad 100644 --- a/frontend/src/views/plugins/components/PluginInstallModal.vue +++ b/frontend/src/views/plugins/components/PluginInstallModal.vue @@ -19,8 +19,8 @@ import { CheckCircle2, Download, FileArchive, Link, PackageOpen } from '@lucide/ import { useEventListener, useSessionStorage } from '@vueuse/core' import { isEqual } from 'es-toolkit' import { computed, nextTick, reactive, shallowRef, watch } from 'vue' -import { formatDateTime } from '@/utils/date' import { formatPluginFileSize, normalizePluginRepository, pluginInstallSelectionKey } from '../utils/model' +import { selectPluginReleaseAsset } from '../utils/updates' import PluginAssetPicker from './PluginAssetPicker.vue' import PluginDownloadAuthentication from './PluginDownloadAuthentication.vue' import PluginHelpPopover from './PluginHelpPopover.vue' @@ -80,7 +80,6 @@ interface InstallDraft { github: Omit } const drafts = useSessionStorage>('cp-plugin-install-drafts', {}, { flush: 'sync' }) -const draftKey = computed(() => props.updateSource ? `plugin:${props.updateSource.pluginId}` : 'install') let activeDraftKey: string | null = null const queriedGithubKey = shallowRef('') const pendingAuthentication = shallowRef(false) @@ -204,7 +203,7 @@ async function verifySelected() { } function reset() { - activeDraftKey = props.acceptanceArtifact ? null : draftKey.value + activeDraftKey = props.acceptanceArtifact || props.updateSource ? null : 'install' uploadFile.value = null Object.assign(urlForm, { url: '', @@ -236,7 +235,6 @@ function reset() { } if (previousGithub?.kind === 'github') { githubForm.repository = previousGithub.repository - githubForm.tag = previousGithub.tag githubForm.credentialIds = previousGithub.credential_ids.filter(id => props.credentials.some(credential => credential.id === id)) githubForm.outboundProxyId = previousGithub.outbound_proxy?.id ?? '' } @@ -254,12 +252,11 @@ function reset() { } githubForm.repository = source.source.repository githubForm.outboundProxyId = source.outboundProxyId ?? '' - if (source.policy.kind !== 'manual') - githubForm.tag = source.policy.kind === 'pinned' ? source.policy.tag : '' + githubForm.tag = source.policy.kind === 'pinned' ? source.policy.tag : '' githubForm.allowPrerelease = source.policy.kind === 'pinned' && source.policy.allow_prerelease } - const draft = drafts.value[draftKey.value] - if (draft && !props.acceptanceArtifact) { + const draft = activeDraftKey ? drafts.value[activeDraftKey] : null + if (draft) { Object.assign(urlForm, draft.url, { credentialIds: draft.url.credentialIds.filter(id => props.credentials.some(credential => credential.id === id)) }) Object.assign(githubForm, draft.github, { credentialIds: draft.github.credentialIds.filter(id => props.credentials.some(credential => credential.id === id)) }) emit('changeMode', draft.mode) @@ -410,8 +407,8 @@ watch( watch( () => props.release, async (release) => { - // 唯一归档可直接解析,多个归档交给用户选择,平台兼容性仍由包检查器确认。 - githubForm.asset = release && releaseAssets.value.length === 1 ? releaseAssets.value[0]!.name : '' + const previous = props.installedArtifacts.find(artifact => artifact.metadata.pluginId === props.updateSource?.pluginId) + githubForm.asset = release ? selectPluginReleaseAsset(release, previous)?.name ?? '' : '' if (githubForm.asset) { await nextTick() await verifySelected() @@ -598,7 +595,7 @@ watch( 返回 - + 重新查询 diff --git a/frontend/src/views/plugins/components/PluginManagement.vue b/frontend/src/views/plugins/components/PluginManagement.vue index e58873d5b..53498b56e 100644 --- a/frontend/src/views/plugins/components/PluginManagement.vue +++ b/frontend/src/views/plugins/components/PluginManagement.vue @@ -67,11 +67,12 @@ const tabOptions = [ () -defineEmits<{ install: [selection: PluginUpdateSelection] }>() +const props = defineProps<{ plugin: InstalledPlugin | null, result: PluginUpdateSelection | null, checking: boolean, upgrading: boolean }>() +defineEmits<{ install: [selection: PluginUpdateSelection], upgrade: [selection: PluginUpdateSelection] }>() const open = defineModel({ required: true }) -const installed = computed(() => props.plugin?.artifacts.some(artifact => artifact.acceptedAt && (props.result?.artifact - ? artifact.metadata.sha256 === props.result.artifact.metadata.sha256 - : props.result?.release && artifact.source.kind === 'github' && artifact.source.repository === props.result.release.repository && artifact.source.tag === props.result.release.tag))) +const current = computed(() => props.plugin?.artifact.metadata) +const candidate = computed(() => props.result?.artifact?.metadata) +const newer = computed(() => Boolean(current.value && candidate.value && isNewerPluginVersion(current.value, candidate.value))) +const addedPermissions = computed(() => candidate.value?.permissionDescriptions.filter(permission => !current.value?.requestedPermissions.includes(permission.permission)) ?? []) const canInstall = computed(() => props.result?.artifact || props.result?.release?.assets.some(asset => /\.(?:tar\.gz|tgz)$/i.test(asset.name))) +const status = computed(() => { + if (!candidate.value) + return '请选择插件包以确认版本与兼容性' + if (current.value?.sha256 === candidate.value.sha256) + return '当前已使用此版本' + if (newer.value) + return '发现新版本,插件包已校验' + return current.value?.version === candidate.value.version ? '版本号相同,插件包内容不同' : '未发现更高版本' +})