mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-11 22:51:04 +00:00
fix(suite): close rollout and concurrency gaps
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
2e0cd691aa
commit
8c0b853023
20 changed files with 499 additions and 180 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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-'))
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
||||
其他部署方式默认保持关闭。全新安装、单实例升级或停机升级可直接设置:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 后启用
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
204
web/src/api/generated/schema.d.ts
vendored
204
web/src/api/generated/schema.d.ts
vendored
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Рабочий процесс маркетингового события",
|
||||
|
|
|
|||
|
|
@ -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": "营销活动工作流",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue