diff --git a/cli/src/services/suite-service.ts b/cli/src/services/suite-service.ts index 02bd1560..335528da 100644 --- a/cli/src/services/suite-service.ts +++ b/cli/src/services/suite-service.ts @@ -35,6 +35,8 @@ export interface SuiteInstallOptions { client?: SkillHubClient | undefined /** Internal seam used to verify atomic rollback after a filesystem commit failure. */ renameOperation?: typeof rename | undefined + /** Internal seam used to verify state observed immediately after target locking. */ + afterTargetLocksAcquired?: (() => Promise) | undefined } export interface SuiteInstallResult { @@ -90,6 +92,7 @@ interface RetiredTarget { item: InventoryItem target: InventoryTarget backupDir: string + fingerprint: string moved: boolean } @@ -194,9 +197,21 @@ export async function installSuite(options: SuiteInstallOptions): Promise + candidate.registry === options.registry && candidate.namespace === plan.namespace && candidate.slug === plan.slug) + assertSuiteSnapshotUnchanged(previousSuite, lockedPreviousSuite) await preflightExistingTargets(lockedInventory, options.registry, plan, options.targets, options.force) + for (const item of prepared) { + item.reuse = await isReusable(lockedInventory, options.registry, item.member, item.installDir) + item.replace = await pathExists(item.installDir) && !item.reuse + } + const lockedRetired = await prepareRetiredTargets( + lockedInventory, lockedPreviousSuite, plan, stageToken) + retired.splice(0, retired.length, ...lockedRetired.filter(item => + lockedPaths.has(resolve(item.target.installDir)))) for (const item of prepared) { if (item.reuse) { @@ -214,6 +229,12 @@ export async function installSuite(options: SuiteInstallOptions): Promise Promise) | undefined }): Promise { const store = new InventoryStore(options.home) const inventory = await store.read() const suite = findInstalledSuite(inventory, options.registry, options.namespace, options.slug) const source = suiteSource(suite.namespace, suite.slug, suite.version) - const removable: Array<{ item: InventoryItem; target: InventoryTarget; backupDir: string }> = [] + const candidates: Array<{ + item: InventoryItem + target: InventoryTarget + fingerprint: string + backupDir: string + }> = [] + const removable: typeof candidates = [] const preserved: SuiteRemoveResult['preserved'] = [] const token = `${process.pid}-${Date.now()}` @@ -338,43 +367,51 @@ export async function removeSuite(options: { for (const installDir of member.installDirs) { const target = item.targets.find(candidate => resolve(candidate.installDir) === resolve(installDir)) if (!target) continue - const remainingSources = targetInstalledBy(item, target).filter(candidate => candidate !== source) - if (remainingSources.length > 0) { - preserved.push({ dir: installDir, reason: 'shared' }) - } else if (!(await pathExists(installDir))) { - preserved.push({ dir: installDir, reason: 'missing' }) - } else if ((await snapshotSkillDirectory(installDir)).fingerprint !== member.fingerprint) { - preserved.push({ dir: installDir, reason: 'modified' }) - } else { - removable.push({ item, target, backupDir: `${installDir}.skillhub-suite-remove-${token}` }) - } + candidates.push({ + item, + target, + fingerprint: member.fingerprint, + backupDir: `${installDir}.skillhub-suite-remove-${token}` + }) } } const releases: Array<() => Promise> = [] const moved: typeof removable = [] try { - for (const candidate of [...removable].sort((a, b) => a.target.installDir.localeCompare(b.target.installDir))) { + for (const candidate of [...candidates].sort((a, b) => a.target.installDir.localeCompare(b.target.installDir))) { releases.push(await acquireSkillTargetLock(candidate.target.rootDir, candidate.item.slug)) } + await options.afterTargetLocksAcquired?.() const lockedInventory = await store.read() - for (const candidate of removable) { + const lockedSuite = findInstalledSuite( + lockedInventory, options.registry, options.namespace, options.slug) + assertSuiteSnapshotUnchanged(suite, lockedSuite) + for (const candidate of candidates) { const current = lockedInventory.items.find(item => item.registry === candidate.item.registry && item.namespace === candidate.item.namespace && item.slug === candidate.item.slug) const target = current?.targets.find(item => resolve(item.installDir) === resolve(candidate.target.installDir)) - if (!current || !target - || targetInstalledBy(current, target).some(candidateSource => candidateSource !== source)) { - throw new CliError(`Suite member ownership changed before removal: ${candidate.target.installDir}`, EXIT.validation, { - path: candidate.target.installDir, - next: 'run `skillhub suite check` and retry' - }) + if (!current || !target || !(await pathExists(candidate.target.installDir))) { + preserved.push({ dir: candidate.target.installDir, reason: 'missing' }) + } else if (targetInstalledBy(current, target).some(candidateSource => candidateSource !== source)) { + preserved.push({ dir: candidate.target.installDir, reason: 'shared' }) + } else if ((await snapshotSkillDirectory(candidate.target.installDir)).fingerprint !== candidate.fingerprint) { + preserved.push({ dir: candidate.target.installDir, reason: 'modified' }) + } else { + removable.push({ ...candidate, item: current, target }) } } for (const candidate of removable) { await rename(candidate.target.installDir, candidate.backupDir) moved.push(candidate) + if ((await snapshotSkillDirectory(candidate.backupDir)).fingerprint !== candidate.fingerprint) { + throw new CliError(`Suite member changed before removal: ${candidate.target.installDir}`, EXIT.validation, { + path: candidate.target.installDir, + next: 'restore the retained directory and run `skillhub suite check` before retrying' + }) + } } await store.mutateAtomic(current => { current.suites = installedSuites(current).filter(candidate => @@ -386,10 +423,11 @@ export async function removeSuite(options: { .map(candidate => resolve(candidate.target.installDir))) item.targets = item.targets .filter(target => !deletedDirs.has(resolve(target.installDir))) - .map(target => ({ - ...target, - installedBy: targetInstalledBy(item, target).filter(candidate => candidate !== source) - })) + .map(target => { + const remainingSources = targetInstalledBy(item, target) + .filter(candidate => candidate !== source) + return { ...target, installedBy: remainingSources.length > 0 ? remainingSources : ['direct'] } + }) item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? []))) } current.items = current.items.filter(item => item.targets.length > 0) @@ -678,8 +716,18 @@ function commitInventory( inventory.suites.push(suite) const retiredDirs = new Set(retired.map(item => resolve(item.target.installDir))) + const preparedDirs = new Set(prepared.map(item => resolve(item.installDir))) for (const item of inventory.items) { - item.targets = item.targets.filter(target => !retiredDirs.has(resolve(target.installDir))) + item.targets = item.targets + .filter(target => !retiredDirs.has(resolve(target.installDir))) + .map(target => { + const installDir = resolve(target.installDir) + if ((target.installedBy?.length ?? 0) > 0 || preparedDirs.has(installDir)) return target + // A retired directory that became shared or locally modified while waiting for locks is + // preserved as user-owned instead of becoming eligible for a later automatic deletion. + return { ...target, installedBy: ['direct'] } + }) + item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? []))) } inventory.items = inventory.items.filter(item => item.targets.length > 0) } @@ -743,6 +791,7 @@ async function prepareRetiredTargets( item, target, backupDir: `${installDir}.skillhub-suite-retired-${token}`, + fingerprint: member.fingerprint, moved: false }) } @@ -750,6 +799,16 @@ async function prepareRetiredTargets( return retired } +function assertSuiteSnapshotUnchanged( + before: InventorySuite | undefined, + locked: InventorySuite | undefined +): void { + if (before?.version === locked?.version && before?.fingerprint === locked?.fingerprint) return + throw new CliError('installed Suite changed while waiting for target locks', EXIT.validation, { + next: 'run `skillhub suite check` and retry' + }) +} + function describe(error: unknown): string { return error instanceof Error ? error.message : String(error) } diff --git a/cli/test/unit/services/suite-service.test.ts b/cli/test/unit/services/suite-service.test.ts index 5b789560..b0fbdfa4 100644 --- a/cli/test/unit/services/suite-service.test.ts +++ b/cli/test/unit/services/suite-service.test.ts @@ -747,4 +747,83 @@ describe('Suite local lifecycle', () => { expect(inventory.suites[0]).toMatchObject({ version: '2.0.0', fingerprint: 'sha256:suite-v2' }) expect(inventory.items.map((item: { slug: string }) => item.slug).sort()).toEqual(['alpha', 'gamma']) }) + + test('preserves a member modified after removal starts but before locked validation', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const { plan, downloads } = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + }) + + const removed = await removeSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + home, + afterTargetLocksAcquired: async () => { + await writeFile(join(rootDir, 'beta', 'SKILL.md'), '# Locally modified Beta') + } + }) + + expect(removed.removed).toEqual([join(rootDir, 'alpha')]) + expect(removed.preserved).toEqual([{ dir: join(rootDir, 'beta'), reason: 'modified' }]) + expect(await readFile(join(rootDir, 'beta', 'SKILL.md'), 'utf8')).toBe('# Locally modified Beta') + const inventory = await new InventoryStore(home).read() + expect(inventory.items).toEqual([expect.objectContaining({ + slug: 'beta', + installedBy: ['direct'] + })]) + }) + + test('preserves a retired member that gains direct ownership before locked upgrade validation', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) + const first = makePlan() + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(first.plan, first.downloads) + }) + + const second: SuiteInstallPlan = { + ...first.plan, + operationId: 'operation-2', + version: '2.0.0', + fingerprint: 'sha256:suite-v2', + members: [first.plan.members[0]!] + } + const beta = first.plan.members[1]! + const betaDir = join(rootDir, beta.slug) + await installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + version: '2.0.0', + targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }], + force: true, + home, + client: clientFor(second, first.downloads), + afterTargetLocksAcquired: async () => { + await new InventoryStore(home).upsertTarget( + registry, beta.namespace, beta.slug, beta.version, + { agent: 'codex', rootDir, installDir: betaDir, installedAt: new Date().toISOString() }, + beta.fingerprint) + } + }) + + expect(await readFile(join(betaDir, 'SKILL.md'), 'utf8')).toBe('# Beta') + const inventory = await new InventoryStore(home).read() + expect(inventory.items.find(item => item.slug === 'beta')).toMatchObject({ installedBy: ['direct'] }) + }) }) diff --git a/docs/25-skill-suites.md b/docs/25-skill-suites.md index 55f47545..545ecd35 100644 --- a/docs/25-skill-suites.md +++ b/docs/25-skill-suites.md @@ -50,6 +50,9 @@ Suite 的可见范围不能宽于成员: SuiteVersion 仍为 PUBLISHED,但安装计划会整体失败。硬删除只清空成员外键;坐标、版本和 fingerprint 快照继续用于历史展示和审计。 +创作页面保存成员时会携带候选接口返回的精确 `skillVersionId`。服务端按 ID 读取版本,并校验坐标和 +版本一致后再保存快照,不会按名称重新解析到另一个所有者的同名 Skill。 + ## `suite.yaml` 定义 `suite.yaml` 是可移植的 Suite 创作格式,不是上传到 Agent 的多 Skill ZIP: diff --git a/openspec/changes/add-skill-suites/design.md b/openspec/changes/add-skill-suites/design.md index 47acaa82..ee2d4206 100644 --- a/openspec/changes/add-skill-suites/design.md +++ b/openspec/changes/add-skill-suites/design.md @@ -118,13 +118,13 @@ spec: version: 2.1.0 ``` -该文件不是下载到 Agent 的包。v1 通过 Web/API 提交等价字段;CLI 导入 `suite.yaml` 属于后续增量能力。服务端在创建 SuiteVersion 时解析并保存精确 `skillVersionId` 和 fingerprint。一个 SuiteVersion 最多包含 100 个不同 Skill;同一 Skill 不允许重复出现。 +该文件不是下载到 Agent 的包。v1 通过 Web/API 提交等价字段;CLI 导入 `suite.yaml` 属于后续增量能力。候选接口返回精确 `skillVersionId`,创作请求原样携带该 ID;服务端按 ID 读取版本,并校验请求中的坐标和版本与该记录一致后保存 fingerprint。服务端不按坐标二次解析版本,避免同一 Namespace 和 slug 下的历史所有权冲突绑定到错误 Skill。一个 SuiteVersion 最多包含 100 个不同 Skill;同一 Skill 不允许重复出现。 SuiteVersion 同时保存短 `summary` 和可选的 Markdown `overview`。`summary` 用于搜索卡片和详情页首屏摘要;`overview` 用于说明成员组合方式、推荐顺序、输入输出和使用边界,并作为审核快照的一部分随 SuiteVersion 冻结。详情页将概述与成员列表分开呈现,成员列表批量解析实时展示名称和摘要,但仅向有权读取该 Skill 的当前查看者返回;安装与审计仍以快照坐标、精确版本和 fingerprint 为准,受限或硬删除成员只保留不可点击且不含实时元数据的历史快照。 成员发布新版本不会改变已有 SuiteVersion。采用新版本、添加、删除、重排成员或修改 Entry Skill 都必须创建新的 SuiteVersion。 -为了降低创作成本,Web 在添加 Member 时默认推荐该 Skill 当前可安装的最新版本,但保存时立即解析为精确 `skillVersionId`、version 和 fingerprint,并向作者展示实际固定的版本。作者可以显式选择其他仍处于 PUBLISHED 的历史版本。 +为了降低创作成本,Web 在添加 Member 时默认推荐该 Skill 当前可安装的最新版本,但保存时提交候选结果中的精确 `skillVersionId`、version 和坐标,服务端校验三者一致后固定 fingerprint,并向作者展示实际固定的版本。作者可以显式选择其他仍处于 PUBLISHED 的历史版本。 不得在已发布 SuiteVersion 中保存 `latest` 或在安装时重新解析最新版本。可以提供“更新成员版本”辅助操作,但该操作必须先展示版本差异,并创建或修改 DRAFT SuiteVersion;它不是后台自动升级。 diff --git a/openspec/changes/add-skill-suites/specs/skill-suites/spec.md b/openspec/changes/add-skill-suites/specs/skill-suites/spec.md index 08b29d38..2762b8b7 100644 --- a/openspec/changes/add-skill-suites/specs/skill-suites/spec.md +++ b/openspec/changes/add-skill-suites/specs/skill-suites/spec.md @@ -22,13 +22,18 @@ ### Requirement: SuiteVersion SHALL reference immutable published Skill versions -系统 SHALL 只允许 SuiteVersion 引用同一 Registry 中状态为 PUBLISHED 的精确 SkillVersion,并 SHALL 保存成员坐标、版本和 fingerprint 快照。一个 SuiteVersion SHALL 最多包含 100 个不同 Skill。 +系统 SHALL 只允许 SuiteVersion 引用同一 Registry 中状态为 PUBLISHED 的精确 SkillVersion。创作请求 SHALL 携带候选接口返回的 `skillVersionId`,服务端 SHALL 按该 ID 读取版本并校验随请求提交的坐标与版本一致,避免同名 Skill 被重新解析到其他所有者。系统 SHALL 保存成员坐标、版本和 fingerprint 快照。一个 SuiteVersion SHALL 最多包含 100 个不同 Skill。 #### Scenario: Create a valid SuiteVersion - **WHEN** 管理者提交不超过 100 个不同的已发布 SkillVersion - **THEN** 系统创建 DRAFT SuiteVersion - **AND** 每个 Member 保存精确 SkillVersion ID、坐标、版本、fingerprint 和顺序 +#### Scenario: Reject mismatched member identity +- **WHEN** 请求中的 `skillVersionId` 与同时提交的坐标或版本不一致 +- **THEN** 系统拒绝该 SuiteVersion 定义 +- **AND** 不按坐标重新解析到另一个同名 SkillVersion + #### Scenario: Add a Member without choosing a version - **WHEN** 作者添加一个 Skill 且没有显式选择版本 - **THEN** 系统向作者推荐当前可安装的最新 SkillVersion diff --git a/scripts/suite-smoke-test.sh b/scripts/suite-smoke-test.sh index ba2c1412..aebd8d1f 100755 --- a/scripts/suite-smoke-test.sh +++ b/scripts/suite-smoke-test.sh @@ -206,11 +206,11 @@ if [[ -z "$SKILL_VERSION_ID" ]]; then fi echo "PASS: member Skill is published and downloadable" -SUITE_PAYLOAD="$(python3 - "$SUITE_SLUG" "$SKILL_SLUG" <<'PY' +SUITE_PAYLOAD="$(python3 - "$SUITE_SLUG" "$SKILL_SLUG" "$SKILL_VERSION_ID" <<'PY' import json import sys -member = {"namespace": "global", "slug": sys.argv[2], "version": "1.0.0"} +member = {"skillVersionId": int(sys.argv[3]), "namespace": "global", "slug": sys.argv[2], "version": "1.0.0"} print(json.dumps({ "namespace": "global", "slug": sys.argv[1], diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 94f328eb..7b38d478 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -25,7 +25,7 @@ import com.iflytek.skillhub.metrics.SkillHubMetrics; import com.iflytek.skillhub.ratelimit.RateLimit; import com.iflytek.skillhub.service.SkillLabelAppService; import com.iflytek.skillhub.service.ComplianceSnapshotProjectionService; -import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository; +import com.iflytek.skillhub.service.SkillSuiteAppService; import org.springframework.core.io.InputStreamResource; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -56,7 +56,7 @@ public class SkillController extends BaseApiController { private final SkillDownloadService skillDownloadService; private final SkillLabelAppService skillLabelAppService; private final ComplianceSnapshotProjectionService complianceSnapshotProjectionService; - private final SkillSuiteReferenceQueryRepository suiteReferenceQueryRepository; + private final SkillSuiteAppService skillSuiteAppService; private final SkillHubMetrics metrics; public SkillController( @@ -64,7 +64,7 @@ public class SkillController extends BaseApiController { SkillDownloadService skillDownloadService, SkillLabelAppService skillLabelAppService, ComplianceSnapshotProjectionService complianceSnapshotProjectionService, - SkillSuiteReferenceQueryRepository suiteReferenceQueryRepository, + SkillSuiteAppService skillSuiteAppService, SkillHubMetrics metrics, ApiResponseFactory responseFactory) { super(responseFactory); @@ -72,7 +72,7 @@ public class SkillController extends BaseApiController { this.skillDownloadService = skillDownloadService; this.skillLabelAppService = skillLabelAppService; this.complianceSnapshotProjectionService = complianceSnapshotProjectionService; - this.suiteReferenceQueryRepository = suiteReferenceQueryRepository; + this.skillSuiteAppService = skillSuiteAppService; this.metrics = metrics; } @@ -118,7 +118,7 @@ public class SkillController extends BaseApiController { toLifecycleVersion(detail.ownerPreviewVersion()), detail.ownerPreviewReviewComment(), detail.resolutionMode(), - suiteReferenceQueryRepository.findVisibleEntryReferences( + skillSuiteAppService.findVisibleEntryReferences( detail.id(), userId, namespaceRoles, principal == null || principal.platformRoles() == null ? Set.of() : principal.platformRoles()) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteMemberRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteMemberRequest.java index 32ad5acc..7b3dd2be 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteMemberRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteMemberRequest.java @@ -1,9 +1,11 @@ package com.iflytek.skillhub.dto; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; /** Exact Skill coordinate selected for a Suite draft. */ public record SkillSuiteMemberRequest( + @NotNull Long skillVersionId, @NotBlank String namespace, @NotBlank String slug, @NotBlank String version diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java index d36f0b4d..3d1a64c1 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java @@ -7,6 +7,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.audit.AuditDetail; import com.iflytek.skillhub.domain.audit.AuditLogService; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.suite.CreateSkillSuiteDraftCommand; @@ -28,9 +29,11 @@ import com.iflytek.skillhub.dto.SkillSuiteInstallMemberResponse; import com.iflytek.skillhub.dto.SkillSuiteInstallPlanResponse; import com.iflytek.skillhub.dto.SkillSuiteMemberCandidateResponse; import com.iflytek.skillhub.dto.SkillSuiteVersionSummaryResponse; +import com.iflytek.skillhub.dto.SkillSuiteReferenceResponse; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.observability.RequestIdAccessor; import com.iflytek.skillhub.repository.SkillSuiteCandidateQueryRepository; +import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository; import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository; import com.iflytek.skillhub.dto.MySkillSuiteSummaryResponse; import com.iflytek.skillhub.dto.PageResponse; @@ -67,6 +70,7 @@ public class SkillSuiteAppService { private final RequestIdAccessor requestIdAccessor; private final SkillSuiteCandidateQueryRepository candidateQueryRepository; private final MySkillSuiteQueryRepository mySkillSuiteQueryRepository; + private final SkillSuiteReferenceQueryRepository referenceQueryRepository; public SkillSuiteAppService( NamespaceRepository namespaceRepository, @@ -79,7 +83,8 @@ public class SkillSuiteAppService { AuditLogService auditLogService, RequestIdAccessor requestIdAccessor, SkillSuiteCandidateQueryRepository candidateQueryRepository, - MySkillSuiteQueryRepository mySkillSuiteQueryRepository + MySkillSuiteQueryRepository mySkillSuiteQueryRepository, + SkillSuiteReferenceQueryRepository referenceQueryRepository ) { this.namespaceRepository = namespaceRepository; this.skillQueryService = skillQueryService; @@ -92,6 +97,7 @@ public class SkillSuiteAppService { this.requestIdAccessor = requestIdAccessor; this.candidateQueryRepository = candidateQueryRepository; this.mySkillSuiteQueryRepository = mySkillSuiteQueryRepository; + this.referenceQueryRepository = referenceQueryRepository; } public PageResponse listMine( @@ -128,7 +134,7 @@ public class SkillSuiteAppService { } boolean superAdmin = platformRoles.contains("SUPER_ADMIN"); if (!superAdmin && !namespaceRoles.containsKey(namespace.getId())) { - throw new DomainBadRequestException("error.suite.lifecycle.noPermission"); + throw new DomainForbiddenException("error.suite.lifecycle.noPermission"); } int boundedSize = Math.max(1, Math.min(size, 100)); List memberNamespaceIds = List.copyOf(namespaceRoles.keySet()); @@ -142,6 +148,16 @@ public class SkillSuiteAppService { adminNamespaceIds, superAdmin, boundedSize); } + public List findVisibleEntryReferences( + Long skillId, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + return referenceQueryRepository.findVisibleEntryReferences( + skillId, userId, namespaceRoles, platformRoles); + } + @Transactional public SkillSuiteInstallPlanResponse createInstallPlan( String namespace, @@ -496,8 +512,13 @@ public class SkillSuiteAppService { String userId, Map namespaceRoles ) { - SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( - member.namespace(), member.slug(), member.version(), null, null, userId, namespaceRoles); + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersionById( + member.skillVersionId(), userId, namespaceRoles); + if (!Objects.equals(resolved.namespace(), member.namespace()) + || !Objects.equals(resolved.slug(), member.slug()) + || !Objects.equals(resolved.version(), member.version())) { + throw new DomainBadRequestException("error.suite.members.selectionMismatch"); + } return new SkillSuiteMemberSelection( resolved.skillId(), resolved.versionId(), resolved.namespace(), resolved.slug(), resolved.version(), resolved.fingerprint()); @@ -527,7 +548,8 @@ public class SkillSuiteAppService { throw new DomainBadRequestException("error.suite.entry.required"); } return members.stream() - .filter(member -> Objects.equals(member.namespaceSlug(), entry.namespace()) + .filter(member -> Objects.equals(member.skillVersionId(), entry.skillVersionId()) + && Objects.equals(member.namespaceSlug(), entry.namespace()) && Objects.equals(member.skillSlug(), entry.slug()) && Objects.equals(member.version(), entry.version())) .map(SkillSuiteMemberSelection::skillVersionId) diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 64fb167e..2e66f227 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -209,6 +209,7 @@ error.suite.members.empty=A Skill Suite must contain at least one Skill error.suite.members.limit=A Skill Suite cannot contain more than {0} Skills error.suite.members.duplicate=A Skill Suite cannot contain multiple versions of the same Skill error.suite.members.unavailable=One or more Suite members are unavailable: {0} +error.suite.members.selectionMismatch=The selected Skill version does not match its coordinate error.suite.entry.notMember=The Entry Skill must be one of the Suite members error.suite.entry.required=An Entry Skill is required error.suite.namespace.notWritable=The Suite namespace is not writable: {0} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 4fb2b56a..672685d7 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -209,6 +209,7 @@ error.suite.members.empty=技能套件至少需要包含一个技能 error.suite.members.limit=技能套件最多包含 {0} 个技能 error.suite.members.duplicate=技能套件不能包含同一技能的多个版本 error.suite.members.unavailable=一个或多个套件成员当前不可用:{0} +error.suite.members.selectionMismatch=所选技能版本与提交的坐标不匹配 error.suite.entry.notMember=入口技能必须是套件成员 error.suite.entry.required=必须选择入口技能 error.suite.namespace.notWritable=套件所在命名空间不可写:{0} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index ae6c9c5d..04aec5ad 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -9,8 +9,8 @@ import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.service.SkillDownloadService; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.dto.SkillSuiteReferenceResponse; -import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository; import com.iflytek.skillhub.service.SkillLabelAppService; +import com.iflytek.skillhub.service.SkillSuiteAppService; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -53,7 +53,7 @@ class SkillControllerTest { private SkillLabelAppService skillLabelAppService; @MockBean - private SkillSuiteReferenceQueryRepository suiteReferenceQueryRepository; + private SkillSuiteAppService skillSuiteAppService; @Test void getVersionDetailShouldReturnMetadataFields() throws Exception { @@ -188,7 +188,7 @@ class SkillControllerTest { null, "OWNER_PREVIEW" )); - when(suiteReferenceQueryRepository.findVisibleEntryReferences( + when(skillSuiteAppService.findVisibleEntryReferences( eq(1L), eq((String) null), eq(Map.of()), anySet())) .thenReturn(List.of(new SkillSuiteReferenceResponse( 9L, "team", "demo-suite", "Demo Suite", "2.0.0", 3))); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java index 8b9dfb73..e05618ee 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java @@ -11,6 +11,8 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.dto.SkillSuiteCreateRequest; +import com.iflytek.skillhub.dto.SkillSuiteMemberRequest; import com.iflytek.skillhub.domain.suite.SkillSuite; import com.iflytek.skillhub.domain.suite.SkillSuiteAllowedAction; import com.iflytek.skillhub.domain.suite.SkillSuiteDraftService; @@ -25,6 +27,7 @@ import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember; import com.iflytek.skillhub.repository.SkillSuiteCandidateQueryRepository; import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository; +import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository; import com.iflytek.skillhub.observability.RequestIdAccessor; import jakarta.servlet.http.HttpServletRequest; import org.junit.jupiter.api.BeforeEach; @@ -60,6 +63,7 @@ class SkillSuiteAppServiceTest { @Mock private RequestIdAccessor requestIdAccessor; @Mock private SkillSuiteCandidateQueryRepository candidateQueryRepository; @Mock private MySkillSuiteQueryRepository mySkillSuiteQueryRepository; + @Mock private SkillSuiteReferenceQueryRepository referenceQueryRepository; @Mock private HttpServletRequest request; private SkillSuiteAppService service; private Namespace namespace; @@ -74,7 +78,7 @@ class SkillSuiteAppServiceTest { namespaceRepository, skillQueryService, draftService, lifecycleService, queryService, installMetricsService, installOperationRepository, auditLogService, requestIdAccessor, - candidateQueryRepository, mySkillSuiteQueryRepository); + candidateQueryRepository, mySkillSuiteQueryRepository, referenceQueryRepository); namespace = new Namespace("global", "Global", "admin"); setField(namespace, "id", 1L); suite = new SkillSuite(1L, "starter", "Starter", "user-1"); @@ -268,6 +272,26 @@ class SkillSuiteAppServiceTest { }); } + @Test + void create_rejectsWhenExactVersionIdDoesNotMatchTheSubmittedCoordinate() { + SkillSuiteMemberRequest member = new SkillSuiteMemberRequest( + 101L, "global", "selected", "1.0.0"); + SkillSuiteCreateRequest createRequest = new SkillSuiteCreateRequest( + "global", "starter", "Starter", null, null, "1.0.0", + SkillVisibility.PRIVATE, null, member, List.of(member)); + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillQueryService.resolveVersionById(101L, "user-1", Map.of())) + .willReturn(resolved(99L, 101L, "different", "1.0.0", "sha256:different")); + + assertThatThrownBy(() -> service.create( + createRequest, "user-1", Map.of(), Set.of(), request)) + .isInstanceOfSatisfying(DomainBadRequestException.class, exception -> + assertThat(exception.messageCode()) + .isEqualTo("error.suite.members.selectionMismatch")); + + verify(draftService, never()).create(any(), any()); + } + private SkillSuiteQueryService.Detail detail(boolean available) { if (!available) { return new SkillSuiteQueryService.Detail(namespace, suite, version, false, List.of()); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index d115e485..07f5e590 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -622,6 +622,33 @@ public class SkillQueryService { ); } + /** Resolves the exact version selected by an authenticated authoring flow. */ + public ResolvedVersionDTO resolveVersionById( + Long versionId, + String currentUserId, + Map userNsRoles + ) { + SkillVersion version = skillVersionRepository.findById(versionId) + .orElseThrow(() -> new DomainBadRequestException("error.skill.version.notFound", versionId)); + Skill skill = skillRepository.findById(version.getSkillId()) + .orElseThrow(() -> new DomainBadRequestException("error.skill.notFound", version.getSkillId())); + Namespace namespace = namespaceRepository.findById(skill.getNamespaceId()) + .orElseThrow(() -> new DomainBadRequestException( + "error.namespace.id.notFound", skill.getNamespaceId())); + assertPublishedAccessible(namespace, skill, currentUserId, userNsRoles); + assertInstallableVersion(version, version.getVersion()); + String fingerprint = computeFingerprint(version); + return new ResolvedVersionDTO( + skill.getId(), namespace.getSlug(), skill.getSlug(), version.getVersion(), version.getId(), + fingerprint, null, + String.format( + "/api/v1/skills/%s/%s/versions/%s/download", + encodePathSegment(namespace.getSlug()), + encodePathSegment(skill.getSlug()), + encodePathSegment(version.getVersion())) + ); + } + private Namespace findNamespace(String slug) { return namespaceRepository.findBySlug(slug) .orElseThrow(() -> new DomainBadRequestException("error.namespace.slug.notFound", slug)); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index 67d34fc4..439f3262 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -745,6 +745,35 @@ class SkillQueryServiceTest { assertEquals("/api/v1/skills/global/smoke-skill-two/versions/1.0.0%20beta/download", result.downloadUrl()); } + @Test + void testResolveVersionById_ShouldKeepTheExactSelectedOwnerWhenSlugsCollide() throws Exception { + Namespace namespace = new Namespace("team", "Team", "owner-1"); + setId(namespace, 1L); + Skill selected = new Skill(1L, "shared", "other-owner", SkillVisibility.PUBLIC); + setId(selected, 22L); + selected.setStatus(SkillStatus.ACTIVE); + selected.setLatestVersionId(220L); + SkillVersion selectedVersion = new SkillVersion(22L, "1.0.0", "other-owner"); + setId(selectedVersion, 220L); + selectedVersion.setStatus(SkillVersionStatus.PUBLISHED); + selectedVersion.setDownloadReady(true); + SkillFile file = new SkillFile(220L, "SKILL.md", 10L, "text/markdown", "hash", "key"); + + when(skillVersionRepository.findById(220L)).thenReturn(Optional.of(selectedVersion)); + when(skillRepository.findById(22L)).thenReturn(Optional.of(selected)); + when(namespaceRepository.findById(1L)).thenReturn(Optional.of(namespace)); + when(skillFileRepository.findByVersionId(220L)).thenReturn(List.of(file)); + + SkillQueryService.ResolvedVersionDTO result = service.resolveVersionById( + 220L, "current-owner", Map.of(1L, NamespaceRole.MEMBER)); + + assertEquals(22L, result.skillId()); + assertEquals(220L, result.versionId()); + assertEquals("team", result.namespace()); + assertEquals("shared", result.slug()); + verify(skillRepository, never()).findByNamespaceIdAndSlug(anyLong(), anyString()); + } + @Test void testResolveVersion_ShouldRejectDownloadUnavailableLatestVersion() throws Exception { String namespaceSlug = "global"; diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 4d4b4943..07428141 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -4414,6 +4414,8 @@ export interface components { members: components["schemas"]["SkillSuiteMemberRequest"][]; }; SkillSuiteMemberRequest: { + /** Format: int64 */ + skillVersionId: number; namespace: string; slug: string; version: string; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 826b4d79..c7b79fce 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -1807,6 +1807,13 @@ "selectedMembers": "Suite members ({{count}}/100)", "setEntry": "Set as entry skill", "entryRequired": "Select an entry skill", + "confirmVersionUpdateTitle": "Confirm pinned version change", + "confirmVersionUpdateDescription": "{{coordinate}} will change from v{{from}} to v{{to}}. The Suite remains pinned to the new version and will not update automatically.", + "confirmVersionUpdate": "Change version", + "moveMemberUp": "Move {{name}} up", + "moveMemberDown": "Move {{name}} down", + "removeMember": "Remove {{name}}", + "setEntryFor": "Set {{name}} as the entry skill", "addMemberHint": "Add at least one skill from the left.", "cancel": "Cancel", "saveDraft": "Save draft", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 1d2ba960..f9856782 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -1804,6 +1804,13 @@ "selectedMembers": "Участники набора ({{count}}/100)", "setEntry": "Назначить входным навыком", "entryRequired": "Выберите входной навык", + "confirmVersionUpdateTitle": "Подтвердите изменение закреплённой версии", + "confirmVersionUpdateDescription": "{{coordinate}} будет изменён с v{{from}} на v{{to}}. Набор останется закреплённым на новой версии и не будет обновляться автоматически.", + "confirmVersionUpdate": "Изменить версию", + "moveMemberUp": "Переместить {{name}} выше", + "moveMemberDown": "Переместить {{name}} ниже", + "removeMember": "Удалить {{name}}", + "setEntryFor": "Назначить {{name}} входным навыком", "addMemberHint": "Добавьте хотя бы один навык слева.", "cancel": "Отмена", "saveDraft": "Сохранить черновик", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 0073efb1..e96dc3fe 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -1806,6 +1806,13 @@ "selectedMembers": "套件成员({{count}}/100)", "setEntry": "设为入口技能", "entryRequired": "请选择一个入口技能", + "confirmVersionUpdateTitle": "确认更换固定版本", + "confirmVersionUpdateDescription": "{{coordinate}} 将从 v{{from}} 更换为 v{{to}}。保存后仍固定到新版本,不会自动升级。", + "confirmVersionUpdate": "确认更换", + "moveMemberUp": "上移 {{name}}", + "moveMemberDown": "下移 {{name}}", + "removeMember": "移除 {{name}}", + "setEntryFor": "将 {{name}} 设为入口技能", "addMemberHint": "从左侧添加至少一个技能。", "cancel": "取消", "saveDraft": "保存草稿", diff --git a/web/src/pages/dashboard/suite-editor.test.tsx b/web/src/pages/dashboard/suite-editor.test.tsx index 6cebb23c..6c1cb575 100644 --- a/web/src/pages/dashboard/suite-editor.test.tsx +++ b/web/src/pages/dashboard/suite-editor.test.tsx @@ -3,7 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SkillSuite } from '@/api/types' +import type { SkillSuite, SkillSuiteMemberCandidate } from '@/api/types' import { SuiteEditor } from './suite-editor' const mocks = vi.hoisted(() => ({ @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ createVersion: { mutateAsync: vi.fn(), isPending: false }, update: { mutateAsync: vi.fn(), isPending: false }, toast: { success: vi.fn(), error: vi.fn() }, + candidates: [] as SkillSuiteMemberCandidate[], })) vi.mock('@tanstack/react-router', () => ({ useNavigate: () => mocks.navigate })) @@ -26,7 +27,7 @@ vi.mock('@/shared/hooks/use-suite-queries', () => ({ useCreateSuite: () => mocks.create, useCreateSuiteVersion: () => mocks.createVersion, useSuiteDetail: () => mocks.detail, - useSuiteMemberCandidates: () => ({ data: [], isLoading: false }), + useSuiteMemberCandidates: () => ({ data: mocks.candidates, isLoading: false }), useUpdateSuiteDraft: () => mocks.update, })) vi.mock('@/shared/ui/select', () => ({ @@ -72,6 +73,7 @@ describe('SuiteEditor', () => { cleanup() vi.clearAllMocks() mocks.detail = { data: undefined, isLoading: false, error: null } + mocks.candidates = [] }) it('shows a source error instead of submitting with a zero Suite id', () => { @@ -123,8 +125,8 @@ describe('SuiteEditor', () => { version: '2.0.0', visibility: 'PUBLIC', changelog: undefined, - entrySkill: { namespace: 'global', slug: 'weather', version: '1.0.0' }, - members: [{ namespace: 'global', slug: 'weather', version: '1.0.0' }], + entrySkill: { skillVersionId: 90, namespace: 'global', slug: 'weather', version: '1.0.0' }, + members: [{ skillVersionId: 90, namespace: 'global', slug: 'weather', version: '1.0.0' }], })) }) @@ -141,4 +143,28 @@ describe('SuiteEditor', () => { expect(mocks.toast.error).toHaveBeenCalledWith('suite.entryRequired') expect(mocks.update.mutateAsync).not.toHaveBeenCalled() }) + + it('shows the pinned version change before replacing a selected member', async () => { + mocks.detail = { data: sourceSuite(['EDIT']), isLoading: false, error: null } + mocks.candidates = [{ + skillId: 9, + skillVersionId: 91, + namespace: 'global', + slug: 'weather', + displayName: 'Weather', + version: '2.0.0', + visibility: 'PUBLIC', + recommended: true, + }] + + render() + await waitFor(() => expect(screen.getByText('@global/weather@1.0.0')).not.toBeNull()) + fireEvent.click(screen.getByText('suite.updatePinnedVersion').closest('button')!) + + expect(screen.getByRole('dialog', { name: 'suite.confirmVersionUpdateTitle' })).not.toBeNull() + expect(screen.getByText('@global/weather@1.0.0')).not.toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'suite.confirmVersionUpdate' })) + + await waitFor(() => expect(screen.getByText('@global/weather@2.0.0')).not.toBeNull()) + }) }) diff --git a/web/src/pages/dashboard/suite-editor.tsx b/web/src/pages/dashboard/suite-editor.tsx index 11c665e7..08b6a4a9 100644 --- a/web/src/pages/dashboard/suite-editor.tsx +++ b/web/src/pages/dashboard/suite-editor.tsx @@ -13,6 +13,7 @@ import { import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' import { useDebounce } from '@/shared/hooks/use-debounce' import { DashboardPageHeader } from '@/shared/components/dashboard-page-header' +import { ConfirmDialog } from '@/shared/components/confirm-dialog' import { Button } from '@/shared/ui/button' import { Card } from '@/shared/ui/card' import { Input } from '@/shared/ui/input' @@ -49,6 +50,7 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio const [candidateQuery, setCandidateQuery] = useState('') const [selected, setSelected] = useState([]) const [entrySkillVersionId, setEntrySkillVersionId] = useState(null) + const [pendingVersionUpdate, setPendingVersionUpdate] = useState(null) const debouncedQuery = useDebounce(candidateQuery.trim(), 250) const { data: candidates, isLoading: isLoadingCandidates } = useSuiteMemberCandidates( namespace, visibility, debouncedQuery, Boolean(namespace), @@ -96,7 +98,7 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio }]) } - const updateCandidate = (candidate: SkillSuiteMemberCandidate) => { + const applyCandidateUpdate = (candidate: SkillSuiteMemberCandidate) => { const previous = selected.find((member) => member.skillId === candidate.skillId) if (previous && entrySkillVersionId === previous.skillVersionId) { setEntrySkillVersionId(candidate.skillVersionId) @@ -138,7 +140,8 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio toast.error(t('suite.entryRequired')) return } - const members = selected.map(({ namespace: memberNamespace, slug: memberSlug, version: memberVersion }) => ({ + const members = selected.map(({ skillVersionId, namespace: memberNamespace, slug: memberSlug, version: memberVersion }) => ({ + skillVersionId, namespace: memberNamespace, slug: memberSlug, version: memberVersion, @@ -157,7 +160,12 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio version: version.trim(), visibility, changelog: changelog.trim() || undefined, - entrySkill: { namespace: entry.namespace, slug: entry.slug, version: entry.version }, + entrySkill: { + skillVersionId: entry.skillVersionId, + namespace: entry.namespace, + slug: entry.slug, + version: entry.version, + }, members, } try { @@ -269,7 +277,7 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio className="flex w-full items-center justify-between rounded-lg border p-3 text-left hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50" disabled={selectedIds.has(candidate.skillVersionId)} onClick={() => selectedSkillIds.has(candidate.skillId) - ? updateCandidate(candidate) + ? setPendingVersionUpdate(candidate) : addCandidate(candidate)} > {candidate.displayName}@{candidate.namespace}/{candidate.slug} @@ -287,7 +295,7 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio

{t('suite.selectedMembers', { count: selected.length })}

-
+
{selected.map((member, index) => (
@@ -295,12 +303,12 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio {member.displayName} @{member.namespace}/{member.slug}@{member.version} - - - + + +
@@ -317,6 +325,21 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio onClick={save} >{t('suite.saveDraft')}
+ + { if (!open) setPendingVersionUpdate(null) }} + title={t('suite.confirmVersionUpdateTitle')} + description={pendingVersionUpdate ? t('suite.confirmVersionUpdateDescription', { + coordinate: `@${pendingVersionUpdate.namespace}/${pendingVersionUpdate.slug}`, + from: selected.find((member) => member.skillId === pendingVersionUpdate.skillId)?.version, + to: pendingVersionUpdate.version, + }) : undefined} + confirmText={t('suite.confirmVersionUpdate')} + onConfirm={() => { + if (pendingVersionUpdate) applyCandidateUpdate(pendingVersionUpdate) + }} + />
) }