mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-11 22:51:04 +00:00
feat(suite): require and expose entry skill
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
a838078cd9
commit
d0e8c168fa
39 changed files with 660 additions and 93 deletions
|
|
@ -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,并链接到完整 Suite;Skill 的独立安装能力保持不变。
|
||||
|
||||
## 部署顺序
|
||||
|
||||
|
|
|
|||
|
|
@ -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。后端继续对每个命令独立鉴权,响应能力只用于正确展示入口,不作为安全边界。
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ==="
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -28,5 +28,6 @@ public record SkillDetailResponse(
|
|||
SkillLifecycleVersionResponse publishedVersion,
|
||||
SkillLifecycleVersionResponse ownerPreviewVersion,
|
||||
String ownerPreviewReviewComment,
|
||||
String resolutionMode
|
||||
String resolutionMode,
|
||||
List<SkillSuiteReferenceResponse> entryForSuites
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=类型化审核完成滚动升级前,暂不开放套件审核提交
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
13
web/src/api/generated/schema.d.ts
vendored
13
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Сохранить черновик",
|
||||
|
|
|
|||
|
|
@ -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": "保存草稿",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue