feat(suite): add first-class skill suites

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-08 19:30:57 +08:00
parent 25e18e047c
commit 859987e3bb
151 changed files with 13555 additions and 185 deletions

View file

@ -1,4 +1,4 @@
.PHONY: build build-backend build-backend-app build-builtin-skills build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-builtin-skills test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci
.PHONY: build build-backend build-backend-app build-builtin-skills build-cli build-frontend build-web check clean cli-install db-reset dev dev-all dev-all-down dev-all-reset dev-down dev-logs dev-server dev-server-restart dev-status dev-web docs-build docs-dev docs-preview generate-api help lint-cli lint-web namespace-smoke suite-smoke parallel-down parallel-init parallel-sync parallel-up pr publish-cli publish-cli-major publish-cli-minor staging staging-down staging-logs test test-backend test-backend-app test-builtin-skills test-cli test-e2e-frontend test-e2e-smoke-frontend test-frontend test-redis-cluster test-web typecheck-cli typecheck-web validate-release-config web-deps web-install web-install-ci
DEV_DIR := .dev
DEV_SERVER_PID := $(DEV_DIR)/server.pid
@ -147,6 +147,9 @@ dev-server-restart: ## 重启后端开发服务器
namespace-smoke: ## 运行命名空间工作流 smoke test
./scripts/namespace-smoke-test.sh $(DEV_API_URL)
suite-smoke: ## 运行 Skill Suite 生命周期 smoke test
./scripts/suite-smoke-test.sh $(DEV_API_URL)
dev-down: ## 停止本地开发环境(含 skill-scanner
$(DEV_COMPOSE) down --remove-orphans

View file

@ -298,6 +298,20 @@ standalone → replication、Redis standalone/replication → Sentinel 等切换
Cluster也不将其计入上述内置架构运行时验证范围。应用侧应另行验证 Spring
Data、Spring Session 与 Redisson Stream 链路。
### Skill Suite 审核滚动升级门禁
Chart 默认将 `server.suiteReviewWritesEnabled` 设为 `false`,避免新旧 Server Pod 混跑时,
旧实例读取到无法识别的 Suite 审核任务。全新安装可以直接启用:
```yaml
server:
suiteReviewWritesEnabled: true
```
从不支持 Suite 的版本滚动升级时,先保持 `false` 完成全部 Server Pod 升级;确认集群中不再有
旧版实例后,再改为 `true` 并执行一次滚动更新。单实例 `compose.release.yml` 不存在混跑窗口,
因此已默认启用。
### Redis Sentinel
内置 Sentinel 使用 Bitnami Redis 的同一份密码同时保护 Redis 数据节点和

View file

@ -77,6 +77,8 @@ spec:
{{- $profiles = printf "%s,redis-sentinel" $profiles }}
{{- end }}
value: {{ $profiles | quote }}
- name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED
value: {{ .Values.server.suiteReviewWritesEnabled | quote }}
# Database
- name: SPRING_DATASOURCE_URL

View file

@ -37,6 +37,14 @@ fi
grep -Fq 'fsGroup: 101' "$TMP_DIR/default.yaml"
grep -Fq 'fsGroupChangePolicy: OnRootMismatch' "$TMP_DIR/default.yaml"
grep -Fq 'type: Recreate' "$TMP_DIR/default.yaml"
grep -A1 -F 'name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED' "$TMP_DIR/default.yaml" \
| grep -Fq 'value: "false"'
render suite-review-enabled "$CHART_DIR" \
--set server.suiteReviewWritesEnabled=true \
--show-only templates/server-deployment.yaml >"$TMP_DIR/suite-review-enabled.yaml"
grep -A1 -F 'name: SKILLHUB_SUITE_REVIEW_WRITES_ENABLED' "$TMP_DIR/suite-review-enabled.yaml" \
| grep -Fq 'value: "true"'
render custom-server-fsgroup "$CHART_DIR" \
--set server.podSecurityContext.fsGroup=2000 \

View file

@ -370,10 +370,11 @@
{
"type": "object",
"additionalProperties": false,
"required": ["enabled", "replicaCount", "image", "dependencyWait", "service", "storage", "podSecurityContext", "resources", "javaOpts", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"],
"required": ["enabled", "replicaCount", "suiteReviewWritesEnabled", "image", "dependencyWait", "service", "storage", "podSecurityContext", "resources", "javaOpts", "extraEnv", "podAnnotations", "imagePullSecrets", "nodeSelector", "tolerations", "affinity", "probes", "autoscaling", "podDisruptionBudget"],
"properties": {
"enabled": { "type": "boolean" },
"replicaCount": { "type": "integer", "minimum": 1 },
"suiteReviewWritesEnabled": { "type": "boolean" },
"image": { "$ref": "#/definitions/image" },
"dependencyWait": {
"type": "object",

View file

@ -282,6 +282,10 @@ server:
enabled: true
replicaCount: 1
# Keep false while old and new Server versions overlap. Set true after every active
# Server instance supports typed review subjects; fresh non-rolling installs may enable it directly.
suiteReviewWritesEnabled: false
image:
registry: ""
tag: ""

View file

@ -76,6 +76,58 @@ export interface DryRunResponse {
resolvedVersion: string | null
}
export interface ServerMetadata {
apiBase?: string
capabilities?: string[]
}
export interface SuiteInstallMember {
skillId: number
skillVersionId: number
namespace: string
slug: string
version: string
fingerprint: string
downloadUrl: string
position: number
entry: boolean
}
export interface SuiteInstallPlan {
operationId: string
namespace: string
slug: string
version: string
fingerprint: string
members: SuiteInstallMember[]
}
export interface SuiteDetailMember {
skillId: number
skillVersionId: number
namespace: string
slug: string
version: string
fingerprint: string
position: number
entry: boolean
blockingReason?: string | null
}
export interface SuiteDetail {
id: number
versionId: number
namespace: string
slug: string
displayName: string
summary?: string | null
version: string
status: string
visibility: string
available: boolean
members: SuiteDetailMember[]
}
interface PublicErrorFields {
msg?: string
requestId?: string
@ -94,6 +146,84 @@ export class SkillHubClient {
return this.getJson('/auth/whoami')
}
async serverMetadata(): Promise<ServerMetadata> {
let response: Response
try {
response = await this.fetchImpl(`${this.registry}/.well-known/clawhub.json`)
} catch {
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
if (!response.ok) return {}
let body: unknown
try {
body = await response.json()
} catch {
// Older registries and reverse proxies may return an HTML landing page at this path.
return {}
}
return typeof body === 'object' && body !== null ? body as ServerMetadata : {}
}
async suiteInstallPlan(
namespace: string,
slug: string,
version?: string,
idempotencyKey?: string
): Promise<SuiteInstallPlan> {
const params = version ? `?version=${encodeURIComponent(version)}` : ''
const url = `${this.registry}/api/v1/suites/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}/install-plan${params}`
for (let attempt = 0; attempt < 2; attempt += 1) {
let response: Response
try {
response = await this.fetchImpl(url, {
method: 'POST',
headers: { ...this.headers(), ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) }
})
} catch {
if (attempt === 0) continue
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
if (attempt === 0 && [502, 503, 504].includes(response.status)) continue
try {
return await this.handleJsonResponse<SuiteInstallPlan>(response)
} catch (error) {
// A successful response whose body is truncated is safe to retry with the same key.
if (attempt === 0 && !(error instanceof CliError)) continue
throw error
}
}
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry })
}
async suiteDetail(namespace: string, slug: string, version?: string): Promise<SuiteDetail> {
const params = version ? `?version=${encodeURIComponent(version)}` : ''
let response: Response
try {
response = await this.fetchImpl(
`${this.registry}/api/v1/suites/${encodeURIComponent(namespace)}/${encodeURIComponent(slug)}${params}`,
{ headers: this.headers() }
)
} catch {
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
if (response.status === 404) {
const error = await this.createResponseError(response, 'json')
throw new CliError(error.message, error.exitCode, { ...error.details, status: 404 })
}
return this.handleJsonResponse<SuiteDetail>(response)
}
async downloadFromUrl(downloadUrl: string): Promise<Response> {
let response: Response
try {
response = await this.fetchImpl(new URL(downloadUrl, `${this.registry}/`).toString(), { headers: this.headers() })
} catch {
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
}
if (!response.ok) throw await this.createResponseError(response, 'download')
return response
}
async search(query: string, limit: number): Promise<SearchResponse> {
const params = new URLSearchParams({ q: query, limit: String(limit) })
return this.getJson(`/skills/search?${params}`)
@ -244,7 +374,7 @@ export class SkillHubClient {
exitCode = EXIT.auth
} else if (response.status === 404) {
fallback = kind === 'download' ? 'skill or version not found' : 'resource not found'
} else if (response.status === 502 || response.status === 503) {
} else if (response.status === 502 || response.status === 503 || response.status === 504) {
fallback = kind === 'download'
? `download failed with status ${response.status}`
: `registry returned ${response.status}`

View file

@ -45,6 +45,16 @@ export const commands = {
'skillhub install pdf-parser --scope project --agent codex'
]
},
suite: {
summary: 'Install and manage a Skill Suite locally',
usage: 'skillhub suite <install|check|upgrade|remove> <coordinate> [options]',
examples: [
'skillhub suite install @global/marketing --scope user',
'skillhub suite check @global/marketing',
'skillhub suite upgrade @global/marketing --check',
'skillhub suite remove @global/marketing'
]
},
upgrade: {
summary: 'Upgrade explicitly selected installed skills',
usage: 'skillhub upgrade <coordinate...> [--namespace <slug>] [--agent <profile>] [--dir <path>] [--registry <url>] [--check] [--force] [--json]',

153
cli/src/commands/suite.ts Normal file
View file

@ -0,0 +1,153 @@
import { ConfigStore } from '../stores/config-store'
import { CredentialsStore } from '../stores/credentials-store'
import { resolveRegistry, resolveToken } from '../services/registry-service'
import { resolveSkillName } from '../shared/skill-name-parser'
import { resolveInstallTargets } from '../agents/resolver'
import { resolveEffectiveScope } from './install'
import { computeStrictIsTTY } from '../shared/tty'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { checkSuite, installSuite, planSuiteUpgrade, removeSuite, upgradeSuite } from '../services/suite-service'
export interface SuiteCommandOptions {
version?: string | undefined
scope?: string | undefined
agent?: string[] | undefined
dir?: string | undefined
force?: boolean | undefined
check?: boolean | undefined
registry?: string | undefined
token?: string | undefined
json?: boolean | undefined
}
export async function suiteCommand(
action: string,
coordinate: string,
options: SuiteCommandOptions
): Promise<string> {
if (!['install', 'check', 'upgrade', 'remove'].includes(action)) {
throw new CliError(`unknown suite action: ${action}`, EXIT.usage, {
next: 'use install, check, upgrade, or remove'
})
}
const configStore = new ConfigStore()
const credentialsStore = new CredentialsStore()
const registry = resolveRegistry(options, process.env, await configStore.read())
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
const { namespace, slug } = resolveSkillName(coordinate)
const common = { registry, token, namespace, slug }
if (action === 'install') {
const isTTY = computeStrictIsTTY({
stdinIsTTY: process.stdin.isTTY === true,
stdoutIsTTY: process.stdout.isTTY === true,
json: Boolean(options.json)
})
const scope = await resolveEffectiveScope(options, {
isTTY,
promptScope: async () => {
const prompts = await import('prompts')
const { selected } = await prompts.default({
type: 'select',
name: 'selected',
message: 'Install Suite for user or project?',
choices: [
{ title: 'User', value: 'user' },
{ title: 'Project', value: 'project' }
]
})
if (!selected) throw new CliError('installation cancelled', EXIT.usage)
return selected as 'user' | 'project'
}
})
const targets = await resolveInstallTargets({
cwd: process.cwd(),
scope,
dir: options.dir,
agents: options.agent ?? [],
json: Boolean(options.json),
interactive: isTTY
})
const result = await installSuite({
...common,
version: options.version,
targets,
force: Boolean(options.force)
})
if (options.json) return JSON.stringify({ ok: true, suite: result.plan, installed: result.installed, reused: result.reused })
return [
`Installed Suite @${namespace}/${slug}@${result.plan.version}`,
...result.installed.map(item => `Installed @${item.namespace}/${item.slug} -> ${item.dir} (${item.agent})`),
...result.reused.map(item => `Reused @${item.namespace}/${item.slug} -> ${item.dir} (${item.agent})`)
].join('\n')
}
if (hasInstallOnlyOptions(options)) {
throw new CliError('--scope, --agent, --dir, --force, and --version are only valid with suite install', EXIT.usage)
}
if (action === 'check') {
const result = await checkSuite(common)
if (options.json) return JSON.stringify({ ok: result.current, ...result })
return [
`Suite @${namespace}/${slug}@${result.suite.version}: ${result.current ? 'current' : 'changes detected'}`,
...(result.remoteVersion && result.remoteVersion !== result.suite.version
? [`Remote version: ${result.remoteVersion}`]
: []),
...(!result.installedVersionAvailable
? [`Installed version unavailable: ${result.blockingReasons.join(', ') || 'unknown reason'}`]
: []),
...result.members.map(member => `${member.status.padEnd(8)} @${member.namespace}/${member.slug}@${member.version} ${member.dir}`)
].join('\n')
}
if (action === 'remove') {
const result = await removeSuite(common)
if (options.json) return JSON.stringify({ ok: true, ...result })
return [
`Removed Suite @${namespace}/${slug}`,
...result.removed.map(dir => `Removed member: ${dir}`),
...result.preserved.map(item => `Preserved member (${item.reason}): ${item.dir}`)
].join('\n')
}
if (options.check) {
const plan = await planSuiteUpgrade(common)
return renderUpgradePlan(plan, Boolean(options.json))
}
const { upgrade, result } = await upgradeSuite(common)
if (options.json) return JSON.stringify({ ok: true, upgrade, result })
if (!result) return `Suite @${namespace}/${slug}@${upgrade.current.version} is current`
return [
`Upgraded Suite @${namespace}/${slug}: ${upgrade.current.version} -> ${upgrade.remote.version}`,
...upgrade.changes.map(change => renderChange(change))
].join('\n')
}
function hasInstallOnlyOptions(options: SuiteCommandOptions): boolean {
return options.scope !== undefined || options.agent !== undefined || options.dir !== undefined ||
options.force !== undefined || options.version !== undefined
}
function renderUpgradePlan(plan: Awaited<ReturnType<typeof planSuiteUpgrade>>, json: boolean): string {
if (json) return JSON.stringify({
ok: true,
currentVersion: plan.current.version,
remoteVersion: plan.remote.version,
changes: plan.changes
})
return [
`Suite upgrade plan: ${plan.current.version} -> ${plan.remote.version}`,
...(plan.changes.length === 0 ? ['No member changes'] : plan.changes.map(renderChange))
].join('\n')
}
function renderChange(change: Awaited<ReturnType<typeof planSuiteUpgrade>>['changes'][number]): string {
const versions = change.action === 'add'
? ` -> ${change.toVersion}`
: change.action === 'remove'
? ` ${change.fromVersion} -> removed`
: ` ${change.fromVersion} -> ${change.toVersion}`
return `${change.action.padEnd(7)} ${change.coordinate}${versions}`
}

View file

@ -9,6 +9,7 @@ import { logoutCommand } from './commands/logout'
import { publishCommand, type PublishCommandOptions } from './commands/publish'
import { removeCommand, type RemoveCommandOptions } from './commands/remove'
import { searchCommand } from './commands/search'
import { suiteCommand, type SuiteCommandOptions } from './commands/suite'
import { syncDiffCommand, syncPullCommand, syncPushCommand, syncStatusCommand, type SyncCommonOptions, type SyncPullOptions, type SyncPushOptions } from './commands/sync'
import { updateCommand } from './commands/update'
import { upgradeCommand, type UpgradeCommandOptions } from './commands/upgrade'
@ -248,6 +249,24 @@ cli
return runCommand(() => installCommand(slug, { ...options, agent: toArray(options.agent) }), Boolean(options.json))
})
cli
.command('suite <action> <coordinate>', 'Install and manage a Skill Suite locally')
.option('--version <v>', 'Exact Suite version for install')
.option('--scope <scope>', 'Install scope: user or project')
.option('--agent <profile>', 'Agent profile (repeatable)')
.option('--dir <path>', 'Install directory')
.option('--force', 'Replace same-source member versions')
.option('--check', 'Show an upgrade plan without writing')
.option('--registry <url>', 'Registry URL')
.option('--token <token>', 'API token')
.option('--json', 'Output JSON')
.action((action: string, coordinate: string, options: SuiteCommandOptions & { agent?: string | string[] }) => {
return runCommand(
() => suiteCommand(action, coordinate, { ...options, agent: toArray(options.agent) }),
Boolean(options.json)
)
})
cli
.command('upgrade [...coordinates]', 'Upgrade explicitly selected installed skills')
.option('--namespace <slug>', 'Filter a bare slug by namespace')

View file

@ -31,6 +31,7 @@ export interface InstallOptions {
expectedTargetFiles?: Record<string, Record<string, string>> | undefined
allowTargetDrift?: boolean | undefined
requireExistingTargets?: boolean | undefined
client?: SkillHubClient | undefined
/** Internal test seam for lock lifecycle failures; production uses acquireSkillTargetLock. */
acquireTargetLock?: typeof acquireSkillTargetLock
}
@ -99,9 +100,11 @@ export async function installSkill(options: InstallOptions): Promise<InstallResu
namespace: options.namespace,
slug: options.slug
}, options.force, inventory)
const client = new SkillHubClient(options.registry, options.token)
const client = options.client ?? new SkillHubClient(options.registry, options.token)
const resolved = options.resolved ?? await client.resolve(options.namespace, options.slug, options.version)
const response = await client.download(options.namespace, options.slug, resolved.version)
const response = resolved.downloadUrl
? await client.downloadFromUrl(resolved.downloadUrl)
: await client.download(options.namespace, options.slug, resolved.version)
const buffer = await readBoundedResponseBody(response)
const staged: StagedInstall[] = []

View file

@ -0,0 +1,755 @@
import { mkdtemp, rename, rm } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { dirname, join, resolve } from 'node:path'
import { tmpdir } from 'node:os'
import { SkillHubClient, type SuiteDetail, type SuiteInstallPlan } from '../clients/skillhub-client'
import {
InventoryStore,
installedBy,
installedSuites,
targetInstalledBy,
type Inventory,
type InventoryItem,
type InventorySuite,
type InventoryTarget
} from '../stores/inventory-store'
import { CliError } from '../shared/errors'
import { EXIT } from '../shared/constants'
import { installSkill } from './install-service'
import { pathExists } from '../platform/paths'
import { snapshotSkillDirectory } from './skill-fingerprint'
import { acquireSkillTargetLock } from './skill-target-lock'
import type { AgentCandidate } from '../agents/types'
const SUITE_CAPABILITY = 'skill-suite-v1'
export interface SuiteInstallOptions {
registry: string
token?: string | undefined
namespace: string
slug: string
version?: string | undefined
targets: AgentCandidate[]
force: boolean
home?: string | undefined
client?: SkillHubClient | undefined
/** Internal seam used to verify atomic rollback after a filesystem commit failure. */
renameOperation?: typeof rename | undefined
}
export interface SuiteInstallResult {
plan: SuiteInstallPlan
installed: Array<{ namespace: string; slug: string; agent: string; dir: string }>
reused: Array<{ namespace: string; slug: string; agent: string; dir: string }>
}
export interface SuiteCheckResult {
suite: InventorySuite
remoteVersion?: string
current: boolean
installedVersionAvailable: boolean
blockingReasons: string[]
members: Array<{
namespace: string
slug: string
version: string
dir: string
status: 'ok' | 'missing' | 'modified'
}>
}
export interface SuiteRemoveResult {
removed: string[]
preserved: Array<{ dir: string; reason: 'shared' | 'modified' | 'missing' }>
}
export interface SuiteUpgradePlan {
current: InventorySuite
remote: SuiteDetail
targets: AgentCandidate[]
changes: Array<{
coordinate: string
action: 'add' | 'remove' | 'change'
fromVersion?: string
toVersion?: string
}>
}
interface PreparedTarget {
member: SuiteInstallPlan['members'][number]
target: AgentCandidate
installDir: string
stagedDir: string
replace: boolean
reuse: boolean
backupDir: string | undefined
committed: boolean
}
interface RetiredTarget {
item: InventoryItem
target: InventoryTarget
backupDir: string
moved: boolean
}
export function suiteSource(namespace: string, slug: string, version: string): string {
return `suite:@${namespace}/${slug}@${version}`
}
export async function assertSuiteCapability(client: SkillHubClient): Promise<void> {
const metadata = await client.serverMetadata()
if (!metadata.capabilities?.includes(SUITE_CAPABILITY)) {
throw new CliError('registry does not support Skill Suites', EXIT.validation, {
capability: SUITE_CAPABILITY,
next: 'upgrade the SkillHub server before using suite commands'
})
}
}
/**
* Installs every exact member as one local transaction. Downloads and fingerprint checks finish
* before any live Skill directory is replaced; commit failures restore all captured backups.
*/
export async function installSuite(options: SuiteInstallOptions): Promise<SuiteInstallResult> {
const client = options.client ?? new SkillHubClient(options.registry, options.token)
const renameOperation = options.renameOperation ?? rename
await assertSuiteCapability(client)
// The same client key survives the single HTTP retry; the Server owns the operation ID.
const idempotencyKey = randomUUID()
const plan = await client.suiteInstallPlan(
options.namespace,
options.slug,
options.version,
idempotencyKey
)
assertNoTargetCollisions(plan)
const store = new InventoryStore(options.home)
const before = await store.read()
const previousSuite = installedSuites(before).find(candidate =>
candidate.registry === options.registry && candidate.namespace === plan.namespace && candidate.slug === plan.slug)
const source = suiteSource(plan.namespace, plan.slug, plan.version)
const stageHome = await mkdtemp(join(tmpdir(), 'skillhub-suite-inventory-'))
const stageToken = `${process.pid}-${Date.now()}`
const prepared: PreparedTarget[] = []
const retired = await prepareRetiredTargets(before, previousSuite, plan, stageToken)
try {
await preflightExistingTargets(before, options.registry, plan, options.targets, options.force)
for (const member of plan.members) {
const stagingTargets = options.targets.map((target, index) => ({
...target,
rootDir: join(resolve(target.rootDir), `.skillhub-suite-stage-${stageToken}-${index}`)
}))
await installSkill({
registry: options.registry,
token: options.token,
namespace: member.namespace,
slug: member.slug,
version: member.version,
targets: stagingTargets,
force: false,
home: stageHome,
client,
resolved: {
namespace: member.namespace,
slug: member.slug,
version: member.version,
versionId: member.skillVersionId,
fingerprint: member.fingerprint,
downloadUrl: member.downloadUrl
}
})
for (let index = 0; index < options.targets.length; index += 1) {
const target = options.targets[index]!
const installDir = join(resolve(target.rootDir), member.slug)
const stagedDir = join(stagingTargets[index]!.rootDir, member.slug)
const reuse = await isReusable(before, options.registry, member, installDir)
prepared.push({
member,
target: { ...target, rootDir: resolve(target.rootDir) },
installDir,
stagedDir,
replace: await pathExists(installDir) && !reuse,
reuse,
committed: false,
backupDir: undefined
})
}
}
const releases: Array<() => Promise<void>> = []
try {
const lockTargets = [
...prepared.map(item => ({ rootDir: item.target.rootDir, slug: item.member.slug })),
...retired.map(item => ({ rootDir: item.target.rootDir, slug: item.item.slug }))
].sort((a, b) => join(a.rootDir, a.slug).localeCompare(join(b.rootDir, b.slug)))
const lockedPaths = new Set<string>()
for (const target of lockTargets) {
const path = join(resolve(target.rootDir), target.slug)
if (lockedPaths.has(path)) continue
lockedPaths.add(path)
releases.push(await acquireSkillTargetLock(target.rootDir, target.slug))
}
// Recheck after target locks so a concurrent direct install cannot invalidate preflight.
const lockedInventory = await store.read()
await preflightExistingTargets(lockedInventory, options.registry, plan, options.targets, options.force)
for (const item of prepared) {
if (item.reuse) {
await rm(item.stagedDir, { recursive: true, force: true })
continue
}
if (item.replace) {
item.backupDir = `${item.installDir}.skillhub-suite-backup-${stageToken}`
await renameOperation(item.installDir, item.backupDir)
}
await renameOperation(item.stagedDir, item.installDir)
item.committed = true
}
for (const item of retired) {
if (await pathExists(item.target.installDir)) {
await renameOperation(item.target.installDir, item.backupDir)
item.moved = true
}
}
try {
await store.mutateAtomic(inventory => commitInventory(
inventory, options.registry, plan, source, prepared, retired))
} catch (error) {
await rollbackTransaction(prepared, retired, error, renameOperation)
throw error
}
for (const item of prepared) {
if (item.backupDir) {
await rm(item.backupDir, { recursive: true, force: true }).catch(() => {})
item.backupDir = undefined
}
}
for (const item of retired) {
await rm(item.backupDir, { recursive: true, force: true }).catch(() => {})
item.moved = false
}
} catch (error) {
if (prepared.some(item => item.committed || item.backupDir) || retired.some(item => item.moved)) {
await rollbackTransaction(prepared, retired, error, renameOperation)
}
throw error
} finally {
for (const release of releases.reverse()) await release().catch(() => {})
}
return {
plan,
installed: prepared.filter(item => !item.reuse).map(item => ({
namespace: item.member.namespace,
slug: item.member.slug,
agent: item.target.agent,
dir: item.installDir
})),
reused: prepared.filter(item => item.reuse).map(item => ({
namespace: item.member.namespace,
slug: item.member.slug,
agent: item.target.agent,
dir: item.installDir
}))
}
} finally {
await rm(stageHome, { recursive: true, force: true }).catch(() => {})
for (const item of prepared) {
await rm(dirname(item.stagedDir), { recursive: true, force: true }).catch(() => {})
}
}
}
export async function checkSuite(options: {
registry: string
token?: string | undefined
namespace: string
slug: string
home?: string | undefined
client?: SkillHubClient | undefined
}): Promise<SuiteCheckResult> {
const client = options.client ?? new SkillHubClient(options.registry, options.token)
await assertSuiteCapability(client)
const inventory = await new InventoryStore(options.home).read()
const suite = findInstalledSuite(inventory, options.registry, options.namespace, options.slug)
const installedRemote = await client.suiteDetail(options.namespace, options.slug, suite.version)
let latestRemote: SuiteDetail | undefined
try {
latestRemote = await client.suiteDetail(options.namespace, options.slug)
} catch (error) {
if (!(error instanceof CliError) || error.details.status !== 404) throw error
}
const members: SuiteCheckResult['members'] = []
for (const member of suite.members) {
for (const installDir of member.installDirs) {
let status: SuiteCheckResult['members'][number]['status'] = 'missing'
if (await pathExists(installDir)) {
status = (await snapshotSkillDirectory(installDir)).fingerprint === member.fingerprint
? 'ok'
: 'modified'
}
members.push({
namespace: member.namespace,
slug: member.slug,
version: member.version,
dir: installDir,
status
})
}
}
return {
suite,
...(latestRemote ? { remoteVersion: latestRemote.version } : {}),
current: latestRemote?.version === suite.version
&& installedRemote.available
&& members.every(member => member.status === 'ok'),
installedVersionAvailable: installedRemote.available,
blockingReasons: installedRemote.members
.flatMap(member => member.blockingReason ? [member.blockingReason] : []),
members
}
}
export async function removeSuite(options: {
registry: string
namespace: string
slug: string
home?: string | undefined
}): Promise<SuiteRemoveResult> {
const store = new InventoryStore(options.home)
const inventory = await store.read()
const suite = findInstalledSuite(inventory, options.registry, options.namespace, options.slug)
const source = suiteSource(suite.namespace, suite.slug, suite.version)
const removable: Array<{ item: InventoryItem; target: InventoryTarget; backupDir: string }> = []
const preserved: SuiteRemoveResult['preserved'] = []
const token = `${process.pid}-${Date.now()}`
for (const member of suite.members) {
const item = inventory.items.find(candidate =>
candidate.registry === options.registry && candidate.namespace === member.namespace && candidate.slug === member.slug)
if (!item) continue
for (const installDir of member.installDirs) {
const target = item.targets.find(candidate => resolve(candidate.installDir) === resolve(installDir))
if (!target) continue
const remainingSources = targetInstalledBy(item, target).filter(candidate => candidate !== source)
if (remainingSources.length > 0) {
preserved.push({ dir: installDir, reason: 'shared' })
} else if (!(await pathExists(installDir))) {
preserved.push({ dir: installDir, reason: 'missing' })
} else if ((await snapshotSkillDirectory(installDir)).fingerprint !== member.fingerprint) {
preserved.push({ dir: installDir, reason: 'modified' })
} else {
removable.push({ item, target, backupDir: `${installDir}.skillhub-suite-remove-${token}` })
}
}
}
const releases: Array<() => Promise<void>> = []
const moved: typeof removable = []
try {
for (const candidate of [...removable].sort((a, b) => a.target.installDir.localeCompare(b.target.installDir))) {
releases.push(await acquireSkillTargetLock(candidate.target.rootDir, candidate.item.slug))
}
const lockedInventory = await store.read()
for (const candidate of removable) {
const current = lockedInventory.items.find(item =>
item.registry === candidate.item.registry && item.namespace === candidate.item.namespace &&
item.slug === candidate.item.slug)
const target = current?.targets.find(item =>
resolve(item.installDir) === resolve(candidate.target.installDir))
if (!current || !target
|| targetInstalledBy(current, target).some(candidateSource => candidateSource !== source)) {
throw new CliError(`Suite member ownership changed before removal: ${candidate.target.installDir}`, EXIT.validation, {
path: candidate.target.installDir,
next: 'run `skillhub suite check` and retry'
})
}
}
for (const candidate of removable) {
await rename(candidate.target.installDir, candidate.backupDir)
moved.push(candidate)
}
await store.mutateAtomic(current => {
current.suites = installedSuites(current).filter(candidate =>
candidate.registry !== options.registry || candidate.namespace !== options.namespace || candidate.slug !== options.slug)
for (const item of current.items) {
const deletedDirs = new Set(removable
.filter(candidate => candidate.item.registry === item.registry &&
candidate.item.namespace === item.namespace && candidate.item.slug === item.slug)
.map(candidate => resolve(candidate.target.installDir)))
item.targets = item.targets
.filter(target => !deletedDirs.has(resolve(target.installDir)))
.map(target => ({
...target,
installedBy: targetInstalledBy(item, target).filter(candidate => candidate !== source)
}))
item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? [])))
}
current.items = current.items.filter(item => item.targets.length > 0)
})
} catch (error) {
for (const candidate of moved.reverse()) {
await rename(candidate.backupDir, candidate.target.installDir).catch(() => {})
}
throw error
} finally {
for (const release of releases.reverse()) await release().catch(() => {})
}
for (const candidate of removable) {
await rm(candidate.backupDir, { recursive: true, force: true }).catch(() => {})
}
return { removed: removable.map(candidate => candidate.target.installDir), preserved }
}
export async function planSuiteUpgrade(options: {
registry: string
token?: string | undefined
namespace: string
slug: string
home?: string | undefined
client?: SkillHubClient | undefined
}): Promise<SuiteUpgradePlan> {
const client = options.client ?? new SkillHubClient(options.registry, options.token)
await assertSuiteCapability(client)
const inventory = await new InventoryStore(options.home).read()
const current = findInstalledSuite(inventory, options.registry, options.namespace, options.slug)
const remote = await client.suiteDetail(options.namespace, options.slug)
if (!remote.available) {
throw new CliError(`Suite @${options.namespace}/${options.slug}@${remote.version} is unavailable`, EXIT.validation, {
blockedMembers: remote.members.filter(member => member.blockingReason).map(member => ({
coordinate: `@${member.namespace}/${member.slug}@${member.version}`,
reason: member.blockingReason
}))
})
}
const currentMembers = new Map(current.members.map(member => [`${member.namespace}\u0000${member.slug}`, member]))
const remoteMembers = new Map(remote.members.map(member => [`${member.namespace}\u0000${member.slug}`, member]))
const changes: SuiteUpgradePlan['changes'] = []
for (const [key, member] of remoteMembers) {
const existing = currentMembers.get(key)
if (!existing) {
changes.push({ coordinate: `@${member.namespace}/${member.slug}`, action: 'add', toVersion: member.version })
} else if (existing.version !== member.version || existing.fingerprint !== member.fingerprint) {
changes.push({
coordinate: `@${member.namespace}/${member.slug}`,
action: 'change',
fromVersion: existing.version,
toVersion: member.version
})
}
}
for (const [key, member] of currentMembers) {
if (!remoteMembers.has(key)) {
changes.push({ coordinate: `@${member.namespace}/${member.slug}`, action: 'remove', fromVersion: member.version })
}
}
return { current, remote, targets: installedSuiteTargets(inventory, current), changes }
}
export async function upgradeSuite(options: {
registry: string
token?: string | undefined
namespace: string
slug: string
home?: string | undefined
client?: SkillHubClient | undefined
}): Promise<{ upgrade: SuiteUpgradePlan; result?: SuiteInstallResult }> {
const upgrade = await planSuiteUpgrade(options)
if (upgrade.current.version === upgrade.remote.version && upgrade.changes.length === 0) {
return { upgrade }
}
const result = await installSuite({
...options,
version: upgrade.remote.version,
targets: upgrade.targets,
force: true
})
return { upgrade, result }
}
function findInstalledSuite(
inventory: Inventory,
registry: string,
namespace: string,
slug: string
): InventorySuite {
const suite = installedSuites(inventory).find(candidate =>
candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug)
if (!suite) {
throw new CliError(`Suite @${namespace}/${slug} is not installed`, EXIT.validation, {
next: `run \`skillhub suite install @${namespace}/${slug}\``
})
}
return suite
}
function installedSuiteTargets(inventory: Inventory, suite: InventorySuite): AgentCandidate[] {
const dirs = new Set(suite.members.flatMap(member => member.installDirs).map(installDir => dirname(resolve(installDir))))
const targets = new Map<string, AgentCandidate>()
for (const item of inventory.items) {
for (const target of item.targets) {
if (!dirs.has(resolve(target.rootDir))) continue
targets.set(resolve(target.rootDir), {
agent: target.agent,
rootDir: resolve(target.rootDir),
scope: 'user',
source: 'explicit'
})
}
}
if (targets.size === 0) {
throw new CliError('installed Suite has no recoverable target directories', EXIT.validation, {
next: 'remove the stale Suite inventory entry and install it again'
})
}
return [...targets.values()]
}
function assertNoTargetCollisions(plan: SuiteInstallPlan): void {
const seen = new Map<string, string>()
for (const member of plan.members) {
const coordinate = `@${member.namespace}/${member.slug}`
const existing = seen.get(member.slug)
if (existing && existing !== coordinate) {
throw new CliError(`suite members ${existing} and ${coordinate} use the same local directory`, EXIT.validation, {
slug: member.slug,
next: 'publish a Suite version without colliding member slugs'
})
}
seen.set(member.slug, coordinate)
}
}
async function preflightExistingTargets(
inventory: Inventory,
registry: string,
plan: SuiteInstallPlan,
targets: AgentCandidate[],
force: boolean
): Promise<void> {
for (const member of plan.members) {
const selectedDirs = new Set(targets.map(target => join(resolve(target.rootDir), member.slug)))
const sameItem = inventory.items.find(item =>
item.registry === registry &&
item.namespace === member.namespace && item.slug === member.slug)
if (sameItem && sameItem.version !== member.version) {
const retained = sameItem.targets.filter(target => !selectedDirs.has(resolve(target.installDir)))
if (retained.length > 0) {
throw new CliError(`partial Suite install would split versions for @${member.namespace}/${member.slug}`, EXIT.validation, {
retainedTargets: retained.map(target => target.installDir),
next: 'select every installed target or keep the existing Suite version'
})
}
}
for (const target of targets) {
const installDir = join(resolve(target.rootDir), member.slug)
const owner = inventory.items.find(item =>
item.targets.some(existing => resolve(existing.installDir) === installDir))
if (!owner && await pathExists(installDir)) {
throw new CliError(`unmanaged directory already exists at ${installDir}`, EXIT.validation, {
path: installDir,
next: 'move the directory or import it with `skillhub doctor` before installing the Suite'
})
}
if (owner && (owner.registry !== registry || owner.namespace !== member.namespace || owner.slug !== member.slug)) {
throw new CliError(`install target is owned by @${owner.namespace}/${owner.slug}`, EXIT.validation, {
path: installDir,
next: 'choose another target or remove the conflicting Skill explicitly'
})
}
const currentSuitePrefix = `suite:@${plan.namespace}/${plan.slug}@`
const ownerTarget = owner?.targets.find(existing => resolve(existing.installDir) === installDir)
if (owner && ownerTarget && owner.version !== member.version && targetInstalledBy(owner, ownerTarget).some(source =>
source.startsWith('suite:') && !source.startsWith(currentSuitePrefix))) {
throw new CliError(`shared Suite member @${member.namespace}/${member.slug} cannot change version in place`, EXIT.validation, {
path: installDir,
next: 'install the Suite into another target or upgrade the sharing Suite first'
})
}
if (owner && owner.version !== member.version && !force) {
throw new CliError(`different Skill version already installed at ${installDir}`, EXIT.validation, {
currentVersion: owner.version,
requestedVersion: member.version,
next: 'pass --force only if replacing this same Skill is intended'
})
}
}
}
}
async function isReusable(
inventory: Inventory,
registry: string,
member: SuiteInstallPlan['members'][number],
installDir: string
): Promise<boolean> {
const item = inventory.items.find(candidate =>
candidate.registry === registry && candidate.namespace === member.namespace && candidate.slug === member.slug &&
candidate.version === member.version && candidate.fingerprint === member.fingerprint &&
candidate.targets.some(target => resolve(target.installDir) === installDir))
if (!item || !(await pathExists(installDir))) return false
return (await snapshotSkillDirectory(installDir)).fingerprint === member.fingerprint
}
function commitInventory(
inventory: Inventory,
registry: string,
plan: SuiteInstallPlan,
source: string,
prepared: PreparedTarget[],
retired: RetiredTarget[]
): void {
const suitePrefix = `suite:@${plan.namespace}/${plan.slug}@`
for (const item of inventory.items) {
if (item.registry === registry) {
item.targets = item.targets.map(target => ({
...target,
installedBy: targetInstalledBy(item, target)
.filter(candidate => !candidate.startsWith(suitePrefix))
}))
item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? [])))
}
}
for (const member of plan.members) {
let item = inventory.items.find(candidate =>
candidate.registry === registry && candidate.namespace === member.namespace && candidate.slug === member.slug)
if (!item) {
item = {
registry,
namespace: member.namespace,
slug: member.slug,
version: member.version,
fingerprint: member.fingerprint,
installedBy: [source],
targets: []
}
inventory.items.push(item)
}
item.version = member.version
item.fingerprint = member.fingerprint
item.installedBy = Array.from(new Set([...installedBy(item), source]))
for (const preparedTarget of prepared.filter(candidate => candidate.member === member)) {
const target: InventoryTarget = {
agent: preparedTarget.target.agent,
rootDir: preparedTarget.target.rootDir,
installDir: preparedTarget.installDir,
installedAt: new Date().toISOString(),
installedBy: [source]
}
const index = item.targets.findIndex(existing => resolve(existing.installDir) === preparedTarget.installDir)
if (index >= 0) {
target.installedBy = Array.from(new Set([
...targetInstalledBy(item, item.targets[index]!),
source
]))
item.targets[index] = target
}
else item.targets.push(target)
}
item.installedBy = Array.from(new Set(item.targets.flatMap(target => target.installedBy ?? [])))
}
const suite: InventorySuite = {
registry,
namespace: plan.namespace,
slug: plan.slug,
version: plan.version,
fingerprint: plan.fingerprint,
members: plan.members.map(member => ({
namespace: member.namespace,
slug: member.slug,
version: member.version,
fingerprint: member.fingerprint,
installDirs: prepared.filter(item => item.member === member).map(item => item.installDir)
}))
}
inventory.suites = installedSuites(inventory).filter(candidate =>
candidate.registry !== registry || candidate.namespace !== plan.namespace || candidate.slug !== plan.slug)
inventory.suites.push(suite)
const retiredDirs = new Set(retired.map(item => resolve(item.target.installDir)))
for (const item of inventory.items) {
item.targets = item.targets.filter(target => !retiredDirs.has(resolve(target.installDir)))
}
inventory.items = inventory.items.filter(item => item.targets.length > 0)
}
async function rollbackTransaction(
prepared: PreparedTarget[],
retired: RetiredTarget[],
originalError: unknown,
renameOperation: typeof rename
): Promise<void> {
const failures: Array<{ path: string; error: string }> = []
for (const item of [...retired].reverse()) {
if (!item.moved) continue
try {
await renameOperation(item.backupDir, item.target.installDir)
item.moved = false
} catch (error) {
failures.push({ path: item.backupDir, error: describe(error) })
}
}
for (const item of [...prepared].reverse()) {
try {
if (item.committed) await rm(item.installDir, { recursive: true, force: true })
if (item.backupDir) await renameOperation(item.backupDir, item.installDir)
item.committed = false
item.backupDir = undefined
} catch (error) {
failures.push({ path: item.backupDir ?? item.installDir, error: describe(error) })
}
}
if (failures.length > 0) {
throw new CliError('Suite installation failed and rollback was incomplete', EXIT.filesystem, {
originalError: describe(originalError),
rollbackFailures: failures,
next: 'restore the retained backup directories before retrying'
})
}
}
async function prepareRetiredTargets(
inventory: Inventory,
previous: InventorySuite | undefined,
next: SuiteInstallPlan,
token: string
): Promise<RetiredTarget[]> {
if (!previous) return []
const nextCoordinates = new Set(next.members.map(member => `${member.namespace}\u0000${member.slug}`))
const oldSource = suiteSource(previous.namespace, previous.slug, previous.version)
const retired: RetiredTarget[] = []
for (const member of previous.members) {
if (nextCoordinates.has(`${member.namespace}\u0000${member.slug}`)) continue
const item = inventory.items.find(candidate =>
candidate.registry === previous.registry && candidate.namespace === member.namespace && candidate.slug === member.slug)
if (!item) continue
for (const installDir of member.installDirs) {
const target = item.targets.find(candidate => resolve(candidate.installDir) === resolve(installDir))
if (!target || !(await pathExists(installDir))) continue
if (targetInstalledBy(item, target).some(source => source !== oldSource)) continue
if ((await snapshotSkillDirectory(installDir)).fingerprint !== member.fingerprint) continue
retired.push({
item,
target,
backupDir: `${installDir}.skillhub-suite-retired-${token}`,
moved: false
})
}
}
return retired
}
function describe(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}

View file

@ -8,6 +8,8 @@ export interface InventoryTarget {
rootDir: string
installDir: string
installedAt: string
/** Sources that own this exact target. Missing values inherit the legacy item-level sources. */
installedBy?: string[]
}
export interface InventoryItem {
@ -16,11 +18,54 @@ export interface InventoryItem {
slug: string
version: string
fingerprint?: string
/** Missing on legacy records and therefore interpreted as a direct install. */
installedBy?: string[]
targets: InventoryTarget[]
}
export interface InventorySuiteMember {
namespace: string
slug: string
version: string
fingerprint: string
installDirs: string[]
}
export interface InventorySuite {
registry: string
namespace: string
slug: string
version: string
fingerprint: string
members: InventorySuiteMember[]
}
export interface Inventory {
items: InventoryItem[]
suites?: InventorySuite[]
}
export function installedBy(item: InventoryItem): string[] {
return item.installedBy ?? ['direct']
}
export function targetInstalledBy(item: InventoryItem, target: InventoryTarget): string[] {
return target.installedBy ?? installedBy(item)
}
function addDirectSource(item: InventoryItem, target: InventoryTarget): InventoryTarget {
return {
...target,
installedBy: Array.from(new Set([...targetInstalledBy(item, target), 'direct']))
}
}
function refreshItemSources(item: InventoryItem): void {
item.installedBy = Array.from(new Set(item.targets.flatMap(target => targetInstalledBy(item, target))))
}
export function installedSuites(inventory: Inventory): InventorySuite[] {
return inventory.suites ?? []
}
export class InventoryVersionConflictError extends Error {
@ -58,7 +103,7 @@ export class InventoryStore {
}
}
private async mutateAtomic<T>(mutate: (inventory: Inventory) => T): Promise<T> {
async mutateAtomic<T>(mutate: (inventory: Inventory) => T): Promise<T> {
await ensureDir(dirname(this.path))
let release: (() => Promise<void>) | null = null
try {
@ -118,8 +163,11 @@ export class InventoryStore {
item.version = version
if (fingerprint !== undefined) item.fingerprint = fingerprint
const existingIdx = item.targets.findIndex(t => t.installDir === target.installDir)
if (existingIdx >= 0) item.targets[existingIdx] = target
else item.targets.push(target)
const previous = existingIdx >= 0 ? item.targets[existingIdx]! : target
const next = addDirectSource(item, { ...target, installedBy: targetInstalledBy(item, previous) })
if (existingIdx >= 0) item.targets[existingIdx] = next
else item.targets.push(next)
refreshItemSources(item)
})
}
@ -165,12 +213,13 @@ export class InventoryStore {
let item = inventory.items.find(candidate =>
candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug)
if (!item) {
item = { registry, namespace, slug, version, targets: [] }
item = { registry, namespace, slug, version, installedBy: ['direct'], targets: [] }
inventory.items.push(item)
}
item.version = version
if (fingerprint !== undefined) item.fingerprint = fingerprint
item.targets.push(target)
item.targets.push({ ...target, installedBy: ['direct'] })
refreshItemSources(item)
})
}
@ -204,12 +253,13 @@ export class InventoryStore {
let item = inventory.items.find(candidate =>
candidate.registry === registry && candidate.namespace === namespace && candidate.slug === slug)
if (!item) {
item = { registry, namespace, slug, version, targets: [] }
item = { registry, namespace, slug, version, installedBy: ['direct'], targets: [] }
inventory.items.push(item)
}
item.version = version
if (fingerprint !== undefined) item.fingerprint = fingerprint
item.targets.push(...targets)
item.targets.push(...targets.map(target => ({ ...target, installedBy: ['direct'] })))
refreshItemSources(item)
})
}
}

View file

@ -0,0 +1,750 @@
import { createHash } from 'node:crypto'
import { access, mkdir, mkdtemp, readFile, readdir, rename, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, test } from 'bun:test'
import { zipSync } from 'fflate'
import { SkillHubClient, type SuiteInstallPlan } from '../../../src/clients/skillhub-client'
import { InventoryStore } from '../../../src/stores/inventory-store'
import {
checkSuite,
installSuite,
planSuiteUpgrade,
removeSuite,
upgradeSuite
} from '../../../src/services/suite-service'
const registry = 'http://registry.test'
function archive(content: string): { bytes: Uint8Array; fingerprint: string } {
const bytes = zipSync({ 'SKILL.md': new TextEncoder().encode(content) })
const aggregate = createHash('sha256')
const fileHash = createHash('sha256').update(content).digest('hex')
aggregate.update(`SKILL.md:${fileHash}\n`, 'utf8')
return { bytes, fingerprint: `sha256:${aggregate.digest('hex')}` }
}
function clientFor(
plan: SuiteInstallPlan,
downloads: Record<string, Uint8Array>,
capabilities: string[] = ['skill-suite-v1'],
available = true
): SkillHubClient {
const fetchImpl = (async (input: URL | RequestInfo) => {
const url = new URL(String(input))
if (url.pathname === '/.well-known/clawhub.json') {
return Response.json({ apiBase: '/api/v1', capabilities })
}
if (url.pathname.endsWith('/install-plan')) return Response.json({ code: 0, data: plan })
if (url.pathname === `/api/v1/suites/${plan.namespace}/${plan.slug}`) {
return Response.json({ code: 0, data: {
id: 1,
versionId: 2,
namespace: plan.namespace,
slug: plan.slug,
displayName: plan.slug,
version: plan.version,
status: 'PUBLISHED',
visibility: 'PUBLIC',
available,
members: plan.members.map(member => ({
...member,
blockingReason: available ? null : 'member_unavailable'
}))
} })
}
const bytes = downloads[url.pathname]
return bytes
? new Response(bytes.slice().buffer as ArrayBuffer, { status: 200 })
: Response.json({ code: 404 }, { status: 404 })
}) as unknown as typeof fetch
return new SkillHubClient(registry, undefined, fetchImpl)
}
function clientWithPlanFailure(status: number, message: string): SkillHubClient {
const fetchImpl = (async (input: URL | RequestInfo) => {
const url = new URL(String(input))
if (url.pathname === '/.well-known/clawhub.json') {
return Response.json({ apiBase: '/api/v1', capabilities: ['skill-suite-v1'] })
}
if (url.pathname.endsWith('/install-plan')) {
return Response.json({ code: status, msg: message }, { status })
}
return Response.json({ code: 404 }, { status: 404 })
}) as unknown as typeof fetch
return new SkillHubClient(registry, undefined, fetchImpl)
}
function makePlan(): { plan: SuiteInstallPlan; downloads: Record<string, Uint8Array> } {
const alpha = archive('# Alpha')
const beta = archive('# Beta')
return {
plan: {
operationId: 'operation-1',
namespace: 'global',
slug: 'starter-pack',
version: '1.0.0',
fingerprint: 'sha256:suite',
members: [
{
skillId: 10,
skillVersionId: 11,
namespace: 'global',
slug: 'alpha',
version: '1.0.0',
fingerprint: alpha.fingerprint,
downloadUrl: '/downloads/alpha',
position: 0,
entry: true
},
{
skillId: 20,
skillVersionId: 21,
namespace: 'global',
slug: 'beta',
version: '2.0.0',
fingerprint: beta.fingerprint,
downloadUrl: '/downloads/beta',
position: 1,
entry: false
}
]
},
downloads: { '/downloads/alpha': alpha.bytes, '/downloads/beta': beta.bytes }
}
}
async function exists(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch {
return false
}
}
describe('Suite local lifecycle', () => {
test('installs every exact member and writes one Suite inventory snapshot', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const { plan, downloads } = makePlan()
const result = await installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(plan, downloads)
})
expect(result.installed).toHaveLength(2)
expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha')
expect(await readFile(join(rootDir, 'beta', 'SKILL.md'), 'utf8')).toBe('# Beta')
const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8'))
expect(inventory.suites).toEqual([expect.objectContaining({ slug: 'starter-pack', version: '1.0.0' })])
expect(inventory.items).toHaveLength(2)
expect(inventory.items[0].installedBy).toEqual(['suite:@global/starter-pack@1.0.0'])
const checked = await checkSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
home,
client: clientFor(plan, downloads)
})
expect(checked.current).toBe(true)
expect(checked.members.every(member => member.status === 'ok')).toBe(true)
})
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-'))
const { plan, downloads } = makePlan()
plan.members[1]!.fingerprint = 'sha256:wrong'
await expect(installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(plan, downloads)
})).rejects.toThrow('fingerprint does not match')
expect(await exists(join(rootDir, 'alpha'))).toBe(false)
expect(await exists(join(rootDir, 'beta'))).toBe(false)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('sends one idempotency key with the install-plan request', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const { plan, downloads } = makePlan()
let idempotencyKey: string | null = null
const fetchImpl = (async (input: URL | RequestInfo, init?: RequestInit) => {
const url = new URL(String(input))
if (url.pathname === '/.well-known/clawhub.json') {
return Response.json({ capabilities: ['skill-suite-v1'] })
}
if (url.pathname.endsWith('/install-plan')) {
idempotencyKey = new Headers(init?.headers).get('Idempotency-Key')
return Response.json({ code: 0, data: plan })
}
const bytes = downloads[url.pathname]
return bytes
? new Response(bytes.slice().buffer as ArrayBuffer)
: Response.json({ code: 404 }, { status: 404 })
}) as unknown as typeof fetch
await installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: new SkillHubClient(registry, undefined, fetchImpl)
})
expect(idempotencyKey).toMatch(/^[0-9a-f-]{36}$/)
})
test('retries a transient install-plan failure with the same idempotency key', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const { plan, downloads } = makePlan()
const keys: Array<string | null> = []
let planAttempts = 0
const fetchImpl = (async (input: URL | RequestInfo, init?: RequestInit) => {
const url = new URL(String(input))
if (url.pathname === '/.well-known/clawhub.json') {
return Response.json({ capabilities: ['skill-suite-v1'] })
}
if (url.pathname.endsWith('/install-plan')) {
keys.push(new Headers(init?.headers).get('Idempotency-Key'))
planAttempts += 1
if (planAttempts === 1) return Response.json({ msg: 'temporary outage' }, { status: 503 })
return Response.json({ code: 0, data: plan })
}
const bytes = downloads[url.pathname]
return bytes
? new Response(bytes.slice().buffer as ArrayBuffer)
: Response.json({ code: 404 }, { status: 404 })
}) as unknown as typeof fetch
await installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: new SkillHubClient(registry, undefined, fetchImpl)
})
expect(keys).toHaveLength(2)
expect(keys[0]).toMatch(/^[0-9a-f-]{36}$/)
expect(keys[1]).toBe(keys[0])
})
test('reports an installed exact version as stale when a member becomes unavailable', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const { plan, downloads } = makePlan()
await installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(plan, downloads)
})
const checked = await checkSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
home,
client: clientFor(plan, downloads, ['skill-suite-v1'], false)
})
expect(checked.current).toBe(false)
expect(checked.installedVersionAvailable).toBe(false)
expect(checked.blockingReasons).toContain('member_unavailable')
})
test('checks the installed version when the suite has no latest published version', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const { plan, downloads } = makePlan()
await installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(plan, downloads)
})
const fetchImpl = (async (input: URL | RequestInfo) => {
const url = new URL(String(input))
if (url.pathname === '/.well-known/clawhub.json') {
return Response.json({ capabilities: ['skill-suite-v1'] })
}
if (url.pathname === `/api/v1/suites/${plan.namespace}/${plan.slug}`) {
if (!url.searchParams.has('version')) {
return Response.json({ code: 404, msg: 'no latest version' }, { status: 404 })
}
return Response.json({ code: 0, data: {
id: 1,
versionId: 2,
namespace: plan.namespace,
slug: plan.slug,
displayName: plan.slug,
version: plan.version,
status: 'YANKED',
visibility: 'PUBLIC',
available: false,
members: plan.members.map(member => ({ ...member, blockingReason: 'VERSION_UNAVAILABLE' }))
} })
}
return Response.json({ code: 404 }, { status: 404 })
}) as unknown as typeof fetch
const checked = await checkSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
home,
client: new SkillHubClient(registry, undefined, fetchImpl)
})
expect(checked.remoteVersion).toBeUndefined()
expect(checked.current).toBe(false)
expect(checked.installedVersionAvailable).toBe(false)
expect(checked.blockingReasons).toContain('VERSION_UNAVAILABLE')
})
test('rejects an old Server before resolving or writing Suite members', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const { plan, downloads } = makePlan()
await expect(installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(plan, downloads, [])
})).rejects.toThrow('registry does not support Skill Suites')
expect(await exists(join(rootDir, 'alpha'))).toBe(false)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('treats a non-JSON metadata response as an unsupported old Server', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const fetchImpl = (async () => new Response('<html>legacy registry</html>', {
status: 200,
headers: { 'Content-Type': 'text/html' }
})) as unknown as typeof fetch
await expect(installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: new SkillHubClient(registry, undefined, fetchImpl)
})).rejects.toThrow('registry does not support Skill Suites')
expect(await exists(join(rootDir, 'starter-pack'))).toBe(false)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('keeps every target untouched when the Server denies a member plan', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
await expect(installSuite({
registry,
namespace: 'private-team',
slug: 'restricted-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientWithPlanFailure(403, 'access denied')
})).rejects.toThrow('access denied')
expect(await readdir(rootDir)).toEqual([])
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('blocks an upgrade when the exact remote snapshot has an unavailable member', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const { plan, downloads } = makePlan()
await installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(plan, downloads)
})
await expect(planSuiteUpgrade({
registry,
namespace: 'global',
slug: 'starter-pack',
home,
client: clientFor(plan, downloads, ['skill-suite-v1'], false)
})).rejects.toThrow('is unavailable')
expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha')
})
test('rolls back all live members when a filesystem commit fails midway', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const { plan, downloads } = makePlan()
const renameOperation: typeof rename = async (source, target): Promise<void> => {
if (String(source).includes('.skillhub-suite-stage-') && String(target) === join(rootDir, 'beta')) {
throw new Error('injected rename failure')
}
await rename(source, target)
}
await expect(installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(plan, downloads),
renameOperation
})).rejects.toThrow('injected rename failure')
expect(await exists(join(rootDir, 'alpha'))).toBe(false)
expect(await exists(join(rootDir, 'beta'))).toBe(false)
expect(await exists(join(home, '.skillhub', 'inventory.json'))).toBe(false)
})
test('reports retained backup paths when rollback cannot restore a replaced member', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const { plan, downloads } = makePlan()
const old = archive('# Old Alpha')
const alphaDir = join(rootDir, 'alpha')
await mkdir(alphaDir, { recursive: true })
await writeFile(join(alphaDir, 'SKILL.md'), '# Old Alpha')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(join(home, '.skillhub', 'inventory.json'), JSON.stringify({
items: [{
registry,
namespace: 'global',
slug: 'alpha',
version: '0.9.0',
fingerprint: old.fingerprint,
installedBy: ['direct'],
targets: [{ agent: 'codex', rootDir, installDir: alphaDir, installedAt: '2026-09-01T00:00:00Z' }]
}],
suites: []
}))
const renameOperation: typeof rename = async (source, target): Promise<void> => {
const from = String(source)
const to = String(target)
if (from.includes('.skillhub-suite-stage-') && to === join(rootDir, 'beta')) {
throw new Error('injected commit failure')
}
if (from.includes('.skillhub-suite-backup-') && to === alphaDir) {
throw new Error('injected restore failure')
}
await rename(source, target)
}
await expect(installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: true,
home,
client: clientFor(plan, downloads),
renameOperation
})).rejects.toThrow('rollback was incomplete')
const retainedBackup = (await readdir(rootDir)).find(name => name.startsWith('alpha.skillhub-suite-backup-'))
expect(retainedBackup).toBeDefined()
expect(await readFile(join(rootDir, retainedBackup!, 'SKILL.md'), 'utf8')).toBe('# Old Alpha')
const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8'))
expect(inventory.items[0]).toMatchObject({ slug: 'alpha', version: '0.9.0', installedBy: ['direct'] })
})
test('installs one exact snapshot across multiple Agent targets', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const codexRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-codex-'))
const claudeRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-claude-'))
const { plan, downloads } = makePlan()
const result = await installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [
{ agent: 'codex', rootDir: codexRoot, scope: 'project', source: 'explicit' },
{ agent: 'claude', rootDir: claudeRoot, scope: 'project', source: 'explicit' }
],
force: false,
home,
client: clientFor(plan, downloads)
})
expect(result.installed).toHaveLength(4)
expect(await readFile(join(codexRoot, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha')
expect(await readFile(join(claudeRoot, 'beta', 'SKILL.md'), 'utf8')).toBe('# Beta')
const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8'))
expect(inventory.suites[0].members[0].installDirs).toHaveLength(2)
})
test('rejects member coordinates that collide in the local directory layout', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const { plan, downloads } = makePlan()
plan.members[1] = { ...plan.members[1]!, namespace: 'team', slug: 'alpha' }
await expect(installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(plan, downloads)
})).rejects.toThrow('use the same local directory')
expect(await exists(join(rootDir, 'alpha'))).toBe(false)
})
test('does not create a Suite directory when a Skill and Suite share one coordinate', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const { plan, downloads } = makePlan()
plan.slug = 'marketing'
plan.members = [plan.members[1]!]
const directSkillDir = join(rootDir, 'marketing')
await mkdir(directSkillDir, { recursive: true })
await writeFile(join(directSkillDir, 'SKILL.md'), '# Direct marketing Skill')
await installSuite({
registry,
namespace: 'global',
slug: 'marketing',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(plan, downloads)
})
expect(await readFile(join(directSkillDir, 'SKILL.md'), 'utf8')).toBe('# Direct marketing Skill')
expect(await readFile(join(rootDir, 'beta', 'SKILL.md'), 'utf8')).toBe('# Beta')
})
test('preserves a member shared by two installed Suites', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const first = makePlan()
first.plan.members = [first.plan.members[0]!]
await installSuite({
registry,
namespace: 'global',
slug: first.plan.slug,
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(first.plan, first.downloads)
})
const secondPlan = { ...first.plan, slug: 'editor-pack', fingerprint: 'sha256:editor-pack' }
await installSuite({
registry,
namespace: 'global',
slug: secondPlan.slug,
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(secondPlan, first.downloads)
})
const removed = await removeSuite({
registry,
namespace: 'global',
slug: first.plan.slug,
home
})
expect(removed.preserved).toEqual([{ dir: join(rootDir, 'alpha'), reason: 'shared' }])
expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha')
const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8'))
expect(inventory.items[0].installedBy).toEqual(['suite:@global/editor-pack@1.0.0'])
})
test('reuses a matching legacy direct install and preserves it when Suite is removed', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const { plan, downloads } = makePlan()
plan.members = [plan.members[0]!]
const skillDir = join(rootDir, 'alpha')
await mkdir(skillDir, { recursive: true })
await writeFile(join(skillDir, 'SKILL.md'), '# Alpha')
await mkdir(join(home, '.skillhub'), { recursive: true })
await writeFile(join(home, '.skillhub', 'inventory.json'), JSON.stringify({
items: [{
registry,
namespace: 'global',
slug: 'alpha',
version: '1.0.0',
fingerprint: plan.members[0]!.fingerprint,
targets: [{ agent: 'codex', rootDir, installDir: skillDir, installedAt: '2026-09-01T00:00:00Z' }]
}]
}))
const installed = await installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(plan, downloads)
})
expect(installed.reused).toHaveLength(1)
const removed = await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home })
expect(removed.preserved).toEqual([{ dir: skillDir, reason: 'shared' }])
expect(await exists(skillDir)).toBe(true)
const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8'))
expect(inventory.suites).toEqual([])
expect(inventory.items[0].installedBy).toEqual(['direct'])
})
test('tracks direct and Suite ownership independently for each Agent target', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const directRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-direct-root-'))
const suiteOnlyRoot = await mkdtemp(join(tmpdir(), 'skillhub-suite-only-root-'))
const { plan, downloads } = makePlan()
const alpha = plan.members[0]!
await mkdir(join(directRoot, alpha.slug), { recursive: true })
await writeFile(join(directRoot, alpha.slug, 'SKILL.md'), '# Alpha')
const store = new InventoryStore(home)
await store.upsertTarget(registry, alpha.namespace, alpha.slug, alpha.version, {
agent: 'codex', rootDir: directRoot, installDir: join(directRoot, alpha.slug),
installedAt: new Date().toISOString()
}, alpha.fingerprint)
await installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [
{ agent: 'codex', rootDir: directRoot, scope: 'project', source: 'explicit' },
{ agent: 'claude', rootDir: suiteOnlyRoot, scope: 'project', source: 'explicit' }
],
force: false,
home,
client: clientFor(plan, downloads)
})
await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home })
expect(await access(join(directRoot, 'alpha')).then(() => true)).toBe(true)
await expect(access(join(suiteOnlyRoot, 'alpha'))).rejects.toThrow()
const inventory = await store.read()
const alphaItem = inventory.items.find(item => item.slug === 'alpha')!
expect(alphaItem.targets).toHaveLength(1)
expect(alphaItem.targets[0]!.installedBy).toEqual(['direct'])
})
test('removes unmodified members that are owned only by the Suite', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const { plan, downloads } = makePlan()
await installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(plan, downloads)
})
const removed = await removeSuite({ registry, namespace: 'global', slug: 'starter-pack', home })
expect(removed.removed.sort()).toEqual([join(rootDir, 'alpha'), join(rootDir, 'beta')].sort())
expect(await exists(join(rootDir, 'alpha'))).toBe(false)
expect(await exists(join(rootDir, 'beta'))).toBe(false)
const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8'))
expect(inventory).toMatchObject({ items: [], suites: [] })
})
test('upgrades the exact snapshot and retires members removed by the new Suite version', async () => {
const home = await mkdtemp(join(tmpdir(), 'skillhub-suite-home-'))
const rootDir = await mkdtemp(join(tmpdir(), 'skillhub-suite-root-'))
const first = makePlan()
await installSuite({
registry,
namespace: 'global',
slug: 'starter-pack',
targets: [{ agent: 'codex', rootDir, scope: 'project', source: 'explicit' }],
force: false,
home,
client: clientFor(first.plan, first.downloads)
})
const alphaV2 = archive('# Alpha v2')
const gamma = archive('# Gamma')
const second: SuiteInstallPlan = {
...first.plan,
operationId: 'operation-2',
version: '2.0.0',
fingerprint: 'sha256:suite-v2',
members: [
{ ...first.plan.members[0]!, skillVersionId: 12, version: '2.0.0', fingerprint: alphaV2.fingerprint },
{
skillId: 30,
skillVersionId: 31,
namespace: 'global',
slug: 'gamma',
version: '1.0.0',
fingerprint: gamma.fingerprint,
downloadUrl: '/downloads/gamma',
position: 1,
entry: false
}
]
}
const client = clientFor(second, {
'/downloads/alpha': alphaV2.bytes,
'/downloads/gamma': gamma.bytes
})
const upgraded = await upgradeSuite({ registry, namespace: 'global', slug: 'starter-pack', home, client })
expect(upgraded.upgrade.changes.map(change => change.action).sort()).toEqual(['add', 'change', 'remove'])
expect(await readFile(join(rootDir, 'alpha', 'SKILL.md'), 'utf8')).toBe('# Alpha v2')
expect(await exists(join(rootDir, 'beta'))).toBe(false)
expect(await readFile(join(rootDir, 'gamma', 'SKILL.md'), 'utf8')).toBe('# Gamma')
const inventory = JSON.parse(await readFile(join(home, '.skillhub', 'inventory.json'), 'utf8'))
expect(inventory.suites[0]).toMatchObject({ version: '2.0.0', fingerprint: 'sha256:suite-v2' })
expect(inventory.items.map((item: { slug: string }) => item.slug).sort()).toEqual(['alpha', 'gamma'])
})
})

View file

@ -122,6 +122,6 @@ describe('InventoryStore', () => {
const inventory = await store.read()
expect(inventory.items).toHaveLength(1)
expect(inventory.items[0]).toMatchObject({ version: '1.0.0', fingerprint: 'fp-v1' })
expect(inventory.items[0]?.targets).toEqual([retained])
expect(inventory.items[0]?.targets).toEqual([{ ...retained, installedBy: ['direct'] }])
})
})

View file

@ -71,6 +71,8 @@ services:
SPRING_DATA_REDIS_TIMEOUT:
SPRING_DATA_REDIS_CLIENT_NAME:
SKILLHUB_REDIS_SENTINEL_CHECK_SENTINELS_LIST:
# Compose replaces its single Server without an old/new mixed-version window.
SKILLHUB_SUITE_REVIEW_WRITES_ENABLED: ${SKILLHUB_SUITE_REVIEW_WRITES_ENABLED:-true}
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
SKILLHUB_PUBLIC_BASE_URL: ${SKILLHUB_PUBLIC_BASE_URL:-}
DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-}

142
docs/25-skill-suites.md Normal file
View file

@ -0,0 +1,142 @@
# Skill Suite 设计与使用
## 定位
Skill Suite 是一个有独立身份和版本的 Skill 集合。它只引用当前 SkillHub 中已经发布的精确
SkillVersion不复制 Skill 文件,也不替成员重新执行扫描或审核。
Skill 与 Suite 的完整身份都包含资源类型,因此下列两个资源可以同时存在:
```text
SKILL @global/marketing
SUITE @global/marketing
```
原有 `skillhub install @global/marketing` 始终安装 SkillSuite 必须使用
`skillhub suite install @global/marketing`,不会根据名称猜测类型。
## 版本和生命周期
SuiteVersion 固定成员的 Skill ID、SkillVersion ID、坐标、版本和 fingerprint。成员发布新版本不会
改变现有 SuiteVersion调整成员、顺序、Entry Skill 或可见范围都需要创建新的 SuiteVersion。
```text
DRAFT -> PENDING_REVIEW -> PUBLISHED -> YANKED
\-> REJECTED -> DRAFT
```
- PRIVATE Suite 可以从 DRAFT 直接发布。
- PUBLIC 和 NAMESPACE_ONLY Suite 需要一次 Suite 级审核。
- Suite 没有可执行包,因此没有 SCANNING 或 SCAN_FAILED 状态。
- 隐藏、归档、下架或删除 Suite 不会改变任何成员 Skill。
- 成员失效后,已发布 SuiteVersion 保留历史快照并显示为不可安装,不会自动切换到成员最新版本。
## 成员与权限
一个 SuiteVersion 最多包含 100 个不同 Skill。Entry Skill 是可选成员,必须明确选自成员列表。
v1 不支持嵌套 Suite、版本范围、外部 Registry 成员或条件成员。
Suite 的可见范围不能宽于成员:
| Suite 可见性 | 允许的成员 |
| --- | --- |
| PUBLIC | 仅 PUBLIC Skill |
| NAMESPACE_ONLY | PUBLIC或同 Namespace 的 NAMESPACE_ONLY Skill |
| PRIVATE | PUBLIC或同 Namespace 的 NAMESPACE_ONLY/PRIVATE Skill |
创建、提交、审核和安装时都会重新检查成员资格。成员被下架、隐藏、归档、收窄权限或硬删除后,
SuiteVersion 仍为 PUBLISHED但安装计划会整体失败。硬删除只清空成员外键坐标、版本和 fingerprint
快照继续用于历史展示和审计。
## `suite.yaml` 定义
`suite.yaml` 是可移植的 Suite 创作格式,不是上传到 Agent 的多 Skill ZIP
```yaml
apiVersion: skillhub.iflytek.com/v1alpha1
kind: SkillSuite
metadata:
namespace: global
slug: superpowers
version: 1.0.0
displayName: Superpowers
spec:
visibility: PUBLIC
entrySkill: "@global/using-superpowers@1.0.0"
members:
- skill: "@global/using-superpowers"
version: 1.0.0
- skill: "@global/brainstorming"
version: 2.1.0
```
当前版本通过 Web 编辑器或 Suite API 创建同一份定义CLI v1 负责安装生命周期,尚不读取或发布
`suite.yaml`。保留该格式是为了后续增加 CLI 导入时不改变服务端领域模型。
## CLI 安装生命周期
```bash
skillhub suite install @global/superpowers --version 1.0.0
skillhub suite check @global/superpowers
skillhub suite upgrade @global/superpowers --check
skillhub suite upgrade @global/superpowers
skillhub suite remove @global/superpowers
```
安装先解析精确计划并下载、校验全部成员,再按稳定顺序锁定目标目录并整体提交。提交中途失败时,
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 安装请求和审计。
安装计划本身不预增成员下载数;每个成员仍由原有 Skill 下载接口按实际请求计数。
本地 `local` profile 可直接运行 `make suite-smoke`。验证 release Compose 时必须使用真实管理员会话:
```bash
SMOKE_ADMIN_USERNAME=admin \
SMOKE_ADMIN_PASSWORD='<configured-password>' \
./scripts/suite-smoke-test.sh http://localhost:8080
```
脚本不会输出密码,并在结束时删除其创建的临时 Suite 和 Skill。
## API 与发现
- Suite 管理与详情:`/api/v1/suites/**``/api/web/suites/**`
- 当前用户可管理的 Suite`/api/v1/me/suites``/api/web/me/suites`
- 类型化资源发现:`/api/v1/resources``/api/web/resources`
- 原有 Skill 搜索接口继续只返回 Skill。
类型化发现结果通过 `resourceType=SKILL|SUITE` 区分同名资源。Suite 详情返回固定版本、按顺序排列
的成员快照、Entry Skill、实时可安装状态和阻塞原因。
## 部署顺序
数据库迁移会先把既有审核任务回填为 `SKILL_VERSION`,并保留旧 Skill 专用列。官方单实例
`compose.release.yml` 和本地开发 profile 已默认开启 Suite 审核写入,因为它们不会同时运行新旧 Server。
其他部署方式默认保持关闭。全新安装、单实例升级或停机升级可直接设置:
```bash
SKILLHUB_SUITE_REVIEW_WRITES_ENABLED=true
```
旧版与新版 Server 会同时运行的滚动升级,应在发布新版前保持:
```bash
SKILLHUB_SUITE_REVIEW_WRITES_ENABLED=false
```
用该配置完成所有 Server 实例升级;确认不再有旧版实例后,将其改为 `true` 并再次滚动重启。开关关闭
期间,现有 Skill 审核保持可用Suite 草稿和 PRIVATE 直发不受影响PUBLIC 与 NAMESPACE_ONLY
Suite 的新审核提交会被拒绝。Server 启动时会记录明确告警,避免门禁被长期遗忘。
## 日志与审计
Suite 创建、编辑、提交、审核、发布、下架、隐藏、恢复、归档和删除都会写审计记录。业务日志仅记录
Suite ID、SuiteVersion ID、actor ID 和 request ID 等定位字段不记录成员内容、Token 或下载地址。

View file

@ -95,7 +95,7 @@ skillhub suite install @global/marketing
### 3. SuiteVersion 引用精确的已发布 SkillVersion
作者可以在 `suite.yaml` 中选择成员坐标和版本
Suite 定义可稳定映射为以下 `suite.yaml` 交换格式
```yaml
apiVersion: skillhub.iflytek.com/v1alpha1
@ -107,6 +107,9 @@ metadata:
displayName: Superpowers
spec:
visibility: PUBLIC
overview: |
## How to use this suite
Run the entry skill first, then use the remaining skills as needed.
entrySkill: "@global/using-superpowers@1.0.0"
members:
- skill: "@global/using-superpowers"
@ -115,11 +118,13 @@ spec:
version: 2.1.0
```
该文件是 API/CLI 的创作输入,不是下载到 Agent 的包。服务端在创建 SuiteVersion 时解析并保存精确 `skillVersionId` 和 fingerprint。一个 SuiteVersion 最多包含 100 个不同 Skill同一 Skill 不允许重复出现。
该文件不是下载到 Agent 的包。v1 通过 Web/API 提交等价字段CLI 导入 `suite.yaml` 属于后续增量能力。服务端在创建 SuiteVersion 时解析并保存精确 `skillVersionId` 和 fingerprint。一个 SuiteVersion 最多包含 100 个不同 Skill同一 Skill 不允许重复出现。
SuiteVersion 同时保存短 `summary` 和可选的 Markdown `overview``summary` 用于搜索卡片和详情页首屏摘要;`overview` 用于说明成员组合方式、推荐顺序、输入输出和使用边界,并作为审核快照的一部分随 SuiteVersion 冻结。详情页将概述与成员列表分开呈现,成员列表批量解析实时展示名称和摘要,但仅向有权读取该 Skill 的当前查看者返回;安装与审计仍以快照坐标、精确版本和 fingerprint 为准,受限或硬删除成员只保留不可点击且不含实时元数据的历史快照。
成员发布新版本不会改变已有 SuiteVersion。采用新版本、添加、删除、重排成员或修改 Entry Skill 都必须创建新的 SuiteVersion。
为了降低创作成本Web/CLI 在添加 Member 时默认推荐该 Skill 当前可安装的最新版本,但保存时立即解析为精确 `skillVersionId`、version 和 fingerprint并向作者展示实际固定的版本。作者可以显式选择其他仍处于 PUBLISHED 的历史版本。
为了降低创作成本Web 在添加 Member 时默认推荐该 Skill 当前可安装的最新版本,但保存时立即解析为精确 `skillVersionId`、version 和 fingerprint并向作者展示实际固定的版本。作者可以显式选择其他仍处于 PUBLISHED 的历史版本。
不得在已发布 SuiteVersion 中保存 `latest` 或在安装时重新解析最新版本。可以提供“更新成员版本”辅助操作,但该操作必须先展示版本差异,并创建或修改 DRAFT SuiteVersion它不是后台自动升级。
@ -174,6 +179,8 @@ Suite 容器复用 `ACTIVE/ARCHIVED` 和独立的 hidden 治理覆盖。Suite
这意味着 Suite 是成员 Skill 的“版本化清单”,不是它们的父生命周期。删除 Suite 不删除 Skill更新 Skill 不更新 Suite更新 Suite 也不重新发布 Skill。
Suite 删除是容器级硬删除:与 Skill 硬删除保持一致,删除其版本、成员关系和 Suite 审核任务,避免审核中心保留无法解析的多态目标;`DELETE_SKILL_SUITE` 审计记录独立保留。该规则不影响 REJECTED Suite 在删除前保留并查询全部审核轮次。
### 5. 审核任务支持类型化目标
现有 `review_task` 只指向 SkillVersion。为了保留一个审核中心和一致权限规则将其扩展为类型化审核目标
@ -287,6 +294,8 @@ inventory schema 增加 `suites`,并让 Skill 安装目标记录来源集合
新增的类型化资源发现入口返回 `resourceType`Web 使用类型徽标及独立 `/skills/...``/suites/...` 页面。现有 Skill 搜索接口继续只返回 Skill避免旧 CLI 或第三方客户端把 Suite 响应按 Skill 反序列化。Suite 详情显示版本、精确成员、Entry Skill、可用状态和阻塞原因。
Suite 详情响应同时返回服务端计算的管理能力Web 不自行推断 Namespace 角色。作者可编辑草稿、显式重开被拒版本并基于已发布快照创建新版本Namespace 管理员还可下架版本、隐藏、归档和删除 Suite。后端继续对每个命令独立鉴权响应能力只用于正确展示入口不作为安全边界。
`suite check` 比较 inventory 快照、磁盘 fingerprint 和远端 SuiteVersion`suite upgrade` 先显示成员增删改计划,再使用与安装相同的原子流程应用新的精确 SuiteVersion。升级不会单独追随 Member 的最新版本。
### 11. 生命周期兼容通过隔离状态与能力协商保证
@ -311,7 +320,7 @@ Server 和 CLI 按以下组合兼容:
SuiteVersion 被拒绝后允许由管理者退回 DRAFT保留原审核记录并修改未发布版本后再次提交。PUBLISHED/YANKED SuiteVersion 永远不可编辑;这些版本的任何变化都创建新 SuiteVersion。每次重新提交创建新的审核轮次不覆盖旧决定。
一次 Suite 安装计划使用服务端生成的唯一 `operationId` 串联一条 Suite 安装计划审计和多条 Member 下载审计。服务端成功签发完整安装计划后Suite 安装请求数增加一次;计划内每个 Member SkillVersion 按现有下载统计口径增加一次,并标记 `source=SUITE`。同一 `operationId` 的安全重试不得重复计数
CLI 为一次安装计划生成独立的 `Idempotency-Key`,网络重试复用该 key服务端按调用者隔离 retry key并生成唯一 `operationId` 串联 Suite 安装计划及 Member 下载意图审计。成功签发完整计划后仅增加一次 Suite 安装请求数,不预增 Member 下载数。Member 继续由现有 Skill 下载接口按实际请求计数,避免计划签发和文件下载对同一 Member 重复计数。服务端保留 retry-key 映射 24 小时,并复用现有幂等清理任务删除过期映射,避免无界增长
服务端无法可靠知道 CLI 最终是否完成本地文件提交,因此该指标表示“安装计划/下载已签发”不宣称是本地安装成功数。CLI 后续校验或提交失败不反向扣减服务端计数v1 不增加客户端完成回调或遥测上报。
@ -329,7 +338,8 @@ SuiteVersion 被拒绝后允许由管理者退回 DRAFT保留原审核记录
## Migration Plan
1. 新增 Suite 三张表、索引和类型化审核字段;回填现有审核任务为 `SKILL_VERSION`
2. 先发布兼容旧 API 的 Server新表为空时现有行为不变。
2. 先发布兼容旧 API 的 Server新表为空时现有行为不变。单实例 Compose 和本地 profile 默认开放
Suite 审核;未知或多实例部署保持 fail-closed并在确认所有实例升级后显式开放。
3. 发布 Web 的 Suite 管理和类型化审核展示,重新生成 OpenAPI 类型。
4. 发布支持 Suite 和新版 inventory 的 CLI读取旧 inventory 时将缺少的 `suites``installedBy` 视为空。
5. 用本地 exact-SHA 镜像验证 Skill 正常流、Suite 生命周期、成员失效和整组回滚。

View file

@ -11,7 +11,7 @@ SkillHub 当前只能逐个发布和安装 Skill无法把一组已经发布
- 新增 `skillhub suite install/check/upgrade/remove`,成员继续安装为标准 Agent SkillSuite 本身不生成同名 `SKILL.md`
- Suite 安装采用完整预检、全部暂存、fingerprint 校验和整体提交/回滚,避免部分安装。
- CLI inventory 记录 Suite 快照以及每个成员的直接安装和 Suite 来源,安全处理共享成员的卸载。
- 新增人类可编辑的 `suite.yaml`,仅作为 Suite 定义和发布输入,不改变现有单 Skill ZIP 协议。
- 定义人类可编辑的 `suite.yaml` 交换格式,为后续 CLI 导入预留稳定边界v1 仍通过 Web/API 创作,不改变现有单 Skill ZIP 协议。
## Decision Relative to Issue #715

View file

@ -360,6 +360,37 @@ Suite SHALL 有独立 API 和 Web URL。新的类型化资源发现结果中
- **WHEN** 授权用户通过 Suite 专用接口解析某个版本
- **THEN** 响应包含 SuiteVersion 身份以及有序的精确 Member 版本、fingerprint 和可下载状态
### Requirement: Suite authors SHALL have a complete Web management flow
Web SHALL expose only the Suite actions authorized by the Server. An authorized author SHALL be
able to create and inspect a Suite, edit a DRAFT, reopen a REJECTED version before editing, and
create a new immutable version from a published snapshot. Namespace administrators SHALL additionally
be able to yank a published version, hide or restore discovery, archive or restore the Suite container,
and delete a Suite when no review is pending. These actions SHALL NOT modify Member Skills.
#### Scenario: Author manages editable and immutable versions
- **WHEN** an authorized author opens a DRAFT, REJECTED, PUBLISHED, or YANKED SuiteVersion
- **THEN** Web shows only actions permitted for that actor and state
- **AND** REJECTED is explicitly reopened before editing
- **AND** PUBLISHED and YANKED versions remain immutable and changes create a new version
#### Scenario: Administrator governs or deletes a Suite
- **WHEN** a Namespace administrator yanks, hides, restores, archives, unarchives, or deletes a Suite
- **THEN** Web requires confirmation for destructive container or publication actions
- **AND** a yank requires an audit reason
- **AND** deletion is unavailable while a review is pending
- **AND** hard deletion removes Suite-owned review tasks while retaining the deletion audit record
- **AND** no Member Skill lifecycle or content changes
#### Scenario: Public reader views a manageable Suite
- **WHEN** an unauthenticated or unauthorized reader opens a public Suite detail page
- **THEN** Web shows the Suite summary and its author-provided Markdown overview as separate information levels
- **AND** Web shows every ordered Member as a Skill card with its exact pinned version, Entry Skill marker, and current availability
- **AND** an available Member exposes live display metadata and links to Skill detail only when the current viewer can read that Skill
- **AND** a viewer-restricted or deleted Member remains a non-navigable snapshot without exposing live display metadata
- **AND** Web does not display edit, version creation, governance, or deletion controls
- **AND** Server authorization remains the enforcement boundary
### Requirement: Suite operations SHALL be authorized and audited
Suite 创建、编辑、提交、审核、发布、下架、隐藏、恢复、归档和删除 SHALL 使用现有 Namespace 与平台角色原则,并 SHALL 产生包含 Suite 类型、Suite ID、SuiteVersion ID、操作者和变更摘要的审计记录。
@ -413,16 +444,21 @@ REJECTED SuiteVersion MAY 由有权限的管理者退回 DRAFT、修改并重新
- **THEN** 系统拒绝修改
- **AND** 提示创建新的 SuiteVersion
### Requirement: Suite download metrics SHALL remain attributable and idempotent
### Requirement: Suite plan and Member download metrics SHALL remain attributable and idempotent
一次 Suite 安装计划 SHALL 使用服务端生成的唯一 operation ID 关联 Suite 请求与 Member 下载。服务端成功签发完整安装计划后SHALL 记录一次 Suite 安装请求,并 SHALL 按现有下载口径为计划内每个 Member SkillVersion 记录一次来源为 SUITE 的下载。同一 operation ID 的重试 SHALL NOT 重复计数。该指标 SHALL 表示服务端计划/下载签发,不得标记为 CLI 本地安装成功。
客户端 SHALL 为一次安装计划生成独立的 idempotency key并在安全重试时复用服务端 SHALL 按调用者隔离该 key并生成 operation ID 关联该计划的审计记录。服务端成功签发完整计划后 SHALL 记录一次 Suite 安装请求,但 SHALL NOT 在此时预增 Member 下载数。每个 Member 继续通过现有 Skill 下载接口按实际下载请求计数,避免计划签发与文件下载对同一 Member 重复计数。这些指标表示服务端计划签发和实际下载请求,不表示 CLI 本地安装成功。
#### Scenario: Issue a complete Suite install plan
- **WHEN** 服务端完成 Suite 和全部 Member 的权限、状态及可下载性预检并签发完整安装计划
- **THEN** Suite 安装请求数增加一次
- **AND** 每个计划内 Member SkillVersion 下载数按现有口径增加一次并记录 Suite 来源
- **AND** 此时不增加 Member SkillVersion 下载数
- **AND** 相关审计记录共享同一个 operation ID
#### Scenario: Download an exact Member from the issued plan
- **WHEN** CLI 使用安装计划中的下载地址请求某个精确 Member SkillVersion
- **THEN** 现有 Skill 下载接口按原有口径记录一次该 Member 的下载
- **AND** 同一 Member 不因此前签发安装计划而重复计数
#### Scenario: Suite plan preflight fails
- **WHEN** 服务端因权限、状态或成员不可用而无法签发完整安装计划
- **THEN** 不增加 Suite 安装请求数
@ -430,13 +466,15 @@ REJECTED SuiteVersion MAY 由有权限的管理者退回 DRAFT、修改并重新
#### Scenario: Local installation fails after plan issuance
- **WHEN** CLI 在服务端签发计划后因下载、校验或文件提交失败并回滚
- **THEN** 服务端已记录的计划和下载计数保持不变
- **THEN** 服务端已记录的计划计数保持不变
- **AND** 仅实际发出的 Member 下载请求按现有口径保留计数
- **AND** 系统不将这些计数描述为本地安装成功数
#### Scenario: Retry an already recorded operation
- **WHEN** 客户端使用相同 operation ID 安全重试已经记录成功的安装
- **THEN** 系统返回已有计划或幂等成功
- **AND** Suite 和 Member 统计不重复增加
#### Scenario: Retry an already issued plan
- **WHEN** 客户端因网络或响应读取失败,使用相同 idempotency key 重试安装计划请求
- **THEN** 服务端返回同一 operation ID 对应的计划或幂等成功
- **AND** Suite 安装请求数和审计记录不重复增加
- **AND** 该重试保证至少覆盖服务端约定的 24 小时幂等窗口
### Requirement: Existing Skill workflows SHALL remain compatible
@ -490,6 +528,11 @@ Suite 能力 SHALL 以增量方式提供。旧 CLI 使用新 Server 时 SHALL
- **THEN** 新版本应用仍按原 SkillVersion 读取和处理该任务
- **AND** 其审核决定、权限和审计语义保持不变
#### Scenario: Enable Suite review in a non-overlapping deployment
- **WHEN** 用户通过官方单实例 Compose 或本地 profile 运行 Server
- **THEN** Suite 审核写入默认可用
- **AND** 用户无需修改环境变量或数据库才能提交 Suite 审核
#### Scenario: Mixed application versions during rollout
- **WHEN** 部署期间同时存在支持和不支持 Suite subject 的应用实例
- **THEN** 现有 Skill 审核流程保持可用

View file

@ -1,49 +1,52 @@
## 1. Persistence and domain model
- [ ] 1.1 Add Flyway migrations for `skill_suite`, `skill_suite_version`, and `skill_suite_version_member`, including per-type slug uniqueness, version uniqueness, version-level visibility, ordering, snapshot fields, indexes, and `ON DELETE SET NULL` member references.
- [ ] 1.2 Implement Suite aggregate entities, statuses, repositories, package boundaries, and domain invariants for 100-member limits, exact versions, duplicate detection, and Entry Skill membership.
- [ ] 1.3 Add focused repository and domain tests for same-slug Skill/Suite coexistence, immutable published versions, hard-deleted member snapshots, and latest-version recalculation.
- [ ] 1.4 Implement computed Suite availability and blocking reasons without adding degraded to the persisted SuiteVersion lifecycle enum.
- [x] 1.1 Add Flyway migrations for `skill_suite`, `skill_suite_version`, and `skill_suite_version_member`, including per-type slug uniqueness, version uniqueness, version-level visibility, ordering, snapshot fields, indexes, and `ON DELETE SET NULL` member references.
- [x] 1.2 Implement Suite aggregate entities, statuses, repositories, package boundaries, and domain invariants for 100-member limits, exact versions, duplicate detection, and Entry Skill membership.
- [x] 1.3 Add focused repository and domain tests for same-slug Skill/Suite coexistence, immutable published versions, hard-deleted member snapshots, and latest-version recalculation.
- [x] 1.4 Implement computed Suite availability and blocking reasons without adding degraded to the persisted SuiteVersion lifecycle enum.
## 2. Lifecycle, authorization, and review
- [ ] 2.1 Implement Suite draft, submit, approve, reject, direct-private-publish, yank, hide, restore, archive, and delete workflows without Member lifecycle side effects.
- [ ] 2.2 Generalize review tasks to typed subjects, backfill existing rows as `SKILL_VERSION`, and preserve all existing Skill review behavior and queries.
- [ ] 2.3 Reuse Namespace/platform authorization rules and add Suite-specific audit events for every material lifecycle action.
- [ ] 2.4 Add tests covering roles, self-review rules, member eligibility revalidation at submit/approve/publish, visibility compatibility, namespace freeze/archive, and non-cascading governance.
- [ ] 2.5 Add a rollout compatibility gate so Suite review writes are enabled only after all active application versions support typed review subjects.
- [ ] 2.6 Implement rejected-to-draft resubmission with immutable review rounds, and prevent published/yanked version edits.
- [x] 2.1 Implement Suite draft, submit, approve, reject, direct-private-publish, yank, hide, restore, archive, and delete workflows without Member lifecycle side effects.
- [x] 2.2 Generalize review tasks to typed subjects, backfill existing rows as `SKILL_VERSION`, and preserve all existing Skill review behavior and queries.
- [x] 2.3 Reuse Namespace/platform authorization rules and add Suite-specific audit events for every material lifecycle action.
- [x] 2.4 Add tests covering roles, self-review rules, member eligibility revalidation at submit/approve/publish, visibility compatibility, namespace freeze/archive, and non-cascading governance.
- [x] 2.5 Add a rollout compatibility gate so Suite review writes are enabled only after all active application versions support typed review subjects.
- [x] 2.6 Implement rejected-to-draft resubmission with immutable review rounds, and prevent published/yanked version edits.
## 3. Server API and search
- [ ] 3.1 Add transport-only Suite controllers and application services for management, version history, review actions, detail, and typed resolution of install plans.
- [ ] 3.2 Add a typed resource discovery projection with `resourceType` and Suite metadata while keeping the existing Skill search endpoint Skill-only.
- [ ] 3.3 Return ordered Member snapshots, Entry Skill, availability, and degraded reasons without N+1 member resolution.
- [ ] 3.4 Regenerate `web/src/api/generated/schema.d.ts` with `make generate-api` and run the OpenAPI drift check.
- [ ] 3.5 Record idempotent Suite-plan and Member-download audit/statistics using a shared operation ID, preserving the existing server-side download-count semantics.
- [ ] 3.6 Add a server-filtered Member candidate query scoped by caller access, Suite Namespace, target visibility, current installability, and exact versions.
- [x] 3.1 Add transport-only Suite controllers and application services for management, version history, review actions, detail, and typed resolution of install plans.
- [x] 3.2 Add a typed resource discovery projection with `resourceType` and Suite metadata while keeping the existing Skill search endpoint Skill-only.
- [x] 3.3 Return ordered Member snapshots, Entry Skill, availability, and degraded reasons without N+1 member resolution.
- [x] 3.4 Regenerate `web/src/api/generated/schema.d.ts` with `make generate-api` and run the OpenAPI drift check.
- [x] 3.5 Record idempotent Suite-plan statistics with a client retry key and server operation ID; keep Member counters on actual existing download requests.
- [x] 3.6 Add a server-filtered Member candidate query scoped by caller access, Suite Namespace, target visibility, current installability, and exact versions.
## 4. Web experience
- [ ] 4.1 Add typed Skill/Suite search cards and independent Suite list/detail/version routes.
- [ ] 4.2 Add Suite creation and draft editing with the server-filtered Member picker, exact published versions, ordering, visibility, and optional Entry Skill.
- [ ] 4.3 Extend the review center with typed Suite review details and ensure existing Skill review actions remain unchanged.
- [ ] 4.4 Add install instructions using `skillhub suite install`, degraded-member explanations, and responsive/error/loading/empty states.
- [ ] 4.5 Default Member selection to the current installable version, display the pinned exact version, and provide an explicit version-diff update action for drafts.
- [x] 4.1 Add typed Skill/Suite search cards and independent Suite list/detail/version routes.
- [x] 4.2 Add Suite creation and draft editing with the server-filtered Member picker, exact published versions, ordering, visibility, and optional Entry Skill.
- [x] 4.3 Extend the review center with typed Suite review details and ensure existing Skill review actions remain unchanged.
- [x] 4.4 Add install instructions using `skillhub suite install`, degraded-member explanations, and responsive/error/loading/empty states.
- [x] 4.5 Default Member selection to the current installable version, display the pinned exact version, and provide an explicit version-diff update action for drafts.
- [x] 4.6 Complete Web management for new versions, rejected-version reopen, yank, hide/restore, archive/unarchive, and guarded deletion using Server-derived capabilities.
- [x] 4.7 Add a versioned Markdown overview and browsable Member Skill cards with display metadata, pinned versions, Entry markers, and tombstone handling.
## 5. CLI and local lifecycle
- [ ] 5.1 Add `skillhub suite install/check/upgrade/remove` and a typed Suite resolver without changing `skillhub install` resolution.
- [ ] 5.2 Extend the existing staged installer to preflight, download, fingerprint-check, lock, commit, and roll back all Members and selected Agent targets as one operation.
- [ ] 5.3 Extend inventory with backward-compatible Suite snapshots and multi-source `installedBy` provenance.
- [ ] 5.4 Implement safe Suite removal that preserves direct-installed, shared, unknown-source, or locally modified Member directories.
- [ ] 5.5 Add CLI tests for same-slug Skill/Suite, missing permissions, unavailable members, checksum failure, disk/rename failure, incomplete rollback reporting, shared members, legacy inventory, and multi-Agent targets.
- [x] 5.1 Add `skillhub suite install/check/upgrade/remove` and a typed Suite resolver without changing `skillhub install` resolution.
- [x] 5.2 Extend the existing staged installer to preflight, download, fingerprint-check, lock, commit, and roll back all Members and selected Agent targets as one operation.
- [x] 5.3 Extend inventory with backward-compatible Suite snapshots and multi-source `installedBy` provenance.
- [x] 5.4 Implement safe Suite removal that preserves direct-installed, shared, unknown-source, or locally modified Member directories.
- [x] 5.5 Add CLI tests for same-slug Skill/Suite, missing permissions, unavailable members, checksum failure, disk/rename failure, incomplete rollback reporting, shared members, legacy inventory, and multi-Agent targets.
- [ ] 5.6 Add Server capability detection and old-Server/new-CLI plus new-Server/old-CLI compatibility tests.
## 6. Documentation and validation
- [ ] 6.1 Document `suite.yaml`, Suite/Skill terminology, typed coordinates, lifecycle boundaries, CLI commands, compatibility, and operator limits.
- [ ] 6.2 Run targeted backend tests, `make test-backend-app`, frontend unit/type/lint checks, CLI tests/build, and OpenAPI drift validation.
- [ ] 6.3 Build exact-SHA local Server/Web images and run authenticated Compose smoke tests for ordinary Skill and Suite flows.
- [ ] 6.4 Execute the OpenSpec scenario matrix, including normal flow, same-slug compatibility, lifecycle independence, degraded members, authorization, atomic failure/recovery, upgrade, removal, rolling review migration, legacy inventory, and old/new Server/CLI combinations.
- [ ] 6.5 Complete independent implementation review, manual Web/CLI retest instructions, privacy/readiness checks, and the Chinese merge-readiness report before requesting merge authorization.
- [x] 6.1 Document the reserved `suite.yaml` interchange format, Suite/Skill terminology, typed coordinates, lifecycle boundaries, CLI commands, compatibility, and operator limits.
- [x] 6.2 Run targeted backend tests, `make test-backend-app`, frontend unit/type/lint checks, CLI tests/build, and OpenAPI drift validation.
- [x] 6.3 Build exact-SHA local Server/Web images and run authenticated Compose smoke tests for ordinary Skill and Suite flows.
- [x] 6.4 Execute the Server/Web OpenSpec scenario matrix, including normal flow, same-slug compatibility, lifecycle independence, degraded members, authorization, review-history retention, transactional rollback, rolling review migration, and non-cascading deletion.
- [ ] 6.5 Execute the deferred CLI scenario matrix, including atomic failure/recovery, upgrade, removal, legacy inventory, and old/new Server/CLI combinations.
- [ ] 6.6 Complete independent implementation review, manual Web retest instructions, privacy/readiness checks, and the Chinese merge-readiness report before requesting merge authorization. Additional CLI compatibility work remains deferred under 5.6 and 6.5.

259
scripts/suite-smoke-test.sh Executable file
View file

@ -0,0 +1,259 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${1:-http://localhost:8080}"
COOKIE_FILE="$(mktemp)"
WORK_DIR="$(mktemp -d)"
TOKEN="$(date +%s)${RANDOM}"
SKILL_NAME="suite-smoke-member-${TOKEN}"
SUITE_SLUG="suite-smoke-${TOKEN}"
SKILL_ID=""
SKILL_VERSION_ID=""
SUITE_ID=""
SMOKE_ADMIN_USERNAME="${SMOKE_ADMIN_USERNAME:-}"
SMOKE_ADMIN_PASSWORD="${SMOKE_ADMIN_PASSWORD:-}"
AUTH_HEADERS=()
json_field() {
JSON_INPUT="$1" python3 - "$2" <<'PY'
import json
import os
import sys
value = json.loads(os.environ["JSON_INPUT"])
for part in sys.argv[1].split("."):
value = value[int(part)] if part.isdigit() else value[part]
print(json.dumps(value, ensure_ascii=False) if isinstance(value, (dict, list)) else value)
PY
}
assert_code() {
local description="$1"
local body="$2"
local expected="$3"
local actual
actual="$(json_field "$body" code)"
if [[ "$actual" != "$expected" ]]; then
echo "FAIL: $description (expected code $expected, got $actual)"
exit 1
fi
echo "PASS: $description"
}
cleanup() {
if [[ -n "$SUITE_ID" && -n "${CSRF_TOKEN:-}" ]]; then
curl -sS -o /dev/null -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-X DELETE "$BASE_URL/api/web/suites/$SUITE_ID" || true
fi
if [[ -n "$SKILL_ID" && -n "${CSRF_TOKEN:-}" ]]; then
curl -sS -o /dev/null -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-X DELETE "$BASE_URL/api/v1/skills/id/$SKILL_ID" || true
fi
rm -f "$COOKIE_FILE"
rm -rf "$WORK_DIR"
}
trap cleanup EXIT
echo "=== Skill Suite Smoke Test ==="
echo "Target: $BASE_URL"
echo "Suite: @global/$SUITE_SLUG"
if [[ -n "$SMOKE_ADMIN_USERNAME" || -n "$SMOKE_ADMIN_PASSWORD" ]]; then
if [[ -z "$SMOKE_ADMIN_USERNAME" || -z "$SMOKE_ADMIN_PASSWORD" ]]; then
echo "FAIL: SMOKE_ADMIN_USERNAME and SMOKE_ADMIN_PASSWORD must be set together"
exit 1
fi
curl -sS -c "$COOKIE_FILE" "$BASE_URL/api/v1/auth/me" >/dev/null
else
AUTH_HEADERS=(-H "X-Mock-User-Id: local-admin")
curl -sS -c "$COOKIE_FILE" "${AUTH_HEADERS[@]}" \
"$BASE_URL/api/v1/auth/providers" >/dev/null
fi
CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_FILE" | tail -n 1)"
if [[ -z "$CSRF_TOKEN" ]]; then
echo "FAIL: could not bootstrap CSRF token"
exit 1
fi
if [[ -n "$SMOKE_ADMIN_USERNAME" ]]; then
LOGIN_PAYLOAD="$(SMOKE_ADMIN_USERNAME="$SMOKE_ADMIN_USERNAME" SMOKE_ADMIN_PASSWORD="$SMOKE_ADMIN_PASSWORD" \
python3 - <<'PY'
import json
import os
print(json.dumps({
"username": os.environ["SMOKE_ADMIN_USERNAME"],
"password": os.environ["SMOKE_ADMIN_PASSWORD"],
}))
PY
)"
LOGIN_STATUS="$(curl -sS -o "$WORK_DIR/login.json" -w '%{http_code}' \
-b "$COOKIE_FILE" -c "$COOKIE_FILE" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Content-Type: application/json" -X POST \
"$BASE_URL/api/v1/auth/local/login" -d "$LOGIN_PAYLOAD")"
if [[ "$LOGIN_STATUS" != "200" ]]; then
echo "FAIL: local administrator login returned HTTP $LOGIN_STATUS"
exit 1
fi
CSRF_TOKEN="$(awk '$6 == "XSRF-TOKEN" { print $7 }' "$COOKIE_FILE" | tail -n 1)"
echo "PASS: authenticated with the local administrator account"
else
echo "PASS: authenticated with the local mock administrator"
fi
cat > "$WORK_DIR/SKILL.md" <<EOF
---
name: $SKILL_NAME
description: Temporary member for the Skill Suite smoke test
version: 1.0.0
---
# Suite smoke member
EOF
python3 - "$WORK_DIR" <<'PY'
from pathlib import Path
import sys
import zipfile
root = Path(sys.argv[1])
with zipfile.ZipFile(root / "member.zip", "w", zipfile.ZIP_DEFLATED) as archive:
archive.write(root / "SKILL.md", "SKILL.md")
PY
PUBLISH_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-F "file=@$WORK_DIR/member.zip;type=application/zip" -F "visibility=PUBLIC" \
"$BASE_URL/api/web/skills/global/publish")"
assert_code "publish the temporary member Skill" "$PUBLISH_RESPONSE" 0
SKILL_ID="$(json_field "$PUBLISH_RESPONSE" data.skillId)"
SKILL_SLUG="$(json_field "$PUBLISH_RESPONSE" data.slug)"
SKILL_DETAIL=""
for _ in $(seq 1 60); do
SKILL_DETAIL="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" "$BASE_URL/api/web/skills/global/$SKILL_SLUG")"
SKILL_VERSION_ID="$(JSON_INPUT="$SKILL_DETAIL" python3 - <<'PY'
import json
import os
data = json.loads(os.environ["JSON_INPUT"]).get("data") or {}
versions = [data.get("headlineVersion") or {}, data.get("ownerPreviewVersion") or {}]
match = next((item for item in versions if item.get("version") == "1.0.0" and item.get("status") == "PUBLISHED"), {})
print(match.get("id", ""))
PY
)"
[[ -n "$SKILL_VERSION_ID" ]] && break
sleep 1
done
if [[ -z "$SKILL_VERSION_ID" ]]; then
echo "FAIL: member Skill did not become PUBLISHED within 60 seconds"
exit 1
fi
echo "PASS: member Skill is published and downloadable"
SUITE_PAYLOAD="$(python3 - "$SUITE_SLUG" "$SKILL_SLUG" <<'PY'
import json
import sys
member = {"namespace": "global", "slug": sys.argv[2], "version": "1.0.0"}
print(json.dumps({
"namespace": "global",
"slug": sys.argv[1],
"displayName": "Suite smoke test",
"summary": "Temporary private Suite",
"version": "1.0.0",
"visibility": "PRIVATE",
"changelog": "Initial smoke version",
"entrySkill": member,
"members": [member],
}))
PY
)"
CREATE_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Content-Type: application/json" -X POST "$BASE_URL/api/web/suites" \
-d "$SUITE_PAYLOAD")"
assert_code "create a Suite draft with one exact member" "$CREATE_RESPONSE" 0
SUITE_ID="$(json_field "$CREATE_RESPONSE" data.id)"
SUITE_VERSION_ID="$(json_field "$CREATE_RESPONSE" data.versionId)"
PUBLISH_SUITE_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-X POST "$BASE_URL/api/web/suites/$SUITE_ID/versions/$SUITE_VERSION_ID/publish")"
assert_code "publish the private Suite directly" "$PUBLISH_SUITE_RESPONSE" 0
MY_SUITES_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" "$BASE_URL/api/web/me/suites?q=$SUITE_SLUG")"
assert_code "discover the published Suite in the owner dashboard" "$MY_SUITES_RESPONSE" 0
JSON_INPUT="$MY_SUITES_RESPONSE" python3 - "$SUITE_SLUG" <<'PY'
import json
import os
import sys
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
raise SystemExit(0 if any(item["slug"] == sys.argv[1] and item["versionStatus"] == "PUBLISHED" for item in items) else 1)
PY
echo "PASS: owner dashboard contains the published Suite"
IDEMPOTENCY_KEY="suite-smoke-$TOKEN"
PLAN_ONE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" -X POST \
"$BASE_URL/api/web/suites/global/$SUITE_SLUG/install-plan?version=1.0.0")"
assert_code "issue an exact-member install plan" "$PLAN_ONE" 0
PLAN_TWO="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" -X POST \
"$BASE_URL/api/web/suites/global/$SUITE_SLUG/install-plan?version=1.0.0")"
assert_code "safely replay the install plan" "$PLAN_TWO" 0
if [[ "$(json_field "$PLAN_ONE" data.operationId)" != "$(json_field "$PLAN_TWO" data.operationId)" ]]; then
echo "FAIL: replayed plan returned a different operation ID"
exit 1
fi
echo "PASS: replayed plan keeps the server operation ID"
RESOURCE_RESPONSE="$(curl -sS "$BASE_URL/api/v1/resources?resourceType=SKILL&q=$SKILL_SLUG")"
assert_code "ordinary Skill discovery remains available" "$RESOURCE_RESPONSE" 0
JSON_INPUT="$RESOURCE_RESPONSE" python3 - "$SKILL_SLUG" <<'PY'
import json
import os
import sys
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
raise SystemExit(0 if any(item["resourceType"] == "SKILL" and item["slug"] == sys.argv[1] for item in items) else 1)
PY
echo "PASS: typed discovery still returns the ordinary Skill"
YANK_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
-H "Content-Type: application/json" -X POST \
"$BASE_URL/api/v1/admin/skills/versions/$SKILL_VERSION_ID/yank" -d '{"reason":"suite smoke"}')"
assert_code "yank the exact member version" "$YANK_RESPONSE" 0
DEGRADED_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
"${AUTH_HEADERS[@]}" \
"$BASE_URL/api/web/suites/global/$SUITE_SLUG?version=1.0.0")"
assert_code "load the degraded Suite snapshot" "$DEGRADED_RESPONSE" 0
JSON_INPUT="$DEGRADED_RESPONSE" python3 - <<'PY'
import json
import os
data = json.loads(os.environ["JSON_INPUT"])["data"]
reasons = {member.get("blockingReason") for member in data["members"]}
raise SystemExit(0 if data["available"] is False and "VERSION_UNAVAILABLE" in reasons else 1)
PY
echo "PASS: an unavailable member degrades the Suite without changing its snapshot"
HTTP_RESULT="$(curl -sS -o "$WORK_DIR/blocked-plan.json" -w '%{http_code}' \
-b "$COOKIE_FILE" -c "$COOKIE_FILE" "${AUTH_HEADERS[@]}" \
-H "X-XSRF-TOKEN: $CSRF_TOKEN" -H "Idempotency-Key: blocked-$TOKEN" -X POST \
"$BASE_URL/api/web/suites/global/$SUITE_SLUG/install-plan?version=1.0.0")"
if [[ "$HTTP_RESULT" != "400" ]]; then
echo "FAIL: degraded Suite install plan should return HTTP 400, got $HTTP_RESULT"
exit 1
fi
echo "PASS: degraded Suite cannot issue a new install plan"
echo "=== Skill Suite Smoke Test Passed ==="

View file

@ -5,6 +5,7 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.List;
/**
* Serves well-known compatibility metadata used by external clients to discover the API base.
@ -13,12 +14,15 @@ import java.util.Map;
public class WellKnownController {
@GetMapping("/.well-known/clawhub.json")
public Map<String, String> clawhubConfig(HttpServletRequest request) {
public Map<String, Object> clawhubConfig(HttpServletRequest request) {
// Honor the deployment sub-path so CLI clients discover /<prefix>/api/v1 instead of
// the domain-root /api/v1. With forward-headers-strategy=framework, an upstream
// X-Forwarded-Prefix is reflected into the request context path.
String contextPath = request.getContextPath();
String prefix = (contextPath == null) ? "" : contextPath;
return Map.of("apiBase", prefix + "/api/v1");
return Map.of(
"apiBase", prefix + "/api/v1",
"capabilities", List.of("skill-suite-v1")
);
}
}

View file

@ -0,0 +1,45 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.MySkillSuiteSummaryResponse;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.service.SkillSuiteAppService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** Current-user dashboard transport for manageable Suite drafts and published versions. */
@RestController
@Tag(name = "My Skill Suites")
@RequestMapping({"/api/v1/me/suites", "/api/web/me/suites"})
public class MySkillSuiteController extends BaseApiController {
private final SkillSuiteAppService appService;
public MySkillSuiteController(SkillSuiteAppService appService, ApiResponseFactory responseFactory) {
super(responseFactory);
this.appService = appService;
}
@GetMapping
@Operation(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,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles
) {
return ok("response.success.read", appService.listMine(
userId, roles == null ? Map.of() : roles, q, page, size));
}
}

View file

@ -0,0 +1,52 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.ResourceSearchResponse;
import com.iflytek.skillhub.ratelimit.RateLimit;
import com.iflytek.skillhub.service.ResourceDiscoveryAppService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/** Type-explicit discovery endpoint for clients that understand both Skills and Suites. */
@RestController
@Tag(name = "Resource discovery")
@RequestMapping({"/api/v1/resources", "/api/web/resources"})
public class ResourceDiscoveryController extends BaseApiController {
private final ResourceDiscoveryAppService appService;
public ResourceDiscoveryController(
ResourceDiscoveryAppService appService,
ApiResponseFactory responseFactory
) {
super(responseFactory);
this.appService = appService;
}
@GetMapping
@Operation(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(
@RequestParam(required = false) String q,
@RequestParam(required = false) String namespace,
@RequestParam(required = false) String resourceType,
@RequestParam(defaultValue = "newest") String sort,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles
) {
return ok("response.success.read", appService.search(
q, namespace, resourceType, sort, page, size,
roles == null ? java.util.Set.of() : roles.keySet()));
}
}

View file

@ -100,8 +100,11 @@ public class ReviewController extends BaseApiController {
@PostMapping("/{id}/withdraw")
public ApiResponse<Void> withdrawReview(@PathVariable Long id,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false)
Map<Long, NamespaceRole> userNsRoles,
HttpServletRequest httpRequest) {
governanceWorkflowAppService.withdrawReviewTask(id, userId, AuditRequestContext.from(httpRequest));
governanceWorkflowAppService.withdrawReviewTask(
id, userId, userNsRoles, AuditRequestContext.from(httpRequest));
return ok("response.success.updated", null);
}

View file

@ -0,0 +1,341 @@
package com.iflytek.skillhub.controller.portal;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.dto.ApiResponse;
import com.iflytek.skillhub.dto.ApiResponseFactory;
import com.iflytek.skillhub.dto.MessageResponse;
import com.iflytek.skillhub.dto.SkillSuiteCreateRequest;
import com.iflytek.skillhub.dto.SkillSuiteResponse;
import com.iflytek.skillhub.dto.SkillSuiteReviewRequest;
import com.iflytek.skillhub.dto.SkillSuiteReasonRequest;
import com.iflytek.skillhub.dto.SkillSuiteInstallPlanResponse;
import com.iflytek.skillhub.dto.SkillSuiteMemberCandidateResponse;
import com.iflytek.skillhub.dto.SkillSuiteVersionSummaryResponse;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.service.SkillSuiteAppService;
import com.iflytek.skillhub.ratelimit.RateLimit;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.Set;
import java.util.List;
/** Transport-only endpoints for Suite creation, publication, and review decisions. */
@RestController
@Tag(name = "Skill Suites")
@RequestMapping({"/api/v1/suites", "/api/web/suites"})
public class SkillSuiteController extends BaseApiController {
private final SkillSuiteAppService appService;
public SkillSuiteController(SkillSuiteAppService appService, ApiResponseFactory responseFactory) {
super(responseFactory);
this.appService = appService;
}
@GetMapping("/{namespace}/{slug}")
@Operation(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,
@PathVariable String slug,
@RequestParam(required = false) String version,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal
) {
return ok("response.success.read", appService.getDetail(
namespace, slug, version, userId, roles(roles), platformRoles(principal)));
}
@GetMapping("/{namespace}/{slug}/versions")
@Operation(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,
@PathVariable String slug,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal
) {
return ok("response.success.read", appService.listVersions(
namespace, slug, userId, roles(roles), platformRoles(principal)));
}
@GetMapping("/member-candidates")
@Operation(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,
@RequestParam SkillVisibility visibility,
@RequestParam(required = false) String q,
@RequestParam(defaultValue = "20") int size,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal
) {
return ok("response.success.read", appService.searchCandidates(
suiteNamespace, visibility, q, size, userId, roles(roles), platformRoles(principal)));
}
@PostMapping("/{namespace}/{slug}/install-plan")
@Operation(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(
@PathVariable String namespace,
@PathVariable String slug,
@RequestParam(required = false) String version,
@RequestAttribute(value = "userId", required = false) String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
@RequestHeader(value = "Idempotency-Key", required = false) String clientRequestId,
HttpServletRequest request
) {
return ok("response.success.read", appService.createInstallPlan(
namespace, slug, version, userId, roles(roles), platformRoles(principal),
clientRequestId, request));
}
@PostMapping
@Operation(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,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest
) {
return ok("response.success.created", appService.create(
request, userId, roles(roles), platformRoles(principal), httpRequest));
}
@PostMapping("/{suiteId}/versions")
@Operation(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,
@Valid @RequestBody SkillSuiteCreateRequest request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest
) {
return ok("response.success.created", appService.createVersion(
suiteId, request, userId, roles(roles), platformRoles(principal), httpRequest));
}
@PutMapping("/{suiteId}/versions/{versionId}")
@Operation(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,
@PathVariable Long versionId,
@Valid @RequestBody SkillSuiteCreateRequest request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest httpRequest
) {
return ok("response.success.updated", appService.updateDraft(
suiteId, versionId, request, userId, roles(roles), platformRoles(principal), httpRequest));
}
@PostMapping("/{suiteId}/versions/{versionId}/submit")
@Operation(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,
@PathVariable Long versionId,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request
) {
appService.submitForReview(
suiteId, versionId, userId, roles(roles), platformRoles(principal), request);
return ok("response.success.updated", new MessageResponse("Suite submitted for review"));
}
@PostMapping("/{suiteId}/versions/{versionId}/publish")
@Operation(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,
@PathVariable Long versionId,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request
) {
appService.publishPrivate(
suiteId, versionId, userId, roles(roles), platformRoles(principal), request);
return ok("response.success.updated", new MessageResponse("Suite published"));
}
@PostMapping("/reviews/{reviewTaskId}/approve")
@Operation(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,
@Valid @RequestBody(required = false) SkillSuiteReviewRequest body,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request
) {
appService.approve(
reviewTaskId, body == null ? null : body.comment(), userId,
roles(roles), platformRoles(principal), request);
return ok("response.success.updated", new MessageResponse("Suite review approved"));
}
@PostMapping("/reviews/{reviewTaskId}/reject")
@Operation(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,
@Valid @RequestBody(required = false) SkillSuiteReviewRequest body,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request
) {
appService.reject(
reviewTaskId, body == null ? null : body.comment(), userId,
roles(roles), platformRoles(principal), request);
return ok("response.success.updated", new MessageResponse("Suite review rejected"));
}
@PostMapping("/{suiteId}/versions/{versionId}/reopen")
@Operation(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,
@PathVariable Long versionId,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request
) {
appService.reopen(
suiteId, versionId, userId, roles(roles), platformRoles(principal), request);
return ok("response.success.updated", new MessageResponse("Suite draft reopened"));
}
@PostMapping("/{suiteId}/versions/{versionId}/yank")
@Operation(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,
@PathVariable Long versionId,
@Valid @RequestBody SkillSuiteReasonRequest body,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request
) {
appService.yank(
suiteId, versionId, body.reason(), userId,
roles(roles), platformRoles(principal), request);
return ok("response.success.updated", new MessageResponse("Suite version yanked"));
}
@PostMapping("/{suiteId}/hide")
@Operation(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,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request
) {
appService.setHidden(suiteId, true, userId, roles(roles), platformRoles(principal), request);
return ok("response.success.updated", new MessageResponse("Suite hidden"));
}
@PostMapping("/{suiteId}/restore")
@Operation(summary = "Restore a hidden Suite")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite restored")
public ApiResponse<MessageResponse> restore(
@PathVariable Long suiteId,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request
) {
appService.setHidden(suiteId, false, userId, roles(roles), platformRoles(principal), request);
return ok("response.success.updated", new MessageResponse("Suite restored"));
}
@PostMapping("/{suiteId}/archive")
@Operation(summary = "Archive a Suite container")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Suite archived")
public ApiResponse<MessageResponse> archive(
@PathVariable Long suiteId,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request
) {
appService.setArchived(suiteId, true, userId, roles(roles), platformRoles(principal), request);
return ok("response.success.updated", new MessageResponse("Suite archived"));
}
@PostMapping("/{suiteId}/unarchive")
@Operation(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,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request
) {
appService.setArchived(suiteId, false, userId, roles(roles), platformRoles(principal), request);
return ok("response.success.updated", new MessageResponse("Suite unarchived"));
}
@DeleteMapping("/{suiteId}")
@Operation(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,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
@AuthenticationPrincipal PlatformPrincipal principal,
HttpServletRequest request
) {
appService.delete(suiteId, userId, roles(roles), platformRoles(principal), request);
return ok("response.success.updated", new MessageResponse("Suite deleted"));
}
private Map<Long, NamespaceRole> roles(Map<Long, NamespaceRole> roles) {
return roles == null ? Map.of() : roles;
}
private Set<String> platformRoles(PlatformPrincipal principal) {
return principal == null || principal.platformRoles() == null
? Set.of()
: principal.platformRoles();
}
}

View file

@ -7,6 +7,21 @@ public record GovernanceInboxItemResponse(
String subtitle,
String timestamp,
String namespace,
String skillSlug
String skillSlug,
String resourceType,
String resourceSlug
) {
/** Backward-compatible constructor for existing Skill-centric inbox items. */
public GovernanceInboxItemResponse(
String type,
Long id,
String title,
String subtitle,
String timestamp,
String namespace,
String skillSlug
) {
this(type, id, title, subtitle, timestamp, namespace, skillSlug,
skillSlug == null ? null : "SKILL", skillSlug);
}
}

View file

@ -0,0 +1,20 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
/** Latest manageable version of one Suite shown in the current user's dashboard. */
public record MySkillSuiteSummaryResponse(
Long id,
Long versionId,
String namespace,
String slug,
String displayName,
String summary,
String version,
String versionStatus,
String suiteStatus,
String visibility,
boolean hidden,
Instant updatedAt
) {
}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto;
import java.util.List;
public record ResourceSearchResponse(
List<ResourceSummaryResponse> items,
long total,
int page,
int size
) {
}

View file

@ -0,0 +1,20 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
/** Type-explicit discovery item used by new clients without changing the legacy Skill search API. */
public record ResourceSummaryResponse(
String resourceType,
String detailUrl,
Long id,
String namespace,
String slug,
String displayName,
String summary,
String version,
String visibility,
long installCount,
boolean available,
Instant updatedAt
) {
}

View file

@ -2,9 +2,7 @@ package com.iflytek.skillhub.dto;
import java.time.Instant;
/**
* Author-facing summary for one skill version's review attempts.
*/
/** Author-facing summary for one typed resource version's review attempts. */
public record ReviewProgressResponse(
Long latestReviewTaskId,
Long skillId,
@ -15,5 +13,27 @@ public record ReviewProgressResponse(
String latestReviewComment,
Instant latestSubmittedAt,
Instant latestReviewedAt,
long attemptCount
) {}
long attemptCount,
String subjectType,
Long subjectId,
Long subjectVersionId,
String subjectSlug
) {
/** Backward-compatible constructor for existing Skill-only callers and tests. */
public ReviewProgressResponse(
Long latestReviewTaskId,
Long skillId,
String namespace,
String skillSlug,
String skillVersion,
String latestStatus,
String latestReviewComment,
Instant latestSubmittedAt,
Instant latestReviewedAt,
long attemptCount
) {
this(latestReviewTaskId, skillId, namespace, skillSlug, skillVersion,
latestStatus, latestReviewComment, latestSubmittedAt, latestReviewedAt,
attemptCount, "SKILL_VERSION", skillId, null, skillSlug);
}
}

View file

@ -15,5 +15,31 @@ public record ReviewTaskResponse(
String reviewedByName,
String reviewComment,
Instant submittedAt,
Instant reviewedAt
) {}
Instant reviewedAt,
String subjectType,
Long subjectId,
Long subjectVersionId,
String subjectSlug
) {
/** Backward-compatible constructor for Skill-only callers and tests. */
public ReviewTaskResponse(
Long id,
Long skillVersionId,
String namespace,
String skillSlug,
String version,
String status,
String submittedBy,
String submittedByName,
String reviewedBy,
String reviewedByName,
String reviewComment,
Instant submittedAt,
Instant reviewedAt
) {
this(id, skillVersionId, namespace, skillSlug, version, status,
submittedBy, submittedByName, reviewedBy, reviewedByName,
reviewComment, submittedAt, reviewedAt,
"SKILL_VERSION", null, skillVersionId, skillSlug);
}
}

View file

@ -0,0 +1,25 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.List;
/** Request to create a Suite and its first exact-version draft. */
public record SkillSuiteCreateRequest(
@NotBlank String namespace,
@NotBlank String slug,
@NotBlank @Size(max = 256) String displayName,
@Size(max = 4000) String summary,
@Size(max = 20000) String overview,
@NotBlank String version,
@NotNull SkillVisibility visibility,
@Size(max = 4000) String changelog,
@Valid SkillSuiteMemberRequest entrySkill,
@NotEmpty @Size(max = 100) List<@Valid SkillSuiteMemberRequest> members
) {
}

View file

@ -0,0 +1,15 @@
package com.iflytek.skillhub.dto;
/** One exact downloadable Skill in a Suite install plan. */
public record SkillSuiteInstallMemberResponse(
Long skillId,
Long skillVersionId,
String namespace,
String slug,
String version,
String fingerprint,
String downloadUrl,
int position,
boolean entry
) {
}

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.dto;
import java.util.List;
/** Fully preflighted exact-version plan consumed by the CLI staged installer. */
public record SkillSuiteInstallPlanResponse(
String operationId,
String namespace,
String slug,
String version,
String fingerprint,
List<SkillSuiteInstallMemberResponse> members
) {
}

View file

@ -0,0 +1,16 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
/** One exact published Skill version eligible for the target Suite audience. */
public record SkillSuiteMemberCandidateResponse(
Long skillId,
Long skillVersionId,
String namespace,
String slug,
String displayName,
String version,
SkillVisibility visibility,
boolean recommended
) {
}

View file

@ -0,0 +1,11 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
/** Exact Skill coordinate selected for a Suite draft. */
public record SkillSuiteMemberRequest(
@NotBlank String namespace,
@NotBlank String slug,
@NotBlank String version
) {
}

View file

@ -0,0 +1,22 @@
package com.iflytek.skillhub.dto;
/**
* Immutable member snapshot returned from a Suite draft or published version.
* Live display metadata and browsing permission are viewer-specific; mutation responses therefore
* keep them empty until the client fetches the detail projection.
*/
public record SkillSuiteMemberResponse(
Long skillId,
Long skillVersionId,
String namespace,
String slug,
String displayName,
String summary,
String version,
String fingerprint,
int position,
boolean entry,
boolean browsable,
String blockingReason
) {
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/** Required operator reason for destructive Suite lifecycle actions. */
public record SkillSuiteReasonRequest(
@NotBlank @Size(max = 2000) String reason
) {
}

View file

@ -0,0 +1,27 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.suite.SkillSuiteAllowedAction;
import java.util.List;
import java.util.Set;
/** Suite container and one concrete version snapshot. */
public record SkillSuiteResponse(
Long id,
Long versionId,
String namespace,
String slug,
String displayName,
String summary,
String overview,
String version,
String status,
SkillVisibility visibility,
String suiteStatus,
boolean hidden,
Set<SkillSuiteAllowedAction> allowedActions,
boolean available,
List<SkillSuiteMemberResponse> members
) {
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.Size;
/** Optional reviewer explanation for a Suite review decision. */
public record SkillSuiteReviewRequest(@Size(max = 2000) String comment) {
}

View file

@ -0,0 +1,17 @@
package com.iflytek.skillhub.dto;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import java.time.Instant;
/** Viewer-filtered Suite version history item. */
public record SkillSuiteVersionSummaryResponse(
Long id,
String version,
String status,
SkillVisibility visibility,
Instant publishedAt,
Instant yankedAt,
Instant createdAt
) {
}

View file

@ -5,11 +5,16 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.report.SkillReport;
import com.iflytek.skillhub.domain.review.PromotionRequest;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewSubjectType;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersion;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.dto.GovernanceInboxItemResponse;
@ -32,15 +37,21 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
private final SkillVersionRepository skillVersionRepository;
private final NamespaceRepository namespaceRepository;
private final UserAccountRepository userAccountRepository;
private final SkillSuiteRepository suiteRepository;
private final SkillSuiteVersionRepository suiteVersionRepository;
public JpaGovernanceQueryRepository(SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
NamespaceRepository namespaceRepository,
UserAccountRepository userAccountRepository) {
UserAccountRepository userAccountRepository,
SkillSuiteRepository suiteRepository,
SkillSuiteVersionRepository suiteVersionRepository) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.namespaceRepository = namespaceRepository;
this.userAccountRepository = userAccountRepository;
this.suiteRepository = suiteRepository;
this.suiteVersionRepository = suiteVersionRepository;
}
@Override
@ -111,8 +122,26 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
? Map.of()
: skillRepository.findByIdIn(List.copyOf(skillIds)).stream()
.collect(Collectors.toMap(Skill::getId, Function.identity()));
List<Long> suiteVersionIds = distinct(tasks.stream()
.filter(this::isSuiteReview)
.map(ReviewTask::getSubjectVersionId)
.toList());
Map<Long, SkillSuiteVersion> suiteVersionsById = suiteVersionIds.isEmpty()
? Map.of()
: suiteVersionRepository.findByIdIn(suiteVersionIds).stream()
.collect(Collectors.toMap(SkillSuiteVersion::getId, Function.identity()));
Set<Long> suiteIds = new LinkedHashSet<>(distinct(tasks.stream()
.filter(this::isSuiteReview)
.map(ReviewTask::getSubjectId)
.toList()));
suiteIds.addAll(suiteVersionsById.values().stream().map(SkillSuiteVersion::getSuiteId).toList());
Map<Long, SkillSuite> suitesById = suiteIds.isEmpty()
? Map.of()
: suiteRepository.findByIdIn(List.copyOf(suiteIds)).stream()
.collect(Collectors.toMap(SkillSuite::getId, Function.identity()));
Set<Long> namespaceIds = new LinkedHashSet<>(distinct(
skillsById.values().stream().map(Skill::getNamespaceId).toList()));
namespaceIds.addAll(suitesById.values().stream().map(SkillSuite::getNamespaceId).toList());
namespaceIds.addAll(distinct(tasks.stream().map(ReviewTask::getNamespaceId).toList()));
Map<Long, Namespace> namespacesById = namespaceIds.isEmpty()
? Map.of()
@ -126,7 +155,8 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
? Map.of()
: userAccountRepository.findByIdIn(userIds).stream()
.collect(Collectors.toMap(UserAccount::getId, Function.identity()));
return new ReviewReadBundle(versionsById, skillsById, namespacesById, usersById);
return new ReviewReadBundle(
versionsById, skillsById, suiteVersionsById, suitesById, namespacesById, usersById);
}
private PromotionReadBundle loadPromotionBundle(List<PromotionRequest> requests) {
@ -172,6 +202,20 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
}
private ReviewTaskResponse toReviewTaskResponse(ReviewTask task, ReviewReadBundle bundle) {
if (isSuiteReview(task)) {
SkillSuite suite = require(bundle.suitesById(), task.getSubjectId(), "error.suite.notFound");
Namespace namespace = require(bundle.namespacesById(), task.getNamespaceId(), "namespace.not_found");
UserAccount submittedBy = bundle.usersById().get(task.getSubmittedBy());
UserAccount reviewedBy = task.getReviewedBy() != null ? bundle.usersById().get(task.getReviewedBy()) : null;
return new ReviewTaskResponse(
task.getId(), null, namespace.getSlug(), null, task.getSubjectVersion(),
task.getStatus().name(), task.getSubmittedBy(),
submittedBy != null ? submittedBy.getDisplayName() : null,
task.getReviewedBy(), reviewedBy != null ? reviewedBy.getDisplayName() : null,
task.getReviewComment(), task.getSubmittedAt(), task.getReviewedAt(),
task.getSubjectType().name(), task.getSubjectId(),
task.getSubjectVersionId(), suite.getSlug());
}
Long skillId = task.getSkillId() != null
? task.getSkillId()
: require(bundle.versionsById(), task.getSkillVersionId(), "skill_version.not_found").getSkillId();
@ -195,7 +239,10 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
reviewedBy != null ? reviewedBy.getDisplayName() : null,
task.getReviewComment(),
task.getSubmittedAt(),
task.getReviewedAt()
task.getReviewedAt(),
subjectType(task), task.getSubjectId() != null ? task.getSubjectId() : skillId,
task.getSubjectVersionId() != null ? task.getSubjectVersionId() : task.getSkillVersionId(),
skill.getSlug()
);
}
@ -232,6 +279,17 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
}
private GovernanceInboxItemResponse toReviewInboxItem(ReviewTask task, ReviewReadBundle bundle) {
if (isSuiteReview(task)) {
SkillSuite suite = bundle.suitesById().get(task.getSubjectId());
Namespace namespace = bundle.namespacesById().get(task.getNamespaceId());
String namespaceSlug = namespace != null ? namespace.getSlug() : null;
String suiteSlug = suite != null ? suite.getSlug() : null;
return new GovernanceInboxItemResponse(
"REVIEW", task.getId(), join(namespaceSlug, suiteSlug, task.getSubjectVersion()),
"Pending Suite review",
task.getSubmittedAt() != null ? task.getSubmittedAt().toString() : null,
namespaceSlug, null, "SUITE", suiteSlug);
}
SkillVersion version = bundle.versionsById().get(task.getSkillVersionId());
Skill skill = version != null ? bundle.skillsById().get(version.getSkillId()) : null;
Namespace namespace = skill != null ? bundle.namespacesById().get(skill.getNamespaceId()) : null;
@ -306,8 +364,20 @@ public class JpaGovernanceQueryRepository implements GovernanceQueryRepository {
return version != null ? path + "@" + version : path;
}
private boolean isSuiteReview(ReviewTask task) {
return task.getSubjectType() == ReviewSubjectType.SUITE_VERSION;
}
private String subjectType(ReviewTask task) {
return task.getSubjectType() != null
? task.getSubjectType().name()
: ReviewSubjectType.SKILL_VERSION.name();
}
private record ReviewReadBundle(Map<Long, SkillVersion> versionsById,
Map<Long, Skill> skillsById,
Map<Long, SkillSuiteVersion> suiteVersionsById,
Map<Long, SkillSuite> suitesById,
Map<Long, Namespace> namespacesById,
Map<String, UserAccount> usersById) {
}

View file

@ -16,7 +16,7 @@ import org.springframework.transaction.annotation.Transactional;
/**
* PostgreSQL read-model query for review progress.
*
* <p>Direct SQL is intentional here: the page boundary applies to grouped skill-version attempts,
* <p>Direct SQL is intentional here: the page boundary applies to grouped resource-version attempts,
* not individual review tasks. Window functions keep grouping, latest-attempt selection, counts,
* filtering, and pagination in the database instead of loading an author's full history.</p>
*/
@ -27,18 +27,21 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
WITH ranked AS (
SELECT task.id,
task.skill_id,
task.subject_type,
task.subject_id,
task.subject_version_id,
task.subject_version,
task.namespace_id,
task.skill_version,
task.status,
task.review_comment,
task.submitted_at,
task.reviewed_at,
ROW_NUMBER() OVER (
PARTITION BY task.skill_id, task.skill_version
PARTITION BY task.subject_type, task.subject_id, task.subject_version
ORDER BY task.submitted_at DESC, task.id DESC
) AS attempt_rank,
COUNT(*) OVER (
PARTITION BY task.skill_id, task.skill_version
PARTITION BY task.subject_type, task.subject_id, task.subject_version
) AS attempt_count
FROM review_task task
WHERE task.submitted_by = :userId
@ -54,18 +57,25 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
latest.skill_id,
namespace.slug,
skill.slug,
latest.skill_version,
latest.subject_version,
latest.status,
latest.review_comment,
latest.submitted_at,
latest.reviewed_at,
latest.attempt_count
latest.attempt_count,
latest.subject_type,
latest.subject_id,
latest.subject_version_id,
COALESCE(skill.slug, suite.slug) AS subject_slug
FROM latest
JOIN skill ON skill.id = latest.skill_id
LEFT JOIN skill
ON latest.subject_type = 'SKILL_VERSION' AND skill.id = latest.subject_id
LEFT JOIN skill_suite suite
ON latest.subject_type = 'SUITE_VERSION' AND suite.id = latest.subject_id
JOIN namespace ON namespace.id = latest.namespace_id
WHERE (
:query = ''
OR LOWER(skill.slug) LIKE :queryPattern
OR LOWER(COALESCE(skill.slug, suite.slug)) LIKE :queryPattern
OR LOWER(namespace.slug) LIKE :queryPattern
)
AND (:status = '' OR latest.status = :status)
@ -79,10 +89,13 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
COUNT(*) FILTER (WHERE latest.status = 'APPROVED') AS approved_count,
COUNT(*) FILTER (WHERE latest.status = 'REJECTED') AS rejected_count
FROM latest
JOIN skill ON skill.id = latest.skill_id
LEFT JOIN skill
ON latest.subject_type = 'SKILL_VERSION' AND skill.id = latest.subject_id
LEFT JOIN skill_suite suite
ON latest.subject_type = 'SUITE_VERSION' AND suite.id = latest.subject_id
JOIN namespace ON namespace.id = latest.namespace_id
WHERE :query = ''
OR LOWER(skill.slug) LIKE :queryPattern
OR LOWER(COALESCE(skill.slug, suite.slug)) LIKE :queryPattern
OR LOWER(namespace.slug) LIKE :queryPattern
""";
@ -147,7 +160,7 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
private ReviewProgressResponse mapRow(Object[] row) {
return new ReviewProgressResponse(
number(row[0]).longValue(),
number(row[1]).longValue(),
row[1] == null ? null : number(row[1]).longValue(),
(String) row[2],
(String) row[3],
(String) row[4],
@ -155,7 +168,11 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
(String) row[6],
instant(row[7]),
instant(row[8]),
number(row[9]).longValue()
number(row[9]).longValue(),
String.valueOf(row[10]),
number(row[11]).longValue(),
row[12] == null ? null : number(row[12]).longValue(),
(String) row[13]
);
}

View file

@ -0,0 +1,113 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.dto.MySkillSuiteSummaryResponse;
import com.iflytek.skillhub.dto.PageResponse;
import jakarta.persistence.EntityManager;
import jakarta.persistence.Query;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
/**
* Dashboard read model for Suite versions manageable by the current Namespace role.
*
* <p>The native query selects the newest version each caller may manage in one round trip. This
* avoids loading every Suite and then resolving version ownership and Namespace roles with N+1
* repository calls.</p>
*/
@Repository
public class MySkillSuiteQueryRepository {
private static final String CTE = """
WITH manageable AS (
SELECT DISTINCT ON (suite.id)
suite.id, version.id AS version_id, namespace.slug AS namespace_slug,
suite.slug, version.display_name, version.summary, version.version,
version.status AS version_status, suite.status AS suite_status,
version.visibility, suite.hidden, suite.updated_at
FROM skill_suite suite
JOIN namespace ON namespace.id = suite.namespace_id
JOIN skill_suite_version version ON version.suite_id = suite.id
WHERE suite.namespace_id IN (:memberNamespaceIds)
AND (suite.created_by = :userId OR suite.namespace_id IN (:adminNamespaceIds))
ORDER BY suite.id, version.created_at DESC, version.id DESC
)
""";
private static final String FILTER = """
WHERE (:query = '' OR LOWER(slug) LIKE :pattern
OR LOWER(display_name) LIKE :pattern
OR LOWER(COALESCE(summary, '')) LIKE :pattern)
""";
private final EntityManager entityManager;
public MySkillSuiteQueryRepository(EntityManager entityManager) {
this.entityManager = entityManager;
}
@Transactional(readOnly = true)
public PageResponse<MySkillSuiteSummaryResponse> findMine(
String userId,
Set<Long> memberNamespaceIds,
Set<Long> adminNamespaceIds,
String keyword,
int page,
int size
) {
String queryText = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT);
Query select = bind(entityManager.createNativeQuery(CTE + """
SELECT id, version_id, namespace_slug, slug, display_name, summary, version,
version_status, suite_status, visibility, hidden, updated_at
FROM manageable
""" + FILTER + " ORDER BY updated_at DESC, id DESC OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY"),
userId, memberNamespaceIds, adminNamespaceIds, queryText);
select.setParameter("offset", (long) page * size).setParameter("size", size);
Query count = bind(entityManager.createNativeQuery(
CTE + "SELECT COUNT(*) FROM manageable " + FILTER),
userId, memberNamespaceIds, adminNamespaceIds, queryText);
@SuppressWarnings("unchecked")
List<Object[]> rows = select.getResultList();
List<MySkillSuiteSummaryResponse> items = rows.stream().map(this::map).toList();
return new PageResponse<>(items, ((Number) count.getSingleResult()).longValue(), page, size);
}
private Query bind(
Query query,
String userId,
Set<Long> memberNamespaceIds,
Set<Long> adminNamespaceIds,
String keyword
) {
return query.setParameter("userId", userId)
.setParameter("memberNamespaceIds", idsOrSentinel(memberNamespaceIds))
.setParameter("adminNamespaceIds", idsOrSentinel(adminNamespaceIds))
.setParameter("query", keyword)
.setParameter("pattern", "%" + keyword + "%");
}
private Set<Long> idsOrSentinel(Set<Long> ids) {
return ids.isEmpty() ? Set.of(-1L) : ids;
}
private MySkillSuiteSummaryResponse map(Object[] row) {
return new MySkillSuiteSummaryResponse(
((Number) row[0]).longValue(), ((Number) row[1]).longValue(),
(String) row[2], (String) row[3], (String) row[4], (String) row[5],
(String) row[6], String.valueOf(row[7]), String.valueOf(row[8]),
String.valueOf(row[9]), (Boolean) row[10], instant(row[11]));
}
private Instant instant(Object value) {
if (value instanceof Instant instant) return instant;
if (value instanceof OffsetDateTime offsetDateTime) return offsetDateTime.toInstant();
if (value instanceof Timestamp timestamp) return timestamp.toInstant();
throw new IllegalStateException("Expected Suite update timestamp, got " + value);
}
}

View file

@ -0,0 +1,105 @@
package com.iflytek.skillhub.repository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.dto.SkillSuiteMemberCandidateResponse;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
/**
* Server-side candidate projection for Suite authoring.
*
* <p>Direct SQL keeps authorization, installability, audience compatibility, and result limiting
* in one database query; assembling this through aggregate repositories would load inaccessible
* Skills before filtering and introduce an N+1 version lookup.
*/
@Repository
public class SkillSuiteCandidateQueryRepository {
private final NamedParameterJdbcTemplate jdbcTemplate;
public SkillSuiteCandidateQueryRepository(NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<SkillSuiteMemberCandidateResponse> search(
Long suiteNamespaceId,
SkillVisibility suiteVisibility,
String query,
String userId,
List<Long> memberNamespaceIds,
List<Long> adminNamespaceIds,
boolean superAdmin,
int size
) {
String normalizedQuery = query == null ? "" : query.trim().toLowerCase();
MapSqlParameterSource parameters = new MapSqlParameterSource()
.addValue("suiteNamespaceId", suiteNamespaceId)
.addValue("suiteVisibility", suiteVisibility.name())
.addValue("query", "%" + normalizedQuery + "%")
.addValue("userId", userId)
.addValue("memberNamespaceIds", nonEmpty(memberNamespaceIds))
.addValue("adminNamespaceIds", nonEmpty(adminNamespaceIds))
.addValue("superAdmin", superAdmin)
.addValue("size", size);
return jdbcTemplate.query("""
SELECT s.id AS skill_id,
sv.id AS skill_version_id,
n.slug AS namespace_slug,
s.slug AS skill_slug,
COALESCE(s.display_name, s.slug) AS display_name,
sv.version,
s.visibility,
(s.latest_version_id = sv.id) AS recommended
FROM skill s
JOIN namespace n ON n.id = s.namespace_id
JOIN skill_version sv ON sv.skill_id = s.id
WHERE s.status = 'ACTIVE'
AND s.hidden = FALSE
AND n.status = 'ACTIVE'
AND sv.status = 'PUBLISHED'
AND sv.download_ready = TRUE
AND sv.yanked_at IS NULL
AND (
:superAdmin = TRUE
OR s.visibility = 'PUBLIC'
OR (s.visibility = 'NAMESPACE_ONLY' AND s.namespace_id IN (:memberNamespaceIds))
OR (s.visibility = 'PRIVATE' AND (
s.owner_id = :userId OR s.namespace_id IN (:adminNamespaceIds)
))
)
AND (
s.visibility = 'PUBLIC'
OR (:suiteVisibility = 'NAMESPACE_ONLY'
AND s.namespace_id = :suiteNamespaceId
AND s.visibility = 'NAMESPACE_ONLY')
OR (:suiteVisibility = 'PRIVATE'
AND s.namespace_id = :suiteNamespaceId
AND s.visibility IN ('NAMESPACE_ONLY', 'PRIVATE'))
)
AND (
:query = '%%'
OR LOWER(n.slug) LIKE :query
OR LOWER(s.slug) LIKE :query
OR LOWER(COALESCE(s.display_name, '')) LIKE :query
)
ORDER BY recommended DESC, LOWER(COALESCE(s.display_name, s.slug)), sv.published_at DESC, sv.id DESC
LIMIT :size
""", parameters, (resultSet, rowNumber) -> new SkillSuiteMemberCandidateResponse(
resultSet.getLong("skill_id"),
resultSet.getLong("skill_version_id"),
resultSet.getString("namespace_slug"),
resultSet.getString("skill_slug"),
resultSet.getString("display_name"),
resultSet.getString("version"),
SkillVisibility.valueOf(resultSet.getString("visibility")),
resultSet.getBoolean("recommended")));
}
private List<Long> nonEmpty(List<Long> values) {
return values.isEmpty() ? List.of(-1L) : values;
}
}

View file

@ -69,8 +69,11 @@ public class GovernanceWorkflowAppService {
return reviewPortalAppService.rejectReview(reviewTaskId, comment, userId, userNsRoles, auditContext);
}
public void withdrawReviewTask(Long reviewTaskId, String userId, AuditRequestContext auditContext) {
reviewPortalAppService.withdrawReview(reviewTaskId, userId, auditContext);
public void withdrawReviewTask(Long reviewTaskId,
String userId,
Map<Long, NamespaceRole> userNsRoles,
AuditRequestContext auditContext) {
reviewPortalAppService.withdrawReview(reviewTaskId, userId, userNsRoles, auditContext);
}
public PageResponse<ReviewTaskResponse> listReviews(String status,

View file

@ -0,0 +1,50 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.dto.ResourceSearchResponse;
import com.iflytek.skillhub.dto.ResourceSummaryResponse;
import com.iflytek.skillhub.search.ResourceDiscoveryQueryService;
import com.iflytek.skillhub.search.ResourceDiscoveryQueryService.ResourceQuery;
import java.util.Set;
import org.springframework.stereotype.Service;
/** Maps the replaceable resource-search result into the public API projection. */
@Service
public class ResourceDiscoveryAppService {
private final ResourceDiscoveryQueryService queryService;
public ResourceDiscoveryAppService(ResourceDiscoveryQueryService queryService) {
this.queryService = queryService;
}
public ResourceSearchResponse search(
String keyword,
String namespace,
String resourceType,
String sort,
int page,
int size,
Set<Long> memberNamespaceIds
) {
int safePage = Math.max(0, page);
int safeSize = Math.min(Math.max(size, 1), 100);
var result = queryService.search(new ResourceQuery(
keyword, namespace, resourceType, sort, safePage, safeSize, memberNamespaceIds));
return new ResourceSearchResponse(
result.items().stream().map(item -> new ResourceSummaryResponse(
item.resourceType(),
"/" + ("SUITE".equals(item.resourceType()) ? "suite" : "space")
+ "/" + item.namespace() + "/" + item.slug(),
item.id(),
item.namespace(),
item.slug(),
item.displayName(),
item.summary(),
item.version(),
item.visibility(),
item.installCount(),
item.available(),
item.updatedAt())).toList(),
result.total(), result.page(), result.size());
}
}

View file

@ -10,8 +10,11 @@ import com.iflytek.skillhub.domain.review.ReviewService;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.review.ReviewSubjectType;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.suite.SkillSuiteActionContext;
import com.iflytek.skillhub.domain.suite.SkillSuiteLifecycleService;
import com.iflytek.skillhub.dto.PageResponse;
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
@ -40,6 +43,7 @@ public class ReviewPortalAppService {
private final RbacService rbacService;
private final AuditLogService auditLogService;
private final RequestIdAccessor requestIdAccessor;
private final SkillSuiteLifecycleService suiteLifecycleService;
public ReviewPortalAppService(ReviewService reviewService,
ReviewTaskRepository reviewTaskRepository,
@ -48,7 +52,8 @@ public class ReviewPortalAppService {
ReviewProgressQueryRepository reviewProgressQueryRepository,
RbacService rbacService,
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor) {
RequestIdAccessor requestIdAccessor,
SkillSuiteLifecycleService suiteLifecycleService) {
this.reviewService = reviewService;
this.reviewTaskRepository = reviewTaskRepository;
this.namespaceRepository = namespaceRepository;
@ -57,6 +62,7 @@ public class ReviewPortalAppService {
this.rbacService = rbacService;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
this.suiteLifecycleService = suiteLifecycleService;
}
@Transactional
@ -80,6 +86,12 @@ public class ReviewPortalAppService {
String userId,
Map<Long, NamespaceRole> userNsRoles,
AuditRequestContext auditContext) {
ReviewTask existing = findReview(reviewTaskId);
if (existing.getSubjectType() == ReviewSubjectType.SUITE_VERSION) {
ReviewTask task = suiteLifecycleService.approveReview(
reviewTaskId, comment, suiteContext(userId, userNsRoles, auditContext));
return governanceQueryRepository.getReviewTaskResponse(task);
}
ReviewTask task = reviewService.approveReview(
reviewTaskId,
userId,
@ -97,6 +109,12 @@ public class ReviewPortalAppService {
String userId,
Map<Long, NamespaceRole> userNsRoles,
AuditRequestContext auditContext) {
ReviewTask existing = findReview(reviewTaskId);
if (existing.getSubjectType() == ReviewSubjectType.SUITE_VERSION) {
ReviewTask task = suiteLifecycleService.rejectReview(
reviewTaskId, comment, suiteContext(userId, userNsRoles, auditContext));
return governanceQueryRepository.getReviewTaskResponse(task);
}
ReviewTask task = reviewService.rejectReview(
reviewTaskId,
userId,
@ -111,9 +129,14 @@ public class ReviewPortalAppService {
@Transactional
public void withdrawReview(Long reviewTaskId,
String userId,
Map<Long, NamespaceRole> userNsRoles,
AuditRequestContext auditContext) {
ReviewTask task = reviewTaskRepository.findById(reviewTaskId)
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId));
ReviewTask task = findReview(reviewTaskId);
if (task.getSubjectType() == ReviewSubjectType.SUITE_VERSION) {
suiteLifecycleService.withdrawReview(
reviewTaskId, suiteContext(userId, userNsRoles, auditContext));
return;
}
reviewService.withdrawReview(task.getSkillVersionId(), userId);
recordAudit(
"REVIEW_WITHDRAW",
@ -244,7 +267,12 @@ public class ReviewPortalAppService {
throw new DomainForbiddenException("review.no_permission");
}
List<ReviewTask> attempts = reviewTaskRepository
List<ReviewTask> attempts = anchor.getSubjectType() == ReviewSubjectType.SUITE_VERSION
? reviewTaskRepository
.findBySubmittedByAndSubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc(
userId, ReviewSubjectType.SUITE_VERSION,
anchor.getSubjectId(), anchor.getSubjectVersion())
: reviewTaskRepository
.findBySubmittedByAndSkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
userId, anchor.getSkillId(), anchor.getSkillVersion());
return governanceQueryRepository.getReviewTaskResponses(attempts);
@ -268,7 +296,12 @@ public class ReviewPortalAppService {
throw new DomainForbiddenException("review.no_permission");
}
List<ReviewTask> attempts = reviewTaskRepository
List<ReviewTask> attempts = anchor.getSubjectType() == ReviewSubjectType.SUITE_VERSION
? reviewTaskRepository
.findBySubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc(
ReviewSubjectType.SUITE_VERSION,
anchor.getSubjectId(), anchor.getSubjectVersion())
: reviewTaskRepository
.findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
anchor.getSkillId(), anchor.getSkillVersion());
return governanceQueryRepository.getReviewTaskResponses(attempts);
@ -308,6 +341,22 @@ public class ReviewPortalAppService {
return rbacService.getUserRoleCodes(userId);
}
private ReviewTask findReview(Long reviewTaskId) {
return reviewTaskRepository.findById(reviewTaskId)
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId));
}
private SkillSuiteActionContext suiteContext(
String userId,
Map<Long, NamespaceRole> userNsRoles,
AuditRequestContext auditContext
) {
return new SkillSuiteActionContext(
userId, normalizeRoles(userNsRoles), platformRoles(userId), requestIdAccessor.current(),
auditContext != null ? auditContext.clientIp() : null,
auditContext != null ? auditContext.userAgent() : null);
}
private boolean hasPlatformReviewRole(Set<String> platformRoles) {
return platformRoles.contains("SKILL_ADMIN")
|| platformRoles.contains("SUPER_ADMIN");

View file

@ -0,0 +1,609 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.suite.CreateSkillSuiteDraftCommand;
import com.iflytek.skillhub.domain.suite.SkillSuiteActionContext;
import com.iflytek.skillhub.domain.suite.SkillSuiteDraftService;
import com.iflytek.skillhub.domain.suite.SkillSuiteLifecycleService;
import com.iflytek.skillhub.domain.suite.SkillSuiteInstallMetricsService;
import com.iflytek.skillhub.domain.suite.SkillSuiteInstallOperation;
import com.iflytek.skillhub.domain.suite.SkillSuiteInstallOperationRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteAllowedAction;
import com.iflytek.skillhub.domain.suite.SkillSuiteMemberSelection;
import com.iflytek.skillhub.domain.suite.SkillSuiteQueryService;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember;
import com.iflytek.skillhub.dto.SkillSuiteCreateRequest;
import com.iflytek.skillhub.dto.SkillSuiteMemberRequest;
import com.iflytek.skillhub.dto.SkillSuiteMemberResponse;
import com.iflytek.skillhub.dto.SkillSuiteResponse;
import com.iflytek.skillhub.dto.SkillSuiteInstallMemberResponse;
import com.iflytek.skillhub.dto.SkillSuiteInstallPlanResponse;
import com.iflytek.skillhub.dto.SkillSuiteMemberCandidateResponse;
import com.iflytek.skillhub.dto.SkillSuiteVersionSummaryResponse;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.SkillSuiteCandidateQueryRepository;
import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository;
import com.iflytek.skillhub.dto.MySkillSuiteSummaryResponse;
import com.iflytek.skillhub.dto.PageResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.UUID;
/** Application boundary for resolving Suite inputs and invoking domain workflows. */
@Service
public class SkillSuiteAppService {
private static final Logger log = LoggerFactory.getLogger(SkillSuiteAppService.class);
private final NamespaceRepository namespaceRepository;
private final SkillQueryService skillQueryService;
private final SkillSuiteDraftService draftService;
private final SkillSuiteLifecycleService lifecycleService;
private final SkillSuiteQueryService queryService;
private final SkillSuiteInstallMetricsService installMetricsService;
private final SkillSuiteInstallOperationRepository installOperationRepository;
private final AuditLogService auditLogService;
private final RequestIdAccessor requestIdAccessor;
private final SkillSuiteCandidateQueryRepository candidateQueryRepository;
private final MySkillSuiteQueryRepository mySkillSuiteQueryRepository;
public SkillSuiteAppService(
NamespaceRepository namespaceRepository,
SkillQueryService skillQueryService,
SkillSuiteDraftService draftService,
SkillSuiteLifecycleService lifecycleService,
SkillSuiteQueryService queryService,
SkillSuiteInstallMetricsService installMetricsService,
SkillSuiteInstallOperationRepository installOperationRepository,
AuditLogService auditLogService,
RequestIdAccessor requestIdAccessor,
SkillSuiteCandidateQueryRepository candidateQueryRepository,
MySkillSuiteQueryRepository mySkillSuiteQueryRepository
) {
this.namespaceRepository = namespaceRepository;
this.skillQueryService = skillQueryService;
this.draftService = draftService;
this.lifecycleService = lifecycleService;
this.queryService = queryService;
this.installMetricsService = installMetricsService;
this.installOperationRepository = installOperationRepository;
this.auditLogService = auditLogService;
this.requestIdAccessor = requestIdAccessor;
this.candidateQueryRepository = candidateQueryRepository;
this.mySkillSuiteQueryRepository = mySkillSuiteQueryRepository;
}
public PageResponse<MySkillSuiteSummaryResponse> listMine(
String userId,
Map<Long, NamespaceRole> namespaceRoles,
String query,
int page,
int size
) {
Set<Long> adminNamespaceIds = namespaceRoles.entrySet().stream()
.filter(entry -> entry.getValue() == NamespaceRole.OWNER
|| entry.getValue() == NamespaceRole.ADMIN)
.map(Map.Entry::getKey)
.collect(java.util.stream.Collectors.toUnmodifiableSet());
return mySkillSuiteQueryRepository.findMine(
userId, namespaceRoles.keySet(), adminNamespaceIds, query,
Math.max(0, page), Math.min(Math.max(1, size), 100));
}
public List<SkillSuiteMemberCandidateResponse> searchCandidates(
String suiteNamespace,
SkillVisibility visibility,
String query,
int size,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
Namespace namespace = namespaceRepository.findBySlug(suiteNamespace)
.orElseThrow(() -> new DomainBadRequestException(
"error.namespace.slug.notFound", suiteNamespace));
if (namespace.getStatus() != NamespaceStatus.ACTIVE) {
throw new DomainBadRequestException("error.suite.namespace.notWritable", namespace.getStatus());
}
boolean superAdmin = platformRoles.contains("SUPER_ADMIN");
if (!superAdmin && !namespaceRoles.containsKey(namespace.getId())) {
throw new DomainBadRequestException("error.suite.lifecycle.noPermission");
}
int boundedSize = Math.max(1, Math.min(size, 100));
List<Long> memberNamespaceIds = List.copyOf(namespaceRoles.keySet());
List<Long> adminNamespaceIds = namespaceRoles.entrySet().stream()
.filter(entry -> entry.getValue() == NamespaceRole.OWNER
|| entry.getValue() == NamespaceRole.ADMIN)
.map(Map.Entry::getKey)
.toList();
return candidateQueryRepository.search(
namespace.getId(), visibility, query, userId, memberNamespaceIds,
adminNamespaceIds, superAdmin, boundedSize);
}
@Transactional
public SkillSuiteInstallPlanResponse createInstallPlan(
String namespace,
String slug,
String version,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
String clientRequestId,
HttpServletRequest request
) {
String retryKey = normalizeClientRequestId(clientRequestId);
String actorKey = idempotencyActorKey(userId);
SkillSuiteInstallOperation existing = installOperationRepository
.findByClientRequestIdAndActorKey(retryKey, actorKey)
.orElse(null);
if (existing != null) {
return replayInstallPlan(
existing, namespace, slug, version, userId, namespaceRoles, platformRoles);
}
SkillSuiteQueryService.Detail detail = queryService.getDetail(
namespace, slug, version, userId, namespaceRoles, platformRoles);
if (!detail.available()) {
throw new DomainBadRequestException("error.suite.install.unavailable");
}
List<SkillSuiteInstallMemberResponse> members = resolveInstallMembers(detail, userId, namespaceRoles);
String operationId = UUID.randomUUID().toString();
String fingerprint = suiteFingerprint(detail, members);
int inserted = installOperationRepository.insertIfAbsent(
operationId, retryKey, actorKey, detail.suite().getId(), detail.version().getId());
if (inserted == 0) {
SkillSuiteInstallOperation concurrent = installOperationRepository
.findByClientRequestIdAndActorKey(retryKey, actorKey)
.orElseThrow(() -> new IllegalStateException(
"Suite install operation disappeared after an idempotency conflict"));
return replayInstallPlan(
concurrent, namespace, slug, version, userId, namespaceRoles, platformRoles);
}
installMetricsService.recordIssuedPlan(detail.suite().getId());
AuditRequestContext audit = AuditRequestContext.from(request);
for (SkillSuiteQueryService.MemberDetail member : detail.members()) {
auditLogService.record(
userId, "ISSUE_SKILL_DOWNLOAD", "SKILL_VERSION",
member.snapshot().getSkillVersionId(), operationId, audit.clientIp(), audit.userAgent(),
AuditDetail.builder()
.put("source", "SUITE")
.put("suiteId", detail.suite().getId())
.put("suiteVersionId", detail.version().getId())
.build());
}
auditLogService.record(
userId, "ISSUE_SKILL_SUITE_INSTALL_PLAN", "SKILL_SUITE_VERSION",
detail.version().getId(), operationId, audit.clientIp(), audit.userAgent(),
AuditDetail.builder()
.put("suiteId", detail.suite().getId())
.put("memberCount", members.size())
.put("source", "SUITE")
.build());
log.info(
"Suite install plan issued [suiteId={}, versionId={}, actorId={}, memberCount={}, operationId={}]",
detail.suite().getId(), detail.version().getId(), userId, members.size(), operationId);
return new SkillSuiteInstallPlanResponse(
operationId, namespace, slug, detail.version().getVersion(), fingerprint, members);
}
private SkillSuiteInstallPlanResponse replayInstallPlan(
SkillSuiteInstallOperation existing,
String namespace,
String slug,
String requestedVersion,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
SkillSuiteQueryService.Detail detail;
try {
detail = queryService.getDetailByVersionId(
namespace, slug, existing.getSuiteVersionId(), userId, namespaceRoles, platformRoles);
} catch (com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException exception) {
throw new DomainBadRequestException("error.suite.install.operationConflict");
}
if (!existing.getSuiteId().equals(detail.suite().getId())
|| (requestedVersion != null && !requestedVersion.isBlank()
&& !requestedVersion.equals(detail.version().getVersion()))) {
throw new DomainBadRequestException("error.suite.install.operationConflict");
}
if (!detail.available()) {
throw new DomainBadRequestException("error.suite.install.unavailable");
}
List<SkillSuiteInstallMemberResponse> members = resolveInstallMembers(detail, userId, namespaceRoles);
String fingerprint = suiteFingerprint(detail, members);
log.info(
"Suite install plan safely replayed [suiteId={}, versionId={}, actorId={}, memberCount={}, operationId={}]",
detail.suite().getId(), detail.version().getId(), userId, members.size(), existing.getOperationId());
return new SkillSuiteInstallPlanResponse(
existing.getOperationId(), namespace, slug, detail.version().getVersion(), fingerprint, members);
}
private List<SkillSuiteInstallMemberResponse> resolveInstallMembers(
SkillSuiteQueryService.Detail detail,
String userId,
Map<Long, NamespaceRole> namespaceRoles
) {
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);
members.add(new SkillSuiteInstallMemberResponse(
snapshot.getSkillId(), snapshot.getSkillVersionId(),
resolved.namespace(), resolved.slug(), resolved.version(), resolved.fingerprint(),
resolved.downloadUrl(), snapshot.getPosition(),
Objects.equals(snapshot.getSkillVersionId(), detail.version().getEntrySkillVersionId())));
}
} catch (LocalizedDomainException exception) {
// A Suite reader may no longer be allowed to inspect a restricted member. Do not expose
// the member coordinate or the underlying authorization failure through install preflight.
throw new DomainBadRequestException("error.suite.install.unavailable");
}
return members;
}
private String normalizeClientRequestId(String clientRequestId) {
if (clientRequestId == null || clientRequestId.isBlank()) {
return UUID.randomUUID().toString();
}
if (!RequestIdAccessor.isValid(clientRequestId)) {
throw new DomainBadRequestException("error.suite.install.idempotencyKey.invalid");
}
return clientRequestId;
}
private String idempotencyActorKey(String userId) {
return userId == null ? "anonymous" : "user:" + userId;
}
public SkillSuiteResponse getDetail(
String namespace,
String slug,
String version,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
SkillSuiteQueryService.Detail detail = queryService.getDetail(
namespace, slug, version, userId, namespaceRoles, platformRoles);
List<SkillSuiteMemberResponse> members = detail.members().stream()
.map(member -> new SkillSuiteMemberResponse(
member.snapshot().getSkillId(), member.snapshot().getSkillVersionId(),
member.snapshot().getNamespaceSlugSnapshot(), member.snapshot().getSkillSlugSnapshot(),
member.state().viewerCanRead() ? member.state().displayName() : null,
member.state().viewerCanRead() ? member.state().summary() : null,
member.snapshot().getSkillVersionSnapshot(), member.snapshot().getFingerprintSnapshot(),
member.snapshot().getPosition(),
Objects.equals(member.snapshot().getSkillVersionId(), detail.version().getEntrySkillVersionId()),
member.state().viewerCanRead(),
member.availability().reason() == null ? null : member.availability().reason().name()))
.toList();
Set<SkillSuiteAllowedAction> allowedActions =
lifecycleService.allowedActions(
detail.suite(), detail.version(), detail.namespace(),
authorizationContext(userId, namespaceRoles, platformRoles));
return new SkillSuiteResponse(
detail.suite().getId(), detail.version().getId(), detail.namespace().getSlug(),
detail.suite().getSlug(), detail.version().getDisplayName(), detail.version().getSummary(),
detail.version().getOverview(),
detail.version().getVersion(), detail.version().getStatus().name(),
detail.version().getVisibility(), detail.suite().getStatus().name(),
detail.suite().isHidden(), allowedActions, detail.available(), members);
}
public SkillSuiteResponse create(
SkillSuiteCreateRequest request,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
HttpServletRequest httpRequest
) {
Namespace namespace = namespaceRepository.findBySlug(request.namespace())
.orElseThrow(() -> new DomainBadRequestException(
"error.namespace.slug.notFound", request.namespace()));
SkillSuiteDraftService.CreatedDraft created = draftService.create(
toCommand(namespace.getId(), request, userId, namespaceRoles),
context(userId, namespaceRoles, platformRoles, httpRequest));
return toResponse(namespace, created, userId, namespaceRoles, platformRoles);
}
public SkillSuiteResponse createVersion(
Long suiteId,
SkillSuiteCreateRequest request,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
HttpServletRequest httpRequest
) {
Namespace namespace = namespaceRepository.findBySlug(request.namespace())
.orElseThrow(() -> new DomainBadRequestException(
"error.namespace.slug.notFound", request.namespace()));
SkillSuiteDraftService.CreatedDraft created = draftService.createVersion(
suiteId, toCommand(namespace.getId(), request, userId, namespaceRoles),
context(userId, namespaceRoles, platformRoles, httpRequest));
return toResponse(namespace, created, userId, namespaceRoles, platformRoles);
}
public SkillSuiteResponse updateDraft(
Long suiteId,
Long versionId,
SkillSuiteCreateRequest request,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
HttpServletRequest httpRequest
) {
Namespace namespace = namespaceRepository.findBySlug(request.namespace())
.orElseThrow(() -> new DomainBadRequestException(
"error.namespace.slug.notFound", request.namespace()));
SkillSuiteDraftService.CreatedDraft updated = draftService.updateDraft(
suiteId, versionId, toCommand(namespace.getId(), request, userId, namespaceRoles),
context(userId, namespaceRoles, platformRoles, httpRequest));
return toResponse(namespace, updated, userId, namespaceRoles, platformRoles);
}
public List<SkillSuiteVersionSummaryResponse> listVersions(
String namespace,
String slug,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
return queryService.listVersions(namespace, slug, userId, namespaceRoles, platformRoles).stream()
.map(version -> new SkillSuiteVersionSummaryResponse(
version.id(), version.version(), version.status().name(), version.visibility(),
version.publishedAt(), version.yankedAt(), version.createdAt()))
.toList();
}
public void submitForReview(
Long suiteId,
Long versionId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
HttpServletRequest request
) {
lifecycleService.submitForReview(
suiteId, versionId, context(userId, namespaceRoles, platformRoles, request));
}
public void publishPrivate(
Long suiteId,
Long versionId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
HttpServletRequest request
) {
lifecycleService.confirmPrivatePublish(
suiteId, versionId, context(userId, namespaceRoles, platformRoles, request));
}
public void approve(
Long reviewTaskId,
String comment,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
HttpServletRequest request
) {
lifecycleService.approveReview(
reviewTaskId, comment, context(userId, namespaceRoles, platformRoles, request));
}
public void reject(
Long reviewTaskId,
String comment,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
HttpServletRequest request
) {
lifecycleService.rejectReview(
reviewTaskId, comment, context(userId, namespaceRoles, platformRoles, request));
}
public void reopen(
Long suiteId,
Long versionId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
HttpServletRequest request
) {
lifecycleService.reopenRejected(
suiteId, versionId, context(userId, namespaceRoles, platformRoles, request));
}
public void yank(
Long suiteId,
Long versionId,
String reason,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
HttpServletRequest request
) {
lifecycleService.yank(
suiteId, versionId, reason,
context(userId, namespaceRoles, platformRoles, request));
}
public void setHidden(
Long suiteId,
boolean hidden,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
HttpServletRequest request
) {
lifecycleService.setHidden(
suiteId, hidden, context(userId, namespaceRoles, platformRoles, request));
}
public void setArchived(
Long suiteId,
boolean archived,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
HttpServletRequest request
) {
lifecycleService.setArchived(
suiteId, archived, context(userId, namespaceRoles, platformRoles, request));
}
public void delete(
Long suiteId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
HttpServletRequest request
) {
lifecycleService.delete(
suiteId, context(userId, namespaceRoles, platformRoles, request));
}
private SkillSuiteMemberSelection resolve(
SkillSuiteMemberRequest member,
String userId,
Map<Long, NamespaceRole> namespaceRoles
) {
SkillQueryService.ResolvedVersionDTO resolved = skillQueryService.resolveVersion(
member.namespace(), member.slug(), member.version(), null, null, userId, namespaceRoles);
return new SkillSuiteMemberSelection(
resolved.skillId(), resolved.versionId(), resolved.namespace(), resolved.slug(),
resolved.version(), resolved.fingerprint());
}
private CreateSkillSuiteDraftCommand toCommand(
Long namespaceId,
SkillSuiteCreateRequest request,
String userId,
Map<Long, NamespaceRole> namespaceRoles
) {
List<SkillSuiteMemberSelection> selections = request.members().stream()
.map(member -> resolve(member, userId, namespaceRoles))
.toList();
Long entryVersionId = resolveEntryVersionId(request.entrySkill(), selections);
return new CreateSkillSuiteDraftCommand(
namespaceId, request.slug(), request.displayName(), request.summary(), request.overview(),
request.version(), request.visibility(), request.changelog(),
entryVersionId, selections);
}
private Long resolveEntryVersionId(
SkillSuiteMemberRequest entry,
List<SkillSuiteMemberSelection> members
) {
if (entry == null) {
return null;
}
return members.stream()
.filter(member -> Objects.equals(member.namespaceSlug(), entry.namespace())
&& Objects.equals(member.skillSlug(), entry.slug())
&& Objects.equals(member.version(), entry.version()))
.map(SkillSuiteMemberSelection::skillVersionId)
.findFirst()
.orElseThrow(() -> new DomainBadRequestException("error.suite.entry.notMember"));
}
private SkillSuiteActionContext context(
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
HttpServletRequest request
) {
AuditRequestContext audit = AuditRequestContext.from(request);
return new SkillSuiteActionContext(
userId, namespaceRoles, platformRoles, requestIdAccessor.current(),
audit.clientIp(), audit.userAgent());
}
private SkillSuiteResponse toResponse(
Namespace namespace,
SkillSuiteDraftService.CreatedDraft created,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
List<SkillSuiteMemberResponse> members = new ArrayList<>();
for (SkillSuiteVersionMember member : created.members()) {
members.add(new SkillSuiteMemberResponse(
member.getSkillId(), member.getSkillVersionId(), member.getNamespaceSlugSnapshot(),
member.getSkillSlugSnapshot(), null, null, member.getSkillVersionSnapshot(),
member.getFingerprintSnapshot(), member.getPosition(),
Objects.equals(member.getSkillVersionId(), created.version().getEntrySkillVersionId()),
false,
null));
}
Set<SkillSuiteAllowedAction> allowedActions =
lifecycleService.allowedActions(
created.suite(), created.version(), namespace,
authorizationContext(userId, namespaceRoles, platformRoles));
return new SkillSuiteResponse(
created.suite().getId(), created.version().getId(), namespace.getSlug(),
created.suite().getSlug(), created.version().getDisplayName(), created.version().getSummary(),
created.version().getOverview(),
created.version().getVersion(), created.version().getStatus().name(),
created.version().getVisibility(), created.suite().getStatus().name(),
created.suite().isHidden(), allowedActions, false, members);
}
private SkillSuiteActionContext authorizationContext(
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
return new SkillSuiteActionContext(userId, namespaceRoles, platformRoles, null, null, null);
}
private String suiteFingerprint(
SkillSuiteQueryService.Detail detail,
List<SkillSuiteInstallMemberResponse> members
) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
digest.update(("suite:" + detail.namespace().getSlug() + "/" + detail.suite().getSlug()
+ "@" + detail.version().getVersion() + "\n").getBytes(StandardCharsets.UTF_8));
for (SkillSuiteInstallMemberResponse member : members) {
String line = member.position() + ":" + member.namespace() + "/" + member.slug()
+ "@" + member.version() + ":" + member.fingerprint()
+ ":entry=" + member.entry() + "\n";
digest.update(line.getBytes(StandardCharsets.UTF_8));
}
return "sha256:" + HexFormat.of().formatHex(digest.digest());
} catch (Exception exception) {
throw new IllegalStateException("Failed to compute Suite fingerprint", exception);
}
}
}

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.task;
import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteInstallOperationRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
@ -19,12 +20,19 @@ public class IdempotencyCleanupTask {
private static final Logger logger = LoggerFactory.getLogger(IdempotencyCleanupTask.class);
private static final long STALE_THRESHOLD_MINUTES = 30;
private static final long SUITE_RETRY_RETENTION_HOURS = 24;
private final IdempotencyRecordRepository idempotencyRecordRepository;
private final SkillSuiteInstallOperationRepository suiteInstallOperationRepository;
private final Clock clock;
public IdempotencyCleanupTask(IdempotencyRecordRepository idempotencyRecordRepository, Clock clock) {
public IdempotencyCleanupTask(
IdempotencyRecordRepository idempotencyRecordRepository,
SkillSuiteInstallOperationRepository suiteInstallOperationRepository,
Clock clock
) {
this.idempotencyRecordRepository = idempotencyRecordRepository;
this.suiteInstallOperationRepository = suiteInstallOperationRepository;
this.clock = clock;
}
@ -33,7 +41,11 @@ public class IdempotencyCleanupTask {
public void cleanupExpiredRecords() {
Instant now = Instant.now(clock);
int deleted = idempotencyRecordRepository.deleteExpired(now);
logger.info("Cleaned up {} expired idempotency records", deleted);
int suiteOperationsDeleted = suiteInstallOperationRepository.deleteCreatedBefore(
now.minusSeconds(SUITE_RETRY_RETENTION_HOURS * 3600));
logger.info(
"Cleaned up expired idempotency records [requestRecords={}, suiteOperations={}]",
deleted, suiteOperationsDeleted);
}
@Scheduled(fixedDelay = 300000)

View file

@ -24,6 +24,8 @@ spring:
client-secret: ${OAUTH2_GITHUB_CLIENT_SECRET:local-placeholder}
skillhub:
suite:
review-writes-enabled: ${SKILLHUB_SUITE_REVIEW_WRITES_ENABLED:true}
auth:
mock:
enabled: true

View file

@ -96,6 +96,10 @@ spring:
enable: ${SPRING_MAIL_SMTP_STARTTLS_ENABLE:false}
skillhub:
suite:
# Fail closed for deployment modes whose upgrade topology is unknown. Single-instance
# distributions explicitly enable this after ruling out mixed application versions.
review-writes-enabled: ${SKILLHUB_SUITE_REVIEW_WRITES_ENABLED:false}
observability:
tracing-mode: ${SKILLHUB_TRACING_MODE:none}
log-format: ${SKILLHUB_LOG_FORMAT:text}

View file

@ -0,0 +1,71 @@
-- Skill Suites are typed collections of exact published Skill versions.
-- They intentionally use separate tables so a Skill and Suite may share one namespace/slug.
CREATE TABLE skill_suite (
id BIGSERIAL PRIMARY KEY,
namespace_id BIGINT NOT NULL REFERENCES namespace(id),
slug VARCHAR(128) NOT NULL,
display_name VARCHAR(256) NOT NULL,
summary TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
latest_version_id BIGINT,
install_request_count BIGINT NOT NULL DEFAULT 0,
hidden BOOLEAN NOT NULL DEFAULT FALSE,
hidden_at TIMESTAMPTZ,
hidden_by VARCHAR(128),
created_by VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(128),
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_skill_suite_namespace_slug UNIQUE (namespace_id, slug)
);
CREATE INDEX idx_skill_suite_namespace_status
ON skill_suite(namespace_id, status);
CREATE TABLE skill_suite_version (
id BIGSERIAL PRIMARY KEY,
suite_id BIGINT NOT NULL REFERENCES skill_suite(id) ON DELETE CASCADE,
version VARCHAR(64) NOT NULL,
display_name VARCHAR(256) NOT NULL,
summary TEXT,
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT',
visibility VARCHAR(32) NOT NULL,
changelog TEXT,
entry_skill_version_id BIGINT REFERENCES skill_version(id) ON DELETE SET NULL,
published_at TIMESTAMPTZ,
yanked_at TIMESTAMPTZ,
yanked_by VARCHAR(128),
yank_reason TEXT,
created_by VARCHAR(128) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_skill_suite_version UNIQUE (suite_id, version)
);
CREATE INDEX idx_skill_suite_version_suite_status
ON skill_suite_version(suite_id, status);
CREATE TABLE skill_suite_version_member (
id BIGSERIAL PRIMARY KEY,
suite_version_id BIGINT NOT NULL REFERENCES skill_suite_version(id) ON DELETE CASCADE,
skill_id BIGINT REFERENCES skill(id) ON DELETE SET NULL,
skill_version_id BIGINT REFERENCES skill_version(id) ON DELETE SET NULL,
position INT NOT NULL CHECK (position >= 0),
namespace_slug_snapshot VARCHAR(128) NOT NULL,
skill_slug_snapshot VARCHAR(128) NOT NULL,
skill_version_snapshot VARCHAR(64) NOT NULL,
fingerprint_snapshot VARCHAR(255) NOT NULL,
CONSTRAINT uk_skill_suite_member_position UNIQUE (suite_version_id, position)
);
-- The partial index continues to allow multiple tombstoned historical rows after hard deletion.
CREATE UNIQUE INDEX uk_skill_suite_member_skill
ON skill_suite_version_member(suite_version_id, skill_id)
WHERE skill_id IS NOT NULL;
CREATE INDEX idx_skill_suite_member_version
ON skill_suite_version_member(skill_version_id);
ALTER TABLE skill_suite
ADD CONSTRAINT fk_skill_suite_latest_version
FOREIGN KEY (latest_version_id) REFERENCES skill_suite_version(id) ON DELETE SET NULL;

View file

@ -0,0 +1,26 @@
-- Keep legacy Skill columns during the compatibility window while adding one typed review identity.
ALTER TABLE review_task
ADD COLUMN subject_type VARCHAR(32),
ADD COLUMN subject_id BIGINT,
ADD COLUMN subject_version_id BIGINT,
ADD COLUMN subject_version VARCHAR(64);
UPDATE review_task
SET subject_type = 'SKILL_VERSION',
subject_id = skill_id,
subject_version_id = skill_version_id,
subject_version = skill_version;
ALTER TABLE review_task
ALTER COLUMN subject_type SET NOT NULL,
ALTER COLUMN subject_id SET NOT NULL,
ALTER COLUMN subject_version SET NOT NULL,
ALTER COLUMN skill_id DROP NOT NULL,
ALTER COLUMN skill_version DROP NOT NULL;
CREATE INDEX idx_review_task_subject_attempts
ON review_task(subject_type, subject_id, subject_version, submitted_at DESC, id DESC);
CREATE UNIQUE INDEX idx_review_task_suite_version_pending
ON review_task(subject_type, subject_version_id)
WHERE subject_type = 'SUITE_VERSION' AND status = 'PENDING';

View file

@ -0,0 +1,13 @@
-- Keeps Suite install-plan counters idempotent across safe client retries.
CREATE TABLE skill_suite_install_operation (
operation_id VARCHAR(64) PRIMARY KEY,
client_request_id VARCHAR(64) NOT NULL,
actor_key VARCHAR(160) NOT NULL,
suite_id BIGINT NOT NULL,
suite_version_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_skill_suite_install_client_actor UNIQUE (client_request_id, actor_key)
);
CREATE INDEX idx_skill_suite_install_operation_created_at
ON skill_suite_install_operation(created_at);

View file

@ -0,0 +1,2 @@
ALTER TABLE skill_suite_version
ADD COLUMN overview TEXT;

View file

@ -198,3 +198,36 @@ error.skillReview.reason.tooLong=Moderation reason must not exceed {0} character
error.pagination.invalid=Page must be non-negative and size must be between 1 and {0}
error.request.conflict=The data changed while this request was being processed. Refresh and try again.
error.skillReview.notInteractable=Reviews are available only for published skills
# Skill Suites
error.suite.notFound=Skill Suite not found: {0}
error.suite.version.notFound=Skill Suite version not found: {0}
error.suite.version.mismatch=The Suite version does not belong to this Suite
error.suite.version.immutable=Published or submitted Suite version {0} cannot be edited
error.suite.version.notPublished=Skill Suite version {0} is not published
error.suite.members.empty=A Skill Suite must contain at least one Skill
error.suite.members.limit=A Skill Suite cannot contain more than {0} Skills
error.suite.members.duplicate=A Skill Suite cannot contain multiple versions of the same Skill
error.suite.members.unavailable=One or more Suite members are unavailable: {0}
error.suite.entry.notMember=The Entry Skill must be one of the Suite members
error.suite.namespace.notWritable=The Suite namespace is not writable: {0}
error.suite.lifecycle.noPermission=You do not have permission to manage this Skill Suite
error.suite.review.rolloutDisabled=Suite review submission is disabled until the typed-review rollout is complete
error.suite.review.private=Private Skill Suites do not require review
error.suite.review.notDraft=Skill Suite version {0} is not a draft
error.suite.review.notPending=Skill Suite version {0} is not pending review
error.suite.review.notRejected=Skill Suite version {0} was not rejected
error.suite.review.subjectMismatch=The review task does not target a Skill Suite version
error.suite.publish.notPrivate=Only private Skill Suites can be published without review
error.suite.publish.notDraft=Skill Suite version {0} is not a draft
error.suite.displayName.required=Skill Suite display name is required
error.suite.version.required=Skill Suite version is required
error.suite.slug.exists=A Skill Suite with slug {0} already exists in this namespace
error.suite.access.denied=You do not have permission to view this Skill Suite
error.suite.install.unavailable=The Skill Suite cannot be installed because one or more members are unavailable
error.suite.notActive=Skill Suite is not active: {0}
error.suite.version.exists=Skill Suite version already exists: {0}
error.suite.version.renameNotAllowed=A Skill Suite version cannot be renamed; create a new version instead
error.suite.delete.pendingReview=Skill Suite cannot be deleted while a review is pending
error.suite.install.operationConflict=The idempotency key was already used for another Skill Suite version
error.suite.install.idempotencyKey.invalid=The Suite install idempotency key is invalid

View file

@ -198,3 +198,36 @@ error.skillReview.reason.tooLong=管理原因不能超过 {0} 个字符
error.pagination.invalid=页码不能为负数,每页数量必须在 1 到 {0} 之间
error.request.conflict=数据在请求处理期间已发生变化,请刷新后重试
error.skillReview.notInteractable=仅已发布的技能可以评价
# 技能套件
error.suite.notFound=未找到技能套件:{0}
error.suite.version.notFound=未找到技能套件版本:{0}
error.suite.version.mismatch=该套件版本不属于当前套件
error.suite.version.immutable=已提交或已发布的套件版本 {0} 不可编辑
error.suite.version.notPublished=技能套件版本 {0} 未发布
error.suite.members.empty=技能套件至少需要包含一个技能
error.suite.members.limit=技能套件最多包含 {0} 个技能
error.suite.members.duplicate=技能套件不能包含同一技能的多个版本
error.suite.members.unavailable=一个或多个套件成员当前不可用:{0}
error.suite.entry.notMember=入口技能必须是套件成员
error.suite.namespace.notWritable=套件所在命名空间不可写:{0}
error.suite.lifecycle.noPermission=无权管理该技能套件
error.suite.review.rolloutDisabled=类型化审核完成滚动升级前,暂不开放套件审核提交
error.suite.review.private=私有技能套件不需要审核
error.suite.review.notDraft=技能套件版本 {0} 不是草稿
error.suite.review.notPending=技能套件版本 {0} 不在待审状态
error.suite.review.notRejected=技能套件版本 {0} 未被驳回
error.suite.review.subjectMismatch=该审核任务不属于技能套件版本
error.suite.publish.notPrivate=只有私有技能套件可以免审核发布
error.suite.publish.notDraft=技能套件版本 {0} 不是草稿
error.suite.displayName.required=技能套件显示名称不能为空
error.suite.version.required=技能套件版本不能为空
error.suite.slug.exists=当前命名空间已存在 slug 为 {0} 的技能套件
error.suite.access.denied=无权查看该技能套件
error.suite.install.unavailable=技能套件中存在当前不可用的成员,无法安装
error.suite.notActive=技能套件当前不可用:{0}
error.suite.version.exists=技能套件版本已存在:{0}
error.suite.version.renameNotAllowed=不能修改技能套件版本号,请创建新版本
error.suite.delete.pendingReview=技能套件存在待审核版本,暂不能删除
error.suite.install.operationConflict=该幂等键已用于其他技能套件版本
error.suite.install.idempotencyKey.invalid=技能套件安装幂等键格式无效

View file

@ -31,7 +31,8 @@ class WellKnownControllerTest {
void clawhubConfig_returns_apiBase() throws Exception {
mockMvc.perform(get("/.well-known/clawhub.json"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.apiBase").value("/api/v1"));
.andExpect(jsonPath("$.apiBase").value("/api/v1"))
.andExpect(jsonPath("$.capabilities[0]").value("skill-suite-v1"));
}
@Test

View file

@ -0,0 +1,61 @@
package com.iflytek.skillhub.config;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.env.SystemEnvironmentPropertySource;
import org.springframework.core.io.ClassPathResource;
import static org.assertj.core.api.Assertions.assertThat;
class SkillSuiteReviewConfigurationTest {
@Test
void defaultConfigurationKeepsSuiteReviewWritesFailClosed() throws IOException {
ConfigurableEnvironment environment = loadApplicationEnvironment(
List.of("application.yml"), Map.of());
assertThat(environment.getProperty("skillhub.suite.review-writes-enabled", Boolean.class))
.isFalse();
}
@Test
void localProfileEnablesSuiteReviewWrites() throws IOException {
ConfigurableEnvironment environment = loadApplicationEnvironment(
List.of("application-local.yml", "application.yml"), Map.of());
assertThat(environment.getProperty("skillhub.suite.review-writes-enabled", Boolean.class))
.isTrue();
}
@Test
void localProfileCanExplicitlyDisableSuiteReviewWrites() throws IOException {
ConfigurableEnvironment environment = loadApplicationEnvironment(
List.of("application-local.yml", "application.yml"),
Map.of("SKILLHUB_SUITE_REVIEW_WRITES_ENABLED", "false"));
assertThat(environment.getProperty("skillhub.suite.review-writes-enabled", Boolean.class))
.isFalse();
}
private ConfigurableEnvironment loadApplicationEnvironment(List<String> resourceNames,
Map<String, Object> environmentVariables)
throws IOException {
ConfigurableEnvironment environment = new StandardEnvironment();
environment.getPropertySources().addFirst(
new SystemEnvironmentPropertySource("test-env", environmentVariables));
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
for (String resourceName : resourceNames) {
List<org.springframework.core.env.PropertySource<?>> sources = loader.load(
resourceName,
new ClassPathResource(resourceName));
sources.forEach(environment.getPropertySources()::addLast);
}
return environment;
}
}

View file

@ -160,6 +160,8 @@ class SkillSearchControllerTest {
mockMvc.perform(get("/api/web/skills"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items[0].slug").value("demo-skill"))
// Legacy clients keep receiving the Skill-only contract after Suite support ships.
.andExpect(jsonPath("$.data.items[0].resourceType").doesNotExist())
.andExpect(jsonPath("$.data.items[0].labels").doesNotExist());
}

View file

@ -0,0 +1,345 @@
package com.iflytek.skillhub.integration;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import com.iflytek.skillhub.domain.audit.AuditLogRepository;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.ReviewPermissionChecker;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
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.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteActionContext;
import com.iflytek.skillhub.domain.suite.SkillSuiteLifecycleService;
import com.iflytek.skillhub.domain.suite.SkillSuiteMemberSelection;
import com.iflytek.skillhub.domain.suite.SkillSuitePublicationValidator;
import com.iflytek.skillhub.domain.suite.SkillSuiteRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersion;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMemberRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionStatus;
import com.iflytek.skillhub.domain.user.UserAccount;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.PageRequest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.TestPropertySource;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
@Testcontainers
@Import({SkillSuiteLifecycleService.class, ReviewPermissionChecker.class,
AuditLogService.class, SkillSuiteHardDeleteIntegrationTest.ClockConfiguration.class})
@TestPropertySource(properties = "skillhub.suite.review-writes-enabled=true")
class SkillSuiteHardDeleteIntegrationTest {
@Container
private static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configurePostgres(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
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 private TestEntityManager entityManager;
@SpyBean private SkillSuiteRepository suiteRepository;
@Autowired private SkillSuiteVersionRepository suiteVersionRepository;
@Autowired private SkillSuiteVersionMemberRepository suiteMemberRepository;
@Autowired private ReviewTaskRepository reviewTaskRepository;
@Autowired private NamespaceRepository namespaceRepository;
@Autowired private AuditLogRepository auditLogRepository;
@Autowired private TransactionTemplate transactionTemplate;
@Autowired private SkillSuiteLifecycleService service;
@MockBean private SkillSuitePublicationValidator publicationValidator;
@SpyBean private AuditLogService auditLogService;
private void persistUsers(String ownerId, String authorId) {
entityManager.persist(new UserAccount(ownerId, "Owner", null, null));
entityManager.persist(new UserAccount(authorId, "Author", null, null));
entityManager.flush();
}
@Test
void hardDeleteRemovesOnlySuiteOwnedGraphAndKeepsAuditAndMemberSkill() {
persistUsers("owner", "author");
Namespace namespace = entityManager.persistFlushFind(
new Namespace("delete-suite-team", "Delete Suite Team", "owner"));
Skill memberSkill = entityManager.persistFlushFind(
new Skill(namespace.getId(), "member-skill", "author", SkillVisibility.PUBLIC));
SkillVersion memberVersion = entityManager.persistFlushFind(
new SkillVersion(memberSkill.getId(), "1.0.0", "author"));
SuiteGraph target = persistSuiteGraph(
namespace, memberSkill, memberVersion, "target-suite", "author");
SuiteGraph retained = persistSuiteGraph(
namespace, memberSkill, memberVersion, "retained-suite", "author");
ReviewTask skillReview = new ReviewTask(
memberVersion.getId(), memberSkill.getId(), namespace.getId(), "1.0.0", "author");
skillReview.setStatus(ReviewTaskStatus.APPROVED);
skillReview = entityManager.persistFlushFind(skillReview);
entityManager.flush();
service.delete(target.suite().getId(), ownerContext(namespace.getId(), "owner"));
entityManager.flush();
entityManager.clear();
assertThat(suiteRepository.findById(target.suite().getId())).isEmpty();
assertThat(suiteVersionRepository.findById(target.version().getId())).isEmpty();
assertThat(suiteMemberRepository.findBySuiteVersionIdOrderByPosition(target.version().getId())).isEmpty();
assertThat(reviewTaskRepository.findById(target.reviewTask().getId())).isEmpty();
assertThat(suiteRepository.findById(retained.suite().getId())).isPresent();
assertThat(suiteMemberRepository.findBySuiteVersionIdOrderByPosition(retained.version().getId()))
.extracting(SkillSuiteVersionMember::getId)
.containsExactly(retained.member().getId());
assertThat(reviewTaskRepository.findById(retained.reviewTask().getId())).isPresent();
assertThat(reviewTaskRepository.findById(skillReview.getId())).isPresent();
assertThat(entityManager.find(Skill.class, memberSkill.getId())).isNotNull();
assertThat(entityManager.find(SkillVersion.class, memberVersion.getId())).isNotNull();
assertThat(auditLogRepository.search(
"owner", "DELETE_SKILL_SUITE", PageRequest.of(0, 10))).anySatisfy(log -> {
assertThat(log.getAction()).isEqualTo("DELETE_SKILL_SUITE");
assertThat(log.getTargetType()).isEqualTo("SKILL_SUITE");
assertThat(log.getTargetId()).isEqualTo(target.suite().getId());
});
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void hardDeleteRollsBackReviewCleanupWhenSuiteDeleteFails() {
RollbackFixture fixture = transactionTemplate.execute(status -> {
persistUsers("rollback-owner", "rollback-author");
Namespace namespace = entityManager.persistFlushFind(
new Namespace("rollback-suite-team", "Rollback Suite Team", "rollback-owner"));
Skill memberSkill = entityManager.persistFlushFind(
new Skill(namespace.getId(), "rollback-member", "rollback-author", SkillVisibility.PUBLIC));
SkillVersion memberVersion = entityManager.persistFlushFind(
new SkillVersion(memberSkill.getId(), "1.0.0", "rollback-author"));
SuiteGraph graph = persistSuiteGraph(
namespace, memberSkill, memberVersion, "rollback-suite", "rollback-author");
return new RollbackFixture(
namespace.getId(), graph.suite().getId(), graph.version().getId(),
graph.member().getId(), graph.reviewTask().getId());
});
doThrow(new DataIntegrityViolationException("forced Suite delete failure"))
.when(suiteRepository).delete(argThat(
suite -> suite.getId().equals(fixture.suiteId())));
assertThatThrownBy(() -> service.delete(
fixture.suiteId(), ownerContext(fixture.namespaceId(), "rollback-owner")))
.isInstanceOf(DataIntegrityViolationException.class);
assertThat(suiteRepository.findById(fixture.suiteId())).isPresent();
assertThat(suiteVersionRepository.findById(fixture.versionId())).isPresent();
assertThat(suiteMemberRepository.findBySuiteVersionIdOrderByPosition(fixture.versionId()))
.extracting(SkillSuiteVersionMember::getId)
.containsExactly(fixture.memberId());
assertThat(reviewTaskRepository.findById(fixture.reviewTaskId())).isPresent();
assertThat(auditLogRepository.search(
"rollback-owner", "DELETE_SKILL_SUITE", PageRequest.of(0, 10))).isEmpty();
}
@Test
void rejectedSuiteCanBeReopenedAndResubmittedWithoutLosingReviewHistory() {
persistUsers("review-owner", "review-author");
Namespace namespace = entityManager.persistFlushFind(
new Namespace("review-history-team", "Review History Team", "review-owner"));
SkillSuite suite = entityManager.persistFlushFind(
new SkillSuite(namespace.getId(), "review-history-suite", "Review History Suite", "review-author"));
SkillSuiteVersion version = new SkillSuiteVersion(
suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "review-author");
version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW);
version = entityManager.persistFlushFind(version);
ReviewTask firstRound = entityManager.persistFlushFind(ReviewTask.forSuiteVersion(
version.getId(), suite.getId(), namespace.getId(), version.getVersion(), "review-author"));
service.rejectReview(
firstRound.getId(), "Please update the member description",
ownerContext(namespace.getId(), "review-owner"));
service.reopenRejected(
suite.getId(), version.getId(), authorContext(namespace.getId(), "review-author"));
ReviewTask secondRound = service.submitForReview(
suite.getId(), version.getId(), authorContext(namespace.getId(), "review-author"));
entityManager.flush();
entityManager.clear();
assertThat(reviewTaskRepository
.findBySubmittedByAndSubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc(
"review-author", firstRound.getSubjectType(), suite.getId(), version.getVersion()))
.satisfiesExactly(
review -> {
assertThat(review.getId()).isEqualTo(secondRound.getId());
assertThat(review.getStatus()).isEqualTo(ReviewTaskStatus.PENDING);
},
review -> {
assertThat(review.getId()).isEqualTo(firstRound.getId());
assertThat(review.getStatus()).isEqualTo(ReviewTaskStatus.REJECTED);
assertThat(review.getReviewedBy()).isEqualTo("review-owner");
assertThat(review.getReviewComment()).isEqualTo("Please update the member description");
});
assertThat(suiteVersionRepository.findById(version.getId()))
.get()
.extracting(SkillSuiteVersion::getStatus)
.isEqualTo(SkillSuiteVersionStatus.PENDING_REVIEW);
}
@Test
@Transactional(propagation = Propagation.NOT_SUPPORTED)
void approveReviewRollsBackTheDecisionAndPublicationWhenAuditFails() {
ApprovalRollbackFixture fixture = transactionTemplate.execute(status -> {
persistUsers("approval-owner", "approval-author");
Namespace namespace = entityManager.persistFlushFind(
new Namespace("approval-rollback-team", "Approval Rollback Team", "approval-owner"));
SkillSuite suite = entityManager.persistFlushFind(
new SkillSuite(namespace.getId(), "approval-rollback-suite", "Approval Suite", "approval-author"));
SkillSuiteVersion version = new SkillSuiteVersion(
suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "approval-author");
version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW);
version = entityManager.persistFlushFind(version);
ReviewTask review = entityManager.persistFlushFind(ReviewTask.forSuiteVersion(
version.getId(), suite.getId(), namespace.getId(), version.getVersion(), "approval-author"));
return new ApprovalRollbackFixture(namespace.getId(), suite.getId(), version.getId(), review.getId());
});
doThrow(new IllegalStateException("forced audit failure after publication"))
.when(auditLogService).record(
eq("approval-owner"), eq("APPROVE_SKILL_SUITE_REVIEW"),
eq("REVIEW_TASK"), eq(fixture.reviewTaskId()),
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
assertThatThrownBy(() -> service.approveReview(
fixture.reviewTaskId(), "Approved",
ownerContext(fixture.namespaceId(), "approval-owner")))
.isInstanceOf(IllegalStateException.class);
transactionTemplate.executeWithoutResult(status -> {
ReviewTask review = reviewTaskRepository.findById(fixture.reviewTaskId()).orElseThrow();
SkillSuiteVersion version = suiteVersionRepository.findById(fixture.versionId()).orElseThrow();
SkillSuite suite = suiteRepository.findById(fixture.suiteId()).orElseThrow();
assertThat(review.getStatus()).isEqualTo(ReviewTaskStatus.PENDING);
assertThat(review.getReviewedBy()).isNull();
assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.PENDING_REVIEW);
assertThat(suite.getLatestVersionId()).isNull();
assertThat(auditLogRepository.search(
"approval-owner", "APPROVE_SKILL_SUITE_REVIEW", PageRequest.of(0, 10))).isEmpty();
});
}
private SuiteGraph persistSuiteGraph(
Namespace namespace,
Skill memberSkill,
SkillVersion memberVersion,
String slug,
String authorId) {
SkillSuite suite = entityManager.persistFlushFind(
new SkillSuite(namespace.getId(), slug, slug, authorId));
SkillSuiteVersion version = new SkillSuiteVersion(
suite.getId(), "1.0.0", SkillVisibility.PUBLIC, authorId);
version.setStatus(SkillSuiteVersionStatus.PUBLISHED);
version = entityManager.persistFlushFind(version);
SkillSuiteVersionMember member = entityManager.persistFlushFind(new SkillSuiteVersionMember(
version.getId(),
new SkillSuiteMemberSelection(
memberSkill.getId(), memberVersion.getId(), namespace.getSlug(),
memberSkill.getSlug(), memberVersion.getVersion(), "a".repeat(64)),
0));
ReviewTask reviewTask = ReviewTask.forSuiteVersion(
version.getId(), suite.getId(), namespace.getId(), version.getVersion(), authorId);
reviewTask.setStatus(ReviewTaskStatus.APPROVED);
reviewTask = entityManager.persistFlushFind(reviewTask);
return new SuiteGraph(suite, version, member, reviewTask);
}
private SkillSuiteActionContext ownerContext(Long namespaceId, String ownerId) {
return new SkillSuiteActionContext(
ownerId,
Map.of(namespaceId, NamespaceRole.OWNER),
Set.of(),
"delete-suite-request",
"127.0.0.1",
"integration-test");
}
private SkillSuiteActionContext authorContext(Long namespaceId, String authorId) {
return new SkillSuiteActionContext(
authorId,
Map.of(namespaceId, NamespaceRole.MEMBER),
Set.of(),
"suite-review-request",
"127.0.0.1",
"integration-test");
}
private record SuiteGraph(
SkillSuite suite,
SkillSuiteVersion version,
SkillSuiteVersionMember member,
ReviewTask reviewTask) {}
private record RollbackFixture(
Long namespaceId,
Long suiteId,
Long versionId,
Long memberId,
Long reviewTaskId) {}
private record ApprovalRollbackFixture(
Long namespaceId,
Long suiteId,
Long versionId,
Long reviewTaskId) {}
@TestConfiguration
static class ClockConfiguration {
@Bean
Clock clock() {
return Clock.fixed(Instant.parse("2026-09-08T06:00:00Z"), ZoneOffset.UTC);
}
}
}

View file

@ -0,0 +1,280 @@
package com.iflytek.skillhub.integration;
import static org.assertj.core.api.Assertions.assertThat;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteMemberSelection;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersion;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMemberRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionStatus;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.search.postgres.PostgresResourceDiscoveryQueryService;
import com.iflytek.skillhub.service.ResourceDiscoveryAppService;
import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.TestPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ActiveProfiles("test")
@Import({PostgresResourceDiscoveryQueryService.class, ResourceDiscoveryAppService.class,
MySkillSuiteQueryRepository.class})
@Testcontainers
@TestPropertySource(properties = {
"spring.flyway.enabled=true",
"spring.jpa.hibernate.ddl-auto=validate"
})
class SuiteDiscoveryIntegrationTest {
@Container
private static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configurePostgres(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
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");
}
@Autowired
private TestEntityManager entityManager;
@Autowired
private ResourceDiscoveryAppService appService;
@Autowired
private MySkillSuiteQueryRepository mySuiteRepository;
@Autowired
private SkillSuiteVersionMemberRepository suiteMemberRepository;
@BeforeEach
void seedReferencedUsers() {
entityManager.persist(new UserAccount("owner", "Owner", null, null));
entityManager.persist(new UserAccount("author", "Author", null, null));
entityManager.persist(new UserAccount("other-author", "Other Author", null, null));
entityManager.flush();
}
@Test
void returnsSkillAndSuiteWithTheSameCoordinateAsDistinctResourceTypes() {
Namespace namespace = entityManager.persistFlushFind(
new Namespace("team-ai", "AI Team", "owner"));
Skill skill = new Skill(namespace.getId(), "starter", "owner", SkillVisibility.PUBLIC);
skill.setDisplayName("Starter Skill");
skill.setSummary("A standalone skill");
skill = entityManager.persistFlushFind(skill);
SkillVersion skillVersion = new SkillVersion(skill.getId(), "1.2.0", "owner");
skillVersion.setStatus(SkillVersionStatus.PUBLISHED);
skillVersion.setDownloadReady(true);
skillVersion.setPublishedAt(Instant.parse("2026-09-01T10:00:00Z"));
skillVersion = entityManager.persistFlushFind(skillVersion);
skill.setLatestVersionId(skillVersion.getId());
entityManager.persistAndFlush(skill);
SkillSuite suite = new SkillSuite(namespace.getId(), "starter", "Mutable container name", "owner");
suite.setSummary("Mutable container summary");
suite = entityManager.persistFlushFind(suite);
SkillSuiteVersion suiteVersion = new SkillSuiteVersion(
suite.getId(), "2.0.0", "Published snapshot name", "Published snapshot summary",
SkillVisibility.PUBLIC, "owner");
suiteVersion.setStatus(SkillSuiteVersionStatus.PUBLISHED);
suiteVersion.setPublishedAt(Instant.parse("2026-09-02T10:00:00Z"));
suiteVersion = entityManager.persistFlushFind(suiteVersion);
entityManager.persist(new SkillSuiteVersionMember(
suiteVersion.getId(),
new SkillSuiteMemberSelection(
skill.getId(), skillVersion.getId(), namespace.getSlug(),
skill.getSlug(), skillVersion.getVersion(), "a".repeat(64)),
0));
suite.setLatestVersionId(suiteVersion.getId());
entityManager.persistAndFlush(suite);
entityManager.clear();
var result = appService.search("starter", "team-ai", "", "relevance", 0, 20, Set.of());
assertThat(result.total()).isEqualTo(2);
assertThat(result.items()).extracting(item -> item.resourceType())
.containsExactlyInAnyOrder("SKILL", "SUITE");
assertThat(result.items()).allSatisfy(item -> {
assertThat(item.namespace()).isEqualTo("team-ai");
assertThat(item.slug()).isEqualTo("starter");
assertThat(item.available()).isTrue();
});
assertThat(result.items()).extracting(item -> item.detailUrl())
.containsExactlyInAnyOrder("/space/team-ai/starter", "/suite/team-ai/starter");
assertThat(result.items()).filteredOn(item -> "SUITE".equals(item.resourceType()))
.singleElement()
.satisfies(item -> {
assertThat(item.displayName()).isEqualTo("Published snapshot name");
assertThat(item.summary()).isEqualTo("Published snapshot summary");
});
}
@Test
void exposesNamespaceOnlyResourcesOnlyToNamespaceMembers() {
Namespace namespace = entityManager.persistFlushFind(
new Namespace("private-team", "Private Team", "owner"));
Skill skill = entityManager.persistFlushFind(
new Skill(namespace.getId(), "internal", "owner", SkillVisibility.NAMESPACE_ONLY));
SkillVersion version = new SkillVersion(skill.getId(), "1.0.0", "owner");
version.setStatus(SkillVersionStatus.PUBLISHED);
version.setDownloadReady(true);
version = entityManager.persistFlushFind(version);
skill.setLatestVersionId(version.getId());
entityManager.persistAndFlush(skill);
entityManager.clear();
assertThat(appService.search("", "", "SKILL", "newest", 0, 20, Set.of()).items())
.isEmpty();
assertThat(appService.search(
"", "", "SKILL", "newest", 0, 20, Set.of(namespace.getId())).items())
.singleElement()
.satisfies(item -> assertThat(item.slug()).isEqualTo("internal"));
}
@Test
void dashboardReturnsTheLatestVersionTheCallerCanManage() {
Namespace namespace = entityManager.persistFlushFind(
new Namespace("managed-team", "Managed Team", "owner"));
SkillSuite suite = entityManager.persistFlushFind(
new SkillSuite(namespace.getId(), "writers", "Writers", "author"));
SkillSuiteVersion ownVersion = entityManager.persistFlushFind(
new SkillSuiteVersion(suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "author"));
SkillSuiteVersion adminVersion = entityManager.persistFlushFind(
new SkillSuiteVersion(
suite.getId(), "2.0.0", "Writers 2", "Second draft",
SkillVisibility.PUBLIC, "other-author"));
entityManager.clear();
var authorPage = mySuiteRepository.findMine(
"author", Set.of(namespace.getId()), Set.of(), "writers", 0, 20);
assertThat(authorPage.items()).singleElement().satisfies(item -> {
assertThat(item.versionId()).isEqualTo(adminVersion.getId());
assertThat(item.version()).isEqualTo("2.0.0");
});
assertThat(mySuiteRepository.findMine(
"other-author", Set.of(namespace.getId()), Set.of(), "writers", 0, 20).items())
.isEmpty();
var adminPage = mySuiteRepository.findMine(
"owner", Set.of(namespace.getId()), Set.of(namespace.getId()), "", 0, 20);
assertThat(adminPage.items()).singleElement().satisfies(item -> {
assertThat(item.versionId()).isEqualTo(adminVersion.getId());
assertThat(item.version()).isEqualTo("2.0.0");
assertThat(item.displayName()).isEqualTo("Writers 2");
assertThat(item.summary()).isEqualTo("Second draft");
});
}
@Test
void hardDeletingMemberSkillPreservesThePublishedSuiteSnapshot() {
Namespace namespace = entityManager.persistFlushFind(
new Namespace("snapshot-team", "Snapshot Team", "owner"));
Skill skill = entityManager.persistFlushFind(
new Skill(namespace.getId(), "archived-writer", "owner", SkillVisibility.PUBLIC));
SkillVersion skillVersion = new SkillVersion(skill.getId(), "3.1.4", "owner");
skillVersion.setStatus(SkillVersionStatus.PUBLISHED);
skillVersion = entityManager.persistFlushFind(skillVersion);
SkillSuite suite = entityManager.persistFlushFind(
new SkillSuite(namespace.getId(), "historical-pack", "Historical Pack", "owner"));
SkillSuiteVersion suiteVersion = new SkillSuiteVersion(
suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "owner");
suiteVersion.setStatus(SkillSuiteVersionStatus.PUBLISHED);
suiteVersion = entityManager.persistFlushFind(suiteVersion);
SkillSuiteVersionMember member = entityManager.persistFlushFind(
new SkillSuiteVersionMember(
suiteVersion.getId(),
new SkillSuiteMemberSelection(
skill.getId(), skillVersion.getId(), "snapshot-team",
"archived-writer", "3.1.4", "sha512:" + "b".repeat(128)),
0));
entityManager.getEntityManager().createNativeQuery("DELETE FROM skill_version WHERE id = :id")
.setParameter("id", skillVersion.getId())
.executeUpdate();
entityManager.getEntityManager().createNativeQuery("DELETE FROM skill WHERE id = :id")
.setParameter("id", skill.getId())
.executeUpdate();
entityManager.flush();
entityManager.clear();
Object[] snapshot = (Object[]) entityManager.getEntityManager().createNativeQuery("""
SELECT skill_id, skill_version_id, namespace_slug_snapshot,
skill_slug_snapshot, skill_version_snapshot, fingerprint_snapshot
FROM skill_suite_version_member
WHERE id = :id
""").setParameter("id", member.getId()).getSingleResult();
assertThat(snapshot[0]).isNull();
assertThat(snapshot[1]).isNull();
assertThat(snapshot[2]).isEqualTo("snapshot-team");
assertThat(snapshot[3]).isEqualTo("archived-writer");
assertThat(snapshot[4]).isEqualTo("3.1.4");
assertThat(snapshot[5]).isEqualTo("sha512:" + "b".repeat(128));
}
@Test
void replacesSuiteMembersAtTheSamePositionWithoutAUniqueConstraintConflict() {
Namespace namespace = entityManager.persistFlushFind(
new Namespace("replace-team", "Replace Team", "owner"));
Skill skill = entityManager.persistFlushFind(
new Skill(namespace.getId(), "replacement", "owner", SkillVisibility.PUBLIC));
SkillVersion firstVersion = entityManager.persistFlushFind(
new SkillVersion(skill.getId(), "1.0.0", "owner"));
SkillVersion secondVersion = entityManager.persistFlushFind(
new SkillVersion(skill.getId(), "2.0.0", "owner"));
SkillSuite suite = entityManager.persistFlushFind(
new SkillSuite(namespace.getId(), "replaceable", "Replaceable", "owner"));
SkillSuiteVersion suiteVersion = entityManager.persistFlushFind(
new SkillSuiteVersion(suite.getId(), "1.0.0", SkillVisibility.PRIVATE, "owner"));
entityManager.persistAndFlush(new SkillSuiteVersionMember(
suiteVersion.getId(),
new SkillSuiteMemberSelection(
skill.getId(), firstVersion.getId(), "replace-team",
"replacement", "1.0.0", "sha256:" + "a".repeat(64)),
0));
entityManager.clear();
suiteMemberRepository.deleteBySuiteVersionId(suiteVersion.getId());
suiteMemberRepository.saveAll(List.of(new SkillSuiteVersionMember(
suiteVersion.getId(),
new SkillSuiteMemberSelection(
skill.getId(), secondVersion.getId(), "replace-team",
"replacement", "2.0.0", "sha256:" + "b".repeat(64)),
0)));
entityManager.clear();
assertThat(suiteMemberRepository.findBySuiteVersionIdOrderByPosition(suiteVersion.getId()))
.singleElement()
.satisfies(member -> {
assertThat(member.getPosition()).isZero();
assertThat(member.getSkillVersionId()).isEqualTo(secondVersion.getId());
assertThat(member.getSkillVersionSnapshot()).isEqualTo("2.0.0");
});
}
}

View file

@ -14,6 +14,9 @@ import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import java.time.Instant;
@ -39,6 +42,12 @@ class JpaGovernanceQueryRepositoryTest {
@Mock
private UserAccountRepository userAccountRepository;
@Mock
private SkillSuiteRepository suiteRepository;
@Mock
private SkillSuiteVersionRepository suiteVersionRepository;
private JpaGovernanceQueryRepository repository;
@BeforeEach
@ -47,7 +56,9 @@ class JpaGovernanceQueryRepositoryTest {
skillRepository,
skillVersionRepository,
namespaceRepository,
userAccountRepository
userAccountRepository,
suiteRepository,
suiteVersionRepository
);
}
@ -112,6 +123,31 @@ class JpaGovernanceQueryRepositoryTest {
});
}
@Test
void getReviewTaskResponses_assemblesTypedSuiteReviewWithoutSkillColumns() {
ReviewTask task = ReviewTask.forSuiteVersion(301L, 201L, 11L, "1.0.0", "submitter");
setField(task, "id", 7L);
SkillSuite suite = new SkillSuite(11L, "starter-pack", "Starter Pack", "submitter");
setField(suite, "id", 201L);
Namespace namespace = new Namespace("team-a", "Team A", "submitter");
setField(namespace, "id", 11L);
UserAccount submitter = new UserAccount("submitter", "Submitter", "submitter@example.com", null);
given(suiteVersionRepository.findByIdIn(List.of(301L))).willReturn(List.of());
given(suiteRepository.findByIdIn(List.of(201L))).willReturn(List.of(suite));
given(namespaceRepository.findByIdIn(List.of(11L))).willReturn(List.of(namespace));
given(userAccountRepository.findByIdIn(List.of("submitter"))).willReturn(List.of(submitter));
var response = repository.getReviewTaskResponses(List.of(task)).get(0);
assertThat(response.subjectType()).isEqualTo("SUITE_VERSION");
assertThat(response.subjectId()).isEqualTo(201L);
assertThat(response.subjectVersionId()).isEqualTo(301L);
assertThat(response.subjectSlug()).isEqualTo("starter-pack");
assertThat(response.skillVersionId()).isNull();
assertThat(response.skillSlug()).isNull();
}
@Test
void getPromotionResponses_assemblesPromotionReadModel() {
PromotionRequest request = new PromotionRequest(201L, 101L, 12L, "submitter");

View file

@ -5,6 +5,8 @@ import static org.assertj.core.api.Assertions.assertThat;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.review.ReviewTask;
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.SkillVisibility;
import java.time.Instant;
@ -133,6 +135,33 @@ class JpaReviewProgressQueryRepositoryTest {
assertThat(searchMiss.statusCounts().rejected()).isZero();
}
@Test
void includesSuiteAttemptsWithoutRequiringLegacySkillColumns() {
Namespace namespace = entityManager.persistFlushFind(
new Namespace("team-suite-review", "Suite Review Team", "owner"));
SkillSuite suite = entityManager.persistFlushFind(
new SkillSuite(namespace.getId(), "starter-pack", "Starter Pack", "author-1"));
SkillSuiteVersion suiteVersion = entityManager.persistFlushFind(
new SkillSuiteVersion(suite.getId(), "1.0.0", SkillVisibility.PUBLIC, "author-1"));
ReviewTask task = ReviewTask.forSuiteVersion(
suiteVersion.getId(), suite.getId(), namespace.getId(), suiteVersion.getVersion(), "author-1");
entityManager.persist(task);
entityManager.flush();
entityManager.clear();
var progress = repository.findMyProgress("author-1", null, "STARTER", 0, 20);
assertThat(progress.items()).singleElement().satisfies(item -> {
assertThat(item.skillId()).isNull();
assertThat(item.skillSlug()).isNull();
assertThat(item.subjectType()).isEqualTo("SUITE_VERSION");
assertThat(item.subjectId()).isEqualTo(suite.getId());
assertThat(item.subjectVersionId()).isEqualTo(suiteVersion.getId());
assertThat(item.subjectSlug()).isEqualTo("starter-pack");
});
assertThat(progress.statusCounts().pending()).isEqualTo(1);
}
private void persistAttempt(
Skill skill,
Namespace namespace,

View file

@ -0,0 +1,131 @@
package com.iflytek.skillhub.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.ReviewService;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteActionContext;
import com.iflytek.skillhub.domain.suite.SkillSuiteLifecycleService;
import com.iflytek.skillhub.dto.ReviewTaskResponse;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import com.iflytek.skillhub.repository.GovernanceQueryRepository;
import com.iflytek.skillhub.repository.ReviewProgressQueryRepository;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class ReviewPortalAppServiceTest {
@Mock private ReviewService reviewService;
@Mock private ReviewTaskRepository reviewTaskRepository;
@Mock private NamespaceRepository namespaceRepository;
@Mock private GovernanceQueryRepository governanceQueryRepository;
@Mock private ReviewProgressQueryRepository reviewProgressQueryRepository;
@Mock private RbacService rbacService;
@Mock private AuditLogService auditLogService;
@Mock private RequestIdAccessor requestIdAccessor;
@Mock private SkillSuiteLifecycleService suiteLifecycleService;
private ReviewPortalAppService service;
@BeforeEach
void setUp() {
service = new ReviewPortalAppService(
reviewService,
reviewTaskRepository,
namespaceRepository,
governanceQueryRepository,
reviewProgressQueryRepository,
rbacService,
auditLogService,
requestIdAccessor,
suiteLifecycleService);
}
@Test
void approveReviewDispatchesSuiteSubjectsToSuiteLifecycle() {
ReviewTask task = suiteTask(91L);
ReviewTaskResponse response = response(91L);
when(reviewTaskRepository.findById(91L)).thenReturn(Optional.of(task));
when(rbacService.getUserRoleCodes("reviewer")).thenReturn(Set.of("SKILL_ADMIN"));
when(requestIdAccessor.current()).thenReturn("request-1");
when(suiteLifecycleService.approveReview(
org.mockito.ArgumentMatchers.eq(91L),
org.mockito.ArgumentMatchers.eq("looks good"),
org.mockito.ArgumentMatchers.any(SkillSuiteActionContext.class)))
.thenReturn(task);
when(governanceQueryRepository.getReviewTaskResponse(task)).thenReturn(response);
ReviewTaskResponse actual = service.approveReview(
91L, "looks good", "reviewer", Map.of(7L, NamespaceRole.ADMIN),
new AuditRequestContext("127.0.0.1", "test"));
assertThat(actual).isSameAs(response);
ArgumentCaptor<SkillSuiteActionContext> context = ArgumentCaptor.forClass(SkillSuiteActionContext.class);
verify(suiteLifecycleService).approveReview(
org.mockito.ArgumentMatchers.eq(91L),
org.mockito.ArgumentMatchers.eq("looks good"),
context.capture());
assertThat(context.getValue().requestId()).isEqualTo("request-1");
assertThat(context.getValue().platformRoles()).containsExactly("SKILL_ADMIN");
verify(reviewService, never()).approveReview(
org.mockito.ArgumentMatchers.anyLong(),
org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.anyMap(),
org.mockito.ArgumentMatchers.anySet());
}
@Test
void withdrawReviewDispatchesSuiteSubjectsWithoutSkillVersionAccess() {
ReviewTask task = suiteTask(92L);
when(reviewTaskRepository.findById(92L)).thenReturn(Optional.of(task));
when(rbacService.getUserRoleCodes("author")).thenReturn(Set.of());
service.withdrawReview(92L, "author", Map.of(7L, NamespaceRole.MEMBER), null);
verify(suiteLifecycleService).withdrawReview(
org.mockito.ArgumentMatchers.eq(92L),
org.mockito.ArgumentMatchers.any(SkillSuiteActionContext.class));
verify(reviewService, never()).withdrawReview(
org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyString());
}
private ReviewTask suiteTask(Long id) {
ReviewTask task = ReviewTask.forSuiteVersion(31L, 21L, 7L, "1.0.0", "author");
setField(task, "id", id);
return task;
}
private ReviewTaskResponse response(Long id) {
return new ReviewTaskResponse(
id, null, "team", null, "1.0.0", "PENDING", "author", "Author",
null, null, null, null, null,
"SUITE_VERSION", 21L, 31L, "starter-pack");
}
private void setField(Object target, String fieldName, Object value) {
try {
var field = target.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException error) {
throw new AssertionError(error);
}
}
}

View file

@ -0,0 +1,333 @@
package com.iflytek.skillhub.service;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
import com.iflytek.skillhub.domain.suite.SkillSuite;
import com.iflytek.skillhub.domain.suite.SkillSuiteAllowedAction;
import com.iflytek.skillhub.domain.suite.SkillSuiteDraftService;
import com.iflytek.skillhub.domain.suite.SkillSuiteInstallMetricsService;
import com.iflytek.skillhub.domain.suite.SkillSuiteInstallOperation;
import com.iflytek.skillhub.domain.suite.SkillSuiteInstallOperationRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteLifecycleService;
import com.iflytek.skillhub.domain.suite.SkillSuiteMemberAvailability;
import com.iflytek.skillhub.domain.suite.SkillSuiteMemberState;
import com.iflytek.skillhub.domain.suite.SkillSuiteQueryService;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersion;
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionMember;
import com.iflytek.skillhub.repository.SkillSuiteCandidateQueryRepository;
import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository;
import com.iflytek.skillhub.observability.RequestIdAccessor;
import jakarta.servlet.http.HttpServletRequest;
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.junit.jupiter.MockitoExtension;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
class SkillSuiteAppServiceTest {
@Mock private NamespaceRepository namespaceRepository;
@Mock private SkillQueryService skillQueryService;
@Mock private SkillSuiteDraftService draftService;
@Mock private SkillSuiteLifecycleService lifecycleService;
@Mock private SkillSuiteQueryService queryService;
@Mock private SkillSuiteInstallMetricsService installMetricsService;
@Mock private SkillSuiteInstallOperationRepository installOperationRepository;
@Mock private AuditLogService auditLogService;
@Mock private RequestIdAccessor requestIdAccessor;
@Mock private SkillSuiteCandidateQueryRepository candidateQueryRepository;
@Mock private MySkillSuiteQueryRepository mySkillSuiteQueryRepository;
@Mock private HttpServletRequest request;
private SkillSuiteAppService service;
private Namespace namespace;
private SkillSuite suite;
private SkillSuiteVersion version;
private SkillSuiteVersionMember firstMember;
private SkillSuiteVersionMember secondMember;
@BeforeEach
void setUp() {
service = new SkillSuiteAppService(
namespaceRepository, skillQueryService, draftService, lifecycleService,
queryService, installMetricsService, installOperationRepository, auditLogService,
requestIdAccessor,
candidateQueryRepository, mySkillSuiteQueryRepository);
namespace = new Namespace("global", "Global", "admin");
setField(namespace, "id", 1L);
suite = new SkillSuite(1L, "starter", "Starter", "user-1");
setField(suite, "id", 7L);
version = new SkillSuiteVersion(7L, "1.0.0", SkillVisibility.PUBLIC, "user-1");
setField(version, "id", 70L);
version.setOverview("## Install in order");
firstMember = member(11L, 101L, "first", "1.0.0", "sha256:first", 0);
secondMember = member(12L, 102L, "second", "2.0.0", "sha256:second", 1);
version.setEntrySkillVersionId(101L);
}
@Test
void createInstallPlan_resolvesEveryExactMemberBeforeRecordingMetrics() {
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()))
.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()))
.willReturn(resolved(12L, 102L, "second", "2.0.0", "sha256:second"));
given(installOperationRepository.insertIfAbsent(
any(), org.mockito.ArgumentMatchers.eq("retry-1"),
org.mockito.ArgumentMatchers.eq("user:user-1"),
org.mockito.ArgumentMatchers.eq(7L), org.mockito.ArgumentMatchers.eq(70L))).willReturn(1);
var result = service.createInstallPlan(
"global", "starter", null, "user-1", Map.of(), Set.of(), "retry-1", request);
assertThat(result.operationId()).isNotBlank().isNotEqualTo("retry-1");
assertThat(result.fingerprint()).startsWith("sha256:");
assertThat(result.members()).extracting(member -> member.slug())
.containsExactly("first", "second");
assertThat(result.members()).extracting(member -> member.entry())
.containsExactly(true, false);
verify(installMetricsService).recordIssuedPlan(7L);
verify(auditLogService, times(3)).record(
any(), any(), any(), any(), any(), any(), any(), any());
}
@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()))
.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()))
.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");
given(operation.getSuiteId()).willReturn(7L);
given(operation.getSuiteVersionId()).willReturn(70L);
given(installOperationRepository.findByClientRequestIdAndActorKey("retry-1", "user:user-1"))
.willReturn(java.util.Optional.of(operation));
var result = service.createInstallPlan(
"global", "starter", null, "user-1", Map.of(), Set.of(), "retry-1", request);
assertThat(result.operationId()).isEqualTo("server-operation-1");
assertThat(result.version()).isEqualTo("1.0.0");
verify(installOperationRepository, never()).insertIfAbsent(any(), any(), any(), any(), any());
verify(installMetricsService, never()).recordIssuedPlan(any());
verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any());
}
@Test
void createInstallPlan_replaysTheConcurrentWinnerWithoutDuplicateMetrics() {
SkillSuiteQueryService.Detail detail = detail(true);
given(queryService.getDetail("global", "starter", null, "user-1", Map.of(), Set.of()))
.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()))
.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()))
.willReturn(resolved(12L, 102L, "second", "2.0.0", "sha256:second"));
given(installOperationRepository.insertIfAbsent(
any(), org.mockito.ArgumentMatchers.eq("retry-race"),
org.mockito.ArgumentMatchers.eq("user:user-1"),
org.mockito.ArgumentMatchers.eq(7L), org.mockito.ArgumentMatchers.eq(70L))).willReturn(0);
SkillSuiteInstallOperation operation = org.mockito.Mockito.mock(SkillSuiteInstallOperation.class);
given(operation.getOperationId()).willReturn("concurrent-operation");
given(operation.getSuiteId()).willReturn(7L);
given(operation.getSuiteVersionId()).willReturn(70L);
given(installOperationRepository.findByClientRequestIdAndActorKey("retry-race", "user:user-1"))
.willReturn(java.util.Optional.empty(), java.util.Optional.of(operation));
var result = service.createInstallPlan(
"global", "starter", null, "user-1", Map.of(), Set.of(), "retry-race", request);
assertThat(result.operationId()).isEqualTo("concurrent-operation");
verify(installMetricsService, never()).recordIssuedPlan(any());
verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any());
}
@Test
void createInstallPlan_whenAnyMemberCannotBeResolved_recordsNothingAndHidesMemberDetails() {
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()))
.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()))
.willThrow(new DomainForbiddenException("error.skill.access.denied", "private-skill"));
assertThatThrownBy(() -> service.createInstallPlan(
"global", "starter", null, "user-1", Map.of(), Set.of(), "retry-2", request))
.isInstanceOf(DomainBadRequestException.class)
.hasMessageNotContaining("private-skill");
verify(installMetricsService, never()).recordIssuedPlan(any());
verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any());
}
@Test
void createInstallPlan_rejectsDegradedSuiteBeforeResolvingMembers() {
SkillSuiteQueryService.Detail detail = detail(false);
given(queryService.getDetail("global", "starter", null, null, Map.of(), Set.of()))
.willReturn(detail);
assertThatThrownBy(() -> service.createInstallPlan(
"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(installMetricsService, never()).recordIssuedPlan(any());
}
@Test
void createInstallPlan_rejectsAnInvalidIdempotencyKey() {
assertThatThrownBy(() -> service.createInstallPlan(
"global", "starter", null, "user-1", Map.of(), Set.of(), "not allowed!", request))
.isInstanceOf(DomainBadRequestException.class);
verify(installOperationRepository, never()).insertIfAbsent(any(), any(), any(), any(), any());
verify(installMetricsService, never()).recordIssuedPlan(any());
}
@Test
void getDetail_exposesServerDerivedAllowedActions() {
SkillSuiteQueryService.Detail detail = detail(true);
given(queryService.getDetail(
"global", "starter", null, "user-1", Map.of(1L, NamespaceRole.MEMBER), Set.of()))
.willReturn(detail);
given(lifecycleService.allowedActions(
org.mockito.ArgumentMatchers.eq(suite), org.mockito.ArgumentMatchers.eq(version),
org.mockito.ArgumentMatchers.eq(namespace), any()))
.willReturn(Set.of(SkillSuiteAllowedAction.EDIT, SkillSuiteAllowedAction.CREATE_VERSION));
var result = service.getDetail(
"global", "starter", null, "user-1", Map.of(1L, NamespaceRole.MEMBER), Set.of());
assertThat(result.allowedActions())
.containsExactlyInAnyOrder(SkillSuiteAllowedAction.EDIT, SkillSuiteAllowedAction.CREATE_VERSION);
assertThat(result.suiteStatus()).isEqualTo("ACTIVE");
assertThat(result.hidden()).isFalse();
assertThat(result.overview()).isEqualTo("## Install in order");
assertThat(result.members()).extracting(member -> member.displayName())
.containsExactly("First Skill", "Second Skill");
}
@Test
void getDetail_hidesLiveMemberMetadataWhenTheViewerCannotReadThatSkill() {
SkillSuiteMemberState restricted = new SkillSuiteMemberState(
11L, 101L, 1L, "Private Skill", "Private summary", NamespaceStatus.ACTIVE,
SkillVisibility.PRIVATE, SkillStatus.ACTIVE, false,
SkillVersionStatus.PUBLISHED, true, false, false);
SkillSuiteQueryService.Detail detail = new SkillSuiteQueryService.Detail(
namespace, suite, version, true,
List.of(new SkillSuiteQueryService.MemberDetail(
firstMember, restricted, SkillSuiteMemberAvailability.availableMember())));
given(queryService.getDetail(
"global", "starter", null, "suite-author", Map.of(1L, NamespaceRole.MEMBER), Set.of()))
.willReturn(detail);
var result = service.getDetail(
"global", "starter", null, "suite-author", Map.of(1L, NamespaceRole.MEMBER), Set.of());
assertThat(result.members()).singleElement().satisfies(member -> {
assertThat(member.displayName()).isNull();
assertThat(member.summary()).isNull();
assertThat(member.browsable()).isFalse();
});
}
private SkillSuiteQueryService.Detail detail(boolean available) {
if (!available) {
return new SkillSuiteQueryService.Detail(namespace, suite, version, false, List.of());
}
var first = new SkillSuiteQueryService.MemberDetail(
firstMember, memberState(11L, 101L, "First Skill", "First summary"),
SkillSuiteMemberAvailability.availableMember());
var second = new SkillSuiteQueryService.MemberDetail(
secondMember, memberState(12L, 102L, "Second Skill", "Second summary"),
SkillSuiteMemberAvailability.availableMember());
return new SkillSuiteQueryService.Detail(namespace, suite, version, true, List.of(first, second));
}
private SkillSuiteMemberState memberState(
Long skillId,
Long versionId,
String displayName,
String summary
) {
return new SkillSuiteMemberState(
skillId, versionId, 1L, displayName, summary, NamespaceStatus.ACTIVE,
SkillVisibility.PUBLIC, SkillStatus.ACTIVE, false,
SkillVersionStatus.PUBLISHED, true, false, true);
}
private SkillSuiteVersionMember member(
Long skillId,
Long versionId,
String slug,
String memberVersion,
String fingerprint,
int position
) {
return new SkillSuiteVersionMember(
70L,
new com.iflytek.skillhub.domain.suite.SkillSuiteMemberSelection(
skillId, versionId, "global", slug, memberVersion, fingerprint),
position);
}
private SkillQueryService.ResolvedVersionDTO resolved(
Long skillId,
Long versionId,
String slug,
String version,
String fingerprint
) {
return new SkillQueryService.ResolvedVersionDTO(
skillId, "global", slug, version, versionId, fingerprint, true,
"/api/v1/skills/global/" + slug + "/versions/" + version + "/download");
}
private void setField(Object target, String name, Object value) {
try {
Field field = target.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(target, value);
} catch (ReflectiveOperationException exception) {
throw new AssertionError(exception);
}
}
}

View file

@ -879,6 +879,13 @@ class ScanTaskConsumerTest {
throw unsupported();
}
@Override
public void deleteBySubjectTypeAndSubjectId(
com.iflytek.skillhub.domain.review.ReviewSubjectType subjectType,
Long subjectId) {
throw unsupported();
}
@Override
public void delete(ReviewTask reviewTask) {
this.deletedTask = reviewTask;

View file

@ -1,6 +1,7 @@
package com.iflytek.skillhub.task;
import com.iflytek.skillhub.domain.idempotency.IdempotencyRecordRepository;
import com.iflytek.skillhub.domain.suite.SkillSuiteInstallOperationRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@ -20,12 +21,16 @@ class IdempotencyCleanupTaskTest {
@Mock
private IdempotencyRecordRepository idempotencyRecordRepository;
@Mock
private SkillSuiteInstallOperationRepository suiteInstallOperationRepository;
private IdempotencyCleanupTask cleanupTask;
@BeforeEach
void setUp() {
Clock clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC);
cleanupTask = new IdempotencyCleanupTask(idempotencyRecordRepository, clock);
cleanupTask = new IdempotencyCleanupTask(
idempotencyRecordRepository, suiteInstallOperationRepository, clock);
}
@Test
@ -35,6 +40,8 @@ class IdempotencyCleanupTaskTest {
cleanupTask.cleanupExpiredRecords();
verify(idempotencyRecordRepository).deleteExpired(any(Instant.class));
verify(suiteInstallOperationRepository).deleteCreatedBefore(
Instant.parse("2026-03-17T00:00:00Z"));
}
@Test

View file

@ -82,6 +82,46 @@ public class RouteSecurityPolicyRegistry {
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/files"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/skills/*/*/tags/*/file"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/labels"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/suites/*/*"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/resources"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/me/suites"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/v1/suites/*/*/versions"),
RouteAuthorizationPolicy.permitAll(HttpMethod.POST, "/api/v1/suites/*/*/install-plan"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/v1/suites/member-candidates"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/versions"),
RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/v1/suites/*/versions/*"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/versions/*/submit"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/versions/*/publish"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/versions/*/reopen"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/versions/*/yank"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/hide"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/restore"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/archive"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/*/unarchive"),
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/v1/suites/*"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/reviews/*/approve"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/v1/suites/reviews/*/reject"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/suites/*/*"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/resources"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/me/suites"),
RouteAuthorizationPolicy.permitAll(HttpMethod.GET, "/api/web/suites/*/*/versions"),
RouteAuthorizationPolicy.permitAll(HttpMethod.POST, "/api/web/suites/*/*/install-plan"),
RouteAuthorizationPolicy.authenticated(HttpMethod.GET, "/api/web/suites/member-candidates"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/versions"),
RouteAuthorizationPolicy.authenticated(HttpMethod.PUT, "/api/web/suites/*/versions/*"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/versions/*/submit"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/versions/*/publish"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/versions/*/reopen"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/versions/*/yank"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/hide"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/restore"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/archive"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/*/unarchive"),
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/suites/*"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/reviews/*/approve"),
RouteAuthorizationPolicy.authenticated(HttpMethod.POST, "/api/web/suites/reviews/*/reject"),
RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/id/*", "SUPER_ADMIN"),
RouteAuthorizationPolicy.roles(HttpMethod.DELETE, "/api/v1/skills/*/*", "SUPER_ADMIN"),
RouteAuthorizationPolicy.authenticated(HttpMethod.DELETE, "/api/web/skills/id/*"),
@ -131,6 +171,38 @@ public class RouteSecurityPolicyRegistry {
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/namespaces/*"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/namespaces/*"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/suites/**"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/resources"),
ApiTokenPolicy.require(HttpMethod.GET, "/api/v1/me/suites", "skill:read"),
ApiTokenPolicy.allow(HttpMethod.POST, "/api/v1/suites/*/*/install-plan"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/versions", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.PUT, "/api/v1/suites/*/versions/*", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/versions/*/submit", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/versions/*/publish", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/versions/*/reopen", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/versions/*/yank", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/hide", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/restore", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/archive", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/v1/suites/*/unarchive", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.DELETE, "/api/v1/suites/*", "skill:delete"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/suites/**"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/web/resources"),
ApiTokenPolicy.require(HttpMethod.GET, "/api/web/me/suites", "skill:read"),
ApiTokenPolicy.allow(HttpMethod.POST, "/api/web/suites/*/*/install-plan"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/versions", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.PUT, "/api/web/suites/*/versions/*", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/versions/*/submit", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/versions/*/publish", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/versions/*/reopen", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/versions/*/yank", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/hide", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/restore", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/archive", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.POST, "/api/web/suites/*/unarchive", "skill:publish"),
ApiTokenPolicy.require(HttpMethod.DELETE, "/api/web/suites/*", "skill:delete"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/resolve/**"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/download"),
ApiTokenPolicy.allow(HttpMethod.GET, "/api/v1/download/**"),
@ -173,6 +245,10 @@ public class RouteSecurityPolicyRegistry {
"ANY /api/v1/auth/direct/login",
"ANY /api/v1/auth/local/**",
"ANY /api/v1/admin/**",
"POST /api/v1/suites/reviews/*/approve",
"POST /api/v1/suites/reviews/*/reject",
"POST /api/web/suites/reviews/*/approve",
"POST /api/web/suites/reviews/*/reject",
"DELETE /api/web/skills/id/*",
"DELETE /api/web/skills/*/*"
);

View file

@ -64,6 +64,45 @@ class RouteSecurityPolicyRegistryTest {
assertTrue(allowed.allowed());
}
@Test
void suiteRoutes_keepDiscoveryPublicAndProtectMutations() {
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL,
registry.accessLevel("GET", "/api/v1/resources"));
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL,
registry.accessLevel("GET", "/api/web/resources"));
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED,
registry.accessLevel("GET", "/api/web/me/suites"));
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL,
registry.accessLevel("GET", "/api/v1/suites/global/starter"));
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.PERMIT_ALL,
registry.accessLevel("POST", "/api/v1/suites/global/starter/install-plan"));
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED,
registry.accessLevel("GET", "/api/v1/suites/member-candidates"));
assertEquals(RouteSecurityPolicyRegistry.AccessLevel.AUTHENTICATED,
registry.accessLevel("POST", "/api/v1/suites"));
}
@Test
void suiteApiTokenPolicies_requirePublishScopeAndKeepReviewSessionOnly() {
assertTrue(registry.authorizeApiToken(
"GET", "/api/v1/suites/global/starter", Set.of()).allowed());
assertTrue(registry.authorizeApiToken(
"POST", "/api/v1/suites/global/starter/install-plan", Set.of()).allowed());
var deniedCreate = registry.authorizeApiToken("POST", "/api/v1/suites", Set.of("skill:read"));
var allowedCreate = registry.authorizeApiToken("POST", "/api/v1/suites", Set.of("skill:publish"));
var deniedSubmit = registry.authorizeApiToken(
"POST", "/api/v1/suites/8/versions/42/submit", Set.of("skill:read"));
assertFalse(deniedCreate.allowed());
assertEquals("skill:publish", deniedCreate.requiredScope());
assertTrue(allowedCreate.allowed());
assertFalse(deniedSubmit.allowed());
assertEquals("skill:publish", deniedSubmit.requiredScope());
assertFalse(registry.authorizeApiToken(
"POST", "/api/v1/suites/reviews/7/approve", ALL_SCOPES).allowed());
}
@Test
void authorizeApiToken_requiresPublishScopeForSecurityScanRetry() {
var denied = registry.authorizeApiToken(

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.domain.review;
/** Resource version types handled by the shared review queue. */
public enum ReviewSubjectType {
SKILL_VERSION,
SUITE_VERSION
}

View file

@ -14,12 +14,25 @@ public class ReviewTask {
@Column(name = "skill_version_id")
private Long skillVersionId;
@Column(name = "skill_id", nullable = false)
@Column(name = "skill_id")
private Long skillId;
@Column(name = "skill_version", nullable = false, length = 64)
@Column(name = "skill_version", length = 64)
private String skillVersion;
@Enumerated(EnumType.STRING)
@Column(name = "subject_type", nullable = false, length = 32)
private ReviewSubjectType subjectType;
@Column(name = "subject_id", nullable = false)
private Long subjectId;
@Column(name = "subject_version_id")
private Long subjectVersionId;
@Column(name = "subject_version", nullable = false, length = 64)
private String subjectVersion;
@Column(name = "namespace_id", nullable = false)
private Long namespaceId;
@ -62,6 +75,28 @@ public class ReviewTask {
this.namespaceId = namespaceId;
this.skillVersion = skillVersion;
this.submittedBy = submittedBy;
this.subjectType = ReviewSubjectType.SKILL_VERSION;
this.subjectId = skillId;
this.subjectVersionId = skillVersionId;
this.subjectVersion = skillVersion;
}
/** Creates a typed Suite review without populating legacy Skill-specific columns. */
public static ReviewTask forSuiteVersion(
Long suiteVersionId,
Long suiteId,
Long namespaceId,
String suiteVersion,
String submittedBy
) {
ReviewTask task = new ReviewTask();
task.subjectType = ReviewSubjectType.SUITE_VERSION;
task.subjectId = suiteId;
task.subjectVersionId = suiteVersionId;
task.subjectVersion = suiteVersion;
task.namespaceId = namespaceId;
task.submittedBy = submittedBy;
return task;
}
public Long getId() { return id; }
@ -72,6 +107,14 @@ public class ReviewTask {
public String getSkillVersion() { return skillVersion; }
public ReviewSubjectType getSubjectType() { return subjectType; }
public Long getSubjectId() { return subjectId; }
public Long getSubjectVersionId() { return subjectVersionId; }
public String getSubjectVersion() { return subjectVersion; }
public Long getNamespaceId() { return namespaceId; }
public ReviewTaskStatus getStatus() { return status; }

View file

@ -13,6 +13,10 @@ public interface ReviewTaskRepository {
ReviewTask save(ReviewTask reviewTask);
Optional<ReviewTask> findById(Long id);
Optional<ReviewTask> findBySkillVersionIdAndStatus(Long skillVersionId, ReviewTaskStatus status);
default Optional<ReviewTask> findBySubjectTypeAndSubjectVersionIdAndStatus(
ReviewSubjectType subjectType, Long subjectVersionId, ReviewTaskStatus status) {
throw new UnsupportedOperationException("Typed review subjects are not supported by this repository");
}
Page<ReviewTask> findByStatus(ReviewTaskStatus status, Pageable pageable);
Page<ReviewTask> findByNamespaceIdAndStatus(Long namespaceId, ReviewTaskStatus status, Pageable pageable);
Page<ReviewTask> findBySubmittedByAndStatus(String submittedBy, ReviewTaskStatus status, Pageable pageable);
@ -20,9 +24,23 @@ public interface ReviewTaskRepository {
String submittedBy, Long skillId, String skillVersion);
List<ReviewTask> findBySkillIdAndSkillVersionOrderBySubmittedAtDescIdDesc(
Long skillId, String skillVersion);
default List<ReviewTask> findBySubmittedByAndSubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc(
String submittedBy,
ReviewSubjectType subjectType,
Long subjectId,
String subjectVersion) {
throw new UnsupportedOperationException("Typed review subjects are not supported by this repository");
}
default List<ReviewTask> findBySubjectTypeAndSubjectIdAndSubjectVersionOrderBySubmittedAtDescIdDesc(
ReviewSubjectType subjectType,
Long subjectId,
String subjectVersion) {
throw new UnsupportedOperationException("Typed review subjects are not supported by this repository");
}
boolean existsByNamespaceId(Long namespaceId);
void deleteBySkillVersionIdIn(Collection<Long> skillVersionIds);
void deleteBySkillId(Long skillId);
void deleteBySubjectTypeAndSubjectId(ReviewSubjectType subjectType, Long subjectId);
void delete(ReviewTask reviewTask);
int updateStatusWithVersion(Long id, ReviewTaskStatus status, String reviewedBy,
String reviewComment, Integer expectedVersion);

View file

@ -0,0 +1,23 @@
package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import java.util.List;
/** Fully resolved input for creating a Suite and its first immutable-version draft. */
public record CreateSkillSuiteDraftCommand(
Long namespaceId,
String slug,
String displayName,
String summary,
String overview,
String version,
SkillVisibility visibility,
String changelog,
Long entrySkillVersionId,
List<SkillSuiteMemberSelection> members
) {
public CreateSkillSuiteDraftCommand {
members = List.copyOf(members);
}
}

View file

@ -0,0 +1,116 @@
package com.iflytek.skillhub.domain.suite;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.Clock;
import java.time.Instant;
/** Namespace-owned container whose published content is represented by immutable Suite versions. */
@Entity
@Table(name = "skill_suite")
public class SkillSuite {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "namespace_id", nullable = false)
private Long namespaceId;
@Column(nullable = false, length = 128)
private String slug;
@Column(name = "display_name", nullable = false, length = 256)
private String displayName;
@Column(columnDefinition = "TEXT")
private String summary;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private SkillSuiteStatus status;
@Column(name = "latest_version_id")
private Long latestVersionId;
@Column(name = "install_request_count", nullable = false)
private Long installRequestCount = 0L;
@Column(nullable = false)
private boolean hidden;
@Column(name = "hidden_at")
private Instant hiddenAt;
@Column(name = "hidden_by", length = 128)
private String hiddenBy;
@Column(name = "created_by", nullable = false, length = 128)
private String createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_by", length = 128)
private String updatedBy;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
protected SkillSuite() {
}
public SkillSuite(Long namespaceId, String slug, String displayName, String createdBy) {
this.namespaceId = namespaceId;
this.slug = slug;
this.displayName = displayName;
this.createdBy = createdBy;
this.status = SkillSuiteStatus.ACTIVE;
}
@PrePersist
protected void onCreate() {
createdAt = Instant.now(Clock.systemUTC());
updatedAt = createdAt;
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now(Clock.systemUTC());
}
public Long getId() { return id; }
public Long getNamespaceId() { return namespaceId; }
public String getSlug() { return slug; }
public String getDisplayName() { return displayName; }
public String getSummary() { return summary; }
public SkillSuiteStatus getStatus() { return status; }
public Long getLatestVersionId() { return latestVersionId; }
public Long getInstallRequestCount() { return installRequestCount; }
public boolean isHidden() { return hidden; }
public Instant getHiddenAt() { return hiddenAt; }
public String getHiddenBy() { return hiddenBy; }
public String getCreatedBy() { return createdBy; }
public Instant getCreatedAt() { return createdAt; }
public String getUpdatedBy() { return updatedBy; }
public Instant getUpdatedAt() { return updatedAt; }
public void setDisplayName(String displayName) { this.displayName = displayName; }
public void setSummary(String summary) { this.summary = summary; }
public void setStatus(SkillSuiteStatus status) { this.status = status; }
public void setLatestVersionId(Long latestVersionId) { this.latestVersionId = latestVersionId; }
public void setInstallRequestCount(Long installRequestCount) { this.installRequestCount = installRequestCount; }
public void setHidden(boolean hidden) { this.hidden = hidden; }
public void setHiddenAt(Instant hiddenAt) { this.hiddenAt = hiddenAt; }
public void setHiddenBy(String hiddenBy) { this.hiddenBy = hiddenBy; }
public void setUpdatedBy(String updatedBy) { this.updatedBy = updatedBy; }
}

View file

@ -0,0 +1,21 @@
package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import java.util.Map;
import java.util.Set;
/** Caller authorization and request metadata shared by Suite lifecycle operations. */
public record SkillSuiteActionContext(
String actorUserId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles,
String requestId,
String clientIp,
String userAgent
) {
public SkillSuiteActionContext {
namespaceRoles = namespaceRoles == null ? Map.of() : Map.copyOf(namespaceRoles);
platformRoles = platformRoles == null ? Set.of() : Set.copyOf(platformRoles);
}
}

View file

@ -0,0 +1,16 @@
package com.iflytek.skillhub.domain.suite;
/** Suite actions the current caller may invoke for one concrete version. */
public enum SkillSuiteAllowedAction {
EDIT,
SUBMIT,
PUBLISH_PRIVATE,
REOPEN,
CREATE_VERSION,
YANK,
HIDE,
RESTORE,
ARCHIVE,
UNARCHIVE,
DELETE
}

View file

@ -0,0 +1,43 @@
package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
/** Shared caller policy used by both Suite commands and action discovery. */
public final class SkillSuiteAuthorizationPolicy {
private SkillSuiteAuthorizationPolicy() {
}
public static boolean canManageVersion(
SkillSuite suite,
SkillSuiteVersion version,
SkillSuiteActionContext context
) {
NamespaceRole role = context.namespaceRoles().get(suite.getNamespaceId());
return isPlatformOrNamespaceAdmin(role, context)
|| (context.actorUserId() != null
&& role != null
&& context.actorUserId().equals(suite.getCreatedBy()));
}
public static boolean canCreateVersion(SkillSuite suite, SkillSuiteActionContext context) {
NamespaceRole role = context.namespaceRoles().get(suite.getNamespaceId());
return isPlatformOrNamespaceAdmin(role, context)
|| (context.actorUserId() != null
&& role != null
&& context.actorUserId().equals(suite.getCreatedBy()));
}
public static boolean canAdminister(SkillSuite suite, SkillSuiteActionContext context) {
return isPlatformOrNamespaceAdmin(context.namespaceRoles().get(suite.getNamespaceId()), context);
}
private static boolean isPlatformOrNamespaceAdmin(
NamespaceRole role,
SkillSuiteActionContext context
) {
return context.platformRoles().contains("SUPER_ADMIN")
|| role == NamespaceRole.OWNER
|| role == NamespaceRole.ADMIN;
}
}

View file

@ -0,0 +1,30 @@
package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import java.util.ArrayList;
import java.util.List;
/** Live aggregate availability derived from exact member state; never stored as lifecycle state. */
public record SkillSuiteAvailability(boolean available, List<SkillSuiteBlockedMember> blockedMembers) {
public SkillSuiteAvailability {
blockedMembers = List.copyOf(blockedMembers);
}
public static SkillSuiteAvailability evaluate(
Long suiteNamespaceId,
SkillVisibility suiteVisibility,
List<SkillSuiteMemberState> members
) {
SkillSuiteMemberEligibilityPolicy policy = new SkillSuiteMemberEligibilityPolicy();
List<SkillSuiteBlockedMember> blocked = new ArrayList<>();
for (SkillSuiteMemberState member : members) {
SkillSuiteMemberAvailability result = policy.evaluate(suiteNamespaceId, suiteVisibility, member);
if (!result.available()) {
blocked.add(new SkillSuiteBlockedMember(member.skillVersionId(), result.reason()));
}
}
return new SkillSuiteAvailability(blocked.isEmpty(), blocked);
}
}

View file

@ -0,0 +1,5 @@
package com.iflytek.skillhub.domain.suite;
/** Non-sensitive identity and reason for one blocked exact member. */
public record SkillSuiteBlockedMember(Long skillVersionId, SkillSuiteMemberBlockingReason reason) {
}

View file

@ -0,0 +1,40 @@
package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* Validates rules that depend only on a Suite draft's selected members.
*/
public final class SkillSuiteCompositionPolicy {
public static final int MAX_MEMBERS = 100;
private SkillSuiteCompositionPolicy() {
}
public static void validate(List<SkillSuiteMemberSelection> members, Long entrySkillVersionId) {
if (members.isEmpty()) {
throw new DomainBadRequestException("error.suite.members.empty");
}
if (members.size() > MAX_MEMBERS) {
throw new DomainBadRequestException("error.suite.members.limit", MAX_MEMBERS);
}
Set<Long> skillIds = new HashSet<>();
boolean entryFound = entrySkillVersionId == null;
for (SkillSuiteMemberSelection member : members) {
if (!skillIds.add(member.skillId())) {
throw new DomainBadRequestException("error.suite.members.duplicate");
}
if (member.skillVersionId().equals(entrySkillVersionId)) {
entryFound = true;
}
}
if (!entryFound) {
throw new DomainBadRequestException("error.suite.entry.notMember");
}
}
}

View file

@ -0,0 +1,259 @@
package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.namespace.SlugValidator;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
/** Creates Suite drafts after member coordinates have been resolved to exact Skill versions. */
@Service
public class SkillSuiteDraftService {
private static final Logger log = LoggerFactory.getLogger(SkillSuiteDraftService.class);
private final SkillSuiteRepository suiteRepository;
private final SkillSuiteVersionRepository versionRepository;
private final SkillSuiteVersionMemberRepository memberRepository;
private final NamespaceRepository namespaceRepository;
private final SkillSuitePublicationValidator publicationValidator;
private final AuditLogService auditLogService;
public SkillSuiteDraftService(
SkillSuiteRepository suiteRepository,
SkillSuiteVersionRepository versionRepository,
SkillSuiteVersionMemberRepository memberRepository,
NamespaceRepository namespaceRepository,
SkillSuitePublicationValidator publicationValidator,
AuditLogService auditLogService
) {
this.suiteRepository = suiteRepository;
this.versionRepository = versionRepository;
this.memberRepository = memberRepository;
this.namespaceRepository = namespaceRepository;
this.publicationValidator = publicationValidator;
this.auditLogService = auditLogService;
}
@Transactional
public CreatedDraft create(
CreateSkillSuiteDraftCommand command,
SkillSuiteActionContext context
) {
requireWritableNamespace(command.namespaceId());
assertCanCreate(command.namespaceId(), context);
SlugValidator.validate(command.slug());
validateDefinition(command);
if (suiteRepository.findByNamespaceIdAndSlug(command.namespaceId(), command.slug()).isPresent()) {
throw new DomainBadRequestException("error.suite.slug.exists", command.slug());
}
SkillSuite suite = new SkillSuite(
command.namespaceId(), command.slug(), command.displayName(), context.actorUserId());
suite.setSummary(command.summary());
suite.setUpdatedBy(context.actorUserId());
suite = suiteRepository.save(suite);
SkillSuiteVersion version = new SkillSuiteVersion(
suite.getId(), command.version(), command.displayName(), command.summary(),
command.visibility(), context.actorUserId());
version.setOverview(command.overview());
version.setChangelog(command.changelog());
version.setEntrySkillVersionId(command.entrySkillVersionId());
version = versionRepository.save(version);
List<SkillSuiteVersionMember> members = saveMembers(version, command.members());
// Validate after persistence so the same resolver is used for draft creation and publication.
// The transaction rolls the draft back if any exact member changed during creation.
publicationValidator.validate(suite, version);
auditLogService.record(
context.actorUserId(), "CREATE_SKILL_SUITE_DRAFT", "SKILL_SUITE_VERSION",
version.getId(), context.requestId(), context.clientIp(), context.userAgent(),
AuditDetail.builder()
.put("suiteId", suite.getId())
.put("memberCount", members.size())
.build());
log.info("Suite draft created [suiteId={}, versionId={}, namespaceId={}, actorId={}, memberCount={}, requestId={}]",
suite.getId(), version.getId(), suite.getNamespaceId(), context.actorUserId(),
members.size(), context.requestId());
return new CreatedDraft(suite, version, members);
}
@Transactional
public CreatedDraft createVersion(
Long suiteId,
CreateSkillSuiteDraftCommand command,
SkillSuiteActionContext context
) {
SkillSuite suite = suiteRepository.findById(suiteId)
.orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteId));
Namespace namespace = requireWritableNamespace(suite.getNamespaceId());
assertMatchesSuite(command, suite);
assertCanManageSuite(suite, context);
if (suite.getStatus() != SkillSuiteStatus.ACTIVE) {
throw new DomainBadRequestException("error.suite.notActive", suite.getSlug());
}
if (versionRepository.findBySuiteIdAndVersion(suiteId, command.version()).isPresent()) {
throw new DomainBadRequestException("error.suite.version.exists", command.version());
}
validateDefinition(command);
CreatedDraft created = persistVersion(suite, command, context);
log.info("Suite version draft created [suiteId={}, versionId={}, namespaceId={}, actorId={}, memberCount={}, requestId={}]",
suiteId, created.version().getId(), namespace.getId(), context.actorUserId(),
created.members().size(), context.requestId());
return created;
}
@Transactional
public CreatedDraft updateDraft(
Long suiteId,
Long versionId,
CreateSkillSuiteDraftCommand command,
SkillSuiteActionContext context
) {
SkillSuite suite = suiteRepository.findById(suiteId)
.orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteId));
requireWritableNamespace(suite.getNamespaceId());
SkillSuiteVersion version = versionRepository.findById(versionId)
.orElseThrow(() -> new DomainNotFoundException("error.suite.version.notFound", versionId));
if (!suiteId.equals(version.getSuiteId())) {
throw new DomainBadRequestException("error.suite.version.mismatch");
}
assertMatchesSuite(command, suite);
assertCanManageVersion(suite, version, context);
version.assertEditable();
if (!version.getVersion().equals(command.version())) {
throw new DomainBadRequestException("error.suite.version.renameNotAllowed");
}
validateDefinition(command);
version.setDisplayName(command.displayName());
version.setSummary(command.summary());
version.setOverview(command.overview());
version.setVisibility(command.visibility());
version.setChangelog(command.changelog());
version.setEntrySkillVersionId(command.entrySkillVersionId());
versionRepository.save(version);
memberRepository.deleteBySuiteVersionId(versionId);
List<SkillSuiteVersionMember> members = saveMembers(version, command.members());
publicationValidator.validate(suite, version);
auditLogService.record(
context.actorUserId(), "UPDATE_SKILL_SUITE_DRAFT", "SKILL_SUITE_VERSION",
versionId, context.requestId(), context.clientIp(), context.userAgent(),
AuditDetail.builder()
.put("suiteId", suiteId)
.put("memberCount", members.size())
.build());
log.info("Suite draft updated [suiteId={}, versionId={}, namespaceId={}, actorId={}, memberCount={}, requestId={}]",
suiteId, versionId, suite.getNamespaceId(), context.actorUserId(),
members.size(), context.requestId());
return new CreatedDraft(suite, version, members);
}
private Namespace requireWritableNamespace(Long namespaceId) {
Namespace namespace = namespaceRepository.findById(namespaceId)
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceId));
if (namespace.getStatus() != NamespaceStatus.ACTIVE) {
throw new DomainBadRequestException("error.suite.namespace.notWritable", namespace.getStatus());
}
return namespace;
}
private void assertMatchesSuite(CreateSkillSuiteDraftCommand command, SkillSuite suite) {
if (!suite.getNamespaceId().equals(command.namespaceId()) || !suite.getSlug().equals(command.slug())) {
throw new DomainBadRequestException("error.suite.version.mismatch");
}
}
private void assertCanManageSuite(SkillSuite suite, SkillSuiteActionContext context) {
if (!SkillSuiteAuthorizationPolicy.canCreateVersion(suite, context)) {
throw new DomainForbiddenException("error.suite.lifecycle.noPermission");
}
}
private void assertCanManageVersion(
SkillSuite suite,
SkillSuiteVersion version,
SkillSuiteActionContext context
) {
if (!SkillSuiteAuthorizationPolicy.canManageVersion(suite, version, context)) {
throw new DomainForbiddenException("error.suite.lifecycle.noPermission");
}
}
private void validateDefinition(CreateSkillSuiteDraftCommand command) {
if (command.displayName() == null || command.displayName().isBlank()) {
throw new DomainBadRequestException("error.suite.displayName.required");
}
if (command.version() == null || command.version().isBlank()) {
throw new DomainBadRequestException("error.suite.version.required");
}
SkillSuiteCompositionPolicy.validate(command.members(), command.entrySkillVersionId());
}
private CreatedDraft persistVersion(
SkillSuite suite,
CreateSkillSuiteDraftCommand command,
SkillSuiteActionContext context
) {
SkillSuiteVersion version = new SkillSuiteVersion(
suite.getId(), command.version(), command.displayName(), command.summary(),
command.visibility(), context.actorUserId());
version.setOverview(command.overview());
version.setChangelog(command.changelog());
version.setEntrySkillVersionId(command.entrySkillVersionId());
version = versionRepository.save(version);
List<SkillSuiteVersionMember> members = saveMembers(version, command.members());
publicationValidator.validate(suite, version);
auditLogService.record(
context.actorUserId(), "CREATE_SKILL_SUITE_VERSION_DRAFT", "SKILL_SUITE_VERSION",
version.getId(), context.requestId(), context.clientIp(), context.userAgent(),
AuditDetail.builder()
.put("suiteId", suite.getId())
.put("memberCount", members.size())
.build());
return new CreatedDraft(suite, version, members);
}
private List<SkillSuiteVersionMember> saveMembers(
SkillSuiteVersion version,
List<SkillSuiteMemberSelection> selections
) {
List<SkillSuiteVersionMember> members = new ArrayList<>(selections.size());
for (int position = 0; position < selections.size(); position++) {
members.add(new SkillSuiteVersionMember(version.getId(), selections.get(position), position));
}
return memberRepository.saveAll(members);
}
private void assertCanCreate(Long namespaceId, SkillSuiteActionContext context) {
NamespaceRole role = context.namespaceRoles().get(namespaceId);
if (!context.platformRoles().contains("SUPER_ADMIN") && role == null) {
throw new DomainForbiddenException("error.suite.lifecycle.noPermission");
}
}
public record CreatedDraft(
SkillSuite suite,
SkillSuiteVersion version,
List<SkillSuiteVersionMember> members
) {
public CreatedDraft {
members = List.copyOf(members);
}
}
}

View file

@ -0,0 +1,20 @@
package com.iflytek.skillhub.domain.suite;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Records one successfully issued Suite plan without pre-counting member downloads. */
@Service
public class SkillSuiteInstallMetricsService {
private final SkillSuiteRepository suiteRepository;
public SkillSuiteInstallMetricsService(SkillSuiteRepository suiteRepository) {
this.suiteRepository = suiteRepository;
}
@Transactional
public void recordIssuedPlan(Long suiteId) {
suiteRepository.incrementInstallRequestCount(suiteId);
}
}

View file

@ -0,0 +1,43 @@
package com.iflytek.skillhub.domain.suite;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
/** Durable idempotency marker for one successfully issued Suite install plan. */
@Entity
@Table(name = "skill_suite_install_operation")
public class SkillSuiteInstallOperation {
@Id
@Column(name = "operation_id", length = 64)
private String operationId;
@Column(name = "client_request_id", nullable = false, length = 64)
private String clientRequestId;
@Column(name = "actor_key", nullable = false, length = 160)
private String actorKey;
@Column(name = "suite_id", nullable = false)
private Long suiteId;
@Column(name = "suite_version_id", nullable = false)
private Long suiteVersionId;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
protected SkillSuiteInstallOperation() {
}
public String getOperationId() { return operationId; }
public String getClientRequestId() { return clientRequestId; }
public String getActorKey() { return actorKey; }
public Long getSuiteId() { return suiteId; }
public Long getSuiteVersionId() { return suiteVersionId; }
public Instant getCreatedAt() { return createdAt; }
}

View file

@ -0,0 +1,15 @@
package com.iflytek.skillhub.domain.suite;
import java.util.Optional;
import java.time.Instant;
/** Persistence contract for atomic Suite install-plan idempotency claims. */
public interface SkillSuiteInstallOperationRepository {
int insertIfAbsent(
String operationId, String clientRequestId, String actorKey, Long suiteId, Long suiteVersionId);
Optional<SkillSuiteInstallOperation> findByClientRequestIdAndActorKey(String clientRequestId, String actorKey);
int deleteCreatedBefore(Instant threshold);
}

View file

@ -0,0 +1,444 @@
package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.audit.AuditDetail;
import com.iflytek.skillhub.domain.audit.AuditLogService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
import com.iflytek.skillhub.domain.review.ReviewPermissionChecker;
import com.iflytek.skillhub.domain.review.ReviewSubjectType;
import com.iflytek.skillhub.domain.review.ReviewTask;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.util.Comparator;
import java.util.ConcurrentModificationException;
import java.util.EnumSet;
import java.util.Set;
/** Coordinates Suite version review and publication without changing any member lifecycle. */
@Service
public class SkillSuiteLifecycleService {
private static final Logger log = LoggerFactory.getLogger(SkillSuiteLifecycleService.class);
private final SkillSuiteRepository suiteRepository;
private final SkillSuiteVersionRepository versionRepository;
private final ReviewTaskRepository reviewTaskRepository;
private final ReviewPermissionChecker reviewPermissionChecker;
private final NamespaceRepository namespaceRepository;
private final SkillSuitePublicationValidator publicationValidator;
private final AuditLogService auditLogService;
private final Clock clock;
private final boolean reviewWritesEnabled;
public SkillSuiteLifecycleService(
SkillSuiteRepository suiteRepository,
SkillSuiteVersionRepository versionRepository,
ReviewTaskRepository reviewTaskRepository,
ReviewPermissionChecker reviewPermissionChecker,
NamespaceRepository namespaceRepository,
SkillSuitePublicationValidator publicationValidator,
AuditLogService auditLogService,
Clock clock,
@Value("${skillhub.suite.review-writes-enabled}") boolean reviewWritesEnabled
) {
this.suiteRepository = suiteRepository;
this.versionRepository = versionRepository;
this.reviewTaskRepository = reviewTaskRepository;
this.reviewPermissionChecker = reviewPermissionChecker;
this.namespaceRepository = namespaceRepository;
this.publicationValidator = publicationValidator;
this.auditLogService = auditLogService;
this.clock = clock;
this.reviewWritesEnabled = reviewWritesEnabled;
if (!reviewWritesEnabled) {
log.warn("Suite review writes are disabled for a mixed-version rolling upgrade");
}
}
@Transactional
public ReviewTask submitForReview(Long suiteId, Long versionId, SkillSuiteActionContext context) {
if (!reviewWritesEnabled) {
throw new DomainBadRequestException("error.suite.review.rolloutDisabled");
}
Loaded loaded = load(suiteId, versionId);
assertNamespaceWritable(loaded.namespace());
assertCanManageDraft(loaded.suite(), loaded.version(), context);
if (loaded.version().getVisibility() == SkillVisibility.PRIVATE) {
throw new DomainBadRequestException("error.suite.review.private");
}
if (loaded.version().getStatus() != SkillSuiteVersionStatus.DRAFT) {
throw new DomainBadRequestException("error.suite.review.notDraft", loaded.version().getVersion());
}
publicationValidator.validate(loaded.suite(), loaded.version());
loaded.version().setStatus(SkillSuiteVersionStatus.PENDING_REVIEW);
versionRepository.save(loaded.version());
ReviewTask task = reviewTaskRepository.save(ReviewTask.forSuiteVersion(
versionId, suiteId, loaded.suite().getNamespaceId(),
loaded.version().getVersion(), context.actorUserId()));
audit(context, "SUBMIT_SKILL_SUITE_REVIEW", "SKILL_SUITE_VERSION", versionId, null);
log.info("Suite review submitted [suiteId={}, versionId={}, actorId={}, requestId={}]",
suiteId, versionId, context.actorUserId(), context.requestId());
return task;
}
@Transactional
public SkillSuiteVersion confirmPrivatePublish(
Long suiteId,
Long versionId,
SkillSuiteActionContext context
) {
Loaded loaded = load(suiteId, versionId);
assertNamespaceWritable(loaded.namespace());
assertCanManageDraft(loaded.suite(), loaded.version(), context);
if (loaded.version().getVisibility() != SkillVisibility.PRIVATE) {
throw new DomainBadRequestException("error.suite.publish.notPrivate");
}
if (loaded.version().getStatus() != SkillSuiteVersionStatus.DRAFT) {
throw new DomainBadRequestException("error.suite.publish.notDraft", loaded.version().getVersion());
}
publicationValidator.validate(loaded.suite(), loaded.version());
publish(loaded.suite(), loaded.version(), context.actorUserId());
audit(context, "PUBLISH_SKILL_SUITE_VERSION", "SKILL_SUITE_VERSION", versionId, null);
log.info("Private Suite published [suiteId={}, versionId={}, actorId={}, requestId={}]",
suiteId, versionId, context.actorUserId(), context.requestId());
return loaded.version();
}
@Transactional
public ReviewTask approveReview(Long reviewTaskId, String comment, SkillSuiteActionContext context) {
ReviewTask task = loadPendingSuiteReview(reviewTaskId);
Loaded loaded = load(task.getSubjectId(), task.getSubjectVersionId());
assertNamespaceWritable(loaded.namespace());
assertCanReview(task, loaded.namespace(), context);
if (loaded.version().getStatus() != SkillSuiteVersionStatus.PENDING_REVIEW) {
throw new DomainBadRequestException("error.suite.review.notPending", loaded.version().getVersion());
}
publicationValidator.validate(loaded.suite(), loaded.version());
completeTask(task, ReviewTaskStatus.APPROVED, context.actorUserId(), comment);
publish(loaded.suite(), loaded.version(), context.actorUserId());
audit(context, "APPROVE_SKILL_SUITE_REVIEW", "REVIEW_TASK", reviewTaskId,
AuditDetail.of("suiteVersionId", loaded.version().getId()));
log.info("Suite review approved [reviewTaskId={}, suiteId={}, versionId={}, actorId={}, requestId={}]",
reviewTaskId, loaded.suite().getId(), loaded.version().getId(),
context.actorUserId(), context.requestId());
return task;
}
@Transactional
public ReviewTask rejectReview(Long reviewTaskId, String comment, SkillSuiteActionContext context) {
ReviewTask task = loadPendingSuiteReview(reviewTaskId);
Loaded loaded = load(task.getSubjectId(), task.getSubjectVersionId());
assertNamespaceWritable(loaded.namespace());
assertCanReview(task, loaded.namespace(), context);
if (loaded.version().getStatus() != SkillSuiteVersionStatus.PENDING_REVIEW) {
throw new DomainBadRequestException("error.suite.review.notPending", loaded.version().getVersion());
}
completeTask(task, ReviewTaskStatus.REJECTED, context.actorUserId(), comment);
loaded.version().setStatus(SkillSuiteVersionStatus.REJECTED);
versionRepository.save(loaded.version());
audit(context, "REJECT_SKILL_SUITE_REVIEW", "REVIEW_TASK", reviewTaskId,
AuditDetail.of("suiteVersionId", loaded.version().getId()));
log.info("Suite review rejected [reviewTaskId={}, suiteId={}, versionId={}, actorId={}, requestId={}]",
reviewTaskId, loaded.suite().getId(), loaded.version().getId(),
context.actorUserId(), context.requestId());
return task;
}
@Transactional
public void withdrawReview(Long reviewTaskId, SkillSuiteActionContext context) {
ReviewTask task = loadPendingSuiteReview(reviewTaskId);
Loaded loaded = load(task.getSubjectId(), task.getSubjectVersionId());
assertCanManageDraft(loaded.suite(), loaded.version(), context);
if (loaded.version().getStatus() != SkillSuiteVersionStatus.PENDING_REVIEW) {
throw new DomainBadRequestException("error.suite.review.notPending", loaded.version().getVersion());
}
loaded.version().setStatus(SkillSuiteVersionStatus.DRAFT);
versionRepository.save(loaded.version());
reviewTaskRepository.delete(task);
audit(context, "WITHDRAW_SKILL_SUITE_REVIEW", "SKILL_SUITE_VERSION",
loaded.version().getId(), null);
log.info("Suite review withdrawn [reviewTaskId={}, suiteId={}, versionId={}, actorId={}, requestId={}]",
reviewTaskId, loaded.suite().getId(), loaded.version().getId(),
context.actorUserId(), context.requestId());
}
@Transactional
public SkillSuiteVersion reopenRejected(Long suiteId, Long versionId, SkillSuiteActionContext context) {
Loaded loaded = load(suiteId, versionId);
assertNamespaceWritable(loaded.namespace());
assertCanManageDraft(loaded.suite(), loaded.version(), context);
if (loaded.version().getStatus() != SkillSuiteVersionStatus.REJECTED) {
throw new DomainBadRequestException("error.suite.review.notRejected", loaded.version().getVersion());
}
loaded.version().setStatus(SkillSuiteVersionStatus.DRAFT);
versionRepository.save(loaded.version());
audit(context, "REOPEN_SKILL_SUITE_VERSION", "SKILL_SUITE_VERSION", versionId, null);
log.info("Rejected Suite reopened [suiteId={}, versionId={}, actorId={}, requestId={}]",
suiteId, versionId, context.actorUserId(), context.requestId());
return loaded.version();
}
@Transactional
public SkillSuiteVersion yank(Long suiteId, Long versionId, String reason, SkillSuiteActionContext context) {
Loaded loaded = load(suiteId, versionId);
assertCanAdminister(loaded.suite(), context);
if (loaded.version().getStatus() != SkillSuiteVersionStatus.PUBLISHED) {
throw new DomainBadRequestException("error.suite.version.notPublished", loaded.version().getVersion());
}
loaded.version().setStatus(SkillSuiteVersionStatus.YANKED);
loaded.version().setYankedAt(Instant.now(clock));
loaded.version().setYankedBy(context.actorUserId());
loaded.version().setYankReason(reason);
versionRepository.save(loaded.version());
if (versionId.equals(loaded.suite().getLatestVersionId())) {
SkillSuiteVersion latest = versionRepository
.findBySuiteIdAndStatus(suiteId, SkillSuiteVersionStatus.PUBLISHED)
.stream()
.max(Comparator.comparing(SkillSuiteVersion::getPublishedAt,
Comparator.nullsFirst(Comparator.naturalOrder())))
.orElse(null);
loaded.suite().setLatestVersionId(latest == null ? null : latest.getId());
if (latest != null) {
loaded.suite().setDisplayName(latest.getDisplayName());
loaded.suite().setSummary(latest.getSummary());
}
loaded.suite().setUpdatedBy(context.actorUserId());
suiteRepository.save(loaded.suite());
}
audit(context, "YANK_SKILL_SUITE_VERSION", "SKILL_SUITE_VERSION", versionId,
AuditDetail.of("reason", reason));
log.info("Suite version yanked [suiteId={}, versionId={}, actorId={}, requestId={}]",
suiteId, versionId, context.actorUserId(), context.requestId());
return loaded.version();
}
@Transactional
public SkillSuite setHidden(Long suiteId, boolean hidden, SkillSuiteActionContext context) {
SkillSuite suite = loadSuite(suiteId);
assertCanAdminister(suite, context);
if (suite.isHidden() == hidden) {
return suite;
}
suite.setHidden(hidden);
suite.setHiddenAt(hidden ? Instant.now(clock) : null);
suite.setHiddenBy(hidden ? context.actorUserId() : null);
suite.setUpdatedBy(context.actorUserId());
suiteRepository.save(suite);
String action = hidden ? "HIDE_SKILL_SUITE" : "RESTORE_SKILL_SUITE";
audit(context, action, "SKILL_SUITE", suiteId, null);
log.info("Suite visibility overlay changed [suiteId={}, hidden={}, actorId={}, requestId={}]",
suiteId, hidden, context.actorUserId(), context.requestId());
return suite;
}
@Transactional
public SkillSuite setArchived(Long suiteId, boolean archived, SkillSuiteActionContext context) {
SkillSuite suite = loadSuite(suiteId);
assertCanAdminister(suite, context);
SkillSuiteStatus target = archived ? SkillSuiteStatus.ARCHIVED : SkillSuiteStatus.ACTIVE;
if (suite.getStatus() == target) {
return suite;
}
suite.setStatus(target);
suite.setUpdatedBy(context.actorUserId());
suiteRepository.save(suite);
String action = archived ? "ARCHIVE_SKILL_SUITE" : "UNARCHIVE_SKILL_SUITE";
audit(context, action, "SKILL_SUITE", suiteId, null);
log.info("Suite container status changed [suiteId={}, status={}, actorId={}, requestId={}]",
suiteId, target, context.actorUserId(), context.requestId());
return suite;
}
@Transactional
public void delete(Long suiteId, SkillSuiteActionContext context) {
SkillSuite suite = loadSuite(suiteId);
assertCanAdminister(suite, context);
boolean pendingReview = versionRepository.findBySuiteId(suiteId).stream()
.anyMatch(version -> version.getStatus() == SkillSuiteVersionStatus.PENDING_REVIEW);
if (pendingReview) {
throw new DomainBadRequestException("error.suite.delete.pendingReview");
}
audit(context, "DELETE_SKILL_SUITE", "SKILL_SUITE", suiteId,
AuditDetail.of("slug", suite.getSlug()));
// Review tasks are lifecycle-owned by the hard-deleted Suite. Keeping them would leave
// polymorphic subject IDs that can no longer be resolved by governance read models.
reviewTaskRepository.deleteBySubjectTypeAndSubjectId(ReviewSubjectType.SUITE_VERSION, suiteId);
suiteRepository.delete(suite);
log.info("Suite deleted [suiteId={}, namespaceId={}, actorId={}, requestId={}]",
suiteId, suite.getNamespaceId(), context.actorUserId(), context.requestId());
}
/**
* Returns state-aware actions for display. Command methods still authorize independently so a
* cached response cannot grant access after roles or lifecycle state change.
*/
@Transactional(readOnly = true)
public Set<SkillSuiteAllowedAction> allowedActions(
SkillSuite suite,
SkillSuiteVersion version,
Namespace namespace,
SkillSuiteActionContext context
) {
EnumSet<SkillSuiteAllowedAction> actions = EnumSet.noneOf(SkillSuiteAllowedAction.class);
boolean namespaceWritable = namespace.getStatus() == NamespaceStatus.ACTIVE;
boolean canManageVersion = SkillSuiteAuthorizationPolicy.canManageVersion(suite, version, context);
boolean canCreateVersion = SkillSuiteAuthorizationPolicy.canCreateVersion(suite, context);
boolean canAdminister = SkillSuiteAuthorizationPolicy.canAdminister(suite, context);
if (namespaceWritable && canManageVersion) {
if (version.getStatus() == SkillSuiteVersionStatus.DRAFT) {
actions.add(SkillSuiteAllowedAction.EDIT);
if (version.getVisibility() == SkillVisibility.PRIVATE) {
actions.add(SkillSuiteAllowedAction.PUBLISH_PRIVATE);
} else if (reviewWritesEnabled) {
actions.add(SkillSuiteAllowedAction.SUBMIT);
}
} else if (version.getStatus() == SkillSuiteVersionStatus.REJECTED) {
actions.add(SkillSuiteAllowedAction.REOPEN);
}
}
if (namespaceWritable
&& suite.getStatus() == SkillSuiteStatus.ACTIVE
&& canCreateVersion
&& (version.getStatus() == SkillSuiteVersionStatus.PUBLISHED
|| version.getStatus() == SkillSuiteVersionStatus.YANKED)) {
actions.add(SkillSuiteAllowedAction.CREATE_VERSION);
}
if (canAdminister) {
if (version.getStatus() == SkillSuiteVersionStatus.PUBLISHED) {
actions.add(SkillSuiteAllowedAction.YANK);
}
actions.add(suite.isHidden()
? SkillSuiteAllowedAction.RESTORE
: SkillSuiteAllowedAction.HIDE);
actions.add(suite.getStatus() == SkillSuiteStatus.ARCHIVED
? SkillSuiteAllowedAction.UNARCHIVE
: SkillSuiteAllowedAction.ARCHIVE);
boolean pendingReview = versionRepository.findBySuiteIdAndStatus(
suite.getId(), SkillSuiteVersionStatus.PENDING_REVIEW).stream().findAny().isPresent();
if (!pendingReview) {
actions.add(SkillSuiteAllowedAction.DELETE);
}
}
return Set.copyOf(actions);
}
private SkillSuite loadSuite(Long suiteId) {
return suiteRepository.findById(suiteId)
.orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteId));
}
private Loaded load(Long suiteId, Long versionId) {
SkillSuite suite = suiteRepository.findById(suiteId)
.orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteId));
SkillSuiteVersion version = versionRepository.findById(versionId)
.orElseThrow(() -> new DomainNotFoundException("error.suite.version.notFound", versionId));
if (!suiteId.equals(version.getSuiteId())) {
throw new DomainBadRequestException("error.suite.version.mismatch");
}
Namespace namespace = namespaceRepository.findById(suite.getNamespaceId())
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", suite.getNamespaceId()));
return new Loaded(suite, version, namespace);
}
private void publish(SkillSuite suite, SkillSuiteVersion version, String actorUserId) {
version.setStatus(SkillSuiteVersionStatus.PUBLISHED);
version.setPublishedAt(Instant.now(clock));
versionRepository.save(version);
suite.setLatestVersionId(version.getId());
// The container is the searchable projection of the latest published immutable version.
suite.setDisplayName(version.getDisplayName());
suite.setSummary(version.getSummary());
suite.setUpdatedBy(actorUserId);
suiteRepository.save(suite);
}
private ReviewTask loadPendingSuiteReview(Long reviewTaskId) {
ReviewTask task = reviewTaskRepository.findById(reviewTaskId)
.orElseThrow(() -> new DomainNotFoundException("review_task.not_found", reviewTaskId));
if (task.getSubjectType() != ReviewSubjectType.SUITE_VERSION) {
throw new DomainBadRequestException("error.suite.review.subjectMismatch");
}
if (task.getStatus() != ReviewTaskStatus.PENDING) {
throw new DomainBadRequestException("review.not_pending", reviewTaskId);
}
return task;
}
private void completeTask(ReviewTask task, ReviewTaskStatus status, String reviewerId, String comment) {
int updated = reviewTaskRepository.updateStatusWithVersion(
task.getId(), status, reviewerId, comment, task.getVersion());
if (updated == 0) {
throw new ConcurrentModificationException("Review task was modified concurrently");
}
// The bulk update is the concurrency boundary. Keep this detached return value aligned
// with the committed decision without leaking persistence mechanics into the domain layer.
task.setStatus(status);
task.setReviewedBy(reviewerId);
task.setReviewComment(comment);
task.setReviewedAt(Instant.now(clock));
}
private void assertNamespaceWritable(Namespace namespace) {
if (namespace.getStatus() != NamespaceStatus.ACTIVE) {
throw new DomainBadRequestException("error.suite.namespace.notWritable", namespace.getStatus());
}
}
private void assertCanManageDraft(
SkillSuite suite,
SkillSuiteVersion version,
SkillSuiteActionContext context
) {
if (!SkillSuiteAuthorizationPolicy.canManageVersion(suite, version, context)) {
throw new DomainForbiddenException("error.suite.lifecycle.noPermission");
}
}
private void assertCanAdminister(SkillSuite suite, SkillSuiteActionContext context) {
if (!SkillSuiteAuthorizationPolicy.canAdminister(suite, context)) {
throw new DomainForbiddenException("error.suite.lifecycle.noPermission");
}
}
private void assertCanReview(ReviewTask task, Namespace namespace, SkillSuiteActionContext context) {
if (!reviewPermissionChecker.canReview(
task,
context.actorUserId(),
namespace.getType(),
context.namespaceRoles(),
context.platformRoles())) {
throw new DomainForbiddenException("review.no_permission");
}
}
private void audit(
SkillSuiteActionContext context,
String action,
String targetType,
Long targetId,
String detail
) {
auditLogService.record(
context.actorUserId(), action, targetType, targetId, context.requestId(),
context.clientIp(), context.userAgent(), detail);
}
private record Loaded(SkillSuite suite, SkillSuiteVersion version, Namespace namespace) {
}
}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.domain.suite;
/** Computed availability; it is deliberately not persisted as a Suite lifecycle state. */
public record SkillSuiteMemberAvailability(boolean available, SkillSuiteMemberBlockingReason reason) {
public static SkillSuiteMemberAvailability availableMember() {
return new SkillSuiteMemberAvailability(true, null);
}
public static SkillSuiteMemberAvailability blocked(SkillSuiteMemberBlockingReason reason) {
return new SkillSuiteMemberAvailability(false, reason);
}
}

View file

@ -0,0 +1,12 @@
package com.iflytek.skillhub.domain.suite;
/** Stable reason codes used to explain why an exact Suite member cannot be installed. */
public enum SkillSuiteMemberBlockingReason {
DELETED,
NAMESPACE_ARCHIVED,
NAMESPACE_FROZEN,
SKILL_HIDDEN,
SKILL_ARCHIVED,
VERSION_UNAVAILABLE,
VISIBILITY_INCOMPATIBLE
}

View file

@ -0,0 +1,57 @@
package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
/** Evaluates the current installability of an exact member against the Suite's approved audience. */
public final class SkillSuiteMemberEligibilityPolicy {
public SkillSuiteMemberAvailability evaluate(
Long suiteNamespaceId,
SkillVisibility suiteVisibility,
SkillSuiteMemberState member
) {
if (member == null || member.tombstoned()) {
return SkillSuiteMemberAvailability.blocked(SkillSuiteMemberBlockingReason.DELETED);
}
if (member.hidden()) {
return SkillSuiteMemberAvailability.blocked(SkillSuiteMemberBlockingReason.SKILL_HIDDEN);
}
if (member.namespaceStatus() == NamespaceStatus.ARCHIVED) {
return SkillSuiteMemberAvailability.blocked(SkillSuiteMemberBlockingReason.NAMESPACE_ARCHIVED);
}
if (member.namespaceStatus() == NamespaceStatus.FROZEN) {
return SkillSuiteMemberAvailability.blocked(SkillSuiteMemberBlockingReason.NAMESPACE_FROZEN);
}
if (member.skillStatus() != SkillStatus.ACTIVE) {
return SkillSuiteMemberAvailability.blocked(SkillSuiteMemberBlockingReason.SKILL_ARCHIVED);
}
if (member.versionStatus() != SkillVersionStatus.PUBLISHED || !member.downloadReady() || member.yanked()) {
return SkillSuiteMemberAvailability.blocked(SkillSuiteMemberBlockingReason.VERSION_UNAVAILABLE);
}
if (!audienceCanReadMember(suiteNamespaceId, suiteVisibility, member.namespaceId(), member.visibility())) {
return SkillSuiteMemberAvailability.blocked(SkillSuiteMemberBlockingReason.VISIBILITY_INCOMPATIBLE);
}
return SkillSuiteMemberAvailability.availableMember();
}
private boolean audienceCanReadMember(
Long suiteNamespaceId,
SkillVisibility suiteVisibility,
Long memberNamespaceId,
SkillVisibility memberVisibility
) {
if (memberVisibility == SkillVisibility.PUBLIC) {
return true;
}
if (suiteVisibility == SkillVisibility.PUBLIC || !suiteNamespaceId.equals(memberNamespaceId)) {
return false;
}
if (suiteVisibility == SkillVisibility.NAMESPACE_ONLY) {
return memberVisibility == SkillVisibility.NAMESPACE_ONLY;
}
return suiteVisibility == SkillVisibility.PRIVATE;
}
}

View file

@ -0,0 +1,14 @@
package com.iflytek.skillhub.domain.suite;
/**
* Exact published Skill version selected for a Suite draft.
*/
public record SkillSuiteMemberSelection(
Long skillId,
Long skillVersionId,
String namespaceSlug,
String skillSlug,
String version,
String fingerprint
) {
}

View file

@ -0,0 +1,33 @@
package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.skill.SkillStatus;
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
/** Current state resolved for an immutable member reference. Null IDs represent a tombstone. */
public record SkillSuiteMemberState(
Long skillId,
Long skillVersionId,
Long namespaceId,
String displayName,
String summary,
NamespaceStatus namespaceStatus,
SkillVisibility visibility,
SkillStatus skillStatus,
boolean hidden,
SkillVersionStatus versionStatus,
boolean downloadReady,
boolean yanked,
boolean viewerCanRead
) {
public static SkillSuiteMemberState deleted(Long skillId, Long skillVersionId) {
return new SkillSuiteMemberState(
skillId, skillVersionId, null, null, null, null, null, null,
false, null, false, false, false);
}
public boolean tombstoned() {
return skillId == null || skillVersionId == null || namespaceId == null;
}
}

View file

@ -0,0 +1,112 @@
package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.skill.Skill;
import com.iflytek.skillhub.domain.skill.SkillRepository;
import com.iflytek.skillhub.domain.skill.SkillVersion;
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
/** Resolves live Skill state for immutable Suite member references without coordinate relinking. */
@Service
public class SkillSuiteMemberStateResolver {
private final SkillRepository skillRepository;
private final SkillVersionRepository skillVersionRepository;
private final NamespaceRepository namespaceRepository;
private final VisibilityChecker visibilityChecker;
public SkillSuiteMemberStateResolver(
SkillRepository skillRepository,
SkillVersionRepository skillVersionRepository,
NamespaceRepository namespaceRepository,
VisibilityChecker visibilityChecker
) {
this.skillRepository = skillRepository;
this.skillVersionRepository = skillVersionRepository;
this.namespaceRepository = namespaceRepository;
this.visibilityChecker = visibilityChecker;
}
public List<SkillSuiteMemberState> resolve(List<SkillSuiteVersionMember> members) {
return resolve(members, skill -> false);
}
/** Resolves live member state and applies the canonical Skill visibility policy for this viewer. */
public List<SkillSuiteMemberState> resolveForViewer(
List<SkillSuiteVersionMember> members,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
return resolve(members, skill -> visibilityChecker.canAccess(
skill, userId, namespaceRoles, platformRoles));
}
private List<SkillSuiteMemberState> resolve(
List<SkillSuiteVersionMember> members,
Predicate<Skill> viewerAccess
) {
List<Long> versionIds = members.stream()
.map(SkillSuiteVersionMember::getSkillVersionId)
.filter(java.util.Objects::nonNull)
.distinct()
.toList();
Map<Long, SkillVersion> versions = skillVersionRepository.findByIdIn(versionIds).stream()
.collect(Collectors.toMap(SkillVersion::getId, Function.identity()));
List<Long> skillIds = versions.values().stream()
.map(SkillVersion::getSkillId)
.distinct()
.toList();
Map<Long, Skill> skills = skillRepository.findByIdIn(skillIds).stream()
.collect(Collectors.toMap(Skill::getId, Function.identity()));
List<Long> namespaceIds = skills.values().stream()
.map(Skill::getNamespaceId)
.distinct()
.toList();
Map<Long, Namespace> namespaces = namespaceRepository.findByIdIn(namespaceIds).stream()
.collect(Collectors.toMap(Namespace::getId, Function.identity()));
return members.stream()
.map(member -> resolveOne(member, versions, skills, namespaces, viewerAccess))
.toList();
}
private SkillSuiteMemberState resolveOne(
SkillSuiteVersionMember member,
Map<Long, SkillVersion> versions,
Map<Long, Skill> skills,
Map<Long, Namespace> namespaces,
Predicate<Skill> viewerAccess
) {
SkillVersion version = versions.get(member.getSkillVersionId());
if (version == null || !java.util.Objects.equals(version.getSkillId(), member.getSkillId())) {
return SkillSuiteMemberState.deleted(member.getSkillId(), member.getSkillVersionId());
}
Skill skill = skills.get(version.getSkillId());
if (skill == null) {
return SkillSuiteMemberState.deleted(member.getSkillId(), member.getSkillVersionId());
}
Namespace namespace = namespaces.get(skill.getNamespaceId());
if (namespace == null) {
return SkillSuiteMemberState.deleted(member.getSkillId(), member.getSkillVersionId());
}
return new SkillSuiteMemberState(
skill.getId(), version.getId(), skill.getNamespaceId(), skill.getDisplayName(), skill.getSummary(),
namespace.getStatus(), skill.getVisibility(),
skill.getStatus(), skill.isHidden(), version.getStatus(), version.isDownloadReady(),
version.getYankedAt() != null, viewerAccess.test(skill));
}
}

View file

@ -0,0 +1,39 @@
package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import org.springframework.stereotype.Service;
import java.util.List;
/** Revalidates exact members immediately before each Suite publication transition. */
@Service
public class SkillSuitePublicationValidator {
private final SkillSuiteVersionMemberRepository memberRepository;
private final SkillSuiteMemberStateResolver stateResolver;
public SkillSuitePublicationValidator(
SkillSuiteVersionMemberRepository memberRepository,
SkillSuiteMemberStateResolver stateResolver
) {
this.memberRepository = memberRepository;
this.stateResolver = stateResolver;
}
public SkillSuiteAvailability validate(SkillSuite suite, SkillSuiteVersion version) {
List<SkillSuiteVersionMember> members =
memberRepository.findBySuiteVersionIdOrderByPosition(version.getId());
if (members.isEmpty()) {
throw new DomainBadRequestException("error.suite.members.empty");
}
SkillSuiteAvailability availability = SkillSuiteAvailability.evaluate(
suite.getNamespaceId(), version.getVisibility(), stateResolver.resolve(members));
if (!availability.available()) {
String reasons = availability.blockedMembers().stream()
.map(blocked -> blocked.skillVersionId() + ":" + blocked.reason())
.collect(java.util.stream.Collectors.joining(","));
throw new DomainBadRequestException("error.suite.members.unavailable", reasons);
}
return availability;
}
}

View file

@ -0,0 +1,232 @@
package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.review.ReviewPermissionChecker;
import com.iflytek.skillhub.domain.review.ReviewSubjectType;
import com.iflytek.skillhub.domain.review.ReviewTaskRepository;
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** Resolves viewer-specific Suite details while keeping persisted lifecycle and live availability separate. */
@Service
public class SkillSuiteQueryService {
private final NamespaceRepository namespaceRepository;
private final SkillSuiteRepository suiteRepository;
private final SkillSuiteVersionRepository versionRepository;
private final SkillSuiteVersionMemberRepository memberRepository;
private final SkillSuiteMemberStateResolver stateResolver;
private final ReviewTaskRepository reviewTaskRepository;
private final ReviewPermissionChecker reviewPermissionChecker;
public SkillSuiteQueryService(
NamespaceRepository namespaceRepository,
SkillSuiteRepository suiteRepository,
SkillSuiteVersionRepository versionRepository,
SkillSuiteVersionMemberRepository memberRepository,
SkillSuiteMemberStateResolver stateResolver,
ReviewTaskRepository reviewTaskRepository,
ReviewPermissionChecker reviewPermissionChecker
) {
this.namespaceRepository = namespaceRepository;
this.suiteRepository = suiteRepository;
this.versionRepository = versionRepository;
this.memberRepository = memberRepository;
this.stateResolver = stateResolver;
this.reviewTaskRepository = reviewTaskRepository;
this.reviewPermissionChecker = reviewPermissionChecker;
}
@Transactional(readOnly = true)
public Detail getDetail(
String namespaceSlug,
String suiteSlug,
String requestedVersion,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceSlug));
SkillSuite suite = suiteRepository.findByNamespaceIdAndSlug(namespace.getId(), suiteSlug)
.orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteSlug));
SkillSuiteVersion version = resolveVersion(suite, requestedVersion);
return assembleDetail(namespace, suite, version, userId, namespaceRoles, platformRoles);
}
/** Resolves the immutable Suite version captured by an earlier idempotent install operation. */
@Transactional(readOnly = true)
public Detail getDetailByVersionId(
String namespaceSlug,
String suiteSlug,
Long versionId,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceSlug));
SkillSuite suite = suiteRepository.findByNamespaceIdAndSlug(namespace.getId(), suiteSlug)
.orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteSlug));
SkillSuiteVersion version = versionRepository.findById(versionId)
.filter(candidate -> suite.getId().equals(candidate.getSuiteId()))
.orElseThrow(() -> new DomainNotFoundException("error.suite.version.notFound", versionId));
return assembleDetail(namespace, suite, version, userId, namespaceRoles, platformRoles);
}
private Detail assembleDetail(
Namespace namespace,
SkillSuite suite,
SkillSuiteVersion version,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
if (!canRead(namespace, suite, version, userId, namespaceRoles, platformRoles)) {
throw new DomainForbiddenException("error.suite.access.denied");
}
List<SkillSuiteVersionMember> snapshots =
memberRepository.findBySuiteVersionIdOrderByPosition(version.getId());
List<SkillSuiteMemberState> states = stateResolver.resolveForViewer(
snapshots, userId, namespaceRoles, platformRoles);
List<MemberDetail> memberDetails = new ArrayList<>(snapshots.size());
for (int index = 0; index < snapshots.size(); index++) {
SkillSuiteMemberAvailability availability = new SkillSuiteMemberEligibilityPolicy().evaluate(
suite.getNamespaceId(), version.getVisibility(), states.get(index));
memberDetails.add(new MemberDetail(snapshots.get(index), states.get(index), availability));
}
boolean available = version.getStatus() == SkillSuiteVersionStatus.PUBLISHED
&& suite.getStatus() == SkillSuiteStatus.ACTIVE
&& !suite.isHidden()
&& memberDetails.stream().allMatch(member -> member.availability().available());
return new Detail(namespace, suite, version, available, memberDetails);
}
@Transactional(readOnly = true)
public List<VersionSummary> listVersions(
String namespaceSlug,
String suiteSlug,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceSlug));
SkillSuite suite = suiteRepository.findByNamespaceIdAndSlug(namespace.getId(), suiteSlug)
.orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteSlug));
return versionRepository.findBySuiteId(suite.getId()).stream()
.filter(version -> canRead(namespace, suite, version, userId, namespaceRoles, platformRoles))
.sorted(Comparator.comparing(
SkillSuiteVersion::getCreatedAt,
Comparator.nullsLast(Comparator.reverseOrder())))
.map(version -> new VersionSummary(
version.getId(), version.getVersion(), version.getStatus(),
version.getVisibility(), version.getPublishedAt(),
version.getYankedAt(), version.getCreatedAt()))
.toList();
}
private SkillSuiteVersion resolveVersion(SkillSuite suite, String requestedVersion) {
if (requestedVersion != null && !requestedVersion.isBlank()) {
return versionRepository.findBySuiteIdAndVersion(suite.getId(), requestedVersion)
.orElseThrow(() -> new DomainNotFoundException(
"error.suite.version.notFound", requestedVersion));
}
if (suite.getLatestVersionId() == null) {
throw new DomainNotFoundException("error.suite.version.notFound", "latest");
}
return versionRepository.findById(suite.getLatestVersionId())
.orElseThrow(() -> new DomainNotFoundException(
"error.suite.version.notFound", suite.getLatestVersionId()));
}
private boolean canRead(
Namespace namespace,
SkillSuite suite,
SkillSuiteVersion version,
String userId,
Map<Long, NamespaceRole> namespaceRoles,
Set<String> platformRoles
) {
if (platformRoles.contains("SUPER_ADMIN")) {
return true;
}
NamespaceRole role = namespaceRoles.get(suite.getNamespaceId());
boolean namespaceAdmin = role == NamespaceRole.OWNER || role == NamespaceRole.ADMIN;
boolean currentCreator = userId != null && userId.equals(suite.getCreatedBy()) && role != null;
if (version.getStatus() == SkillSuiteVersionStatus.PENDING_REVIEW) {
return reviewTaskRepository.findBySubjectTypeAndSubjectVersionIdAndStatus(
ReviewSubjectType.SUITE_VERSION, version.getId(), ReviewTaskStatus.PENDING)
.map(task -> reviewPermissionChecker.canReadReview(
task, userId, namespace.getType(), namespaceRoles, platformRoles))
.orElse(namespaceAdmin || platformRoles.contains("SKILL_ADMIN"));
}
if (version.getStatus() != SkillSuiteVersionStatus.PUBLISHED
&& version.getStatus() != SkillSuiteVersionStatus.YANKED) {
if (namespaceAdmin || currentCreator || platformRoles.contains("SKILL_ADMIN")) {
return true;
}
return false;
}
if (suite.getStatus() != SkillSuiteStatus.ACTIVE || suite.isHidden()) {
if (namespaceAdmin || currentCreator) {
return true;
}
return false;
}
if (version.getVisibility() == SkillVisibility.PUBLIC) {
return true;
}
if (version.getVisibility() == SkillVisibility.NAMESPACE_ONLY && role != null) {
return true;
}
if (version.getVisibility() == SkillVisibility.PRIVATE && (namespaceAdmin || currentCreator)) {
return true;
}
return false;
}
public record MemberDetail(
SkillSuiteVersionMember snapshot,
SkillSuiteMemberState state,
SkillSuiteMemberAvailability availability
) {
}
public record Detail(
Namespace namespace,
SkillSuite suite,
SkillSuiteVersion version,
boolean available,
List<MemberDetail> members
) {
public Detail {
members = List.copyOf(members);
}
}
public record VersionSummary(
Long id,
String version,
SkillSuiteVersionStatus status,
SkillVisibility visibility,
java.time.Instant publishedAt,
java.time.Instant yankedAt,
java.time.Instant createdAt
) {
}
}

View file

@ -0,0 +1,15 @@
package com.iflytek.skillhub.domain.suite;
import java.util.List;
import java.util.Optional;
/** Persistence contract for Suite containers. */
public interface SkillSuiteRepository {
Optional<SkillSuite> findById(Long id);
Optional<SkillSuite> findByNamespaceIdAndSlug(Long namespaceId, String slug);
List<SkillSuite> findByIdIn(List<Long> ids);
List<SkillSuite> findByNamespaceId(Long namespaceId);
SkillSuite save(SkillSuite suite);
void delete(SkillSuite suite);
void incrementInstallRequestCount(Long suiteId);
}

View file

@ -0,0 +1,7 @@
package com.iflytek.skillhub.domain.suite;
/** Container lifecycle for a Skill Suite. Version publication is tracked separately. */
public enum SkillSuiteStatus {
ACTIVE,
ARCHIVED
}

View file

@ -0,0 +1,228 @@
package com.iflytek.skillhub.domain.suite;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.skill.SkillVisibility;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.Clock;
import java.time.Instant;
/**
* Immutable-after-publication snapshot of a Suite definition and its approved visibility.
*/
@Entity
@Table(name = "skill_suite_version")
public class SkillSuiteVersion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "suite_id", nullable = false)
private Long suiteId;
@Column(nullable = false, length = 64)
private String version;
@Column(name = "display_name", nullable = false, length = 256)
private String displayName;
@Column(columnDefinition = "TEXT")
private String summary;
@Column(columnDefinition = "TEXT")
private String overview;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private SkillSuiteVersionStatus status;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32)
private SkillVisibility visibility;
@Column(columnDefinition = "TEXT")
private String changelog;
@Column(name = "entry_skill_version_id")
private Long entrySkillVersionId;
@Column(name = "published_at")
private Instant publishedAt;
@Column(name = "yanked_at")
private Instant yankedAt;
@Column(name = "yanked_by", length = 128)
private String yankedBy;
@Column(name = "yank_reason", columnDefinition = "TEXT")
private String yankReason;
@Column(name = "created_by", nullable = false, length = 128)
private String createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
protected SkillSuiteVersion() {
}
public SkillSuiteVersion(Long suiteId, String version, SkillVisibility visibility, String createdBy) {
this.suiteId = suiteId;
this.version = version;
this.displayName = version;
this.visibility = visibility;
this.createdBy = createdBy;
this.status = SkillSuiteVersionStatus.DRAFT;
}
public SkillSuiteVersion(
Long suiteId,
String version,
String displayName,
String summary,
SkillVisibility visibility,
String createdBy
) {
this(suiteId, version, visibility, createdBy);
this.displayName = displayName;
this.summary = summary;
}
@PrePersist
protected void onCreate() {
createdAt = Instant.now(Clock.systemUTC());
}
/**
* Guards all definition changes. Review and published snapshots must not drift in place.
*/
public void assertEditable() {
if (status != SkillSuiteVersionStatus.DRAFT) {
throw new DomainBadRequestException("error.suite.version.immutable", version);
}
}
public Long getId() {
return id;
}
public Long getSuiteId() {
return suiteId;
}
public String getVersion() {
return version;
}
public String getDisplayName() {
return displayName;
}
public String getSummary() {
return summary;
}
public String getOverview() {
return overview;
}
public SkillSuiteVersionStatus getStatus() {
return status;
}
public SkillVisibility getVisibility() {
return visibility;
}
public String getChangelog() {
return changelog;
}
public Long getEntrySkillVersionId() {
return entrySkillVersionId;
}
public Instant getPublishedAt() {
return publishedAt;
}
public Instant getYankedAt() {
return yankedAt;
}
public String getYankedBy() {
return yankedBy;
}
public String getYankReason() {
return yankReason;
}
public String getCreatedBy() {
return createdBy;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setStatus(SkillSuiteVersionStatus status) {
this.status = status;
}
public void setVisibility(SkillVisibility visibility) {
assertEditable();
this.visibility = visibility;
}
public void setDisplayName(String displayName) {
assertEditable();
this.displayName = displayName;
}
public void setSummary(String summary) {
assertEditable();
this.summary = summary;
}
public void setOverview(String overview) {
assertEditable();
this.overview = overview;
}
public void setChangelog(String changelog) {
assertEditable();
this.changelog = changelog;
}
public void setEntrySkillVersionId(Long entrySkillVersionId) {
assertEditable();
this.entrySkillVersionId = entrySkillVersionId;
}
public void setPublishedAt(Instant publishedAt) {
this.publishedAt = publishedAt;
}
public void setYankedAt(Instant yankedAt) {
this.yankedAt = yankedAt;
}
public void setYankedBy(String yankedBy) {
this.yankedBy = yankedBy;
}
public void setYankReason(String yankReason) {
this.yankReason = yankReason;
}
}

Some files were not shown because too many files have changed in this diff Show more