diff --git a/cli/src/services/suite-service.ts b/cli/src/services/suite-service.ts index 335528da..a54ab6d5 100644 --- a/cli/src/services/suite-service.ts +++ b/cli/src/services/suite-service.ts @@ -1,7 +1,8 @@ import { mkdtemp, rename, rm } from 'node:fs/promises' -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { dirname, join, resolve } from 'node:path' import { tmpdir } from 'node:os' +import { lock } from 'proper-lockfile' import { SkillHubClient, type SuiteDetail, type SuiteInstallPlan } from '../clients/skillhub-client' import { InventoryStore, @@ -16,9 +17,9 @@ import { import { CliError } from '../shared/errors' import { EXIT } from '../shared/constants' import { installSkill } from './install-service' -import { pathExists } from '../platform/paths' +import { pathExists, userStateDir } from '../platform/paths' import { snapshotSkillDirectory } from './skill-fingerprint' -import { acquireSkillTargetLock } from './skill-target-lock' +import { acquireSkillTargetLock, ensurePrivateLockDir } from './skill-target-lock' import type { AgentCandidate } from '../agents/types' const SUITE_CAPABILITY = 'skill-suite-v1' @@ -65,6 +66,15 @@ export interface SuiteRemoveResult { preserved: Array<{ dir: string; reason: 'shared' | 'modified' | 'missing' }> } +export interface SuiteRemoveOptions { + registry: string + namespace: string + slug: string + home?: string | undefined + /** Internal seam used to verify state observed immediately after target locking. */ + afterTargetLocksAcquired?: (() => Promise) | undefined +} + export interface SuiteUpgradePlan { current: InventorySuite remote: SuiteDetail @@ -128,6 +138,21 @@ export async function installSuite(options: SuiteInstallOptions): Promise {}) + } +} + +async function installSuiteTransaction( + options: SuiteInstallOptions, + client: SkillHubClient, + renameOperation: typeof rename, + plan: SuiteInstallPlan +): Promise { const store = new InventoryStore(options.home) const before = await store.read() const previousSuite = installedSuites(before).find(candidate => @@ -338,14 +363,17 @@ export async function checkSuite(options: { } } -export async function removeSuite(options: { - registry: string - namespace: string - slug: string - home?: string | undefined - /** Internal seam used to verify state observed immediately after target locking. */ - afterTargetLocksAcquired?: (() => Promise) | undefined -}): Promise { +export async function removeSuite(options: SuiteRemoveOptions): Promise { + const releaseSuiteLock = await acquireSuiteOperationLock( + options.home, options.registry, options.namespace, options.slug) + try { + return await removeSuiteTransaction(options) + } finally { + await releaseSuiteLock().catch(() => {}) + } +} + +async function removeSuiteTransaction(options: SuiteRemoveOptions): Promise { const store = new InventoryStore(options.home) const inventory = await store.read() const suite = findInstalledSuite(inventory, options.registry, options.namespace, options.slug) @@ -809,6 +837,38 @@ function assertSuiteSnapshotUnchanged( }) } +/** Serializes local install, upgrade, and remove operations for one Suite inventory identity. */ +async function acquireSuiteOperationLock( + home: string | undefined, + registry: string, + namespace: string, + slug: string +): Promise<() => Promise> { + const uid = typeof process.getuid === 'function' ? process.getuid() : 'user' + const lockDir = join(tmpdir(), `skillhub-cli-suite-locks-${uid}`) + await ensurePrivateLockDir(lockDir) + const digest = createHash('sha256') + .update(`${userStateDir(home)}\0${registry}\0${namespace}\0${slug}`) + .digest('hex') + const lockPath = join(lockDir, `${digest}.lock`) + try { + return await lock(lockPath, { + lockfilePath: lockPath, + realpath: false, + stale: 30_000, + update: 10_000, + retries: 0 + }) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ELOCKED') { + throw new CliError(`Suite operation is busy: @${namespace}/${slug}`, EXIT.filesystem, { + next: 'wait for the other SkillHub CLI process to finish and retry' + }) + } + throw error + } +} + function describe(error: unknown): string { return error instanceof Error ? error.message : String(error) } diff --git a/cli/test/unit/services/suite-service.test.ts b/cli/test/unit/services/suite-service.test.ts index b0fbdfa4..7fcab0ff 100644 --- a/cli/test/unit/services/suite-service.test.ts +++ b/cli/test/unit/services/suite-service.test.ts @@ -158,6 +158,53 @@ describe('Suite local lifecycle', () => { expect(checked.members.every(member => member.status === 'ok')).toBe(true) }) + test('serializes the same Suite across different Agent targets', async () => { + const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) + const firstRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-first-root-')) + const secondRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-second-root-')) + const { plan, downloads } = makePlan() + let signalFirstLocked: (() => void) | undefined + let releaseFirst: (() => void) | undefined + const firstLocked = new Promise((resolvePromise) => { signalFirstLocked = resolvePromise }) + const holdFirst = new Promise((resolvePromise) => { releaseFirst = resolvePromise }) + + const firstInstall = installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'codex', rootDir: firstRoot, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads), + afterTargetLocksAcquired: async () => { + signalFirstLocked?.() + await holdFirst + } + }) + + await firstLocked + try { + await expect(installSuite({ + registry, + namespace: 'global', + slug: 'starter-pack', + targets: [{ agent: 'claude', rootDir: secondRoot, scope: 'project', source: 'explicit' }], + force: false, + home, + client: clientFor(plan, downloads) + })).rejects.toThrow('Suite operation is busy') + } finally { + releaseFirst?.() + await firstInstall + } + + const inventory = await new InventoryStore(home).read() + expect(inventory.suites).toHaveLength(1) + expect(inventory.suites?.[0]?.members.every(member => + member.installDirs.every(dir => dir.startsWith(firstRoot)))).toBe(true) + expect(await exists(join(secondRoot, 'alpha'))).toBe(false) + }) + test('does not change live directories or inventory when a member fingerprint fails', async () => { const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-')) const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-')) diff --git a/docs/25-skill-suites.md b/docs/25-skill-suites.md index 545ecd35..b03850f4 100644 --- a/docs/25-skill-suites.md +++ b/docs/25-skill-suites.md @@ -88,16 +88,18 @@ skillhub suite upgrade @global/superpowers skillhub suite remove @global/superpowers ``` -安装先解析精确计划并下载、校验全部成员,再按稳定顺序锁定目标目录并整体提交。提交中途失败时, -CLI 恢复本次替换的目录并保持安装前 inventory。卸载只移除当前 Suite 的来源;直接安装、被其他 -Suite 共享或已被本地修改的成员目录会保留。 +安装、升级和卸载先获取当前 Suite 的本地操作锁,避免两个 CLI 进程基于同一份旧 inventory 并发 +提交。安装随后解析精确计划并下载、校验全部成员,再按稳定顺序锁定目标目录并整体提交。提交中途 +失败时,CLI 恢复本次替换的目录并保持安装前 inventory。卸载只移除当前 Suite 的来源;直接安装、 +被其他 Suite 共享或已被本地修改的成员目录会保留。 CLI inventory 向后兼容旧记录。旧记录没有 `installedBy` 时按直接安装处理,不会在移除 Suite 时被 误删。新 CLI 在 Server 未声明 `skill-suite-v1` 能力时会明确停止 Suite 命令,普通 Skill 命令不受影响。 CLI 获取安装计划时会发送独立的 `Idempotency-Key`,遇到网络错误或 502/503/504 时使用同一个 key -重试一次。Server 按调用者隔离该 key,并为计划生成 `operationId`,在 24 小时窗口内避免重复记录 -Suite 安装请求和审计。 +重试一次。Server 按登录用户隔离该 key;匿名请求使用经过哈希的请求来源、客户端标识和 Suite 坐标 +隔离,不保存原始身份字段。Server 为计划生成 `operationId`,在 24 小时窗口内避免重复记录 Suite +安装请求和审计。 安装计划本身不预增成员下载数;每个成员仍由原有 Skill 下载接口按实际请求计数。 本地 `local` profile 可直接运行 `make suite-smoke`。验证 release Compose 时必须使用真实管理员会话: @@ -123,7 +125,8 @@ SMOKE_ADMIN_PASSWORD='' \ ## 部署顺序 -数据库迁移会先把既有审核任务回填为 `SKILL_VERSION`,并保留旧 Skill 专用列。官方单实例 +数据库迁移会先把既有审核任务回填为 `SKILL_VERSION`,保留旧 Skill 专用列,并通过数据库触发器 +把旧版 Server 新写入的 Skill 审核同步补全为类型化 subject。官方单实例 `compose.release.yml` 和本地开发 profile 已默认开启 Suite 审核写入,因为它们不会同时运行新旧 Server。 其他部署方式默认保持关闭。全新安装、单实例升级或停机升级可直接设置: diff --git a/openspec/changes/add-skill-suites/design.md b/openspec/changes/add-skill-suites/design.md index ee2d4206..36bf24f3 100644 --- a/openspec/changes/add-skill-suites/design.md +++ b/openspec/changes/add-skill-suites/design.md @@ -255,11 +255,11 @@ Skill 详情只返回当前用户有权查看的、以该 Skill 为 Entry 的最 2. 检查 Suite/成员权限、状态、目标目录、现有来源冲突和空间限制。 3. 下载全部成员到目标根目录内的临时目录。 4. 校验每个成员的 fingerprint 和 Skill 元数据。 -5. 按稳定顺序获取所有目标锁,备份将被替换的同源目录。 +5. 先获取当前 Suite 的本地操作锁,再按稳定顺序获取所有目标锁,备份将被替换的同源目录。 6. 移动全部成员并一次性写入 inventory。 7. 任一步失败时恢复所有备份并保持原 inventory。 -服务端只返回安装计划和成员下载能力,不尝试对用户文件系统提供分布式事务。CLI 在现有 staged install、target lock 和 rollback 机制上扩展为多成员计划。 +服务端只返回安装计划和成员下载能力,不尝试对用户文件系统提供分布式事务。CLI 在现有 staged install、target lock 和 rollback 机制上扩展为多成员计划;Suite 级操作锁串行化同一 registry、Suite 坐标和本地状态目录上的安装、升级与卸载,避免不同目标组合并发覆盖 inventory。 ### 9. inventory 记录来源集合而不是单一所有者 @@ -322,7 +322,7 @@ Server 和 CLI 按以下组合兼容: SuiteVersion 被拒绝后允许由管理者退回 DRAFT,保留原审核记录并修改未发布版本后再次提交。PUBLISHED/YANKED SuiteVersion 永远不可编辑;这些版本的任何变化都创建新 SuiteVersion。每次重新提交创建新的审核轮次,不覆盖旧决定。 -CLI 为一次安装计划生成独立的 `Idempotency-Key`,网络重试复用该 key;服务端按调用者隔离 retry key,并生成唯一 `operationId` 串联 Suite 安装计划及 Member 下载意图审计。成功签发完整计划后仅增加一次 Suite 安装请求数,不预增 Member 下载数。Member 继续由现有 Skill 下载接口按实际请求计数,避免计划签发和文件下载对同一 Member 重复计数。服务端保留 retry-key 映射 24 小时,并复用现有幂等清理任务删除过期映射,避免无界增长。 +CLI 为一次安装计划生成独立的 `Idempotency-Key`,网络重试复用该 key;服务端按调用者隔离 retry key:登录请求使用用户 ID,匿名请求使用经过哈希的请求来源、客户端标识和 Suite 坐标,不持久化原始组合值。服务端生成唯一 `operationId` 串联 Suite 安装计划及 Member 下载意图审计。成功签发完整计划后仅增加一次 Suite 安装请求数,不预增 Member 下载数。Member 继续由现有 Skill 下载接口按实际请求计数,避免计划签发和文件下载对同一 Member 重复计数。服务端保留 retry-key 映射 24 小时,并复用现有幂等清理任务删除过期映射,避免无界增长。 服务端无法可靠知道 CLI 最终是否完成本地文件提交,因此该指标表示“安装计划/下载已签发”,不宣称是本地安装成功数。CLI 后续校验或提交失败不反向扣减服务端计数;v1 不增加客户端完成回调或遥测上报。 @@ -334,7 +334,7 @@ CLI 为一次安装计划生成独立的 `Idempotency-Key`,网络重试复用 - **共享成员卸载可能误删直接安装内容** → inventory 保存多来源引用,本地修改和来源不明时 fail closed。 - **Skill/Suite 同 slug 可能让自然语言含糊** → CLI、API、URL、搜索结果和安装提示始终携带资源类型;旧 `install` 固定解析 Skill。 - **在现有 Skill 搜索中直接混入 Suite 会破坏旧客户端** → 保留 Skill-only 旧接口,另增类型化资源发现入口。 -- **审核目标和状态枚举扩展可能破坏滚动升级** → Suite 使用独立状态;审核表按兼容窗口增量迁移,并在混合版本验证后再收紧旧字段。 +- **审核目标和状态枚举扩展可能破坏滚动升级** → Suite 使用独立状态;审核表回填历史记录,并在兼容窗口用数据库触发器补全旧版 Server 写入的类型化字段,混合版本验证后再开放 Suite 审核。 - **不支持一份 ZIP 创建全部成员,首次迁移多 Skill 仓库仍需发布成员** → v1 优先保证领域和生命周期正确;以后可增加调用现有发布 API 的批量 CLI 编排,但不改变 Suite 模型。 ## Migration Plan 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 2762b8b7..39acfe0b 100644 --- a/openspec/changes/add-skill-suites/specs/skill-suites/spec.md +++ b/openspec/changes/add-skill-suites/specs/skill-suites/spec.md @@ -273,6 +273,11 @@ CLI SHALL 在修改目标目录前完成全部成员和全部 Agent 目标的解 - **AND** 恢复备份和安装前 inventory - **AND** 无法完成的回滚必须保留备份路径并明确报告 +#### Scenario: Concurrent operations target the same local Suite +- **WHEN** 两个 CLI 进程并发安装、升级或卸载同一 registry 和 Suite 坐标 +- **THEN** CLI 通过 Suite 级本地锁只允许一个操作进入事务 +- **AND** 另一个操作明确报告繁忙,不得基于旧 inventory 提交 + ### Requirement: Suite installation SHALL preserve Agent Skills compatibility CLI SHALL 将每个 Member 作为普通 Skill 安装到 Agent 已支持的 Skill 根目录。CLI SHALL NOT 为 Suite 创建同名 `SKILL.md` 或要求 Agent 理解 Suite 协议。 @@ -492,6 +497,11 @@ REJECTED SuiteVersion MAY 由有权限的管理者退回 DRAFT、修改并重新 - **AND** Suite 安装请求数和审计记录不重复增加 - **AND** 该重试保证至少覆盖服务端约定的 24 小时幂等窗口 +#### Scenario: Anonymous callers reuse the same client key +- **WHEN** 两个匿名调用者对 Suite 安装计划使用相同的 idempotency key +- **THEN** 服务端使用经过哈希的调用者上下文和 Suite 坐标隔离幂等记录 +- **AND** 不在幂等 actor key 中保存原始 IP 或 User-Agent + ### Requirement: Existing Skill workflows SHALL remain compatible 引入 Suite 后,现有单 Skill 包协议、发布、扫描、审核、URL、API 和 CLI 安装行为 SHALL 保持不变。Suite 专用能力 SHALL 是增量接口。 @@ -552,4 +562,5 @@ Suite 能力 SHALL 以增量方式提供。旧 CLI 使用新 Server 时 SHALL #### Scenario: Mixed application versions during rollout - **WHEN** 部署期间同时存在支持和不支持 Suite subject 的应用实例 - **THEN** 现有 Skill 审核流程保持可用 +- **AND** 数据库为旧版实例写入的 Skill 审核补全类型化 subject - **AND** Suite 审核写入只在所有处理实例均支持类型化 subject 后启用 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MySkillSuiteController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MySkillSuiteController.java index 5b476f99..9903a2c5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MySkillSuiteController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/MySkillSuiteController.java @@ -30,7 +30,7 @@ public class MySkillSuiteController extends BaseApiController { } @GetMapping - @Operation(summary = "List Suite versions manageable by the current user") + @Operation(operationId = "listMySkillSuites", summary = "List Suite versions manageable by the current user") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Manageable Suite page returned") public ApiResponse> list( @RequestParam(required = false) String q, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ResourceDiscoveryController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ResourceDiscoveryController.java index e333c7b8..9ae84305 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ResourceDiscoveryController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/ResourceDiscoveryController.java @@ -33,7 +33,7 @@ public class ResourceDiscoveryController extends BaseApiController { } @GetMapping - @Operation(summary = "Search Skills and Suites with explicit resource types") + @Operation(operationId = "searchResources", summary = "Search Skills and Suites with explicit resource types") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Resource page returned") @RateLimit(category = "search", authenticated = 60, anonymous = 20) public ApiResponse search( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteController.java index d7b87256..530aca4e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/SkillSuiteController.java @@ -51,7 +51,7 @@ public class SkillSuiteController extends BaseApiController { } @GetMapping("/{namespace}/{slug}") - @Operation(summary = "Get one visible Suite version") + @Operation(operationId = "getSkillSuite", summary = "Get one visible Suite version") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite version returned") public ApiResponse getDetail( @PathVariable String namespace, @@ -66,7 +66,7 @@ public class SkillSuiteController extends BaseApiController { } @GetMapping("/{namespace}/{slug}/versions") - @Operation(summary = "List visible Suite versions") + @Operation(operationId = "listSkillSuiteVersions", summary = "List visible Suite versions") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite version history returned") public ApiResponse> listVersions( @PathVariable String namespace, @@ -80,7 +80,7 @@ public class SkillSuiteController extends BaseApiController { } @GetMapping("/member-candidates") - @Operation(summary = "Search exact Skill versions eligible for a Suite draft") + @Operation(operationId = "searchSkillSuiteMemberCandidates", summary = "Search exact Skill versions eligible for a Suite draft") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Eligible member candidates returned") public ApiResponse> searchCandidates( @RequestParam String suiteNamespace, @@ -96,7 +96,7 @@ public class SkillSuiteController extends BaseApiController { } @PostMapping("/{namespace}/{slug}/install-plan") - @Operation(summary = "Issue an idempotent exact-member Suite install plan") + @Operation(operationId = "createSkillSuiteInstallPlan", summary = "Issue an idempotent exact-member Suite install plan") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Install plan issued") @RateLimit(category = "download", authenticated = 120, anonymous = 30) public ApiResponse createInstallPlan( @@ -115,7 +115,7 @@ public class SkillSuiteController extends BaseApiController { } @PostMapping - @Operation(summary = "Create a Suite and its first draft version") + @Operation(operationId = "createSkillSuite", summary = "Create a Suite and its first draft version") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite draft created") public ApiResponse create( @Valid @RequestBody SkillSuiteCreateRequest request, @@ -129,7 +129,7 @@ public class SkillSuiteController extends BaseApiController { } @PostMapping("/{suiteId}/versions") - @Operation(summary = "Create a new draft version for a Suite") + @Operation(operationId = "createSkillSuiteVersion", summary = "Create a new draft version for a Suite") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite version draft created") public ApiResponse createVersion( @PathVariable Long suiteId, @@ -144,7 +144,7 @@ public class SkillSuiteController extends BaseApiController { } @PutMapping("/{suiteId}/versions/{versionId}") - @Operation(summary = "Update an editable Suite draft") + @Operation(operationId = "updateSkillSuiteDraft", summary = "Update an editable Suite draft") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite draft updated") public ApiResponse updateDraft( @PathVariable Long suiteId, @@ -160,7 +160,7 @@ public class SkillSuiteController extends BaseApiController { } @PostMapping("/{suiteId}/versions/{versionId}/submit") - @Operation(summary = "Submit a public or namespace Suite draft for review") + @Operation(operationId = "submitSkillSuiteReview", summary = "Submit a public or namespace Suite draft for review") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite draft submitted") public ApiResponse submit( @PathVariable Long suiteId, @@ -176,7 +176,7 @@ public class SkillSuiteController extends BaseApiController { } @PostMapping("/{suiteId}/versions/{versionId}/publish") - @Operation(summary = "Publish a private Suite draft directly") + @Operation(operationId = "publishPrivateSkillSuite", summary = "Publish a private Suite draft directly") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Private Suite published") public ApiResponse publishPrivate( @PathVariable Long suiteId, @@ -192,7 +192,7 @@ public class SkillSuiteController extends BaseApiController { } @PostMapping("/reviews/{reviewTaskId}/approve") - @Operation(summary = "Approve a pending Suite review") + @Operation(operationId = "approveSkillSuiteReview", summary = "Approve a pending Suite review") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite review approved") public ApiResponse approve( @PathVariable Long reviewTaskId, @@ -209,7 +209,7 @@ public class SkillSuiteController extends BaseApiController { } @PostMapping("/reviews/{reviewTaskId}/reject") - @Operation(summary = "Reject a pending Suite review") + @Operation(operationId = "rejectSkillSuiteReview", summary = "Reject a pending Suite review") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite review rejected") public ApiResponse reject( @PathVariable Long reviewTaskId, @@ -226,7 +226,7 @@ public class SkillSuiteController extends BaseApiController { } @PostMapping("/{suiteId}/versions/{versionId}/reopen") - @Operation(summary = "Reopen a rejected Suite version as a draft") + @Operation(operationId = "reopenSkillSuiteDraft", summary = "Reopen a rejected Suite version as a draft") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite draft reopened") public ApiResponse reopen( @PathVariable Long suiteId, @@ -242,7 +242,7 @@ public class SkillSuiteController extends BaseApiController { } @PostMapping("/{suiteId}/versions/{versionId}/yank") - @Operation(summary = "Yank a published Suite version") + @Operation(operationId = "yankSkillSuiteVersion", summary = "Yank a published Suite version") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite version yanked") public ApiResponse yank( @PathVariable Long suiteId, @@ -260,7 +260,7 @@ public class SkillSuiteController extends BaseApiController { } @PostMapping("/{suiteId}/hide") - @Operation(summary = "Hide a Suite from discovery") + @Operation(operationId = "hideSkillSuite", summary = "Hide a Suite from discovery") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite hidden") public ApiResponse hide( @PathVariable Long suiteId, @@ -274,7 +274,7 @@ public class SkillSuiteController extends BaseApiController { } @PostMapping("/{suiteId}/restore") - @Operation(summary = "Restore a hidden Suite") + @Operation(operationId = "restoreSkillSuite", summary = "Restore a hidden Suite") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite restored") public ApiResponse restore( @PathVariable Long suiteId, @@ -288,7 +288,7 @@ public class SkillSuiteController extends BaseApiController { } @PostMapping("/{suiteId}/archive") - @Operation(summary = "Archive a Suite container") + @Operation(operationId = "archiveSkillSuite", summary = "Archive a Suite container") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite archived") public ApiResponse archive( @PathVariable Long suiteId, @@ -302,7 +302,7 @@ public class SkillSuiteController extends BaseApiController { } @PostMapping("/{suiteId}/unarchive") - @Operation(summary = "Restore an archived Suite container") + @Operation(operationId = "unarchiveSkillSuite", summary = "Restore an archived Suite container") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite unarchived") public ApiResponse unarchive( @PathVariable Long suiteId, @@ -316,7 +316,7 @@ public class SkillSuiteController extends BaseApiController { } @DeleteMapping("/{suiteId}") - @Operation(summary = "Delete a Suite without changing member Skills") + @Operation(operationId = "deleteSkillSuite", summary = "Delete a Suite without changing member Skills") @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite deleted") public ApiResponse delete( @PathVariable Long suiteId, 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 22557485..f035bbac 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 @@ -44,6 +44,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; import java.security.MessageDigest; import java.util.ArrayList; import java.util.HexFormat; @@ -170,7 +171,7 @@ public class SkillSuiteAppService { HttpServletRequest request ) { String retryKey = normalizeClientRequestId(clientRequestId); - String actorKey = idempotencyActorKey(userId); + String actorKey = idempotencyActorKey(userId, namespace, slug, request); SkillSuiteInstallOperation existing = installOperationRepository .findByClientRequestIdAndActorKey(retryKey, actorKey) .orElse(null); @@ -184,7 +185,8 @@ public class SkillSuiteAppService { if (!detail.available()) { throw new DomainBadRequestException("error.suite.install.unavailable"); } - List members = resolveInstallMembers(detail, userId, namespaceRoles); + List members = resolveInstallMembers( + detail, userId, namespaceRoles, platformRoles); String operationId = UUID.randomUUID().toString(); String fingerprint = suiteFingerprint(detail, members); @@ -250,7 +252,8 @@ public class SkillSuiteAppService { if (!detail.available()) { throw new DomainBadRequestException("error.suite.install.unavailable"); } - List members = resolveInstallMembers(detail, userId, namespaceRoles); + List members = resolveInstallMembers( + detail, userId, namespaceRoles, platformRoles); String fingerprint = suiteFingerprint(detail, members); log.info( "Suite install plan safely replayed [suiteId={}, versionId={}, actorId={}, memberCount={}, operationId={}]", @@ -262,16 +265,21 @@ public class SkillSuiteAppService { private List resolveInstallMembers( SkillSuiteQueryService.Detail detail, String userId, - Map namespaceRoles + Map namespaceRoles, + Set platformRoles ) { List members = new ArrayList<>(detail.members().size()); try { for (SkillSuiteQueryService.MemberDetail member : detail.members()) { var snapshot = member.snapshot(); - SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion( - snapshot.getNamespaceSlugSnapshot(), snapshot.getSkillSlugSnapshot(), - snapshot.getSkillVersionSnapshot(), null, snapshot.getFingerprintSnapshot(), - userId, namespaceRoles); + SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersionById( + snapshot.getSkillVersionId(), userId, namespaceRoles, platformRoles); + if (!Objects.equals(resolved.namespace(), snapshot.getNamespaceSlugSnapshot()) + || !Objects.equals(resolved.slug(), snapshot.getSkillSlugSnapshot()) + || !Objects.equals(resolved.version(), snapshot.getSkillVersionSnapshot()) + || !Objects.equals(resolved.fingerprint(), snapshot.getFingerprintSnapshot())) { + throw new DomainBadRequestException("error.suite.install.unavailable"); + } members.add(new SkillSuiteInstallMemberResponse( snapshot.getSkillId(), snapshot.getSkillVersionId(), resolved.namespace(), resolved.slug(), resolved.version(), resolved.fingerprint(), @@ -295,8 +303,26 @@ public class SkillSuiteAppService { return clientRequestId; } - private String idempotencyActorKey(String userId) { - return userId == null ? "anonymous" : "user:" + userId; + private String idempotencyActorKey( + String userId, + String namespace, + String slug, + HttpServletRequest request + ) { + if (userId != null) { + return "user:" + userId; + } + AuditRequestContext context = AuditRequestContext.from(request); + String callerScope = Objects.toString(context.clientIp(), "") + '\0' + + Objects.toString(context.userAgent(), "") + '\0' + + namespace + '/' + slug; + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return "anonymous:" + HexFormat.of().formatHex( + digest.digest(callerScope.getBytes(StandardCharsets.UTF_8))); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("Failed to scope anonymous Suite idempotency", exception); + } } public SkillSuiteResponse getDetail( @@ -532,9 +558,21 @@ public class SkillSuiteAppService { Map namespaceRoles, Set platformRoles ) { - List selections = request.members().stream() - .map(member -> resolve(member, userId, namespaceRoles, platformRoles)) - .toList(); + List selections = new ArrayList<>(request.members().size()); + List invalidMembers = new ArrayList<>(); + for (SkillSuiteMemberRequest member : request.members()) { + try { + selections.add(resolve(member, userId, namespaceRoles, platformRoles)); + } catch (LocalizedDomainException exception) { + invalidMembers.add(String.format( + "@%s/%s@%s (%s)", member.namespace(), member.slug(), member.version(), + exception.messageCode())); + } + } + if (!invalidMembers.isEmpty()) { + throw new DomainBadRequestException( + "error.suite.members.invalid", String.join("; ", invalidMembers)); + } Long entryVersionId = resolveEntryVersionId(request.entrySkill(), selections); return new CreateSkillSuiteDraftCommand( namespaceId, request.slug(), request.displayName(), request.summary(), request.overview(), diff --git a/server/skillhub-app/src/main/resources/db/migration/V50__typed_review_subjects.sql b/server/skillhub-app/src/main/resources/db/migration/V50__typed_review_subjects.sql index f55db85f..a5a93929 100644 --- a/server/skillhub-app/src/main/resources/db/migration/V50__typed_review_subjects.sql +++ b/server/skillhub-app/src/main/resources/db/migration/V50__typed_review_subjects.sql @@ -11,6 +11,29 @@ SET subject_type = 'SKILL_VERSION', subject_version_id = skill_version_id, subject_version = skill_version; +-- During a rolling deployment, an older Server still inserts only the legacy Skill columns. +-- Populate the typed identity in PostgreSQL before NOT NULL validation so existing Skill review +-- writes remain compatible until every Server instance understands typed review subjects. +CREATE OR REPLACE FUNCTION populate_review_task_subject_from_legacy() +RETURNS TRIGGER AS $$ +BEGIN + IF NEW.subject_type IS NULL + AND NEW.skill_id IS NOT NULL + AND NEW.skill_version IS NOT NULL THEN + NEW.subject_type := 'SKILL_VERSION'; + NEW.subject_id := NEW.skill_id; + NEW.subject_version_id := NEW.skill_version_id; + NEW.subject_version := NEW.skill_version; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_review_task_legacy_subject + BEFORE INSERT ON review_task + FOR EACH ROW + EXECUTE FUNCTION populate_review_task_subject_from_legacy(); + ALTER TABLE review_task ALTER COLUMN subject_type SET NOT NULL, ALTER COLUMN subject_id SET NOT NULL, diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 2e66f227..bb038dfb 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.members.selectionMismatch=The selected Skill version does not match its coordinate +error.suite.members.invalid=Invalid Suite members: {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} diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 672685d7..add78f54 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.members.selectionMismatch=所选技能版本与提交的坐标不匹配 +error.suite.members.invalid=套件成员无效:{0} error.suite.entry.notMember=入口技能必须是套件成员 error.suite.entry.required=必须选择入口技能 error.suite.namespace.notWritable=套件所在命名空间不可写:{0} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java index 343ef8a2..e590f8bd 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/repository/JpaReviewProgressQueryRepositoryTest.java @@ -8,7 +8,9 @@ import com.iflytek.skillhub.domain.review.ReviewTaskStatus; import com.iflytek.skillhub.domain.suite.SkillSuite; import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; import com.iflytek.skillhub.domain.skill.Skill; +import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.user.UserAccount; import java.time.Instant; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -41,6 +43,8 @@ class JpaReviewProgressQueryRepositoryTest { registry.add("spring.datasource.password", POSTGRES::getPassword); registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect"); + registry.add("spring.flyway.enabled", () -> true); + registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); } @Autowired @@ -51,6 +55,7 @@ class JpaReviewProgressQueryRepositoryTest { @Test void groupsAttemptsFiltersLatestStatusAndKeepsTotalsOnEmptyPage() { + persistUsers("owner", "author-1", "other-author"); Namespace namespace = entityManager.persistFlushFind( new Namespace("team-review", "Review Team", "owner")); Skill alpha = entityManager.persistFlushFind( @@ -137,6 +142,7 @@ class JpaReviewProgressQueryRepositoryTest { @Test void includesSuiteAttemptsWithoutRequiringLegacySkillColumns() { + persistUsers("owner", "author-1"); Namespace namespace = entityManager.persistFlushFind( new Namespace("team-suite-review", "Suite Review Team", "owner")); SkillSuite suite = entityManager.persistFlushFind( @@ -162,6 +168,53 @@ class JpaReviewProgressQueryRepositoryTest { assertThat(progress.statusCounts().pending()).isEqualTo(1); } + @Test + void legacySkillReviewInsertPopulatesTypedSubjectDuringRollingUpgrade() { + entityManager.persist(new UserAccount( + "legacy-review-author", "Legacy Review Author", null, null)); + Namespace namespace = entityManager.persistFlushFind( + new Namespace("legacy-review", "Legacy Review", "legacy-review-author")); + Skill skill = entityManager.persistFlushFind( + new Skill(namespace.getId(), "legacy-skill", "legacy-review-author", SkillVisibility.PUBLIC)); + SkillVersion skillVersion = entityManager.persistFlushFind( + new SkillVersion(skill.getId(), "1.0.0", "legacy-review-author")); + + int inserted = entityManager.getEntityManager().createNativeQuery(""" + INSERT INTO review_task ( + skill_version_id, skill_id, skill_version, namespace_id, + status, version, submitted_by, submitted_at + ) VALUES ( + :skillVersionId, :skillId, :skillVersion, :namespaceId, + 'PENDING', 1, :submittedBy, CURRENT_TIMESTAMP + ) + """) + .setParameter("skillVersionId", skillVersion.getId()) + .setParameter("skillId", skill.getId()) + .setParameter("skillVersion", skillVersion.getVersion()) + .setParameter("namespaceId", namespace.getId()) + .setParameter("submittedBy", "legacy-review-author") + .executeUpdate(); + + Object[] typedSubject = (Object[]) entityManager.getEntityManager().createNativeQuery(""" + SELECT subject_type, subject_id, subject_version_id, subject_version + FROM review_task + WHERE skill_version_id = :skillVersionId + """) + .setParameter("skillVersionId", skillVersion.getId()) + .getSingleResult(); + + assertThat(inserted).isEqualTo(1); + assertThat(typedSubject).containsExactly( + "SKILL_VERSION", skill.getId(), skillVersion.getId(), "1.0.0"); + } + + private void persistUsers(String... userIds) { + for (String userId : userIds) { + entityManager.persist(new UserAccount(userId, userId, null, null)); + } + entityManager.flush(); + } + private void persistAttempt( Skill skill, Namespace namespace, 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 ec6f0f6e..71693fa3 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 @@ -34,6 +34,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; import java.lang.reflect.Field; @@ -43,6 +44,7 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.catchThrowableOfType; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; @@ -95,11 +97,9 @@ class SkillSuiteAppServiceTest { SkillSuiteQueryService.Detail detail = detail(true); given(queryService.getDetail("global", "starter", null, "user-1", Map.of(), Set.of())) .willReturn(detail); - given(skillQueryService.resolveVersion( - "global", "first", "1.0.0", null, "sha256:first", "user-1", Map.of())) + given(skillQueryService.resolveVersionById(101L, "user-1", Map.of(), Set.of())) .willReturn(resolved(11L, 101L, "first", "1.0.0", "sha256:first")); - given(skillQueryService.resolveVersion( - "global", "second", "2.0.0", null, "sha256:second", "user-1", Map.of())) + given(skillQueryService.resolveVersionById(102L, "user-1", Map.of(), Set.of())) .willReturn(resolved(12L, 102L, "second", "2.0.0", "sha256:second")); given(installOperationRepository.insertIfAbsent( any(), org.mockito.ArgumentMatchers.eq("retry-1"), @@ -120,16 +120,72 @@ class SkillSuiteAppServiceTest { any(), any(), any(), any(), any(), any(), any(), any()); } + @Test + void createInstallPlan_preservesSuperAdminAccessWhenResolvingPrivateMembers() { + Set platformRoles = Set.of("SUPER_ADMIN"); + SkillSuiteQueryService.Detail detail = detail(true); + given(queryService.getDetail( + "global", "starter", null, "super-admin", Map.of(), platformRoles)) + .willReturn(detail); + given(skillQueryService.resolveVersionById( + 101L, "super-admin", Map.of(), platformRoles)) + .willReturn(resolved(11L, 101L, "first", "1.0.0", "sha256:first")); + given(skillQueryService.resolveVersionById( + 102L, "super-admin", Map.of(), platformRoles)) + .willReturn(resolved(12L, 102L, "second", "2.0.0", "sha256:second")); + given(installOperationRepository.insertIfAbsent( + any(), org.mockito.ArgumentMatchers.eq("retry-super-admin"), + org.mockito.ArgumentMatchers.eq("user:super-admin"), + org.mockito.ArgumentMatchers.eq(7L), org.mockito.ArgumentMatchers.eq(70L))).willReturn(1); + + var result = service.createInstallPlan( + "global", "starter", null, "super-admin", Map.of(), platformRoles, + "retry-super-admin", request); + + assertThat(result.members()).hasSize(2); + } + + @Test + void createInstallPlan_scopesAnonymousIdempotencyByCallerAndSuite() { + HttpServletRequest secondRequest = org.mockito.Mockito.mock(HttpServletRequest.class); + given(request.getRemoteAddr()).willReturn("192.0.2.10"); + given(request.getHeader("User-Agent")).willReturn("skillhub-cli/test"); + given(secondRequest.getRemoteAddr()).willReturn("192.0.2.11"); + given(secondRequest.getHeader("User-Agent")).willReturn("skillhub-cli/test"); + SkillSuiteQueryService.Detail detail = detail(true); + given(queryService.getDetail("global", "starter", null, null, Map.of(), Set.of())) + .willReturn(detail); + given(skillQueryService.resolveVersionById(101L, null, Map.of(), Set.of())) + .willReturn(resolved(11L, 101L, "first", "1.0.0", "sha256:first")); + given(skillQueryService.resolveVersionById(102L, null, Map.of(), Set.of())) + .willReturn(resolved(12L, 102L, "second", "2.0.0", "sha256:second")); + given(installOperationRepository.insertIfAbsent( + any(), org.mockito.ArgumentMatchers.eq("retry-anonymous"), any(), + org.mockito.ArgumentMatchers.eq(7L), org.mockito.ArgumentMatchers.eq(70L))).willReturn(1); + + service.createInstallPlan( + "global", "starter", null, null, Map.of(), Set.of(), "retry-anonymous", request); + service.createInstallPlan( + "global", "starter", null, null, Map.of(), Set.of(), "retry-anonymous", secondRequest); + + ArgumentCaptor actorKeys = ArgumentCaptor.forClass(String.class); + verify(installOperationRepository, times(2)).insertIfAbsent( + any(), org.mockito.ArgumentMatchers.eq("retry-anonymous"), actorKeys.capture(), + org.mockito.ArgumentMatchers.eq(7L), org.mockito.ArgumentMatchers.eq(70L)); + assertThat(actorKeys.getAllValues()) + .hasSize(2) + .doesNotHaveDuplicates() + .allMatch(value -> value.startsWith("anonymous:")); + } + @Test void createInstallPlan_replaysTheCapturedVersionWithoutDuplicateMetrics() { SkillSuiteQueryService.Detail detail = detail(true); given(queryService.getDetailByVersionId( "global", "starter", 70L, "user-1", Map.of(), Set.of())).willReturn(detail); - given(skillQueryService.resolveVersion( - "global", "first", "1.0.0", null, "sha256:first", "user-1", Map.of())) + given(skillQueryService.resolveVersionById(101L, "user-1", Map.of(), Set.of())) .willReturn(resolved(11L, 101L, "first", "1.0.0", "sha256:first")); - given(skillQueryService.resolveVersion( - "global", "second", "2.0.0", null, "sha256:second", "user-1", Map.of())) + given(skillQueryService.resolveVersionById(102L, "user-1", Map.of(), Set.of())) .willReturn(resolved(12L, 102L, "second", "2.0.0", "sha256:second")); SkillSuiteInstallOperation operation = org.mockito.Mockito.mock(SkillSuiteInstallOperation.class); given(operation.getOperationId()).willReturn("server-operation-1"); @@ -155,11 +211,9 @@ class SkillSuiteAppServiceTest { .willReturn(detail); given(queryService.getDetailByVersionId( "global", "starter", 70L, "user-1", Map.of(), Set.of())).willReturn(detail); - given(skillQueryService.resolveVersion( - "global", "first", "1.0.0", null, "sha256:first", "user-1", Map.of())) + given(skillQueryService.resolveVersionById(101L, "user-1", Map.of(), Set.of())) .willReturn(resolved(11L, 101L, "first", "1.0.0", "sha256:first")); - given(skillQueryService.resolveVersion( - "global", "second", "2.0.0", null, "sha256:second", "user-1", Map.of())) + given(skillQueryService.resolveVersionById(102L, "user-1", Map.of(), Set.of())) .willReturn(resolved(12L, 102L, "second", "2.0.0", "sha256:second")); given(installOperationRepository.insertIfAbsent( any(), org.mockito.ArgumentMatchers.eq("retry-race"), @@ -185,11 +239,9 @@ class SkillSuiteAppServiceTest { SkillSuiteQueryService.Detail detail = detail(true); given(queryService.getDetail("global", "starter", null, "user-1", Map.of(), Set.of())) .willReturn(detail); - given(skillQueryService.resolveVersion( - "global", "first", "1.0.0", null, "sha256:first", "user-1", Map.of())) + given(skillQueryService.resolveVersionById(101L, "user-1", Map.of(), Set.of())) .willReturn(resolved(11L, 101L, "first", "1.0.0", "sha256:first")); - given(skillQueryService.resolveVersion( - "global", "second", "2.0.0", null, "sha256:second", "user-1", Map.of())) + given(skillQueryService.resolveVersionById(102L, "user-1", Map.of(), Set.of())) .willThrow(new DomainForbiddenException("error.skill.access.denied", "private-skill")); assertThatThrownBy(() -> service.createInstallPlan( @@ -211,7 +263,7 @@ class SkillSuiteAppServiceTest { "global", "starter", null, null, Map.of(), Set.of(), "retry-3", request)) .isInstanceOf(DomainBadRequestException.class); - verify(skillQueryService, never()).resolveVersion(any(), any(), any(), any(), any(), any(), any()); + verify(skillQueryService, never()).resolveVersionById(any(), any(), any(), any()); verify(installMetricsService, never()).recordIssuedPlan(any()); } @@ -287,12 +339,39 @@ class SkillSuiteAppServiceTest { assertThatThrownBy(() -> service.create( createRequest, "user-1", Map.of(), Set.of("SUPER_ADMIN"), request)) .isInstanceOfSatisfying(DomainBadRequestException.class, exception -> - assertThat(exception.messageCode()) - .isEqualTo("error.suite.members.selectionMismatch")); + assertThat(exception.messageArgs()[0].toString()) + .contains("@global/selected@1.0.0") + .contains("error.suite.members.selectionMismatch")); verify(draftService, never()).create(any(), any()); } + @Test + void create_reportsEveryInvalidMemberCoordinateAndReason() { + SkillSuiteMemberRequest first = new SkillSuiteMemberRequest( + 101L, "global", "missing", "1.0.0"); + SkillSuiteMemberRequest second = new SkillSuiteMemberRequest( + 102L, "private-team", "restricted", "2.0.0"); + SkillSuiteCreateRequest createRequest = new SkillSuiteCreateRequest( + "global", "starter", "Starter", null, null, "1.0.0", + SkillVisibility.PRIVATE, null, first, List.of(first, second)); + given(namespaceRepository.findBySlug("global")).willReturn(java.util.Optional.of(namespace)); + given(skillQueryService.resolveVersionById(101L, "user-1", Map.of(), Set.of())) + .willThrow(new DomainBadRequestException("error.skill.version.notFound", 101L)); + given(skillQueryService.resolveVersionById(102L, "user-1", Map.of(), Set.of())) + .willThrow(new DomainForbiddenException("error.skill.access.denied", "restricted")); + + DomainBadRequestException exception = catchThrowableOfType( + () -> service.create(createRequest, "user-1", Map.of(), Set.of(), request), + DomainBadRequestException.class); + + assertThat(exception.messageCode()).isEqualTo("error.suite.members.invalid"); + assertThat((String) exception.messageArgs()[0]) + .contains("@global/missing@1.0.0 (error.skill.version.notFound)") + .contains("@private-team/restricted@2.0.0 (error.skill.access.denied)"); + verify(draftService, never()).create(any(), any()); + } + private SkillSuiteQueryService.Detail detail(boolean available) { if (!available) { return new SkillSuiteQueryService.Detail(namespace, suite, version, false, List.of()); diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 07428141..668e1a57 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -13,7 +13,7 @@ export interface paths { }; get?: never; /** Update an editable Suite draft */ - put: operations["updateDraft"]; + put: operations["updateSkillSuiteDraft"]; post?: never; delete?: never; options?: never; @@ -30,7 +30,7 @@ export interface paths { }; get?: never; /** Update an editable Suite draft */ - put: operations["updateDraft_1"]; + put: operations["updateSkillSuiteDraft_1"]; post?: never; delete?: never; options?: never; @@ -496,7 +496,7 @@ export interface paths { get?: never; put?: never; /** Yank a published Suite version */ - post: operations["yank"]; + post: operations["yankSkillSuiteVersion"]; delete?: never; options?: never; head?: never; @@ -513,7 +513,7 @@ export interface paths { get?: never; put?: never; /** Yank a published Suite version */ - post: operations["yank_1"]; + post: operations["yankSkillSuiteVersion_1"]; delete?: never; options?: never; head?: never; @@ -530,7 +530,7 @@ export interface paths { get?: never; put?: never; /** Submit a public or namespace Suite draft for review */ - post: operations["submit"]; + post: operations["submitSkillSuiteReview"]; delete?: never; options?: never; head?: never; @@ -547,7 +547,7 @@ export interface paths { get?: never; put?: never; /** Submit a public or namespace Suite draft for review */ - post: operations["submit_1"]; + post: operations["submitSkillSuiteReview_1"]; delete?: never; options?: never; head?: never; @@ -564,7 +564,7 @@ export interface paths { get?: never; put?: never; /** Reopen a rejected Suite version as a draft */ - post: operations["reopen"]; + post: operations["reopenSkillSuiteDraft"]; delete?: never; options?: never; head?: never; @@ -581,7 +581,7 @@ export interface paths { get?: never; put?: never; /** Reopen a rejected Suite version as a draft */ - post: operations["reopen_1"]; + post: operations["reopenSkillSuiteDraft_1"]; delete?: never; options?: never; head?: never; @@ -598,7 +598,7 @@ export interface paths { get?: never; put?: never; /** Publish a private Suite draft directly */ - post: operations["publishPrivate"]; + post: operations["publishPrivateSkillSuite"]; delete?: never; options?: never; head?: never; @@ -615,7 +615,7 @@ export interface paths { get?: never; put?: never; /** Publish a private Suite draft directly */ - post: operations["publishPrivate_1"]; + post: operations["publishPrivateSkillSuite_1"]; delete?: never; options?: never; head?: never; @@ -632,7 +632,7 @@ export interface paths { get?: never; put?: never; /** Create a new draft version for a Suite */ - post: operations["createVersion"]; + post: operations["createSkillSuiteVersion"]; delete?: never; options?: never; head?: never; @@ -649,7 +649,7 @@ export interface paths { get?: never; put?: never; /** Create a new draft version for a Suite */ - post: operations["createVersion_1"]; + post: operations["createSkillSuiteVersion_1"]; delete?: never; options?: never; head?: never; @@ -666,7 +666,7 @@ export interface paths { get?: never; put?: never; /** Restore an archived Suite container */ - post: operations["unarchive"]; + post: operations["unarchiveSkillSuite"]; delete?: never; options?: never; head?: never; @@ -683,7 +683,7 @@ export interface paths { get?: never; put?: never; /** Restore an archived Suite container */ - post: operations["unarchive_1"]; + post: operations["unarchiveSkillSuite_1"]; delete?: never; options?: never; head?: never; @@ -700,7 +700,7 @@ export interface paths { get?: never; put?: never; /** Restore a hidden Suite */ - post: operations["restore"]; + post: operations["restoreSkillSuite"]; delete?: never; options?: never; head?: never; @@ -717,7 +717,7 @@ export interface paths { get?: never; put?: never; /** Restore a hidden Suite */ - post: operations["restore_1"]; + post: operations["restoreSkillSuite_1"]; delete?: never; options?: never; head?: never; @@ -734,7 +734,7 @@ export interface paths { get?: never; put?: never; /** Hide a Suite from discovery */ - post: operations["hide"]; + post: operations["hideSkillSuite"]; delete?: never; options?: never; head?: never; @@ -751,7 +751,7 @@ export interface paths { get?: never; put?: never; /** Hide a Suite from discovery */ - post: operations["hide_1"]; + post: operations["hideSkillSuite_1"]; delete?: never; options?: never; head?: never; @@ -768,7 +768,7 @@ export interface paths { get?: never; put?: never; /** Archive a Suite container */ - post: operations["archive"]; + post: operations["archiveSkillSuite"]; delete?: never; options?: never; head?: never; @@ -785,7 +785,7 @@ export interface paths { get?: never; put?: never; /** Archive a Suite container */ - post: operations["archive_1"]; + post: operations["archiveSkillSuite_1"]; delete?: never; options?: never; head?: never; @@ -802,7 +802,7 @@ export interface paths { get?: never; put?: never; /** Issue an idempotent exact-member Suite install plan */ - post: operations["createInstallPlan"]; + post: operations["createSkillSuiteInstallPlan"]; delete?: never; options?: never; head?: never; @@ -819,7 +819,7 @@ export interface paths { get?: never; put?: never; /** Issue an idempotent exact-member Suite install plan */ - post: operations["createInstallPlan_1"]; + post: operations["createSkillSuiteInstallPlan_1"]; delete?: never; options?: never; head?: never; @@ -836,7 +836,7 @@ export interface paths { get?: never; put?: never; /** Reject a pending Suite review */ - post: operations["reject"]; + post: operations["rejectSkillSuiteReview"]; delete?: never; options?: never; head?: never; @@ -853,7 +853,7 @@ export interface paths { get?: never; put?: never; /** Reject a pending Suite review */ - post: operations["reject_1"]; + post: operations["rejectSkillSuiteReview_1"]; delete?: never; options?: never; head?: never; @@ -870,7 +870,7 @@ export interface paths { get?: never; put?: never; /** Approve a pending Suite review */ - post: operations["approve"]; + post: operations["approveSkillSuiteReview"]; delete?: never; options?: never; head?: never; @@ -887,7 +887,7 @@ export interface paths { get?: never; put?: never; /** Approve a pending Suite review */ - post: operations["approve_1"]; + post: operations["approveSkillSuiteReview_1"]; delete?: never; options?: never; head?: never; @@ -904,7 +904,7 @@ export interface paths { get?: never; put?: never; /** Create a Suite and its first draft version */ - post: operations["create"]; + post: operations["createSkillSuite"]; delete?: never; options?: never; head?: never; @@ -921,7 +921,7 @@ export interface paths { get?: never; put?: never; /** Create a Suite and its first draft version */ - post: operations["create_1"]; + post: operations["createSkillSuite_1"]; delete?: never; options?: never; head?: never; @@ -1703,9 +1703,9 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["list_6"]; + get: operations["list_4"]; put?: never; - post: operations["create_2"]; + post: operations["create"]; delete?: never; options?: never; head?: never; @@ -2073,7 +2073,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["restore_2"]; + post: operations["restore"]; delete?: never; options?: never; head?: never; @@ -2089,7 +2089,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["hide_2"]; + post: operations["hide"]; delete?: never; options?: never; head?: never; @@ -2153,7 +2153,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["reject_2"]; + post: operations["reject"]; delete?: never; options?: never; head?: never; @@ -2169,7 +2169,7 @@ export interface paths { }; get?: never; put?: never; - post: operations["approve_2"]; + post: operations["approve"]; delete?: never; options?: never; head?: never; @@ -2408,7 +2408,7 @@ export interface paths { cookie?: never; }; /** List visible Suite versions */ - get: operations["listVersions"]; + get: operations["listSkillSuiteVersions"]; put?: never; post?: never; delete?: never; @@ -2425,7 +2425,7 @@ export interface paths { cookie?: never; }; /** List visible Suite versions */ - get: operations["listVersions_1"]; + get: operations["listSkillSuiteVersions_1"]; put?: never; post?: never; delete?: never; @@ -2442,7 +2442,7 @@ export interface paths { cookie?: never; }; /** Get one visible Suite version */ - get: operations["getDetail"]; + get: operations["getSkillSuite"]; put?: never; post?: never; delete?: never; @@ -2459,7 +2459,7 @@ export interface paths { cookie?: never; }; /** Get one visible Suite version */ - get: operations["getDetail_1"]; + get: operations["getSkillSuite_1"]; put?: never; post?: never; delete?: never; @@ -2476,7 +2476,7 @@ export interface paths { cookie?: never; }; /** Search exact Skill versions eligible for a Suite draft */ - get: operations["searchCandidates"]; + get: operations["searchSkillSuiteMemberCandidates"]; put?: never; post?: never; delete?: never; @@ -2493,7 +2493,7 @@ export interface paths { cookie?: never; }; /** Search exact Skill versions eligible for a Suite draft */ - get: operations["searchCandidates_1"]; + get: operations["searchSkillSuiteMemberCandidates_1"]; put?: never; post?: never; delete?: never; @@ -2717,7 +2717,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["listVersions_2"]; + get: operations["listVersions"]; put?: never; post?: never; delete?: never; @@ -2733,7 +2733,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["listVersions_3"]; + get: operations["listVersions_1"]; put?: never; post?: never; delete?: never; @@ -3294,7 +3294,7 @@ export interface paths { cookie?: never; }; /** Search Skills and Suites with explicit resource types */ - get: operations["search_1"]; + get: operations["searchResources"]; put?: never; post?: never; delete?: never; @@ -3311,7 +3311,7 @@ export interface paths { cookie?: never; }; /** Search Skills and Suites with explicit resource types */ - get: operations["search_2"]; + get: operations["searchResources_1"]; put?: never; post?: never; delete?: never; @@ -3488,7 +3488,7 @@ export interface paths { cookie?: never; }; /** List Suite versions manageable by the current user */ - get: operations["list_4"]; + get: operations["listMySkillSuites"]; put?: never; post?: never; delete?: never; @@ -3505,7 +3505,7 @@ export interface paths { cookie?: never; }; /** List Suite versions manageable by the current user */ - get: operations["list_5"]; + get: operations["listMySkillSuites_1"]; put?: never; post?: never; delete?: never; @@ -3857,7 +3857,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["search_3"]; + get: operations["search_1"]; put?: never; post?: never; delete?: never; @@ -4033,7 +4033,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["list_7"]; + get: operations["list_5"]; put?: never; post?: never; delete?: never; @@ -4161,7 +4161,7 @@ export interface paths { path?: never; cookie?: never; }; - get: operations["search_4"]; + get: operations["search_2"]; put?: never; post?: never; delete?: never; @@ -4229,7 +4229,7 @@ export interface paths { put?: never; post?: never; /** Delete a Suite without changing member Skills */ - delete: operations["delete"]; + delete: operations["deleteSkillSuite"]; options?: never; head?: never; patch?: never; @@ -4246,7 +4246,7 @@ export interface paths { put?: never; post?: never; /** Delete a Suite without changing member Skills */ - delete: operations["delete_1"]; + delete: operations["deleteSkillSuite_1"]; options?: never; head?: never; patch?: never; @@ -6658,7 +6658,7 @@ export interface components { } export type $defs = Record; export interface operations { - updateDraft: { + updateSkillSuiteDraft: { parameters: { query?: never; header?: never; @@ -6685,7 +6685,7 @@ export interface operations { }; }; }; - updateDraft_1: { + updateSkillSuiteDraft_1: { parameters: { query?: never; header?: never; @@ -7955,7 +7955,7 @@ export interface operations { }; }; }; - yank: { + yankSkillSuiteVersion: { parameters: { query?: never; header?: never; @@ -7982,7 +7982,7 @@ export interface operations { }; }; }; - yank_1: { + yankSkillSuiteVersion_1: { parameters: { query?: never; header?: never; @@ -8009,7 +8009,7 @@ export interface operations { }; }; }; - submit: { + submitSkillSuiteReview: { parameters: { query?: never; header?: never; @@ -8032,7 +8032,7 @@ export interface operations { }; }; }; - submit_1: { + submitSkillSuiteReview_1: { parameters: { query?: never; header?: never; @@ -8055,7 +8055,7 @@ export interface operations { }; }; }; - reopen: { + reopenSkillSuiteDraft: { parameters: { query?: never; header?: never; @@ -8078,7 +8078,7 @@ export interface operations { }; }; }; - reopen_1: { + reopenSkillSuiteDraft_1: { parameters: { query?: never; header?: never; @@ -8101,7 +8101,7 @@ export interface operations { }; }; }; - publishPrivate: { + publishPrivateSkillSuite: { parameters: { query?: never; header?: never; @@ -8124,7 +8124,7 @@ export interface operations { }; }; }; - publishPrivate_1: { + publishPrivateSkillSuite_1: { parameters: { query?: never; header?: never; @@ -8147,7 +8147,7 @@ export interface operations { }; }; }; - createVersion: { + createSkillSuiteVersion: { parameters: { query?: never; header?: never; @@ -8173,7 +8173,7 @@ export interface operations { }; }; }; - createVersion_1: { + createSkillSuiteVersion_1: { parameters: { query?: never; header?: never; @@ -8199,7 +8199,7 @@ export interface operations { }; }; }; - unarchive: { + unarchiveSkillSuite: { parameters: { query?: never; header?: never; @@ -8221,7 +8221,7 @@ export interface operations { }; }; }; - unarchive_1: { + unarchiveSkillSuite_1: { parameters: { query?: never; header?: never; @@ -8243,7 +8243,7 @@ export interface operations { }; }; }; - restore: { + restoreSkillSuite: { parameters: { query?: never; header?: never; @@ -8265,7 +8265,7 @@ export interface operations { }; }; }; - restore_1: { + restoreSkillSuite_1: { parameters: { query?: never; header?: never; @@ -8287,7 +8287,7 @@ export interface operations { }; }; }; - hide: { + hideSkillSuite: { parameters: { query?: never; header?: never; @@ -8309,7 +8309,7 @@ export interface operations { }; }; }; - hide_1: { + hideSkillSuite_1: { parameters: { query?: never; header?: never; @@ -8331,7 +8331,7 @@ export interface operations { }; }; }; - archive: { + archiveSkillSuite: { parameters: { query?: never; header?: never; @@ -8353,7 +8353,7 @@ export interface operations { }; }; }; - archive_1: { + archiveSkillSuite_1: { parameters: { query?: never; header?: never; @@ -8375,7 +8375,7 @@ export interface operations { }; }; }; - createInstallPlan: { + createSkillSuiteInstallPlan: { parameters: { query?: { version?: string; @@ -8402,7 +8402,7 @@ export interface operations { }; }; }; - createInstallPlan_1: { + createSkillSuiteInstallPlan_1: { parameters: { query?: { version?: string; @@ -8429,7 +8429,7 @@ export interface operations { }; }; }; - reject: { + rejectSkillSuiteReview: { parameters: { query?: never; header?: never; @@ -8455,7 +8455,7 @@ export interface operations { }; }; }; - reject_1: { + rejectSkillSuiteReview_1: { parameters: { query?: never; header?: never; @@ -8481,7 +8481,7 @@ export interface operations { }; }; }; - approve: { + approveSkillSuiteReview: { parameters: { query?: never; header?: never; @@ -8507,7 +8507,7 @@ export interface operations { }; }; }; - approve_1: { + approveSkillSuiteReview_1: { parameters: { query?: never; header?: never; @@ -8533,7 +8533,7 @@ export interface operations { }; }; }; - create: { + createSkillSuite: { parameters: { query?: never; header?: never; @@ -8557,7 +8557,7 @@ export interface operations { }; }; }; - create_1: { + createSkillSuite_1: { parameters: { query?: never; header?: never; @@ -9995,7 +9995,7 @@ export interface operations { }; }; }; - list_6: { + list_4: { parameters: { query?: { page?: number; @@ -10018,7 +10018,7 @@ export interface operations { }; }; }; - create_2: { + create: { parameters: { query?: never; header?: never; @@ -10609,7 +10609,7 @@ export interface operations { }; }; }; - restore_2: { + restore: { parameters: { query?: never; header?: never; @@ -10631,7 +10631,7 @@ export interface operations { }; }; }; - hide_2: { + hide: { parameters: { query?: never; header?: never; @@ -10729,7 +10729,7 @@ export interface operations { }; }; }; - reject_2: { + reject: { parameters: { query?: never; header?: never; @@ -10755,7 +10755,7 @@ export interface operations { }; }; }; - approve_2: { + approve: { parameters: { query?: never; header?: never; @@ -11196,7 +11196,7 @@ export interface operations { }; }; }; - listVersions: { + listSkillSuiteVersions: { parameters: { query?: never; header?: never; @@ -11219,7 +11219,7 @@ export interface operations { }; }; }; - listVersions_1: { + listSkillSuiteVersions_1: { parameters: { query?: never; header?: never; @@ -11242,7 +11242,7 @@ export interface operations { }; }; }; - getDetail: { + getSkillSuite: { parameters: { query?: { version?: string; @@ -11267,7 +11267,7 @@ export interface operations { }; }; }; - getDetail_1: { + getSkillSuite_1: { parameters: { query?: { version?: string; @@ -11292,7 +11292,7 @@ export interface operations { }; }; }; - searchCandidates: { + searchSkillSuiteMemberCandidates: { parameters: { query: { suiteNamespace: string; @@ -11317,7 +11317,7 @@ export interface operations { }; }; }; - searchCandidates_1: { + searchSkillSuiteMemberCandidates_1: { parameters: { query: { suiteNamespace: string; @@ -11717,7 +11717,7 @@ export interface operations { }; }; }; - listVersions_2: { + listVersions: { parameters: { query?: { page?: number; @@ -11743,7 +11743,7 @@ export interface operations { }; }; }; - listVersions_3: { + listVersions_1: { parameters: { query?: { page?: number; @@ -12617,7 +12617,7 @@ export interface operations { }; }; }; - search_1: { + searchResources: { parameters: { query?: { q?: string; @@ -12644,7 +12644,7 @@ export interface operations { }; }; }; - search_2: { + searchResources_1: { parameters: { query?: { q?: string; @@ -12899,7 +12899,7 @@ export interface operations { }; }; }; - list_4: { + listMySkillSuites: { parameters: { query?: { q?: string; @@ -12923,7 +12923,7 @@ export interface operations { }; }; }; - list_5: { + listMySkillSuites_1: { parameters: { query?: { q?: string; @@ -13440,7 +13440,7 @@ export interface operations { }; }; }; - search_3: { + search_1: { parameters: { query: { q: string; @@ -13688,7 +13688,7 @@ export interface operations { }; }; }; - list_7: { + list_5: { parameters: { query?: { status?: string; @@ -13889,7 +13889,7 @@ export interface operations { }; }; }; - search_4: { + search_2: { parameters: { query?: { q?: string; @@ -13979,7 +13979,7 @@ export interface operations { }; }; }; - delete: { + deleteSkillSuite: { parameters: { query?: never; header?: never; @@ -14001,7 +14001,7 @@ export interface operations { }; }; }; - delete_1: { + deleteSkillSuite_1: { parameters: { query?: never; header?: never; diff --git a/web/src/features/suite/resource-card.tsx b/web/src/features/suite/resource-card.tsx index 077e382f..d6b242f7 100644 --- a/web/src/features/suite/resource-card.tsx +++ b/web/src/features/suite/resource-card.tsx @@ -32,7 +32,7 @@ export function ResourceCard({ resource, onClick }: { resource: ResourceSummary; diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index c7b79fce..949ad92a 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -880,7 +880,7 @@ "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.", + "suiteEntryDescription": "This skill is the entry point for the following skill suites. Install a suite below to use all of its included skills.", "suiteEntryMemberCount": "View complete suite ({{count}} skills)", "installMethodClawhub": "ClawHub CLI", "installMethodSkillhub": "SkillHub CLI", @@ -1791,6 +1791,7 @@ "editorAccessDenied": "Your account cannot edit this Suite version.", "editorDescription": "Select exact published skill versions. Published suites never drift to newer versions automatically.", "namespace": "Namespace", + "slug": "Slug", "selectNamespace": "Select a namespace", "name": "Name", "namePlaceholder": "Marketing event workflow", diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index f9856782..2676815a 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -945,7 +945,7 @@ "loginToRate": "Войдите, чтобы отметить звёздочкой и оценить", "install": "Установить", "suiteEntryTitle": "Используется как вход набора", - "suiteEntryDescription": "Этот навык запускает указанные ниже наборы. Установите полный набор, чтобы получить все навыки, необходимые рабочему процессу.", + "suiteEntryDescription": "Этот навык является входным для указанных ниже наборов. Установите соответствующий набор, чтобы использовать все входящие в него навыки.", "suiteEntryMemberCount": "Открыть полный набор (навыков: {{count}})", "installMethodClawhub": "ClawHub CLI", "installMethodSkillhub": "SkillHub CLI", @@ -1788,6 +1788,7 @@ "editorAccessDenied": "Эта учётная запись не может редактировать данную версию набора.", "editorDescription": "Выберите точные опубликованные версии. Опубликованный набор не обновляется автоматически.", "namespace": "Пространство имён", + "slug": "Идентификатор", "selectNamespace": "Выберите пространство имён", "name": "Название", "namePlaceholder": "Рабочий процесс маркетингового события", diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index e96dc3fe..cc14067b 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -880,7 +880,7 @@ "loginToRate": "登录后可以收藏和评分", "install": "安装", "suiteEntryTitle": "被套件用作入口", - "suiteEntryDescription": "这个技能是以下套件的编排入口。建议查看并安装完整套件,以获得工作流所需的全部技能。", + "suiteEntryDescription": "此技能是以下技能套件的入口。如需使用完整套件,请安装对应技能套件。", "suiteEntryMemberCount": "查看完整套件({{count}} 个技能)", "installMethodClawhub": "ClawHub CLI", "installMethodSkillhub": "SkillHub CLI", @@ -1790,6 +1790,7 @@ "editorAccessDenied": "当前账号不能编辑此套件版本。", "editorDescription": "选择已发布的精确技能版本;套件发布后不会自动漂移到最新版本。", "namespace": "命名空间", + "slug": "标识", "selectNamespace": "选择命名空间", "name": "名称", "namePlaceholder": "营销活动工作流", diff --git a/web/src/pages/dashboard/suite-editor.tsx b/web/src/pages/dashboard/suite-editor.tsx index 08b6a4a9..5ed58208 100644 --- a/web/src/pages/dashboard/suite-editor.tsx +++ b/web/src/pages/dashboard/suite-editor.tsx @@ -215,7 +215,7 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio
- + setSlug(event.target.value)} placeholder="marketing-workflow" />