fix(suite): close rollout and concurrency gaps

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-09 13:53:20 +08:00
parent 2e0cd691aa
commit 8c0b853023
20 changed files with 499 additions and 180 deletions

View file

@ -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<void>) | undefined
}
export interface SuiteUpgradePlan {
current: InventorySuite
remote: SuiteDetail
@ -128,6 +138,21 @@ export async function installSuite(options: SuiteInstallOptions): Promise<SuiteI
)
assertNoTargetCollisions(plan)
const releaseSuiteLock = await acquireSuiteOperationLock(
options.home, options.registry, plan.namespace, plan.slug)
try {
return await installSuiteTransaction(options, client, renameOperation, plan)
} finally {
await releaseSuiteLock().catch(() => {})
}
}
async function installSuiteTransaction(
options: SuiteInstallOptions,
client: SkillHubClient,
renameOperation: typeof rename,
plan: SuiteInstallPlan
): Promise<SuiteInstallResult> {
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<void>) | undefined
}): Promise<SuiteRemoveResult> {
export async function removeSuite(options: SuiteRemoveOptions): Promise<SuiteRemoveResult> {
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<SuiteRemoveResult> {
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<void>> {
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)
}

View file

@ -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<void>((resolvePromise) => { signalFirstLocked = resolvePromise })
const holdFirst = new Promise<void>((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-'))

View file

@ -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='<configured-password>' \
## 部署顺序
数据库迁移会先把既有审核任务回填为 `SKILL_VERSION`,并保留旧 Skill 专用列。官方单实例
数据库迁移会先把既有审核任务回填为 `SKILL_VERSION`,保留旧 Skill 专用列,并通过数据库触发器
把旧版 Server 新写入的 Skill 审核同步补全为类型化 subject。官方单实例
`compose.release.yml` 和本地开发 profile 已默认开启 Suite 审核写入,因为它们不会同时运行新旧 Server。
其他部署方式默认保持关闭。全新安装、单实例升级或停机升级可直接设置:

View file

@ -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

View file

@ -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 后启用

View file

@ -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<PageResponse<MySkillSuiteSummaryResponse>> list(
@RequestParam(required = false) String q,

View file

@ -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<ResourceSearchResponse> search(

View file

@ -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<SkillSuiteResponse> 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<List<SkillSuiteVersionSummaryResponse>> 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<List<SkillSuiteMemberCandidateResponse>> 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<SkillSuiteInstallPlanResponse> 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<SkillSuiteResponse> 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<SkillSuiteResponse> 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<SkillSuiteResponse> 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<MessageResponse> 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<MessageResponse> 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<MessageResponse> 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<MessageResponse> 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<MessageResponse> 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<MessageResponse> 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<MessageResponse> 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<MessageResponse> 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<MessageResponse> 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<MessageResponse> 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<MessageResponse> delete(
@PathVariable Long suiteId,

View file

@ -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<SkillSuiteInstallMemberResponse> members = resolveInstallMembers(detail, userId, namespaceRoles);
List<SkillSuiteInstallMemberResponse> 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<SkillSuiteInstallMemberResponse> members = resolveInstallMembers(detail, userId, namespaceRoles);
List<SkillSuiteInstallMemberResponse> 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<SkillSuiteInstallMemberResponse> resolveInstallMembers(
SkillSuiteQueryService.Detail detail,
String userId,
Map<Long, NamespaceRole> namespaceRoles
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
List<SkillSuiteInstallMemberResponse> 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<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
List<SkillSuiteMemberSelection> selections = request.members().stream()
.map(member -> resolve(member, userId, namespaceRoles, platformRoles))
.toList();
List<SkillSuiteMemberSelection> selections = new ArrayList<>(request.members().size());
List<String> 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(),

View file

@ -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,

View file

@ -210,6 +210,7 @@ error.suite.members.limit=A Skill Suite cannot contain more than {0} Skills
error.suite.members.duplicate=A Skill Suite cannot contain multiple versions of the same Skill
error.suite.members.unavailable=One or more Suite members are unavailable: {0}
error.suite.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}

View file

@ -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}

View file

@ -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,

View file

@ -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<String> 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<String> 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());

View file

@ -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<string, never>;
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;

View file

@ -32,7 +32,7 @@ export function ResourceCard({ resource, onClick }: { resource: ResourceSummary;
</div>
<NamespaceBadge
className="max-w-[50%] shrink-0 truncate"
type="TEAM"
type={resource.namespace === 'global' ? 'GLOBAL' : 'TEAM'}
name={`@${resource.namespace}`}
title={`@${resource.namespace}`}
/>

View file

@ -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",

View file

@ -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": "Рабочий процесс маркетингового события",

View file

@ -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": "营销活动工作流",

View file

@ -215,7 +215,7 @@ export function SuiteEditor({ namespace: routeNamespace, slug: routeSlug, versio
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="suite-slug">Slug</Label>
<Label htmlFor="suite-slug">{t('suite.slug')}</Label>
<Input id="suite-slug" value={slug} disabled={loadingSource} onChange={(event) => setSlug(event.target.value)} placeholder="marketing-workflow" />
</div>
<div className="space-y-2">