From d0e8c168fa060e5dbf53310dcc3bf66e85960932 Mon Sep 17 00:00:00 2001 From: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:52:46 +0800 Subject: [PATCH] feat(suite): require and expose entry skill Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> --- docs/25-skill-suites.md | 7 +- openspec/changes/add-skill-suites/design.md | 12 +- .../specs/skill-suites/spec.md | 21 ++- openspec/changes/add-skill-suites/tasks.md | 5 +- scripts/suite-smoke-test.sh | 151 +++++++++++++++--- .../controller/portal/SkillController.java | 19 ++- .../skillhub/dto/SkillDetailResponse.java | 3 +- .../skillhub/dto/SkillSuiteCreateRequest.java | 2 +- .../dto/SkillSuiteReferenceResponse.java | 12 ++ .../SkillSuiteReferenceQueryRepository.java | 96 +++++++++++ .../service/ReviewSkillDetailAppService.java | 3 +- .../service/SkillSuiteAppService.java | 11 +- .../db/migration/V49__skill_suites.sql | 12 +- .../src/main/resources/messages.properties | 1 + .../src/main/resources/messages_zh.properties | 1 + .../ReviewPortalControllerTest.java | 3 +- .../controller/SkillControllerTest.java | 12 ++ .../SkillSuiteHardDeleteIntegrationTest.java | 3 +- .../SuiteDiscoveryIntegrationTest.java | 70 +++++++- .../service/SkillSuiteAppServiceTest.java | 4 +- .../suite/SkillSuiteCompositionPolicy.java | 5 +- .../domain/suite/SkillSuiteDraftService.java | 20 ++- .../suite/SkillSuitePublicationValidator.java | 3 + .../domain/suite/SkillSuiteVersion.java | 12 -- .../domain/suite/SkillSuiteVersionMember.java | 12 +- .../SkillSuiteCompositionPolicyTest.java | 14 +- ...SkillSuiteMemberEligibilityPolicyTest.java | 71 ++++++++ .../SkillSuiteMemberStateResolverTest.java | 3 +- .../suite/SkillSuiteQueryServiceTest.java | 3 +- web/src/api/generated/schema.d.ts | 13 +- web/src/api/types.ts | 10 ++ web/src/i18n/locales/en.json | 6 +- web/src/i18n/locales/ru.json | 6 +- web/src/i18n/locales/zh.json | 6 +- web/src/pages/dashboard/suite-editor.test.tsx | 14 ++ web/src/pages/dashboard/suite-editor.tsx | 10 +- web/src/pages/skill-detail.test.tsx | 31 ++++ web/src/pages/skill-detail.tsx | 37 ++++- web/src/pages/suite-detail.test.tsx | 29 ++++ 39 files changed, 660 insertions(+), 93 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteReferenceResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteReferenceQueryRepository.java diff --git a/docs/25-skill-suites.md b/docs/25-skill-suites.md index b91119ca..55f47545 100644 --- a/docs/25-skill-suites.md +++ b/docs/25-skill-suites.md @@ -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='' \ - 原有 Skill 搜索接口继续只返回 Skill。 类型化发现结果通过 `resourceType=SKILL|SUITE` 区分同名资源。Suite 详情返回固定版本、按顺序排列 -的成员快照、Entry Skill、实时可安装状态和阻塞原因。 +的成员快照、Entry Skill、实时可安装状态和阻塞原因。普通 Skill 详情会列出当前用户可见、以该 Skill +作为 Entry 的最新已发布 SuiteVersion,并链接到完整 Suite;Skill 的独立安装能力保持不变。 ## 部署顺序 diff --git a/openspec/changes/add-skill-suites/design.md b/openspec/changes/add-skill-suites/design.md index 8ed7a8fc..47acaa82 100644 --- a/openspec/changes/add-skill-suites/design.md +++ b/openspec/changes/add-skill-suites/design.md @@ -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 操作权限为: //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。后端继续对每个命令独立鉴权,响应能力只用于正确展示入口,不作为安全边界。 diff --git a/openspec/changes/add-skill-suites/specs/skill-suites/spec.md b/openspec/changes/add-skill-suites/specs/skill-suites/spec.md index c0b5c2d2..08b29d38 100644 --- a/openspec/changes/add-skill-suites/specs/skill-suites/spec.md +++ b/openspec/changes/add-skill-suites/specs/skill-suites/spec.md @@ -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 diff --git a/openspec/changes/add-skill-suites/tasks.md b/openspec/changes/add-skill-suites/tasks.md index aaac48c4..acb6629a 100644 --- a/openspec/changes/add-skill-suites/tasks.md +++ b/openspec/changes/add-skill-suites/tasks.md @@ -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 diff --git a/scripts/suite-smoke-test.sh b/scripts/suite-smoke-test.sh index 0fdb9a68..ba2c1412 100755 --- a/scripts/suite-smoke-test.sh +++ b/scripts/suite-smoke-test.sh @@ -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 ===" diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java index 59e8be5c..94f328eb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillController.java @@ -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 userNsRoles) { + @RequestAttribute(value = "userNsRoles", required = false) Map userNsRoles, + @AuthenticationPrincipal PlatformPrincipal principal) { + Map 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); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java index 07029180..00cecfa9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillDetailResponse.java @@ -28,5 +28,6 @@ public record SkillDetailResponse( SkillLifecycleVersionResponse publishedVersion, SkillLifecycleVersionResponse ownerPreviewVersion, String ownerPreviewReviewComment, - String resolutionMode + String resolutionMode, + List entryForSuites ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteCreateRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteCreateRequest.java index 53063f9e..592d0794 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteCreateRequest.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteCreateRequest.java @@ -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 ) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteReferenceResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteReferenceResponse.java new file mode 100644 index 00000000..feb95feb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSuiteReferenceResponse.java @@ -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 +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteReferenceQueryRepository.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteReferenceQueryRepository.java new file mode 100644 index 00000000..459bb996 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/repository/SkillSuiteReferenceQueryRepository.java @@ -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. + * + *

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.

+ */ +@Repository +public class SkillSuiteReferenceQueryRepository { + + private final NamedParameterJdbcTemplate jdbcTemplate; + + public SkillSuiteReferenceQueryRepository(NamedParameterJdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional(readOnly = true) + public List findVisibleEntryReferences( + Long skillId, + String userId, + Map namespaceRoles, + Set platformRoles + ) { + List memberNamespaceIds = namespaceRoles.keySet().stream().toList(); + List 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 nonEmpty(List values) { + return values.isEmpty() ? List.of(-1L) : values; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java index 03cd0707..8c9afd68 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewSkillDetailAppService.java @@ -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 versions = snapshot.versions().stream() diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java index 2e88f1a6..d36f0b4d 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/SkillSuiteAppService.java @@ -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 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)); } diff --git a/server/skillhub-app/src/main/resources/db/migration/V49__skill_suites.sql b/server/skillhub-app/src/main/resources/db/migration/V49__skill_suites.sql index b8b02616..bb31e98e 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V49__skill_suites.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V49__skill_suites.sql @@ -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); diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 3f2c0f43..64fb167e 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -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 diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index f78ca09f..4fb2b56a 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -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=类型化审核完成滚动升级前,暂不开放套件审核提交 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java index a3016841..432fdaaa 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/ReviewPortalControllerTest.java @@ -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")), diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 1c1b8229..ae6c9c5d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -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)); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteHardDeleteIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteHardDeleteIntegrationTest.java index beb9d2ef..3e191d91 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteHardDeleteIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SkillSuiteHardDeleteIntegrationTest.java @@ -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); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java index 5f0ce92b..b203bcb6 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/integration/SuiteDiscoveryIntegrationTest.java @@ -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())) diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java index 9932fb61..8b9dfb73 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/SkillSuiteAppServiceTest.java @@ -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( diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteCompositionPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteCompositionPolicy.java index b750d455..3af58659 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteCompositionPolicy.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteCompositionPolicy.java @@ -24,7 +24,10 @@ public final class SkillSuiteCompositionPolicy { throw new DomainBadRequestException("error.suite.members.limit", MAX_MEMBERS); } Set 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"); diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java index 09eead25..3a82e509 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteDraftService.java @@ -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 members = saveMembers(version, command.members()); + List 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 members = saveMembers(version, command.members()); + List 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 members = saveMembers(version, command.members()); + List 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 saveMembers( SkillSuiteVersion version, - List selections + List selections, + Long entrySkillVersionId ) { List 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); } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidator.java index 00826dfa..e920a65f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidator.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuitePublicationValidator.java @@ -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()) { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersion.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersion.java index 0ee2f5bd..7b85a20d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersion.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersion.java @@ -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; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionMember.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionMember.java index 1e7dfad2..a392298c 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionMember.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteVersionMember.java @@ -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; } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteCompositionPolicyTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteCompositionPolicyTest.java index d99ca266..c72b243a 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteCompositionPolicyTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteCompositionPolicyTest.java @@ -10,9 +10,19 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; class SkillSuiteCompositionPolicyTest { + @Test + void rejectsSuiteWithoutEntrySkill() { + List 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"); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberEligibilityPolicyTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberEligibilityPolicyTest.java index fee935fe..5d8d622c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberEligibilityPolicyTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberEligibilityPolicyTest.java @@ -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); + } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberStateResolverTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberStateResolverTest.java index 146bd541..d028b3d1 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberStateResolverTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteMemberStateResolverTest.java @@ -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)); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java index f40545e1..c94e8ad5 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteQueryServiceTest.java @@ -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); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 42a1485b..4d4b4943 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -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 */ diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 442fa69b..fa63b0b8 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -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 { diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 0016299d..826b4d79 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -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", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 13c82fae..1d2ba960 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -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": "Сохранить черновик", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 6b281d7e..0073efb1 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -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": "保存草稿", diff --git a/web/src/pages/dashboard/suite-editor.test.tsx b/web/src/pages/dashboard/suite-editor.test.tsx index 45847715..6cebb23c 100644 --- a/web/src/pages/dashboard/suite-editor.test.tsx +++ b/web/src/pages/dashboard/suite-editor.test.tsx @@ -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() + 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() + }) }) diff --git a/web/src/pages/dashboard/suite-editor.tsx b/web/src/pages/dashboard/suite-editor.tsx index 8203f5c1..11c665e7 100644 --- a/web/src/pages/dashboard/suite-editor.tsx +++ b/web/src/pages/dashboard/suite-editor.tsx @@ -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 { diff --git a/web/src/pages/skill-detail.test.tsx b/web/src/pages/skill-detail.test.tsx index 47906cd1..1ceb4740 100644 --- a/web/src/pages/skill-detail.test.tsx +++ b/web/src/pages/skill-detail.test.tsx @@ -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() + + 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() + + 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' }), diff --git a/web/src/pages/skill-detail.tsx b/web/src/pages/skill-detail.tsx index e53cae79..1264ae7f 100644 --- a/web/src/pages/skill-detail.tsx +++ b/web/src/pages/skill-detail.tsx @@ -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() { + {(skill.entryForSuites?.length ?? 0) > 0 && ( + +
+ + + {t('skillDetail.suiteEntryTitle')} + +
+

+ {t('skillDetail.suiteEntryDescription')} +

+
+ {skill.entryForSuites!.map((suite) => ( + + + {suite.displayName} + + + @{suite.namespace}/{suite.slug}@{suite.version} + + + {t('skillDetail.suiteEntryMemberCount', { count: suite.memberCount })} + + + ))} +
+
+ )} + {publishedVersion && canInteract && (
diff --git a/web/src/pages/suite-detail.test.tsx b/web/src/pages/suite-detail.test.tsx index 37a6677d..c0fd8e03 100644 --- a/web/src/pages/suite-detail.test.tsx +++ b/web/src/pages/suite-detail.test.tsx @@ -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() + 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 }