feat(suite): require and expose entry skill

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-09 11:52:46 +08:00
parent a838078cd9
commit d0e8c168fa
39 changed files with 660 additions and 93 deletions

View file

@ -33,7 +33,9 @@ DRAFT -> PENDING_REVIEW -> PUBLISHED -> YANKED
## 成员与权限
一个 SuiteVersion 最多包含 100 个不同 Skill。Entry Skill 是可选成员,必须明确选自成员列表。
一个 SuiteVersion 最多包含 100 个不同 Skill并且必须明确选择其中一个普通成员作为 Entry Skill。
Entry Skill 仍是完整、可独立安装的 Skill。跨 Namespace 的 PUBLIC Skill 可以作为 Entry非 PUBLIC
成员仍必须满足下表中的同 Namespace 受众约束。
v1 不支持嵌套 Suite、版本范围、外部 Registry 成员或条件成员。
Suite 的可见范围不能宽于成员:
@ -113,7 +115,8 @@ SMOKE_ADMIN_PASSWORD='<configured-password>' \
- 原有 Skill 搜索接口继续只返回 Skill。
类型化发现结果通过 `resourceType=SKILL|SUITE` 区分同名资源。Suite 详情返回固定版本、按顺序排列
的成员快照、Entry Skill、实时可安装状态和阻塞原因。
的成员快照、Entry Skill、实时可安装状态和阻塞原因。普通 Skill 详情会列出当前用户可见、以该 Skill
作为 Entry 的最新已发布 SuiteVersion并链接到完整 SuiteSkill 的独立安装能力保持不变。
## 部署顺序

View file

@ -12,7 +12,7 @@ SkillHub 的现有发布单元是一个根目录包含 `SKILL.md` 的 Skill 包
| Suite | Namespace 所有的、可版本化的 Skill 集合;它不是 Skill也不是多 Skill ZIP。 |
| SuiteVersion | Suite 在某一时刻不可变的成员快照和元数据。 |
| Member | SuiteVersion 引用的一个精确、已发布 SkillVersion。 |
| Entry Skill | 可选的普通 Member用于表达工作流入口不通过名称推断。 |
| Entry Skill | 必填的普通 Member用于表达工作流入口仍是完整、可独立安装的 Skill不通过名称推断。 |
| Install plan | 服务端解析出的 SuiteVersion、成员精确版本、fingerprint 和下载信息。 |
| Degraded Suite | 已发布 SuiteVersion 的至少一个成员当前不可下载;历史快照仍可查看,但不能完整安装。 |
@ -53,12 +53,12 @@ skill_suite
created_by, created_at, updated_by, updated_at
skill_suite_version
id, suite_id, version, status, visibility, changelog, entry_skill_version_id,
id, suite_id, version, status, visibility, changelog,
published_at, yanked_at, yanked_by, yank_reason,
created_by, created_at
skill_suite_version_member
suite_version_id, skill_version_id(nullable), position,
suite_version_id, skill_version_id(nullable), position, entry,
namespace_slug_snapshot, skill_slug_snapshot,
skill_version_snapshot, fingerprint_snapshot
```
@ -241,7 +241,9 @@ Suite 操作权限为:
<skills-root>/<suite-slug>/SKILL.md
```
如果 Suite 表达工作流,`entrySkillVersionId` 指向一个普通 Member。Entry Skill 可以与 Suite 同名,也可以不同名;关系只来自显式 ID不由 slug 推断。
每个 SuiteVersion 必须把一个普通 Member 标记为 Entry Skill。Entry Skill 保留完整 Skill 包及独立安装能力,可以与 Suite 同名,也可以不同名;关系只来自成员快照上的显式 `entry` 标记,不由 slug 推断。Entry 与普通 Member 使用相同的候选、可见性和生命周期规则:跨 Namespace 的 PUBLIC Skill 可以作为 Entry非 PUBLIC Skill 仍受同 Namespace 受众约束。
Skill 详情只返回当前用户有权查看的、以该 Skill 为 Entry 的最新 PUBLISHED SuiteVersion。Web 将这些关系显示为“被套件用作入口”,并链接到完整 Suite普通 Skill 的独立安装入口保持不变。Suite 被隐藏、归档或对当前用户不可见时,不返回其坐标或名称。
这避免腾讯 SkillSet 当前把编排提示写入普通 Skill 目录造成的覆盖问题,也保证所有 Agent 只需理解标准 Skill。
@ -292,7 +294,7 @@ inventory schema 增加 `suites`,并让 Skill 安装目标记录来源集合
### 10. 查询、升级和展示保持类型明确
新增的类型化资源发现入口返回 `resourceType`Web 使用类型徽标及独立 `/skills/...``/suites/...` 页面。现有 Skill 搜索接口继续只返回 Skill避免旧 CLI 或第三方客户端把 Suite 响应按 Skill 反序列化。Suite 详情显示版本、精确成员、Entry Skill、可用状态和阻塞原因。
新增的类型化资源发现入口返回 `resourceType`Web 使用类型徽标及独立 `/skills/...``/suites/...` 页面。现有 Skill 搜索接口继续只返回 Skill避免旧 CLI 或第三方客户端把 Suite 响应按 Skill 反序列化。Suite 详情显示版本、精确成员、Entry Skill、可用状态和阻塞原因;普通 Skill 详情显示当前可见 Suite 的 Entry 反向引用,但不把 Suite 混入 Skill 搜索结果
Suite 详情响应同时返回服务端计算的管理能力Web 不自行推断 Namespace 角色。作者可编辑草稿、显式重开被拒版本并基于已发布快照创建新版本Namespace 管理员还可下架版本、隐藏、归档和删除 Suite。后端继续对每个命令独立鉴权响应能力只用于正确展示入口不作为安全边界。

View file

@ -55,9 +55,9 @@
- **THEN** 系统拒绝该定义
- **AND** 不创建部分成员关系
### Requirement: Entry Skill SHALL be an explicit optional Member
### Requirement: Entry Skill SHALL be one explicit ordinary Member
SuiteVersion MAY 指定一个 Entry Skill。指定时Entry Skill SHALL 精确指向该 SuiteVersion 的一个 Member系统 SHALL NOT 根据 Suite 和 Skill 的同名关系推断入口。
SuiteVersion SHALL 指定且仅指定一个 Entry Skill。Entry Skill SHALL 精确指向该 SuiteVersion 的一个普通 Member,保留完整 Skill 包和独立安装能力;系统 SHALL NOT 根据 Suite 和 Skill 的同名关系推断入口,也 SHALL NOT 要求 Entry 与 Suite 属于同一 Namespace
#### Scenario: Valid Entry Skill
- **WHEN** SuiteVersion 将一个现有 Member 指定为 Entry Skill
@ -69,9 +69,13 @@ SuiteVersion MAY 指定一个 Entry Skill。指定时Entry Skill SHALL 精确
- **THEN** 系统拒绝该 SuiteVersion
#### Scenario: Suite has no Entry Skill
- **WHEN** Suite 仅表示安装集合
- **THEN** 系统允许 Entry Skill 为空
- **AND** 安装后不生成额外的编排 Skill
- **WHEN** 提交的 SuiteVersion 没有指定 Entry Skill
- **THEN** 系统拒绝该 SuiteVersion
#### Scenario: Public cross-Namespace Entry Skill
- **WHEN** SuiteVersion 将其他 Namespace 中符合目标受众规则的 PUBLIC Member 指定为 Entry Skill
- **THEN** 系统允许该 Entry Skill
- **AND** 引用不改变该 Skill 的所有权或生命周期
### Requirement: Member candidates SHALL be filtered by the Server
@ -360,6 +364,13 @@ Suite SHALL 有独立 API 和 Web URL。新的类型化资源发现结果中
- **WHEN** 授权用户通过 Suite 专用接口解析某个版本
- **THEN** 响应包含 SuiteVersion 身份以及有序的精确 Member 版本、fingerprint 和可下载状态
#### Scenario: Skill detail shows visible Suite entry references
- **WHEN** 当前 Skill 是一个或多个最新 PUBLISHED SuiteVersion 的 Entry Skill
- **THEN** Skill 详情返回当前查看者有权读取的 Suite 摘要、精确版本和成员数量
- **AND** Web 将其表达为“被套件用作入口”并链接到完整 Suite
- **AND** Skill 仍保留普通的独立安装入口
- **AND** 系统不返回对当前查看者不可见、已隐藏或已归档的 Suite 信息
### Requirement: Suite authors SHALL have a complete Web management flow
Web SHALL expose only the Suite actions authorized by the Server. An authorized author SHALL be

View file

@ -18,7 +18,7 @@
- [x] 3.1 Add transport-only Suite controllers and application services for management, version history, review actions, detail, and typed resolution of install plans.
- [x] 3.2 Add a typed resource discovery projection with `resourceType` and Suite metadata while keeping the existing Skill search endpoint Skill-only.
- [x] 3.3 Return ordered Member snapshots, Entry Skill, availability, and degraded reasons without N+1 member resolution.
- [x] 3.3 Return ordered Member snapshots, mandatory Entry Skill, availability, and degraded reasons without N+1 member resolution.
- [x] 3.4 Regenerate `web/src/api/generated/schema.d.ts` with `make generate-api` and run the OpenAPI drift check.
- [x] 3.5 Record idempotent Suite-plan statistics with a client retry key and server operation ID; keep Member counters on actual existing download requests.
- [x] 3.6 Add a server-filtered Member candidate query scoped by caller access, Suite Namespace, target visibility, current installability, and exact versions.
@ -26,12 +26,13 @@
## 4. Web experience
- [x] 4.1 Add typed Skill/Suite search cards and independent Suite list/detail/version routes.
- [x] 4.2 Add Suite creation and draft editing with the server-filtered Member picker, exact published versions, ordering, visibility, and optional Entry Skill.
- [x] 4.2 Add Suite creation and draft editing with the server-filtered Member picker, exact published versions, ordering, visibility, and mandatory Entry Skill.
- [x] 4.3 Extend the review center with typed Suite review details and ensure existing Skill review actions remain unchanged.
- [x] 4.4 Add install instructions using `skillhub suite install`, degraded-member explanations, and responsive/error/loading/empty states.
- [x] 4.5 Default Member selection to the current installable version, display the pinned exact version, and provide an explicit version-diff update action for drafts.
- [x] 4.6 Complete Web management for new versions, rejected-version reopen, yank, hide/restore, archive/unarchive, and guarded deletion using Server-derived capabilities.
- [x] 4.7 Add a versioned Markdown overview and browsable Member Skill cards with display metadata, pinned versions, Entry markers, and tombstone handling.
- [x] 4.8 Show privacy-filtered current Suite references on an Entry Skill detail page while preserving standalone Skill installation.
## 5. CLI and local lifecycle

View file

@ -41,6 +41,58 @@ assert_code() {
echo "PASS: $description"
}
assert_suite_availability() {
local description="$1"
local expected_available="$2"
local expected_reason="${3:-}"
local response
response="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" \
"$BASE_URL/api/web/suites/global/$SUITE_SLUG?version=1.0.0")"
assert_code "$description" "$response" 0
JSON_INPUT="$response" EXPECTED_AVAILABLE="$expected_available" EXPECTED_REASON="$expected_reason" \
python3 - <<'PY'
import json
import os
data = json.loads(os.environ["JSON_INPUT"])["data"]
expected_available = os.environ["EXPECTED_AVAILABLE"] == "true"
expected_reason = os.environ["EXPECTED_REASON"] or None
reasons = {member.get("blockingReason") for member in data["members"]}
if data["available"] is not expected_available:
raise SystemExit(1)
if expected_reason is not None and expected_reason not in reasons:
raise SystemExit(1)
PY
echo "PASS: $description has the expected availability"
}
assert_install_plan_rejected() {
local description="$1"
local key="$2"
local status
status="$(curl -sS -o "$WORK_DIR/blocked-plan.json" -w '%{http_code}' \
-b "$COOKIE_FILE" -c "$COOKIE_FILE" "${AUTH_HEADERS[@]}" \
-H "X-XSRF-TOKEN: $CSRF_TOKEN" -H "Idempotency-Key: $key" -X POST \
"$BASE_URL/api/web/suites/global/$SUITE_SLUG/install-plan?version=1.0.0")"
if [[ "$status" != "400" ]]; then
echo "FAIL: $description should return HTTP 400, got $status"
exit 1
fi
echo "PASS: $description"
}
assert_install_plan_available() {
local description="$1"
local key="$2"
local response
response="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Idempotency-Key: $key" -X POST \
"$BASE_URL/api/web/suites/global/$SUITE_SLUG/install-plan?version=1.0.0")"
assert_code "$description" "$response" 0
}
cleanup() {
if [[ -n "$SUITE_ID" && -n "${CSRF_TOKEN:-}" ]]; then
curl -sS -o /dev/null -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
@ -172,6 +224,25 @@ print(json.dumps({
}))
PY
)"
MISSING_ENTRY_PAYLOAD="$(JSON_INPUT="$SUITE_PAYLOAD" python3 - <<'PY'
import json
import os
payload = json.loads(os.environ["JSON_INPUT"])
payload.pop("entrySkill")
print(json.dumps(payload))
PY
)"
MISSING_ENTRY_STATUS="$(curl -sS -o "$WORK_DIR/missing-entry.json" -w '%{http_code}' \
-b "$COOKIE_FILE" -c "$COOKIE_FILE" "${AUTH_HEADERS[@]}" \
-H "X-XSRF-TOKEN: $CSRF_TOKEN" -H "Content-Type: application/json" \
-X POST "$BASE_URL/api/web/suites" -d "$MISSING_ENTRY_PAYLOAD")"
if [[ "$MISSING_ENTRY_STATUS" != "400" ]]; then
echo "FAIL: creating a Suite without an Entry Skill should return HTTP 400, got $MISSING_ENTRY_STATUS"
exit 1
fi
echo "PASS: creating a Suite without an Entry Skill is rejected"
CREATE_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Content-Type: application/json" -X POST "$BASE_URL/api/web/suites" \
@ -204,6 +275,11 @@ PLAN_ONE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" -X POST \
"$BASE_URL/api/web/suites/global/$SUITE_SLUG/install-plan?version=1.0.0")"
assert_code "issue an exact-member install plan" "$PLAN_ONE" 0
if [[ "$(json_field "$PLAN_ONE" data.members.0.entry)" != "True" ]]; then
echo "FAIL: the install plan did not preserve the Entry Skill role"
exit 1
fi
echo "PASS: install plan marks the exact Entry Skill"
PLAN_TWO="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" -X POST \
@ -227,33 +303,62 @@ raise SystemExit(0 if any(item["resourceType"] == "SKILL" and item["slug"] == sy
PY
echo "PASS: typed discovery still returns the ordinary Skill"
ENTRY_DETAIL="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" "$BASE_URL/api/web/skills/global/$SKILL_SLUG")"
assert_code "load the Entry Skill detail" "$ENTRY_DETAIL" 0
JSON_INPUT="$ENTRY_DETAIL" python3 - "$SUITE_SLUG" <<'PY'
import json
import os
import sys
references = json.loads(os.environ["JSON_INPUT"])["data"]["entryForSuites"]
raise SystemExit(0 if any(item["slug"] == sys.argv[1] and item["version"] == "1.0.0" for item in references) else 1)
PY
echo "PASS: Entry Skill detail links back to the visible Suite"
HIDE_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Content-Type: application/json" -X POST \
"$BASE_URL/api/v1/admin/skills/$SKILL_ID/hide" -d '{"reason":"suite smoke"}')"
assert_code "hide the member Skill" "$HIDE_RESPONSE" 0
assert_suite_availability "load the Suite after its member is hidden" false SKILL_HIDDEN
assert_install_plan_rejected "hidden member blocks a new install plan" "hidden-$TOKEN"
UNHIDE_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" -X POST \
"$BASE_URL/api/v1/admin/skills/$SKILL_ID/unhide")"
assert_code "restore the hidden member Skill" "$UNHIDE_RESPONSE" 0
assert_suite_availability "load the Suite after its hidden member is restored" true
assert_install_plan_available "restored hidden member allows a new install plan" "unhidden-$TOKEN"
ARCHIVE_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Content-Type: application/json" -X POST \
"$BASE_URL/api/web/skills/global/$SKILL_SLUG/archive" -d '{"reason":"suite smoke"}')"
assert_code "archive the member Skill" "$ARCHIVE_RESPONSE" 0
assert_suite_availability "load the Suite after its member is archived" false SKILL_ARCHIVED
assert_install_plan_rejected "archived member blocks a new install plan" "archived-$TOKEN"
UNARCHIVE_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" -X POST \
"$BASE_URL/api/web/skills/global/$SKILL_SLUG/unarchive")"
assert_code "restore the archived member Skill" "$UNARCHIVE_RESPONSE" 0
assert_suite_availability "load the Suite after its archived member is restored" true
assert_install_plan_available "restored archived member allows a new install plan" "unarchived-$TOKEN"
YANK_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Content-Type: application/json" -X POST \
"$BASE_URL/api/v1/admin/skills/versions/$SKILL_VERSION_ID/yank" -d '{"reason":"suite smoke"}')"
assert_code "yank the exact member version" "$YANK_RESPONSE" 0
assert_suite_availability "load the Suite after its exact member version is yanked" false VERSION_UNAVAILABLE
assert_install_plan_rejected "yanked member blocks a new install plan" "yanked-$TOKEN"
DEGRADED_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" \
"$BASE_URL/api/web/suites/global/$SUITE_SLUG?version=1.0.0")"
assert_code "load the degraded Suite snapshot" "$DEGRADED_RESPONSE" 0
JSON_INPUT="$DEGRADED_RESPONSE" python3 - <<'PY'
import json
import os
data = json.loads(os.environ["JSON_INPUT"])["data"]
reasons = {member.get("blockingReason") for member in data["members"]}
raise SystemExit(0 if data["available"] is False and "VERSION_UNAVAILABLE" in reasons else 1)
PY
echo "PASS: an unavailable member degrades the Suite without changing its snapshot"
HTTP_RESULT="$(curl -sS -o "$WORK_DIR/blocked-plan.json" -w '%{http_code}' \
-b "$COOKIE_FILE" -c "$COOKIE_FILE" "${AUTH_HEADERS[@]}" \
-H "X-XSRF-TOKEN: $CSRF_TOKEN" -H "Idempotency-Key: blocked-$TOKEN" -X POST \
"$BASE_URL/api/web/suites/global/$SUITE_SLUG/install-plan?version=1.0.0")"
if [[ "$HTTP_RESULT" != "400" ]]; then
echo "FAIL: degraded Suite install plan should return HTTP 400, got $HTTP_RESULT"
exit 1
fi
echo "PASS: degraded Suite cannot issue a new install plan"
DELETE_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-X DELETE "$BASE_URL/api/v1/skills/id/$SKILL_ID")"
assert_code "hard-delete the member Skill" "$DELETE_RESPONSE" 0
SKILL_ID=""
assert_suite_availability "load the Suite after its member is hard-deleted" false DELETED
assert_install_plan_rejected "deleted member blocks a new install plan" "deleted-$TOKEN"
echo "=== Skill Suite Smoke Test Passed ==="

View file

@ -1,5 +1,6 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.SkillFile;
@ -24,6 +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 org.springframework.core.io.InputStreamResource;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
@ -31,6 +33,7 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import jakarta.servlet.http.HttpServletRequest;
@ -38,6 +41,7 @@ import java.io.InputStream;
import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
@ -52,6 +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 SkillHubMetrics metrics;
public SkillController(
@ -59,6 +64,7 @@ public class SkillController extends BaseApiController {
SkillDownloadService skillDownloadService,
SkillLabelAppService skillLabelAppService,
ComplianceSnapshotProjectionService complianceSnapshotProjectionService,
SkillSuiteReferenceQueryRepository suiteReferenceQueryRepository,
SkillHubMetrics metrics,
ApiResponseFactory responseFactory) {
super(responseFactory);
@ -66,6 +72,7 @@ public class SkillController extends BaseApiController {
this.skillDownloadService = skillDownloadService;
this.skillLabelAppService = skillLabelAppService;
this.complianceSnapshotProjectionService = complianceSnapshotProjectionService;
this.suiteReferenceQueryRepository = suiteReferenceQueryRepository;
this.metrics = metrics;
}
@ -78,10 +85,12 @@ public class SkillController extends BaseApiController {
@PathVariable String namespace,
@PathVariable String slug,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles) {
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> userNsRoles,
@AuthenticationPrincipal PlatformPrincipal principal) {
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail(
namespace, slug, userId, userNsRoles != null ? userNsRoles : Map.of());
namespace, slug, userId, namespaceRoles);
SkillDetailResponse response = new SkillDetailResponse(
detail.id(),
@ -108,7 +117,11 @@ public class SkillController extends BaseApiController {
toLifecycleVersion(detail.publishedVersion()),
toLifecycleVersion(detail.ownerPreviewVersion()),
detail.ownerPreviewReviewComment(),
detail.resolutionMode()
detail.resolutionMode(),
suiteReferenceQueryRepository.findVisibleEntryReferences(
detail.id(), userId, namespaceRoles,
principal == null || principal.platformRoles() == null
? Set.of() : principal.platformRoles())
);
return ok("response.success.read", response);

View file

@ -28,5 +28,6 @@ public record SkillDetailResponse(
SkillLifecycleVersionResponse publishedVersion,
SkillLifecycleVersionResponse ownerPreviewVersion,
String ownerPreviewReviewComment,
String resolutionMode
String resolutionMode,
List<SkillSuiteReferenceResponse> entryForSuites
) {}

View file

@ -19,7 +19,7 @@ public record SkillSuiteCreateRequest(
@NotBlank String version,
@NotNull SkillVisibility visibility,
@Size(max = 4000) String changelog,
@Valid SkillSuiteMemberRequest entrySkill,
@NotNull @Valid SkillSuiteMemberRequest entrySkill,
@NotEmpty @Size(max = 100) List<@Valid SkillSuiteMemberRequest> members
) {
}

View file

@ -0,0 +1,12 @@
package com.iflytek.skillhub.dto;
/** One currently visible published Suite that uses this Skill as its orchestration entry. */
public record SkillSuiteReferenceResponse(
Long suiteId,
String namespace,
String slug,
String displayName,
String version,
int memberCount
) {
}

View file

@ -0,0 +1,96 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.dto.SkillSuiteReferenceResponse;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Skill-detail read model for current Suite entry references.
*
* <p>The query starts from each Suite's latest published snapshot so historical Suite versions do
* not look like current installation recommendations. Visibility filtering happens in SQL to avoid
* leaking private Suite coordinates through a public Skill page.</p>
*/
@Repository
public class SkillSuiteReferenceQueryRepository {
private final NamedParameterJdbcTemplate jdbcTemplate;
public SkillSuiteReferenceQueryRepository(NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional(readOnly = true)
public List<SkillSuiteReferenceResponse> findVisibleEntryReferences(
Long skillId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
List<Long> memberNamespaceIds = namespaceRoles.keySet().stream().toList();
List<Long> adminNamespaceIds = namespaceRoles.entrySet().stream()
.filter(entry -> entry.getValue() == NamespaceRole.OWNER
|| entry.getValue() == NamespaceRole.ADMIN)
.map(Map.Entry::getKey)
.toList();
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("skillId", skillId)
.addValue("userId", userId)
.addValue("memberNamespaceIds", nonEmpty(memberNamespaceIds))
.addValue("adminNamespaceIds", nonEmpty(adminNamespaceIds))
.addValue("authenticated", userId != null)
.addValue("superAdmin", platformRoles.contains("SUPER_ADMIN"));
return jdbcTemplate.query("""
SELECT suite.id,
namespace.slug AS namespace_slug,
suite.slug,
version.display_name,
version.version,
COUNT(all_members.id) AS member_count
FROM skill_suite suite
JOIN namespace ON namespace.id = suite.namespace_id
JOIN skill_suite_version version ON version.id = suite.latest_version_id
JOIN skill_suite_version_member entry_member
ON entry_member.suite_version_id = version.id AND entry_member.entry = TRUE
JOIN skill_suite_version_member all_members
ON all_members.suite_version_id = version.id
WHERE entry_member.skill_id = :skillId
AND suite.status = 'ACTIVE'
AND suite.hidden = FALSE
AND namespace.status = 'ACTIVE'
AND version.status = 'PUBLISHED'
AND (
:superAdmin = TRUE
OR version.visibility = 'PUBLIC'
OR (version.visibility = 'NAMESPACE_ONLY'
AND suite.namespace_id IN (:memberNamespaceIds))
OR (version.visibility = 'PRIVATE' AND (
suite.namespace_id IN (:adminNamespaceIds)
OR (:authenticated = TRUE
AND suite.created_by = :userId
AND suite.namespace_id IN (:memberNamespaceIds))
))
)
GROUP BY suite.id, namespace.slug, suite.slug, version.display_name, version.version
ORDER BY LOWER(version.display_name), suite.id
""", parameters, (resultSet, rowNumber) -> new SkillSuiteReferenceResponse(
resultSet.getLong("id"),
resultSet.getString("namespace_slug"),
resultSet.getString("slug"),
resultSet.getString("display_name"),
resultSet.getString("version"),
resultSet.getInt("member_count")));
}
private List<Long> nonEmpty(List<Long> values) {
return values.isEmpty() ? List.of(-1L) : values;
}
}

View file

@ -84,7 +84,8 @@ public class ReviewSkillDetailAppService {
snapshot.publishedVersion() != null ? toLifecycleVersion(snapshot.publishedVersion()) : null,
toLifecycleVersion(snapshot.activeVersion()),
null,
"REVIEW_TASK"
"REVIEW_TASK",
List.of()
);
List<SkillVersionResponse> versions = snapshot.versions().stream()

View file

@ -259,8 +259,7 @@ public class SkillSuiteAppService {
members.add(new SkillSuiteInstallMemberResponse(
snapshot.getSkillId(), snapshot.getSkillVersionId(),
resolved.namespace(), resolved.slug(), resolved.version(), resolved.fingerprint(),
resolved.downloadUrl(), snapshot.getPosition(),
Objects.equals(snapshot.getSkillVersionId(), detail.version().getEntrySkillVersionId())));
resolved.downloadUrl(), snapshot.getPosition(), snapshot.isEntry()));
}
} catch (LocalizedDomainException exception) {
// A Suite reader may no longer be allowed to inspect a restricted member. Do not expose
@ -301,8 +300,7 @@ public class SkillSuiteAppService {
member.state().viewerCanRead() ? member.state().displayName() : null,
member.state().viewerCanRead() ? member.state().summary() : null,
member.snapshot().getSkillVersionSnapshot(), member.snapshot().getFingerprintSnapshot(),
member.snapshot().getPosition(),
Objects.equals(member.snapshot().getSkillVersionId(), detail.version().getEntrySkillVersionId()),
member.snapshot().getPosition(), member.snapshot().isEntry(),
member.state().viewerCanRead(),
member.availability().reason() == null ? null : member.availability().reason().name()))
.toList();
@ -526,7 +524,7 @@ public class SkillSuiteAppService {
List<SkillSuiteMemberSelection> members
) {
if (entry == null) {
return null;
throw new DomainBadRequestException("error.suite.entry.required");
}
return members.stream()
.filter(member -> Objects.equals(member.namespaceSlug(), entry.namespace())
@ -561,8 +559,7 @@ public class SkillSuiteAppService {
members.add(new SkillSuiteMemberResponse(
member.getSkillId(), member.getSkillVersionId(), member.getNamespaceSlugSnapshot(),
member.getSkillSlugSnapshot(), null, null, member.getSkillVersionSnapshot(),
member.getFingerprintSnapshot(), member.getPosition(),
Objects.equals(member.getSkillVersionId(), created.version().getEntrySkillVersionId()),
member.getFingerprintSnapshot(), member.getPosition(), member.isEntry(),
false,
null));
}

View file

@ -32,7 +32,6 @@ CREATE TABLE skill_suite_version (
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT',
visibility VARCHAR(32) NOT NULL,
changelog TEXT,
entry_skill_version_id BIGINT REFERENCES skill_version(id) ON DELETE SET NULL,
published_at TIMESTAMPTZ,
yanked_at TIMESTAMPTZ,
yanked_by VARCHAR(128),
@ -51,6 +50,7 @@ CREATE TABLE skill_suite_version_member (
skill_id BIGINT REFERENCES skill(id) ON DELETE SET NULL,
skill_version_id BIGINT REFERENCES skill_version(id) ON DELETE SET NULL,
position INT NOT NULL CHECK (position >= 0),
entry BOOLEAN NOT NULL DEFAULT FALSE,
namespace_slug_snapshot VARCHAR(128) NOT NULL,
skill_slug_snapshot VARCHAR(128) NOT NULL,
skill_version_snapshot VARCHAR(64) NOT NULL,
@ -63,6 +63,16 @@ CREATE UNIQUE INDEX uk_skill_suite_member_skill
ON skill_suite_version_member(suite_version_id, skill_id)
WHERE skill_id IS NOT NULL;
-- Entry is a role of one exact member. Keeping it on the snapshot row preserves the role even
-- when governance hard-deletes the referenced SkillVersion and clears its foreign keys.
CREATE UNIQUE INDEX uk_skill_suite_member_entry
ON skill_suite_version_member(suite_version_id)
WHERE entry = TRUE;
CREATE INDEX idx_skill_suite_member_entry_skill
ON skill_suite_version_member(skill_id)
WHERE entry = TRUE AND skill_id IS NOT NULL;
CREATE INDEX idx_skill_suite_member_version
ON skill_suite_version_member(skill_version_id);

View file

@ -210,6 +210,7 @@ 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.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}
error.suite.lifecycle.noPermission=You do not have permission to manage this Skill Suite
error.suite.review.rolloutDisabled=Suite review submission is disabled until the typed-review rollout is complete

View file

@ -210,6 +210,7 @@ error.suite.members.limit=技能套件最多包含 {0} 个技能
error.suite.members.duplicate=技能套件不能包含同一技能的多个版本
error.suite.members.unavailable=一个或多个套件成员当前不可用:{0}
error.suite.entry.notMember=入口技能必须是套件成员
error.suite.entry.required=必须选择入口技能
error.suite.namespace.notWritable=套件所在命名空间不可写:{0}
error.suite.lifecycle.noPermission=无权管理该技能套件
error.suite.review.rolloutDisabled=类型化审核完成滚动升级前,暂不开放套件审核提交

View file

@ -200,7 +200,8 @@ class ReviewPortalControllerTest {
new SkillLifecycleVersionResponse(99L, "1.1.0", "PUBLISHED"),
new SkillLifecycleVersionResponse(100L, "1.2.0", "PENDING_REVIEW"),
null,
"REVIEW_TASK"
"REVIEW_TASK",
List.of()
),
List.of(new SkillVersionResponse(100L, "1.2.0", "PENDING_REVIEW", null, 1, 10L, null, true, null)),
List.of(new SkillFileResponse(1L, "README.md", 123L, "text/markdown", "sha")),

View file

@ -8,6 +8,8 @@ import com.iflytek.skillhub.domain.skill.SkillFile;
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 org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@ -50,6 +52,9 @@ class SkillControllerTest {
@MockBean
private SkillLabelAppService skillLabelAppService;
@MockBean
private SkillSuiteReferenceQueryRepository suiteReferenceQueryRepository;
@Test
void getVersionDetailShouldReturnMetadataFields() throws Exception {
String parsedMetadata = """
@ -183,6 +188,10 @@ class SkillControllerTest {
null,
"OWNER_PREVIEW"
));
when(suiteReferenceQueryRepository.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)));
mockMvc.perform(get("/api/web/skills/team/demo"))
.andExpect(status().isOk())
@ -192,6 +201,9 @@ class SkillControllerTest {
.andExpect(jsonPath("$.data.headlineVersion.version").value("1.1.0"))
.andExpect(jsonPath("$.data.ownerPreviewVersion.id").value(11L))
.andExpect(jsonPath("$.data.resolutionMode").value("OWNER_PREVIEW"))
.andExpect(jsonPath("$.data.entryForSuites[0].slug").value("demo-suite"))
.andExpect(jsonPath("$.data.entryForSuites[0].version").value("2.0.0"))
.andExpect(jsonPath("$.data.entryForSuites[0].memberCount").value(3))
.andExpect(jsonPath("$.data.canInteract").value(false))
.andExpect(jsonPath("$.data.canReport").value(false));
}

View file

@ -287,7 +287,8 @@ class SkillSuiteHardDeleteIntegrationTest {
new SkillSuiteMemberSelection(
memberSkill.getId(), memberVersion.getId(), namespace.getSlug(),
memberSkill.getSlug(), memberVersion.getVersion(), "a".repeat(64)),
0));
0,
true));
ReviewTask reviewTask = ReviewTask.forSuiteVersion(
version.getId(), suite.getId(), namespace.getId(), version.getVersion(), authorId);
reviewTask.setStatus(ReviewTaskStatus.APPROVED);

View file

@ -3,6 +3,7 @@ package com.iflytek.skillhub.integration;
import static org.assertj.core.api.Assertions.assertThat;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
@ -17,8 +18,10 @@ import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.search.postgres.PostgresResourceDiscoveryQueryService;
import com.iflytek.skillhub.service.ResourceDiscoveryAppService;
import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository;
import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -39,7 +42,7 @@ import org.testcontainers.junit.jupiter.Testcontainers;
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
@Import({PostgresResourceDiscoveryQueryService.class, ResourceDiscoveryAppService.class,
MySkillSuiteQueryRepository.class})
MySkillSuiteQueryRepository.class, SkillSuiteReferenceQueryRepository.class})
@Testcontainers
@TestPropertySource(properties = {
"spring.flyway.enabled=true",
@ -69,6 +72,9 @@ class SuiteDiscoveryIntegrationTest {
@Autowired
private MySkillSuiteQueryRepository mySuiteRepository;
@Autowired
private SkillSuiteReferenceQueryRepository suiteReferenceRepository;
@Autowired
private SkillSuiteVersionMemberRepository suiteMemberRepository;
@ -111,7 +117,8 @@ class SuiteDiscoveryIntegrationTest {
new SkillSuiteMemberSelection(
skill.getId(), skillVersion.getId(), namespace.getSlug(),
skill.getSlug(), skillVersion.getVersion(), "a".repeat(64)),
0));
0,
true));
suite.setLatestVersionId(suiteVersion.getId());
entityManager.persistAndFlush(suite);
entityManager.clear();
@ -134,6 +141,53 @@ class SuiteDiscoveryIntegrationTest {
assertThat(item.displayName()).isEqualTo("Published snapshot name");
assertThat(item.summary()).isEqualTo("Published snapshot summary");
});
assertThat(suiteReferenceRepository.findVisibleEntryReferences(
skill.getId(), null, Map.of(), Set.of()))
.singleElement()
.satisfies(reference -> {
assertThat(reference.namespace()).isEqualTo("team-ai");
assertThat(reference.slug()).isEqualTo("starter");
assertThat(reference.version()).isEqualTo("2.0.0");
assertThat(reference.memberCount()).isEqualTo(1);
});
}
@Test
void doesNotLeakPrivateSuiteEntryReferenceToUnrelatedViewers() {
Namespace namespace = entityManager.persistFlushFind(
new Namespace("private-suite-team", "Private Suite Team", "owner"));
Skill skill = entityManager.persistFlushFind(
new Skill(namespace.getId(), "entry", "owner", SkillVisibility.PUBLIC));
SkillVersion skillVersion = new SkillVersion(skill.getId(), "1.0.0", "owner");
skillVersion.setStatus(SkillVersionStatus.PUBLISHED);
skillVersion.setDownloadReady(true);
skillVersion = entityManager.persistFlushFind(skillVersion);
SkillSuite suite = entityManager.persistFlushFind(
new SkillSuite(namespace.getId(), "private-suite", "Private Suite", "author"));
SkillSuiteVersion suiteVersion = new SkillSuiteVersion(
suite.getId(), "1.0.0", SkillVisibility.PRIVATE, "author");
suiteVersion.setStatus(SkillSuiteVersionStatus.PUBLISHED);
suiteVersion = entityManager.persistFlushFind(suiteVersion);
entityManager.persist(new SkillSuiteVersionMember(
suiteVersion.getId(),
new SkillSuiteMemberSelection(
skill.getId(), skillVersion.getId(), namespace.getSlug(),
skill.getSlug(), skillVersion.getVersion(), "b".repeat(64)),
0,
true));
suite.setLatestVersionId(suiteVersion.getId());
entityManager.persistAndFlush(suite);
entityManager.clear();
assertThat(suiteReferenceRepository.findVisibleEntryReferences(
skill.getId(), null, Map.of(), Set.of())).isEmpty();
assertThat(suiteReferenceRepository.findVisibleEntryReferences(
skill.getId(), "other-author", Map.of(namespace.getId(), NamespaceRole.MEMBER), Set.of()))
.isEmpty();
assertThat(suiteReferenceRepository.findVisibleEntryReferences(
skill.getId(), "author", Map.of(namespace.getId(), NamespaceRole.MEMBER), Set.of()))
.singleElement()
.satisfies(reference -> assertThat(reference.slug()).isEqualTo("private-suite"));
}
@Test
@ -213,7 +267,8 @@ class SuiteDiscoveryIntegrationTest {
new SkillSuiteMemberSelection(
skill.getId(), skillVersion.getId(), "snapshot-team",
"archived-writer", "3.1.4", "sha512:" + "b".repeat(128)),
0));
0,
true));
entityManager.getEntityManager().createNativeQuery("DELETE FROM skill_version WHERE id = :id")
.setParameter("id", skillVersion.getId())
@ -226,7 +281,7 @@ class SuiteDiscoveryIntegrationTest {
Object[] snapshot = (Object[]) entityManager.getEntityManager().createNativeQuery("""
SELECT skill_id, skill_version_id, namespace_slug_snapshot,
skill_slug_snapshot, skill_version_snapshot, fingerprint_snapshot
skill_slug_snapshot, skill_version_snapshot, fingerprint_snapshot, entry
FROM skill_suite_version_member
WHERE id = :id
""").setParameter("id", member.getId()).getSingleResult();
@ -236,6 +291,7 @@ class SuiteDiscoveryIntegrationTest {
assertThat(snapshot[3]).isEqualTo("archived-writer");
assertThat(snapshot[4]).isEqualTo("3.1.4");
assertThat(snapshot[5]).isEqualTo("sha512:" + "b".repeat(128));
assertThat(snapshot[6]).isEqualTo(true);
}
@Test
@ -257,7 +313,8 @@ class SuiteDiscoveryIntegrationTest {
new SkillSuiteMemberSelection(
skill.getId(), firstVersion.getId(), "replace-team",
"replacement", "1.0.0", "sha256:" + "a".repeat(64)),
0));
0,
true));
entityManager.clear();
suiteMemberRepository.deleteBySuiteVersionId(suiteVersion.getId());
@ -266,7 +323,8 @@ class SuiteDiscoveryIntegrationTest {
new SkillSuiteMemberSelection(
skill.getId(), secondVersion.getId(), "replace-team",
"replacement", "2.0.0", "sha256:" + "b".repeat(64)),
0)));
0,
true)));
entityManager.clear();
assertThat(suiteMemberRepository.findBySuiteVersionIdOrderByPosition(suiteVersion.getId()))

View file

@ -84,7 +84,6 @@ class SkillSuiteAppServiceTest {
version.setOverview("## Install in order");
firstMember = member(11L, 101L, "first", "1.0.0", "sha256:first", 0);
secondMember = member(12L, 102L, "second", "2.0.0", "sha256:second", 1);
version.setEntrySkillVersionId(101L);
}
@Test
@ -306,7 +305,8 @@ class SkillSuiteAppServiceTest {
70L,
new com.iflytek.skillhub.domain.suite.SkillSuiteMemberSelection(
skillId, versionId, "global", slug, memberVersion, fingerprint),
position);
position,
position == 0);
}
private SkillQueryService.ResolvedVersionDTO resolved(

View file

@ -24,7 +24,10 @@ public final class SkillSuiteCompositionPolicy {
throw new DomainBadRequestException("error.suite.members.limit", MAX_MEMBERS);
}
Set<Long> skillIds = new HashSet<>();
boolean entryFound = entrySkillVersionId == null;
if (entrySkillVersionId == null) {
throw new DomainBadRequestException("error.suite.entry.required");
}
boolean entryFound = false;
for (SkillSuiteMemberSelection member : members) {
if (!skillIds.add(member.skillId())) {
throw new DomainBadRequestException("error.suite.members.duplicate");

View file

@ -71,10 +71,10 @@ public class SkillSuiteDraftService {
command.visibility(), context.actorUserId());
version.setOverview(command.overview());
version.setChangelog(command.changelog());
version.setEntrySkillVersionId(command.entrySkillVersionId());
version = versionRepository.save(version);
List<SkillSuiteVersionMember> members = saveMembers(version, command.members());
List<SkillSuiteVersionMember> members = saveMembers(
version, command.members(), command.entrySkillVersionId());
// Validate after persistence so the same resolver is used for draft creation and publication.
// The transaction rolls the draft back if any exact member changed during creation.
@ -146,10 +146,10 @@ public class SkillSuiteDraftService {
version.setOverview(command.overview());
version.setVisibility(command.visibility());
version.setChangelog(command.changelog());
version.setEntrySkillVersionId(command.entrySkillVersionId());
versionRepository.save(version);
memberRepository.deleteBySuiteVersionId(versionId);
List<SkillSuiteVersionMember> members = saveMembers(version, command.members());
List<SkillSuiteVersionMember> members = saveMembers(
version, command.members(), command.entrySkillVersionId());
publicationValidator.validate(suite, version);
auditLogService.record(
context.actorUserId(), "UPDATE_SKILL_SUITE_DRAFT", "SKILL_SUITE_VERSION",
@ -215,9 +215,9 @@ public class SkillSuiteDraftService {
command.visibility(), context.actorUserId());
version.setOverview(command.overview());
version.setChangelog(command.changelog());
version.setEntrySkillVersionId(command.entrySkillVersionId());
version = versionRepository.save(version);
List<SkillSuiteVersionMember> members = saveMembers(version, command.members());
List<SkillSuiteVersionMember> members = saveMembers(
version, command.members(), command.entrySkillVersionId());
publicationValidator.validate(suite, version);
auditLogService.record(
context.actorUserId(), "CREATE_SKILL_SUITE_VERSION_DRAFT", "SKILL_SUITE_VERSION",
@ -231,11 +231,15 @@ public class SkillSuiteDraftService {
private List<SkillSuiteVersionMember> saveMembers(
SkillSuiteVersion version,
List<SkillSuiteMemberSelection> selections
List<SkillSuiteMemberSelection> selections,
Long entrySkillVersionId
) {
List<SkillSuiteVersionMember> members = new ArrayList<>(selections.size());
for (int position = 0; position < selections.size(); position++) {
members.add(new SkillSuiteVersionMember(version.getId(), selections.get(position), position));
SkillSuiteMemberSelection selection = selections.get(position);
members.add(new SkillSuiteVersionMember(
version.getId(), selection, position,
selection.skillVersionId().equals(entrySkillVersionId)));
}
return memberRepository.saveAll(members);
}

View file

@ -26,6 +26,9 @@ public class SkillSuitePublicationValidator {
if (members.isEmpty()) {
throw new DomainBadRequestException("error.suite.members.empty");
}
if (members.stream().filter(SkillSuiteVersionMember::isEntry).count() != 1) {
throw new DomainBadRequestException("error.suite.entry.required");
}
SkillSuiteAvailability availability = SkillSuiteAvailability.evaluate(
suite.getNamespaceId(), version.getVisibility(), stateResolver.resolve(members));
if (!availability.available()) {

View file

@ -52,9 +52,6 @@ public class SkillSuiteVersion {
@Column(columnDefinition = "TEXT")
private String changelog;
@Column(name = "entry_skill_version_id")
private Long entrySkillVersionId;
@Column(name = "published_at")
private Instant publishedAt;
@ -148,10 +145,6 @@ public class SkillSuiteVersion {
return changelog;
}
public Long getEntrySkillVersionId() {
return entrySkillVersionId;
}
public Instant getPublishedAt() {
return publishedAt;
}
@ -205,11 +198,6 @@ public class SkillSuiteVersion {
this.changelog = changelog;
}
public void setEntrySkillVersionId(Long entrySkillVersionId) {
assertEditable();
this.entrySkillVersionId = entrySkillVersionId;
}
public void setPublishedAt(Instant publishedAt) {
this.publishedAt = publishedAt;
}

View file

@ -31,6 +31,9 @@ public class SkillSuiteVersionMember {
@Column(nullable = false)
private Integer position;
@Column(nullable = false)
private boolean entry;
@Column(name = "namespace_slug_snapshot", nullable = false, length = 128)
private String namespaceSlugSnapshot;
@ -47,11 +50,17 @@ public class SkillSuiteVersionMember {
protected SkillSuiteVersionMember() {
}
public SkillSuiteVersionMember(Long suiteVersionId, SkillSuiteMemberSelection selection, int position) {
public SkillSuiteVersionMember(
Long suiteVersionId,
SkillSuiteMemberSelection selection,
int position,
boolean entry
) {
this.suiteVersionId = suiteVersionId;
this.skillId = selection.skillId();
this.skillVersionId = selection.skillVersionId();
this.position = position;
this.entry = entry;
this.namespaceSlugSnapshot = selection.namespaceSlug();
this.skillSlugSnapshot = selection.skillSlug();
this.skillVersionSnapshot = selection.version();
@ -63,6 +72,7 @@ public class SkillSuiteVersionMember {
public Long getSkillId() { return skillId; }
public Long getSkillVersionId() { return skillVersionId; }
public Integer getPosition() { return position; }
public boolean isEntry() { return entry; }
public String getNamespaceSlugSnapshot() { return namespaceSlugSnapshot; }
public String getSkillSlugSnapshot() { return skillSlugSnapshot; }
public String getSkillVersionSnapshot() { return skillVersionSnapshot; }

View file

@ -10,9 +10,19 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
class SkillSuiteCompositionPolicyTest {
@Test
void rejectsSuiteWithoutEntrySkill() {
List<SkillSuiteMemberSelection> members = List.of(member(10L, 101L, "1.0.0"));
assertThatThrownBy(() -> SkillSuiteCompositionPolicy.validate(members, null))
.isInstanceOf(DomainBadRequestException.class)
.extracting("messageCode")
.isEqualTo("error.suite.entry.required");
}
@Test
void rejectsEmptySuite() {
assertThatThrownBy(() -> SkillSuiteCompositionPolicy.validate(List.of(), null))
assertThatThrownBy(() -> SkillSuiteCompositionPolicy.validate(List.of(), 101L))
.isInstanceOf(DomainBadRequestException.class)
.extracting("messageCode")
.isEqualTo("error.suite.members.empty");
@ -25,7 +35,7 @@ class SkillSuiteCompositionPolicyTest {
member(10L, 102L, "2.0.0")
);
assertThatThrownBy(() -> SkillSuiteCompositionPolicy.validate(members, null))
assertThatThrownBy(() -> SkillSuiteCompositionPolicy.validate(members, 101L))
.isInstanceOf(DomainBadRequestException.class)
.extracting("messageCode")
.isEqualTo("error.suite.members.duplicate");

View file

@ -31,6 +31,15 @@ class SkillSuiteMemberEligibilityPolicyTest {
assertThat(result.available()).isTrue();
}
@Test
void suiteAcceptsPublicMemberFromAnotherNamespace() {
SkillSuiteMemberState member = member(2L, SkillVisibility.PUBLIC);
SkillSuiteMemberAvailability result = policy.evaluate(1L, SkillVisibility.PUBLIC, member);
assertThat(result.available()).isTrue();
}
@Test
void deletedReferenceProducesTombstoneInsteadOfRelinkingByCoordinate() {
SkillSuiteMemberState member = SkillSuiteMemberState.deleted(10L, 20L);
@ -54,6 +63,54 @@ class SkillSuiteMemberEligibilityPolicyTest {
assertThat(result.reason()).isEqualTo(SkillSuiteMemberBlockingReason.VERSION_UNAVAILABLE);
}
@Test
void hiddenMemberSkillIsUnavailable() {
SkillSuiteMemberState member = member(
NamespaceStatus.ACTIVE, SkillVisibility.PUBLIC, SkillStatus.ACTIVE, true,
SkillVersionStatus.PUBLISHED, true, false);
SkillSuiteMemberAvailability result = policy.evaluate(1L, SkillVisibility.PUBLIC, member);
assertThat(result.available()).isFalse();
assertThat(result.reason()).isEqualTo(SkillSuiteMemberBlockingReason.SKILL_HIDDEN);
}
@Test
void archivedMemberSkillIsUnavailable() {
SkillSuiteMemberState member = member(
NamespaceStatus.ACTIVE, SkillVisibility.PUBLIC, SkillStatus.ARCHIVED, false,
SkillVersionStatus.PUBLISHED, true, false);
SkillSuiteMemberAvailability result = policy.evaluate(1L, SkillVisibility.PUBLIC, member);
assertThat(result.available()).isFalse();
assertThat(result.reason()).isEqualTo(SkillSuiteMemberBlockingReason.SKILL_ARCHIVED);
}
@Test
void yankedMemberVersionIsUnavailable() {
SkillSuiteMemberState member = member(
NamespaceStatus.ACTIVE, SkillVisibility.PUBLIC, SkillStatus.ACTIVE, false,
SkillVersionStatus.PUBLISHED, true, true);
SkillSuiteMemberAvailability result = policy.evaluate(1L, SkillVisibility.PUBLIC, member);
assertThat(result.available()).isFalse();
assertThat(result.reason()).isEqualTo(SkillSuiteMemberBlockingReason.VERSION_UNAVAILABLE);
}
@Test
void reversibleMemberRestrictionRecoveryMakesMemberAvailableAgain() {
SkillSuiteMemberState member = member(
NamespaceStatus.ACTIVE, SkillVisibility.PUBLIC, SkillStatus.ACTIVE, false,
SkillVersionStatus.PUBLISHED, true, false);
SkillSuiteMemberAvailability result = policy.evaluate(1L, SkillVisibility.PUBLIC, member);
assertThat(result.available()).isTrue();
assertThat(result.reason()).isNull();
}
@Test
void archivedMemberNamespaceMakesExactMemberUnavailable() {
SkillSuiteMemberState member = new SkillSuiteMemberState(
@ -84,4 +141,18 @@ class SkillSuiteMemberEligibilityPolicyTest {
NamespaceStatus.ACTIVE, visibility, SkillStatus.ACTIVE, false,
SkillVersionStatus.PUBLISHED, true, false, false);
}
private SkillSuiteMemberState member(
NamespaceStatus namespaceStatus,
SkillVisibility visibility,
SkillStatus skillStatus,
boolean hidden,
SkillVersionStatus versionStatus,
boolean downloadReady,
boolean yanked
) {
return new SkillSuiteMemberState(
10L, 20L, 1L, null, null, namespaceStatus, visibility,
skillStatus, hidden, versionStatus, downloadReady, yanked, false);
}
}

View file

@ -46,7 +46,8 @@ class SkillSuiteMemberStateResolverTest {
20L,
new SkillSuiteMemberSelection(
30L, 40L, "team", "private-helper", "1.0.0", "sha256:private"),
0);
0,
true);
when(versionRepository.findByIdIn(List.of(40L))).thenReturn(List.of(version));
when(skillRepository.findByIdIn(List.of(30L))).thenReturn(List.of(skill));
when(namespaceRepository.findByIdIn(List.of(1L))).thenReturn(List.of(namespace));

View file

@ -56,7 +56,8 @@ class SkillSuiteQueryServiceTest {
SkillSuiteVersionMember member = new SkillSuiteVersionMember(
20L,
new SkillSuiteMemberSelection(30L, 40L, "global", "writer", "2.0.0", "sha256:abc"),
0);
0,
true);
setId(namespace, 1L);
setId(suite, 10L);
setId(version, 20L);

View file

@ -4410,7 +4410,7 @@ export interface components {
/** @enum {string} */
visibility: "PUBLIC" | "NAMESPACE_ONLY" | "PRIVATE";
changelog?: string;
entrySkill?: components["schemas"]["SkillSuiteMemberRequest"];
entrySkill: components["schemas"]["SkillSuiteMemberRequest"];
members: components["schemas"]["SkillSuiteMemberRequest"][];
};
SkillSuiteMemberRequest: {
@ -5694,6 +5694,17 @@ export interface components {
ownerPreviewVersion?: components["schemas"]["SkillLifecycleVersionResponse"];
ownerPreviewReviewComment?: string;
resolutionMode?: string;
entryForSuites?: components["schemas"]["SkillSuiteReferenceResponse"][];
};
SkillSuiteReferenceResponse: {
/** Format: int64 */
suiteId?: number;
namespace?: string;
slug?: string;
displayName?: string;
version?: string;
/** Format: int32 */
memberCount?: number;
};
ApiResponseReviewSkillDetailResponse: {
/** Format: int32 */

View file

@ -275,6 +275,15 @@ export interface ComplianceSnapshot {
digest?: string
}
export interface SkillSuiteReference {
suiteId: number
namespace: string
slug: string
displayName: string
version: string
memberCount: number
}
export interface SkillDetail {
id: number
slug: string
@ -300,6 +309,7 @@ export interface SkillDetail {
ownerPreviewVersion?: SkillLifecycleVersion
ownerPreviewReviewComment?: string
resolutionMode?: string
entryForSuites?: SkillSuiteReference[]
}
export interface SubmitPromotionRequest {

View file

@ -879,6 +879,9 @@
"namespaceLabel": "Namespace",
"loginToRate": "Login to star and rate",
"install": "Install",
"suiteEntryTitle": "Used as a suite entry",
"suiteEntryDescription": "This skill starts the following suites. View and install the complete suite to include every skill required by its workflow.",
"suiteEntryMemberCount": "View complete suite ({{count}} skills)",
"installMethodClawhub": "ClawHub CLI",
"installMethodSkillhub": "SkillHub CLI",
"installCommandUnsafeVersion": "This version cannot be represented safely in a cross-platform command. Ask the publisher to correct it.",
@ -1802,7 +1805,8 @@
"searchSkills": "Search eligible skills",
"noCandidates": "No published versions satisfy the selected visibility.",
"selectedMembers": "Suite members ({{count}}/100)",
"setEntry": "Set as optional entry skill",
"setEntry": "Set as entry skill",
"entryRequired": "Select an entry skill",
"addMemberHint": "Add at least one skill from the left.",
"cancel": "Cancel",
"saveDraft": "Save draft",

View file

@ -944,6 +944,9 @@
"namespaceLabel": "Пространство имён",
"loginToRate": "Войдите, чтобы отметить звёздочкой и оценить",
"install": "Установить",
"suiteEntryTitle": "Используется как вход набора",
"suiteEntryDescription": "Этот навык запускает указанные ниже наборы. Установите полный набор, чтобы получить все навыки, необходимые рабочему процессу.",
"suiteEntryMemberCount": "Открыть полный набор (навыков: {{count}})",
"installMethodClawhub": "ClawHub CLI",
"installMethodSkillhub": "SkillHub CLI",
"installCommandUnsafeVersion": "Номер этой версии нельзя безопасно использовать в кроссплатформенной команде. Попросите автора исправить его.",
@ -1799,7 +1802,8 @@
"searchSkills": "Поиск доступных навыков",
"noCandidates": "Нет опубликованных версий с выбранной видимостью.",
"selectedMembers": "Участники набора ({{count}}/100)",
"setEntry": "Назначить необязательным входным навыком",
"setEntry": "Назначить входным навыком",
"entryRequired": "Выберите входной навык",
"addMemberHint": "Добавьте хотя бы один навык слева.",
"cancel": "Отмена",
"saveDraft": "Сохранить черновик",

View file

@ -879,6 +879,9 @@
"namespaceLabel": "命名空间",
"loginToRate": "登录后可以收藏和评分",
"install": "安装",
"suiteEntryTitle": "被套件用作入口",
"suiteEntryDescription": "这个技能是以下套件的编排入口。建议查看并安装完整套件,以获得工作流所需的全部技能。",
"suiteEntryMemberCount": "查看完整套件({{count}} 个技能)",
"installMethodClawhub": "ClawHub CLI",
"installMethodSkillhub": "SkillHub CLI",
"installCommandUnsafeVersion": "该版本号无法安全地生成跨平台命令,请联系发布者修正版本号。",
@ -1801,7 +1804,8 @@
"searchSkills": "搜索可用技能",
"noCandidates": "没有符合当前可见性要求的已发布版本。",
"selectedMembers": "套件成员({{count}}/100",
"setEntry": "设为入口技能(可选)",
"setEntry": "设为入口技能",
"entryRequired": "请选择一个入口技能",
"addMemberHint": "从左侧添加至少一个技能。",
"cancel": "取消",
"saveDraft": "保存草稿",

View file

@ -127,4 +127,18 @@ describe('SuiteEditor', () => {
members: [{ namespace: 'global', slug: 'weather', version: '1.0.0' }],
}))
})
it('requires one selected member to be the entry skill', async () => {
const suite = sourceSuite(['EDIT'])
suite.members[0].entry = false
mocks.detail = { data: suite, isLoading: false, error: null }
render(<SuiteEditor namespace="global" slug="starter" version="1.0.0" mode="edit" />)
await waitFor(() => expect((screen.getByLabelText('suite.name') as HTMLInputElement).value)
.toBe('Starter suite'))
fireEvent.click(screen.getByRole('button', { name: 'suite.saveDraft' }))
expect(mocks.toast.error).toHaveBeenCalledWith('suite.entryRequired')
expect(mocks.update.mutateAsync).not.toHaveBeenCalled()
})
})

View file

@ -134,12 +134,20 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio
toast.error(t('suite.validationRequired'))
return
}
if (entrySkillVersionId === null) {
toast.error(t('suite.entryRequired'))
return
}
const members = selected.map(({ namespace: memberNamespace, slug: memberSlug, version: memberVersion }) => ({
namespace: memberNamespace,
slug: memberSlug,
version: memberVersion,
}))
const entry = selected.find((member) => member.skillVersionId === entrySkillVersionId)
if (!entry) {
toast.error(t('suite.entryRequired'))
return
}
const input = {
namespace,
slug: slug.trim(),
@ -149,7 +157,7 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio
version: version.trim(),
visibility,
changelog: changelog.trim() || undefined,
entrySkill: entry ? { namespace: entry.namespace, slug: entry.slug, version: entry.version } : undefined,
entrySkill: { namespace: entry.namespace, slug: entry.slug, version: entry.version },
members,
}
try {

View file

@ -355,6 +355,37 @@ describe('SkillDetailPage', () => {
expect(html).not.toContain('skillDetail.deleteSkill')
})
it('recommends visible suites that use this skill as their entry', () => {
useSkillDetailMock.mockReturnValue({
data: createSkill({
entryForSuites: [{
suiteId: 7,
namespace: 'team-ai',
slug: 'research-workflow',
displayName: 'Research Workflow',
version: '2.0.0',
memberCount: 4,
}],
}),
isLoading: false,
isFetching: false,
error: null,
})
const html = renderToStaticMarkup(<SkillDetailPage />)
expect(html).toContain('skillDetail.suiteEntryTitle')
expect(html).toContain('Research Workflow')
expect(html).toContain('@team-ai/research-workflow@2.0.0')
expect(html).toContain('skillDetail.suiteEntryMemberCount')
})
it('does not show a suite recommendation for an ordinary member skill', () => {
const html = renderToStaticMarkup(<SkillDetailPage />)
expect(html).not.toContain('skillDetail.suiteEntryTitle')
})
it('wraps a long skill name instead of widening the mobile page', () => {
useSkillDetailMock.mockReturnValue({
data: createSkill({ displayName: 'review-runtime-1788284593-353294' }),

View file

@ -2,7 +2,7 @@ import { startTransition, useCallback, useEffect, useRef, useState, type MouseEv
import { useTranslation } from 'react-i18next'
import { Link, useParams, useNavigate, useRouterState, useSearch } from '@tanstack/react-router'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, ArrowUpCircle, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react'
import { ArrowLeft, ArrowUpCircle, Boxes, ChevronDown, ChevronUp, Clock, Folder, Globe, Lock, RefreshCw, ShieldCheck, Terminal, User, Users } from 'lucide-react'
import { MarkdownRenderer } from '@/features/skill/markdown-renderer'
import { resolvePackageRelativeLink } from '@/features/skill/package-relative-link'
import { FileTree } from '@/features/skill/file-tree'
@ -1185,6 +1185,41 @@ export function SkillDetailPage() {
</div>
</Card>
{(skill.entryForSuites?.length ?? 0) > 0 && (
<Card className="border-primary/20 bg-primary/[0.03] p-5 space-y-4">
<div className="flex items-center gap-2">
<Boxes className="h-4 w-4 text-primary" />
<span className="text-sm font-semibold font-heading text-foreground">
{t('skillDetail.suiteEntryTitle')}
</span>
</div>
<p className="text-sm leading-6 text-muted-foreground">
{t('skillDetail.suiteEntryDescription')}
</p>
<div className="space-y-2">
{skill.entryForSuites!.map((suite) => (
<Link
key={suite.suiteId}
to="/suite/$namespace/$slug"
params={{ namespace: suite.namespace, slug: suite.slug }}
search={{ version: suite.version }}
className="block rounded-xl border border-border/70 bg-background p-3 transition-colors hover:border-primary/40 hover:bg-primary/[0.03] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 focus-visible:ring-offset-2"
>
<span className="block break-words text-sm font-semibold text-foreground [overflow-wrap:anywhere]">
{suite.displayName}
</span>
<span className="mt-1 block break-all font-mono text-xs text-muted-foreground">
@{suite.namespace}/{suite.slug}@{suite.version}
</span>
<span className="mt-2 block text-xs font-medium text-primary">
{t('skillDetail.suiteEntryMemberCount', { count: suite.memberCount })}
</span>
</Link>
))}
</div>
</Card>
)}
{publishedVersion && canInteract && (
<Card className="p-5 space-y-4">
<div className="flex items-center gap-2">

View file

@ -120,6 +120,35 @@ describe('SuiteDetailPage', () => {
expect(screen.getAllByRole('link')).toHaveLength(1)
})
it.each([
'DELETED',
'NAMESPACE_ARCHIVED',
'NAMESPACE_FROZEN',
'SKILL_HIDDEN',
'SKILL_ARCHIVED',
'VERSION_UNAVAILABLE',
'VISIBILITY_INCOMPATIBLE',
] as const)('keeps a member blocked by %s non-navigable', (blockingReason) => {
const blockedSuite = suite()
blockedSuite.members = [{
namespace: 'global',
slug: `blocked-${blockingReason.toLowerCase()}`,
version: '1.0.0',
fingerprint: 'sha256:blocked',
position: 0,
entry: false,
browsable: false,
blockingReason,
}]
mocks.detail = { data: blockedSuite, isLoading: false, error: null }
render(<SuiteDetailPage />)
fireEvent.click(screen.getByRole('tab', { name: 'suite.membersTab' }))
expect(screen.getByText(`suite.blockingReasons.${blockingReason}`)).not.toBeNull()
expect(screen.queryByRole('link', { name: 'suite.viewMember' })).toBeNull()
})
it('places Suite metadata and installation in the detail sidebar', () => {
mocks.detail = { data: suite(), isLoading: false, error: null }