mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-10 22:41:02 +00:00
feat(suite): add first-class skill suites
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
25e18e047c
commit
859987e3bb
151 changed files with 13555 additions and 185 deletions
5
Makefile
5
Makefile
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 数据节点和
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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
153
cli/src/commands/suite.ts
Normal 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}`
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
|
|
|||
755
cli/src/services/suite-service.ts
Normal file
755
cli/src/services/suite-service.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
750
cli/test/unit/services/suite-service.test.ts
Normal file
750
cli/test/unit/services/suite-service.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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'] }])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
142
docs/25-skill-suites.md
Normal 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` 始终安装 Skill;Suite 必须使用
|
||||
`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 或下载地址。
|
||||
|
|
@ -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 生命周期、成员失效和整组回滚。
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ SkillHub 当前只能逐个发布和安装 Skill,无法把一组已经发布
|
|||
- 新增 `skillhub suite install/check/upgrade/remove`,成员继续安装为标准 Agent Skill;Suite 本身不生成同名 `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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 审核流程保持可用
|
||||
|
|
|
|||
|
|
@ -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
259
scripts/suite-smoke-test.sh
Executable 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 ==="
|
||||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE skill_suite_version
|
||||
ADD COLUMN overview TEXT;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=技能套件安装幂等键格式无效
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/*/*"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue