mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-11 22:51:04 +00:00
fix(suite): bind exact members and protect local installs
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
d0e8c168fa
commit
a4b35b236a
21 changed files with 380 additions and 56 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'] })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ Suite 的可见范围不能宽于成员:
|
|||
SuiteVersion 仍为 PUBLISHED,但安装计划会整体失败。硬删除只清空成员外键;坐标、版本和 fingerprint
|
||||
快照继续用于历史展示和审计。
|
||||
|
||||
创作页面保存成员时会携带候选接口返回的精确 `skillVersionId`。服务端按 ID 读取版本,并校验坐标和
|
||||
版本一致后再保存快照,不会按名称重新解析到另一个所有者的同名 Skill。
|
||||
|
||||
## `suite.yaml` 定义
|
||||
|
||||
`suite.yaml` 是可移植的 Suite 创作格式,不是上传到 Agent 的多 Skill ZIP:
|
||||
|
|
|
|||
|
|
@ -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;它不是后台自动升级。
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
2
web/src/api/generated/schema.d.ts
vendored
2
web/src/api/generated/schema.d.ts
vendored
|
|
@ -4414,6 +4414,8 @@ export interface components {
|
|||
members: components["schemas"]["SkillSuiteMemberRequest"][];
|
||||
};
|
||||
SkillSuiteMemberRequest: {
|
||||
/** Format: int64 */
|
||||
skillVersionId: number;
|
||||
namespace: string;
|
||||
slug: string;
|
||||
version: string;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Сохранить черновик",
|
||||
|
|
|
|||
|
|
@ -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": "保存草稿",
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue