fix(suite): bind exact members and protect local installs

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-09 12:29:51 +08:00
parent d0e8c168fa
commit a4b35b236a
21 changed files with 380 additions and 56 deletions

View file

@ -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<void>) | 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<SuiteI
lockedPaths.add(path)
releases.push(await acquireSkillTargetLock(target.rootDir, target.slug))
}
await options.afterTargetLocksAcquired?.()
// Recheck after target locks so a concurrent direct install cannot invalidate preflight.
const lockedInventory = await store.read()
const lockedPreviousSuite = installedSuites(lockedInventory).find(candidate =>
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<SuiteI
if (await pathExists(item.target.installDir)) {
await renameOperation(item.target.installDir, item.backupDir)
item.moved = true
if ((await snapshotSkillDirectory(item.backupDir)).fingerprint !== item.fingerprint) {
throw new CliError(`retired Suite member changed before commit: ${item.target.installDir}`, EXIT.validation, {
path: item.target.installDir,
next: 'restore the retained directory and retry the Suite upgrade'
})
}
}
}
@ -322,12 +343,20 @@ export async function removeSuite(options: {
namespace: string
slug: string
home?: string | undefined
/** Internal seam used to verify state observed immediately after target locking. */
afterTargetLocksAcquired?: (() => Promise<void>) | undefined
}): Promise<SuiteRemoveResult> {
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<void>> = []
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)
}

View file

@ -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'] })
})
})

View file

@ -50,6 +50,9 @@ Suite 的可见范围不能宽于成员:
SuiteVersion 仍为 PUBLISHED但安装计划会整体失败。硬删除只清空成员外键坐标、版本和 fingerprint
快照继续用于历史展示和审计。
创作页面保存成员时会携带候选接口返回的精确 `skillVersionId`。服务端按 ID 读取版本,并校验坐标和
版本一致后再保存快照,不会按名称重新解析到另一个所有者的同名 Skill。
## `suite.yaml` 定义
`suite.yaml` 是可移植的 Suite 创作格式,不是上传到 Agent 的多 Skill ZIP

View file

@ -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它不是后台自动升级。

View file

@ -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

View file

@ -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],

View file

@ -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())

View file

@ -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

View file

@ -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<MySkillSuiteSummaryResponse> 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<Long> memberNamespaceIds = List.copyOf(namespaceRoles.keySet());
@ -142,6 +148,16 @@ public class SkillSuiteAppService {
adminNamespaceIds, superAdmin, boundedSize);
}
public List<SkillSuiteReferenceResponse> findVisibleEntryReferences(
Long skillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
return referenceQueryRepository.findVisibleEntryReferences(
skillId, userId, namespaceRoles, platformRoles);
}
@Transactional
public SkillSuiteInstallPlanResponse createInstallPlan(
String namespace,
@ -496,8 +512,13 @@ public class SkillSuiteAppService {
String userId,
Map<Long, NamespaceRole> 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)

View file

@ -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}

View file

@ -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}

View file

@ -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)));

View file

@ -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());

View file

@ -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<Long, NamespaceRole> 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));

View file

@ -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";

View file

@ -4414,6 +4414,8 @@ export interface components {
members: components["schemas"]["SkillSuiteMemberRequest"][];
};
SkillSuiteMemberRequest: {
/** Format: int64 */
skillVersionId: number;
namespace: string;
slug: string;
version: string;

View file

@ -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",

View file

@ -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": "Сохранить черновик",

View file

@ -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": "保存草稿",

View file

@ -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(<SuiteEditor namespace="global" slug="starter" version="1.0.0" mode="edit" />)
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())
})
})

View file

@ -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<SelectedMember[]>([])
const [entrySkillVersionId, setEntrySkillVersionId] = useState<number | null>(null)
const [pendingVersionUpdate, setPendingVersionUpdate] = useState<SkillSuiteMemberCandidate | null>(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)}
>
<span><span className="block font-medium">{candidate.displayName}</span><span className="text-xs text-muted-foreground">@{candidate.namespace}/{candidate.slug}</span></span>
@ -287,7 +295,7 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio
<Card className="p-6">
<h2 className="font-semibold">{t('suite.selectedMembers', { count: selected.length })}</h2>
<div className="mt-4 space-y-2">
<div className="mt-4 space-y-2" role="radiogroup" aria-label={t('suite.entrySkill')}>
{selected.map((member, index) => (
<div key={member.skillVersionId} className="rounded-lg border p-3">
<div className="flex items-center gap-2">
@ -295,12 +303,12 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio
<span className="block truncate font-medium">{member.displayName}</span>
<span className="text-xs text-muted-foreground">@{member.namespace}/{member.slug}@{member.version}</span>
</button>
<Button variant="outline" size="sm" disabled={index === 0} onClick={() => moveMember(index, -1)}><ArrowUp className="h-4 w-4" /></Button>
<Button variant="outline" size="sm" disabled={index === selected.length - 1} onClick={() => moveMember(index, 1)}><ArrowDown className="h-4 w-4" /></Button>
<Button variant="ghost" size="sm" onClick={() => removeMember(member.skillVersionId)}><Trash2 className="h-4 w-4" /></Button>
<Button aria-label={t('suite.moveMemberUp', { name: member.displayName })} variant="outline" size="sm" disabled={index === 0} onClick={() => moveMember(index, -1)}><ArrowUp className="h-4 w-4" aria-hidden="true" /></Button>
<Button aria-label={t('suite.moveMemberDown', { name: member.displayName })} variant="outline" size="sm" disabled={index === selected.length - 1} onClick={() => moveMember(index, 1)}><ArrowDown className="h-4 w-4" aria-hidden="true" /></Button>
<Button aria-label={t('suite.removeMember', { name: member.displayName })} variant="ghost" size="sm" onClick={() => removeMember(member.skillVersionId)}><Trash2 className="h-4 w-4" aria-hidden="true" /></Button>
</div>
<label className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<input type="radio" checked={entrySkillVersionId === member.skillVersionId} onChange={() => setEntrySkillVersionId(member.skillVersionId)} />
<input type="radio" name="suite-entry-skill" aria-label={t('suite.setEntryFor', { name: member.displayName })} checked={entrySkillVersionId === member.skillVersionId} onChange={() => setEntrySkillVersionId(member.skillVersionId)} />
{t('suite.setEntry')}
</label>
</div>
@ -317,6 +325,21 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio
onClick={save}
>{t('suite.saveDraft')}</Button>
</div>
<ConfirmDialog
open={pendingVersionUpdate !== null}
onOpenChange={(open) => { 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)
}}
/>
</div>
)
}