mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-14 04:08:16 +00:00
Merge remote-tracking branch 'origin/main' into codex/fix-pr866-owner-yank
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com> # Conflicts: # web/src/api/generated/schema.d.ts
This commit is contained in:
commit
74a5b873f0
266 changed files with 23341 additions and 1000 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 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
|
||||
.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 suite-bundle-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
|
||||
|
|
@ -150,6 +150,9 @@ namespace-smoke: ## 运行命名空间工作流 smoke test
|
|||
suite-smoke: ## 运行 Skill Suite 生命周期 smoke test
|
||||
./scripts/suite-smoke-test.sh $(DEV_API_URL)
|
||||
|
||||
suite-bundle-smoke: ## 运行带真实认证的 Suite Bundle 创建/更新 smoke test
|
||||
./scripts/suite-bundle-smoke-test.sh $(DEV_API_URL)
|
||||
|
||||
dev-down: ## 停止本地开发环境(含 skill-scanner)
|
||||
$(DEV_COMPOSE) down --remove-orphans
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ All notable CLI behavior changes are documented in this file.
|
|||
|
||||
### Added
|
||||
|
||||
- Add OAuth Device Flow to `skillhub login` when no API token is supplied, including best-effort
|
||||
browser launch, a `--no-open` headless mode, bounded polling, and non-secret JSON progress output.
|
||||
- Add the `pi` agent profile, displayed as Pi, with `--agent pi`, project-level
|
||||
`<project>/.pi/skills/`, and user-level `~/.pi/agent/skills/` support.
|
||||
- Add the user-level `astudio` agent profile, displayed as AStudio, with automatic detection of
|
||||
`~/.acode/skills` on Linux, macOS, and Windows.
|
||||
- Add repeatable `sync pull --skill <slug>` selection for non-interactive and JSON workflows, with
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ bun add -g @astron-team/skillhub
|
|||
## 🚀 Quick Start
|
||||
|
||||
```bash
|
||||
# Login
|
||||
skillhub login --token sk_xxx
|
||||
# Log in interactively with OAuth Device Flow
|
||||
skillhub login
|
||||
|
||||
# Search skills
|
||||
skillhub search pdf
|
||||
|
|
@ -68,7 +68,8 @@ set SKILLHUB_REGISTRY=https://skillhub.example.com
|
|||
|
||||
## 🔐 Authentication
|
||||
|
||||
Token resolution priority:
|
||||
`skillhub login` uses OAuth Device Flow when no API token is supplied. Token resolution priority for
|
||||
explicit token-based login and all other authenticated commands is:
|
||||
|
||||
1. `--token <token>` command-line argument
|
||||
2. `SKILLHUB_TOKEN` environment variable
|
||||
|
|
@ -77,14 +78,23 @@ Token resolution priority:
|
|||
### Login
|
||||
|
||||
```bash
|
||||
# Login with API token
|
||||
skillhub login --token sk_xxx
|
||||
# Interactive login: opens the registry's verification page and displays a user code
|
||||
skillhub login
|
||||
|
||||
# Login to specific registry
|
||||
# Interactive login on a remote/headless terminal
|
||||
skillhub login --no-open --registry https://skillhub.example.com
|
||||
|
||||
# Non-interactive login with an API token
|
||||
skillhub login --token sk_xxx --registry https://skillhub.example.com
|
||||
```
|
||||
|
||||
`login` validates the token, stores it in `~/.skillhub/credentials.json`, and writes the registry to `~/.skillhub/config.json`.
|
||||
During interactive login, complete authentication in the browser and enter the displayed user code.
|
||||
The CLI polls only until the server-provided expiry. It then validates the issued token, stores it in
|
||||
`~/.skillhub/credentials.json`, and writes the registry to `~/.skillhub/config.json`. `--no-open`
|
||||
suppresses automatic browser launch while retaining the verification URL and code in the terminal.
|
||||
|
||||
Token-based login remains available for CI and other non-interactive automation. In both modes,
|
||||
credentials are persisted only after `whoami` succeeds.
|
||||
|
||||
Both files are updated non-destructively: SkillHub CLI changes only its own `tokens` and `registry`
|
||||
fields and preserves unknown fields written by other compatible tools. This allows tools that share
|
||||
|
|
@ -172,6 +182,9 @@ skillhub install pdf-parser --agent codex
|
|||
# Install to AStudio's fixed user-level directory
|
||||
skillhub install pdf-parser --agent astudio
|
||||
|
||||
# Install to Pi's user-level directory (use --scope project for the project directory)
|
||||
skillhub install pdf-parser --agent pi
|
||||
|
||||
# Install to multiple Agents
|
||||
skillhub install pdf-parser --agent codex --agent claude-code
|
||||
|
||||
|
|
@ -219,6 +232,7 @@ Most Agents have both project-level and user-level skills directories. Use `--sc
|
|||
| `openclaw` | `<project>/.openclaw/skills/` | `~/.openclaw/skills/` |
|
||||
| `opencode` | `<project>/.opencode/skills/` | `~/.opencode/skills/` |
|
||||
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
|
||||
| `pi` (Pi) | `<project>/.pi/skills/` | `~/.pi/agent/skills/` |
|
||||
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |
|
||||
|
||||
For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. AStudio appears in that selector when `~/.acode/skills/` exists. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.
|
||||
|
|
@ -457,7 +471,7 @@ Update mechanism:
|
|||
|---------|-------------|
|
||||
| `skillhub help [command]` | Display help information |
|
||||
| `skillhub version [--json]`, `skillhub --version`, `skillhub -v` | Display CLI version |
|
||||
| `skillhub login --token <token> [--registry <url>] [--json]` | Save token and registry configuration |
|
||||
| `skillhub login [--no-open] [--token <token>] [--registry <url>] [--json]` | Log in with OAuth Device Flow or an API token |
|
||||
| `skillhub logout [--registry <url>] [--json]` | Remove token for specified registry |
|
||||
| `skillhub whoami [--registry <url>] [--token <token>] [--json]` | Validate current token and display user information |
|
||||
| `skillhub search <query> [--registry <url>] [--token <token>] [--limit <n>] [--json]` | Search published skills |
|
||||
|
|
@ -487,7 +501,10 @@ Update mechanism:
|
|||
# Verify token validity
|
||||
skillhub whoami
|
||||
|
||||
# Re-login
|
||||
# Re-login interactively
|
||||
skillhub login
|
||||
|
||||
# Or use a token for non-interactive automation
|
||||
skillhub login --token sk_xxx
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -14,19 +14,20 @@ import { traeProfile } from './profiles/trae'
|
|||
import { traeCnProfile } from './profiles/trae-cn'
|
||||
import { opencodeProfile } from './profiles/opencode'
|
||||
import { kiloProfile } from './profiles/kilo'
|
||||
import { piProfile } from './profiles/pi'
|
||||
|
||||
export {
|
||||
aStudioProfile, claudeCodeProfile, codexProfile, cursorProfile, githubCopilotProfile,
|
||||
geminiCliProfile, openhandsProfile, windsurfProfile, openclawProfile,
|
||||
kiroCliProfile, rooProfile, traeProfile, traeCnProfile,
|
||||
opencodeProfile, kiloProfile
|
||||
opencodeProfile, kiloProfile, piProfile
|
||||
}
|
||||
|
||||
export const allProfiles: AgentProfile[] = [
|
||||
aStudioProfile, claudeCodeProfile, codexProfile, cursorProfile, githubCopilotProfile,
|
||||
geminiCliProfile, openhandsProfile, windsurfProfile, openclawProfile,
|
||||
kiroCliProfile, rooProfile, traeProfile, traeCnProfile,
|
||||
opencodeProfile, kiloProfile
|
||||
opencodeProfile, kiloProfile, piProfile
|
||||
]
|
||||
|
||||
export const profileMap = new Map(allProfiles.map(p => [p.id, p]))
|
||||
|
|
|
|||
2
cli/src/agents/profiles/pi.ts
Normal file
2
cli/src/agents/profiles/pi.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import { makeProfile } from './make-profile'
|
||||
export const piProfile = makeProfile('pi', 'Pi', '.pi/skills', '.pi/agent/skills')
|
||||
|
|
@ -7,6 +7,20 @@ export interface WhoAmIResponse {
|
|||
email?: string
|
||||
}
|
||||
|
||||
export interface DeviceCodeResponse {
|
||||
deviceCode: string
|
||||
userCode: string
|
||||
verificationUri: string
|
||||
expiresIn: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export interface DeviceTokenResponse {
|
||||
accessToken?: string | null
|
||||
tokenType?: string | null
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export interface SearchItem {
|
||||
namespace: string
|
||||
slug: string
|
||||
|
|
@ -146,6 +160,14 @@ export class SkillHubClient {
|
|||
return this.getJson('/auth/whoami')
|
||||
}
|
||||
|
||||
async requestDeviceCode(): Promise<DeviceCodeResponse> {
|
||||
return this.postPublicJson('/api/v1/auth/device/code')
|
||||
}
|
||||
|
||||
async pollDeviceToken(deviceCode: string): Promise<DeviceTokenResponse> {
|
||||
return this.postPublicJson('/api/v1/auth/device/token', { deviceCode })
|
||||
}
|
||||
|
||||
async serverMetadata(): Promise<ServerMetadata> {
|
||||
let response: Response
|
||||
try {
|
||||
|
|
@ -347,6 +369,20 @@ export class SkillHubClient {
|
|||
return this.handleJsonResponse<T>(response)
|
||||
}
|
||||
|
||||
private async postPublicJson<T>(path: string, body?: Record<string, unknown>): Promise<T> {
|
||||
let response: Response
|
||||
try {
|
||||
response = await this.fetchImpl(`${this.registry}${path}`, {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
...(body ? { body: JSON.stringify(body) } : {})
|
||||
})
|
||||
} catch {
|
||||
throw new CliError('registry unreachable', EXIT.network, { registry: this.registry, next: 'check network or pass --registry' })
|
||||
}
|
||||
return this.handleJsonResponse<T>(response)
|
||||
}
|
||||
|
||||
private async handleJsonResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
throw await this.createResponseError(response, 'json')
|
||||
|
|
|
|||
|
|
@ -14,9 +14,13 @@ export const commands = {
|
|||
examples: ['skillhub version', 'skillhub version --json', 'skillhub --version', 'skillhub -v']
|
||||
},
|
||||
login: {
|
||||
summary: 'Save registry and token',
|
||||
usage: 'skillhub login [--token <token>] [--registry <url>] [--json]',
|
||||
examples: ['skillhub login --token sk_xxx', 'skillhub login --registry https://skillhub.example.com']
|
||||
summary: 'Log in with OAuth Device Flow or an API token',
|
||||
usage: 'skillhub login [--token <token>] [--no-open] [--registry <url>] [--json]',
|
||||
examples: [
|
||||
'skillhub login --registry https://skillhub.example.com',
|
||||
'skillhub login --registry https://skillhub.example.com --no-open',
|
||||
'skillhub login --token sk_xxx'
|
||||
]
|
||||
},
|
||||
logout: {
|
||||
summary: 'Remove local token',
|
||||
|
|
|
|||
|
|
@ -2,11 +2,13 @@ import { ConfigStore } from '../stores/config-store'
|
|||
import { CredentialsStore } from '../stores/credentials-store'
|
||||
import { AuthService } from '../services/auth-service'
|
||||
import { resolveRegistry, resolveToken } from '../services/registry-service'
|
||||
import { openExternalUrl } from '../platform/browser'
|
||||
|
||||
export interface LoginCommandOptions {
|
||||
registry?: string
|
||||
token?: string
|
||||
json?: boolean
|
||||
noOpen?: boolean
|
||||
}
|
||||
|
||||
export async function loginCommand(options: LoginCommandOptions): Promise<string> {
|
||||
|
|
@ -14,7 +16,21 @@ export async function loginCommand(options: LoginCommandOptions): Promise<string
|
|||
const credentialsStore = new CredentialsStore()
|
||||
const registry = resolveRegistry(options, process.env, await configStore.read())
|
||||
const token = resolveToken(options, process.env, await credentialsStore.getToken(registry))
|
||||
const result = await new AuthService(configStore, credentialsStore).login(registry, token)
|
||||
const authService = new AuthService(configStore, credentialsStore)
|
||||
const result = token
|
||||
? await authService.login(registry, token)
|
||||
: await authService.loginWithDeviceFlow(registry, async details => {
|
||||
const opened = options.noOpen ? false : openExternalUrl(details.verificationUri)
|
||||
const message = options.json
|
||||
? JSON.stringify({ event: 'device_authorization', ...details, browserOpened: opened })
|
||||
: [
|
||||
`Authorize this device at: ${details.verificationUri}`,
|
||||
`Code: ${details.userCode}`,
|
||||
opened ? 'Browser launch requested. If no browser opened, use the URL above.' : 'Open the URL in a browser to continue.',
|
||||
`Waiting for authorization (expires in ${details.expiresIn}s)...`
|
||||
].join('\n')
|
||||
process.stderr.write(`${message}\n`)
|
||||
})
|
||||
return options.json
|
||||
? JSON.stringify({ ok: true, registry, handle: result.handle })
|
||||
: `Logged in to ${registry} as ${result.handle}`
|
||||
|
|
|
|||
|
|
@ -232,12 +232,16 @@ cli
|
|||
})
|
||||
|
||||
cli
|
||||
.command('login', 'Save registry and token')
|
||||
.command('login', 'Log in with OAuth Device Flow or an API token')
|
||||
.option('--registry <url>', 'Registry URL')
|
||||
.option('--token <token>', 'API token')
|
||||
.option('--no-open', 'Do not open the verification URL in a browser')
|
||||
.option('--json', 'Output JSON')
|
||||
.action((options: { registry?: string; token?: string; json?: boolean }) => {
|
||||
return runCommand(() => loginCommand(options), Boolean(options.json))
|
||||
.action((options: { registry?: string; token?: string; open?: boolean; json?: boolean }) => {
|
||||
return runCommand(
|
||||
() => loginCommand({ ...options, noOpen: options.open === false }),
|
||||
Boolean(options.json)
|
||||
)
|
||||
})
|
||||
|
||||
cli
|
||||
|
|
|
|||
56
cli/src/platform/browser.ts
Normal file
56
cli/src/platform/browser.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { spawn } from 'node:child_process'
|
||||
|
||||
type SupportedPlatform = 'darwin' | 'linux' | 'win32'
|
||||
type Launch = (command: string, args: string[]) => boolean
|
||||
|
||||
interface OpenExternalUrlOptions {
|
||||
platform?: NodeJS.Platform
|
||||
launch?: Launch
|
||||
env?: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
export function canOpenBrowser(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): boolean {
|
||||
if (env.CI || env.SSH_CONNECTION || env.SSH_TTY) return false
|
||||
if (platform === 'linux') return Boolean(env.DISPLAY || env.WAYLAND_DISPLAY)
|
||||
return platform === 'darwin' || platform === 'win32'
|
||||
}
|
||||
|
||||
function launchDetached(command: string, args: string[]): boolean {
|
||||
try {
|
||||
const child = spawn(command, args, { detached: true, stdio: 'ignore' })
|
||||
child.on('error', () => {})
|
||||
child.unref()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function launcherFor(platform: SupportedPlatform, url: string): [string, string[]] {
|
||||
if (platform === 'darwin') return ['open', [url]]
|
||||
if (platform === 'win32') return ['rundll32', ['url.dll,FileProtocolHandler', url]]
|
||||
return ['xdg-open', [url]]
|
||||
}
|
||||
|
||||
export function openExternalUrl(url: string, options: OpenExternalUrlOptions = {}): boolean {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false
|
||||
|
||||
const platform = options.platform ?? process.platform
|
||||
if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') return false
|
||||
if (!canOpenBrowser(platform, options.env ?? process.env)) return false
|
||||
const [command, args] = launcherFor(platform, parsed.toString())
|
||||
try {
|
||||
return (options.launch ?? launchDetached)(command, args)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -5,21 +5,82 @@ import { CliError } from '../shared/errors'
|
|||
import { EXIT } from '../shared/constants'
|
||||
|
||||
export class AuthService {
|
||||
private readonly clientFactory: (registry: string, token?: string) => SkillHubClient
|
||||
private readonly sleep: (milliseconds: number) => Promise<void>
|
||||
private readonly now: () => number
|
||||
|
||||
constructor(
|
||||
private readonly configStore: ConfigStore,
|
||||
private readonly credentialsStore: CredentialsStore
|
||||
) {}
|
||||
private readonly credentialsStore: CredentialsStore,
|
||||
options: {
|
||||
clientFactory?: (registry: string, token?: string) => SkillHubClient
|
||||
sleep?: (milliseconds: number) => Promise<void>
|
||||
now?: () => number
|
||||
} = {}
|
||||
) {
|
||||
this.clientFactory = options.clientFactory ?? ((registry, token) => new SkillHubClient(registry, token))
|
||||
this.sleep = options.sleep ?? (milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)))
|
||||
this.now = options.now ?? Date.now
|
||||
}
|
||||
|
||||
async login(registry: string, token?: string): Promise<{ handle: string }> {
|
||||
if (!token) {
|
||||
throw new CliError('token is required', EXIT.usage, { next: 'pass --token, set SKILLHUB_TOKEN, or use interactive login' })
|
||||
}
|
||||
const user = await new SkillHubClient(registry, token).whoami()
|
||||
async login(registry: string, token: string): Promise<{ handle: string }> {
|
||||
const user = await this.clientFactory(registry, token).whoami()
|
||||
await this.configStore.setRegistry(registry)
|
||||
await this.credentialsStore.setToken(registry, token)
|
||||
return { handle: user.handle }
|
||||
}
|
||||
|
||||
async loginWithDeviceFlow(
|
||||
registry: string,
|
||||
onVerification: (details: { userCode: string; verificationUri: string; expiresIn: number }) => Promise<void>
|
||||
): Promise<{ handle: string }> {
|
||||
const client = this.clientFactory(registry)
|
||||
const device = await client.requestDeviceCode()
|
||||
if (
|
||||
!device.deviceCode ||
|
||||
!device.userCode ||
|
||||
!device.verificationUri ||
|
||||
!Number.isFinite(device.expiresIn) ||
|
||||
!Number.isFinite(device.interval) ||
|
||||
device.expiresIn <= 0 ||
|
||||
device.interval <= 0
|
||||
) {
|
||||
throw new CliError('registry returned invalid device authorization data', EXIT.auth, { registry })
|
||||
}
|
||||
|
||||
let verificationUri: string
|
||||
try {
|
||||
const parsedVerificationUri = new URL(device.verificationUri, `${registry}/`)
|
||||
if (parsedVerificationUri.protocol !== 'http:' && parsedVerificationUri.protocol !== 'https:') {
|
||||
throw new Error('unsupported protocol')
|
||||
}
|
||||
verificationUri = parsedVerificationUri.toString()
|
||||
} catch {
|
||||
throw new CliError('registry returned invalid device verification URL', EXIT.auth, { registry })
|
||||
}
|
||||
await onVerification({ userCode: device.userCode, verificationUri, expiresIn: device.expiresIn })
|
||||
|
||||
const expiresAt = this.now() + device.expiresIn * 1_000
|
||||
const intervalMilliseconds = device.interval * 1_000
|
||||
while (this.now() < expiresAt) {
|
||||
await this.sleep(Math.min(intervalMilliseconds, Math.max(0, expiresAt - this.now())))
|
||||
if (this.now() >= expiresAt) break
|
||||
|
||||
const response = await client.pollDeviceToken(device.deviceCode)
|
||||
if (response.accessToken) {
|
||||
if (response.tokenType && response.tokenType.toLowerCase() !== 'bearer') {
|
||||
throw new CliError('registry returned unsupported device token type', EXIT.auth, { registry })
|
||||
}
|
||||
return this.login(registry, response.accessToken)
|
||||
}
|
||||
if (response.error && response.error !== 'authorization_pending') {
|
||||
throw new CliError(`device authorization failed: ${response.error}`, EXIT.auth, { registry })
|
||||
}
|
||||
}
|
||||
|
||||
throw new CliError('device authorization expired', EXIT.auth, { registry, next: 'run `skillhub login` again' })
|
||||
}
|
||||
|
||||
async logout(registry: string): Promise<void> {
|
||||
await this.credentialsStore.deleteToken(registry)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -168,6 +168,13 @@ interface FakeRegistryOptions {
|
|||
dryRunResponse?: { valid: boolean; errors: string[]; warnings: string[]; resolvedSlug: string | null; resolvedVersion: string | null }
|
||||
publishStatus?: string
|
||||
namespacePageSize?: number
|
||||
deviceFlow?: {
|
||||
accessToken: string
|
||||
pendingPolls?: number
|
||||
userCode?: string
|
||||
expiresIn?: number
|
||||
interval?: number
|
||||
}
|
||||
/**
|
||||
* Per-endpoint failure injection. When set for an endpoint, that endpoint
|
||||
* ignores all other logic and returns the specified failure (or throws for
|
||||
|
|
@ -228,6 +235,7 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
downloads: number
|
||||
reviews: number
|
||||
namespaceRequests: number
|
||||
devicePolls: number
|
||||
} = {
|
||||
publish: null,
|
||||
resolve: null,
|
||||
|
|
@ -237,7 +245,8 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
resolves: 0,
|
||||
downloads: 0,
|
||||
reviews: 0,
|
||||
namespaceRequests: 0
|
||||
namespaceRequests: 0,
|
||||
devicePolls: 0
|
||||
}
|
||||
|
||||
// If any endpoint is configured with 'network' failure mode, we need a real
|
||||
|
|
@ -283,6 +292,33 @@ export async function startFakeRegistry(options: FakeRegistryOptions = {}) {
|
|||
const path = url.pathname
|
||||
const baseUrl = `${url.protocol}//${url.host}`
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// POST /api/v1/auth/device/code and /api/v1/auth/device/token
|
||||
// ------------------------------------------------------------------ //
|
||||
if (path === '/api/v1/auth/device/code' && req.method === 'POST' && options.deviceFlow) {
|
||||
return Response.json({
|
||||
code: 0,
|
||||
data: {
|
||||
deviceCode: 'device-secret',
|
||||
userCode: options.deviceFlow.userCode ?? 'ABCD-2345',
|
||||
verificationUri: `${baseUrl}/device`,
|
||||
expiresIn: options.deviceFlow.expiresIn ?? 60,
|
||||
interval: options.deviceFlow.interval ?? 0.001
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (path === '/api/v1/auth/device/token' && req.method === 'POST' && options.deviceFlow) {
|
||||
state.devicePolls += 1
|
||||
if (state.devicePolls <= (options.deviceFlow.pendingPolls ?? 0)) {
|
||||
return Response.json({ code: 0, data: { accessToken: null, tokenType: null, error: 'authorization_pending' } })
|
||||
}
|
||||
return Response.json({
|
||||
code: 0,
|
||||
data: { accessToken: options.deviceFlow.accessToken, tokenType: 'Bearer', error: null }
|
||||
})
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ //
|
||||
// GET /api/cli/v1/auth/whoami
|
||||
// ------------------------------------------------------------------ //
|
||||
|
|
|
|||
|
|
@ -41,7 +41,9 @@ export async function runCli(
|
|||
const proc = Bun.spawn({
|
||||
cmd: [bunPath, entry, ...args],
|
||||
cwd: options.cwd ?? cliRoot,
|
||||
env: { ...sanitizeProcessEnv(), ...env },
|
||||
// Integration tests must never launch real desktop applications. Tests
|
||||
// that exercise browser-launch behavior inject a fake launcher directly.
|
||||
env: { ...sanitizeProcessEnv(), CI: 'true', ...env },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { createTempHome } from '../helpers/temp-env'
|
|||
import { startFakeRegistry } from '../helpers/fake-registry'
|
||||
import { runCli } from '../helpers/run-cli'
|
||||
|
||||
let registry: { url: string; stop: () => void } | undefined
|
||||
let registry: Awaited<ReturnType<typeof startFakeRegistry>> | undefined
|
||||
|
||||
afterEach(() => {
|
||||
registry?.stop()
|
||||
|
|
@ -79,18 +79,52 @@ describe('auth commands', () => {
|
|||
expect(result.stderr).toContain('authentication failed')
|
||||
})
|
||||
|
||||
// [P0] missing token → EXIT.usage, stderr contains "token is required"
|
||||
test('login without --token exits with usage error', async () => {
|
||||
test('login without a token uses device flow and never prints the access token', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({ token: 'sk_ok' })
|
||||
registry = await startFakeRegistry({
|
||||
token: 'oauth-secret',
|
||||
user: { handle: 'oauth-user', displayName: 'OAuth User' },
|
||||
deviceFlow: { accessToken: 'oauth-secret', pendingPolls: 1 }
|
||||
})
|
||||
|
||||
const result = await runCli(['login', '--registry', registry.url], {
|
||||
const result = await runCli(['login', '--registry', registry.url, '--no-open'], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(5) // EXIT.usage
|
||||
expect(result.stderr).toContain('token is required')
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('Logged in')
|
||||
expect(result.stderr).toContain('ABCD-2345')
|
||||
expect(result.stderr).toContain('/device')
|
||||
expect(`${result.stdout}\n${result.stderr}`).not.toContain('oauth-secret')
|
||||
expect(await Bun.file(`${env.home}/.skillhub/credentials.json`).json())
|
||||
.toMatchObject({ tokens: { [registry.url]: 'oauth-secret' } })
|
||||
expect(registry.received.devicePolls).toBe(2)
|
||||
})
|
||||
|
||||
test('device login --json emits a non-secret authorization event and final result', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'oauth-secret',
|
||||
user: { handle: 'oauth-user', displayName: 'OAuth User' },
|
||||
deviceFlow: { accessToken: 'oauth-secret' }
|
||||
})
|
||||
|
||||
const result = await runCli(['login', '--registry', registry.url, '--no-open', '--json'], {
|
||||
HOME: env.home,
|
||||
USERPROFILE: env.home
|
||||
})
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(JSON.parse(result.stdout)).toEqual({ ok: true, registry: registry.url, handle: 'oauth-user' })
|
||||
expect(JSON.parse(result.stderr)).toMatchObject({
|
||||
event: 'device_authorization',
|
||||
userCode: 'ABCD-2345',
|
||||
verificationUri: `${registry.url}/device`,
|
||||
browserOpened: false
|
||||
})
|
||||
expect(`${result.stdout}\n${result.stderr}`).not.toContain('oauth-secret')
|
||||
expect(`${result.stdout}\n${result.stderr}`).not.toContain('device-secret')
|
||||
})
|
||||
|
||||
// [P0] whoami failure must NOT write credentials
|
||||
|
|
|
|||
|
|
@ -2,6 +2,14 @@ import { describe, expect, test } from 'bun:test'
|
|||
import { runCli } from '../helpers/run-cli'
|
||||
|
||||
describe('help command', () => {
|
||||
test('documents interactive device login and the headless fallback', async () => {
|
||||
const result = await runCli(['help', 'login'])
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain('OAuth Device Flow')
|
||||
expect(result.stdout).toContain('--no-open')
|
||||
expect(result.stdout).toContain('--token')
|
||||
})
|
||||
test('prints detailed help for install', async () => {
|
||||
const result = await runCli(['help', 'install'])
|
||||
expect(result.exitCode).toBe(0)
|
||||
|
|
|
|||
|
|
@ -667,6 +667,72 @@ describe('install command — server errors', () => {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('install command — multi-agent & auto-detect', () => {
|
||||
test('--agent pi defaults to the user root and persists the Pi agent id', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
|
||||
const result = await runCli(
|
||||
[
|
||||
'install', 'pdf-parser',
|
||||
'--agent', 'pi',
|
||||
'--registry', registry.url,
|
||||
'--token', 'sk_ok',
|
||||
'--json'
|
||||
],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const installDir = join(env.home, '.pi', 'agent', 'skills', 'pdf-parser')
|
||||
const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string; dir: string }> }
|
||||
expect(parsed.installed).toEqual([{ agent: 'pi', dir: installDir }])
|
||||
|
||||
const metadataPath = join(installDir, '.skillhub', 'metadata.json')
|
||||
expect(JSON.parse(await readFile(metadataPath, 'utf-8')).agent).toBe('pi')
|
||||
|
||||
const inventory = JSON.parse(await readFile(join(env.home, '.skillhub', 'inventory.json'), 'utf-8')) as {
|
||||
items: Array<{ targets: Array<{ agent: string; installDir: string }> }>
|
||||
}
|
||||
expect(inventory.items[0]?.targets[0]).toMatchObject({ agent: 'pi', installDir })
|
||||
})
|
||||
|
||||
test('auto-detects an existing Pi project skills directory end to end', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u', displayName: 'U' },
|
||||
skills: [{ namespace: 'global', slug: 'pdf-parser', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
await runCli(['login', '--registry', registry.url, '--token', 'sk_ok'], { HOME: env.home, USERPROFILE: env.home })
|
||||
await mkdir(join(env.cwd, '.pi', 'skills'), { recursive: true })
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'pdf-parser', '--registry', registry.url, '--token', 'sk_ok', '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string; dir: string }> }
|
||||
expect(parsed.installed).toHaveLength(1)
|
||||
expect(parsed.installed[0]?.agent).toBe('pi')
|
||||
expect(parsed.installed[0]?.dir).toMatch(/[/\\]\.pi[/\\]skills[/\\]pdf-parser/)
|
||||
expect(await Bun.file(join(
|
||||
env.cwd,
|
||||
'.pi',
|
||||
'skills',
|
||||
'pdf-parser',
|
||||
'.skillhub',
|
||||
'metadata.json'
|
||||
)).exists()).toBe(true)
|
||||
})
|
||||
|
||||
test('--agent astudio installs and persists the stable lowercase agent id', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
|
|
@ -993,6 +1059,38 @@ describe('install command — multi-agent & auto-detect', () => {
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('install command — --scope', () => {
|
||||
test('--scope project --agent pi installs to <cwd>/.pi/skills', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
token: 'sk_ok',
|
||||
user: { handle: 'u1', displayName: 'User One' },
|
||||
skills: [{ namespace: 'global', slug: 'foo', version: '1.0.0', zipBytes: makeSkillZip() }]
|
||||
})
|
||||
|
||||
await runCli(
|
||||
['login', '--registry', registry.url, '--token', 'sk_ok'],
|
||||
{ HOME: env.home, USERPROFILE: env.home }
|
||||
)
|
||||
|
||||
const result = await runCli(
|
||||
['install', 'foo', '--scope', 'project', '--agent', 'pi',
|
||||
'--registry', registry.url, '--token', 'sk_ok', '--json'],
|
||||
{ HOME: env.home, USERPROFILE: env.home },
|
||||
{ cwd: env.cwd }
|
||||
)
|
||||
|
||||
expect(result.exitCode).toBe(0)
|
||||
const installDir = join(env.cwd, '.pi', 'skills', 'foo')
|
||||
const parsed = JSON.parse(result.stdout) as { installed: Array<{ agent: string; dir: string }> }
|
||||
expect(parsed.installed).toHaveLength(1)
|
||||
expect(parsed.installed[0]?.agent).toBe('pi')
|
||||
expect(parsed.installed[0]?.dir).toMatch(/[/\\]\.pi[/\\]skills[/\\]foo/)
|
||||
expect(JSON.parse(await readFile(
|
||||
join(installDir, '.skillhub', 'metadata.json'),
|
||||
'utf-8'
|
||||
)).agent).toBe('pi')
|
||||
})
|
||||
|
||||
test('--scope project --agent codex installs to <cwd>/.codex/skills', async () => {
|
||||
const env = await createTempHome()
|
||||
registry = await startFakeRegistry({
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { describe, expect, test } from 'bun:test'
|
|||
import { allProfiles, profileMap } from '../../../src/agents/detector'
|
||||
|
||||
describe('agent profiles', () => {
|
||||
test('has 15 tier 1 profiles', () => {
|
||||
expect(allProfiles).toHaveLength(15)
|
||||
test('has 16 tier 1 profiles', () => {
|
||||
expect(allProfiles).toHaveLength(16)
|
||||
})
|
||||
|
||||
test('all profiles have unique ids', () => {
|
||||
|
|
@ -15,12 +15,13 @@ describe('agent profiles', () => {
|
|||
})
|
||||
|
||||
test('profileMap contains all profiles', () => {
|
||||
expect(profileMap.size).toBe(15)
|
||||
expect(profileMap.size).toBe(16)
|
||||
expect(profileMap.has('astudio')).toBe(true)
|
||||
expect(profileMap.has('claude-code')).toBe(true)
|
||||
expect(profileMap.has('codex')).toBe(true)
|
||||
expect(profileMap.has('cursor')).toBe(true)
|
||||
expect(profileMap.has('kilo')).toBe(true)
|
||||
expect(profileMap.has('pi')).toBe(true)
|
||||
})
|
||||
|
||||
test('claude-code profile returns correct roots', () => {
|
||||
|
|
@ -40,6 +41,53 @@ describe('agent profiles', () => {
|
|||
expect(profile.projectRoots('/repo')).toEqual(['/repo/.cursor/skills'])
|
||||
})
|
||||
|
||||
test('Pi exposes and detects its project and user skills directories', async () => {
|
||||
const base = await mkdtemp(join(tmpdir(), 'skillhub-pi-profile-'))
|
||||
const cwd = join(base, 'repo')
|
||||
const home = join(base, 'home')
|
||||
const projectRoot = `${cwd}/.pi/skills`
|
||||
const userRoot = `${home}/.pi/agent/skills`
|
||||
const profile = profileMap.get('pi')!
|
||||
|
||||
try {
|
||||
await mkdir(cwd, { recursive: true })
|
||||
await mkdir(home, { recursive: true })
|
||||
|
||||
expect(profile.displayName).toBe('Pi')
|
||||
expect(profile.projectRoots(cwd)).toEqual([projectRoot])
|
||||
expect(profile.userRoots(home)).toEqual([userRoot])
|
||||
expect(await profile.detectInstalled(cwd, home)).toEqual([])
|
||||
|
||||
await mkdir(join(cwd, '.pi'), { recursive: true })
|
||||
await mkdir(join(home, '.pi', 'agent'), { recursive: true })
|
||||
await writeFile(projectRoot, 'not a directory')
|
||||
await writeFile(userRoot, 'not a directory')
|
||||
expect(await profile.detectInstalled(cwd, home)).toEqual([])
|
||||
|
||||
await rm(projectRoot)
|
||||
await rm(userRoot)
|
||||
await mkdir(projectRoot, { recursive: true })
|
||||
await mkdir(userRoot, { recursive: true })
|
||||
|
||||
expect(await profile.detectInstalled(cwd, home)).toEqual([
|
||||
{
|
||||
agent: 'pi',
|
||||
rootDir: projectRoot,
|
||||
scope: 'project',
|
||||
source: 'detected'
|
||||
},
|
||||
{
|
||||
agent: 'pi',
|
||||
rootDir: userRoot,
|
||||
scope: 'user',
|
||||
source: 'detected'
|
||||
}
|
||||
])
|
||||
} finally {
|
||||
await rm(base, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('AStudio exposes only its fixed user-level directory on Linux, macOS, and Windows', () => {
|
||||
const profile = profileMap.get('astudio')!
|
||||
|
||||
|
|
|
|||
|
|
@ -133,6 +133,39 @@ describe('SkillHubClient', () => {
|
|||
await err.toHaveProperty('exitCode', EXIT.auth)
|
||||
})
|
||||
|
||||
test('device authorization uses the public endpoints without bearer credentials', async () => {
|
||||
const requests: Array<{ path: string; authorization: string | null; body?: unknown }> = []
|
||||
const fetchImpl = (async (input: URL | RequestInfo, init?: RequestInit) => {
|
||||
const url = new URL(String(input))
|
||||
requests.push({
|
||||
path: url.pathname,
|
||||
authorization: new Headers(init?.headers).get('authorization'),
|
||||
...(init?.body ? { body: JSON.parse(String(init.body)) } : {})
|
||||
})
|
||||
if (url.pathname.endsWith('/code')) {
|
||||
return Response.json({
|
||||
data: {
|
||||
deviceCode: 'device-secret',
|
||||
userCode: 'ABCD-2345',
|
||||
verificationUri: '/device',
|
||||
expiresIn: 60,
|
||||
interval: 5
|
||||
}
|
||||
})
|
||||
}
|
||||
return Response.json({ data: { accessToken: null, tokenType: null, error: 'authorization_pending' } })
|
||||
}) as unknown as typeof fetch
|
||||
const client = new SkillHubClient('https://skillhub.example.com', 'stored-token', fetchImpl)
|
||||
|
||||
await client.requestDeviceCode()
|
||||
await client.pollDeviceToken('device-secret')
|
||||
|
||||
expect(requests).toEqual([
|
||||
{ path: '/api/v1/auth/device/code', authorization: null },
|
||||
{ path: '/api/v1/auth/device/token', authorization: null, body: { deviceCode: 'device-secret' } }
|
||||
])
|
||||
})
|
||||
|
||||
// --- search() (P1) ---
|
||||
|
||||
test('search() returns items', async () => {
|
||||
|
|
|
|||
72
cli/test/unit/platform/browser.test.ts
Normal file
72
cli/test/unit/platform/browser.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, mock, test } from 'bun:test'
|
||||
import { canOpenBrowser, openExternalUrl } from '../../../src/platform/browser'
|
||||
|
||||
describe('openExternalUrl', () => {
|
||||
test.each([
|
||||
['darwin', 'open', ['https://skillhub.example.com/device']],
|
||||
['linux', 'xdg-open', ['https://skillhub.example.com/device']],
|
||||
['win32', 'rundll32', ['url.dll,FileProtocolHandler', 'https://skillhub.example.com/device']]
|
||||
] as const)('uses the platform launcher on %s', (platform, command, args) => {
|
||||
const launch = mock(() => true)
|
||||
|
||||
const env = platform === 'linux' ? { DISPLAY: ':0' } : {}
|
||||
expect(openExternalUrl('https://skillhub.example.com/device', { platform, launch, env })).toBe(true)
|
||||
expect(launch).toHaveBeenCalledWith(command, [...args])
|
||||
})
|
||||
|
||||
test('rejects non-http URLs without launching a process', () => {
|
||||
const launch = mock(() => true)
|
||||
|
||||
expect(openExternalUrl('javascript:alert(1)', { platform: 'linux', launch, env: { DISPLAY: ':0' } })).toBe(false)
|
||||
expect(launch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('falls back cleanly when the browser launcher is unavailable', () => {
|
||||
const launch = mock(() => false)
|
||||
|
||||
expect(openExternalUrl('https://skillhub.example.com/device', { platform: 'linux', launch, env: { DISPLAY: ':0' } })).toBe(false)
|
||||
})
|
||||
|
||||
test.each([
|
||||
['CI', { CI: 'true', DISPLAY: ':0' }],
|
||||
['SSH', { SSH_CONNECTION: 'client server', DISPLAY: ':0' }],
|
||||
['SSH TTY', { SSH_TTY: '/dev/pts/0', DISPLAY: ':0' }],
|
||||
['Linux without a display', {}]
|
||||
])('does not launch in %s environments', (_name, env) => {
|
||||
const launch = mock(() => true)
|
||||
|
||||
expect(openExternalUrl('https://skillhub.example.com/device', { platform: 'linux', launch, env })).toBe(false)
|
||||
expect(launch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('allows Linux desktops with X11 or Wayland', () => {
|
||||
expect(canOpenBrowser('linux', { DISPLAY: ':0' })).toBe(true)
|
||||
expect(canOpenBrowser('linux', { WAYLAND_DISPLAY: 'wayland-0' })).toBe(true)
|
||||
})
|
||||
|
||||
test('rejects unsupported platforms without launching a process', () => {
|
||||
const launch = mock(() => true)
|
||||
|
||||
expect(openExternalUrl('https://skillhub.example.com/device', {
|
||||
platform: 'freebsd',
|
||||
launch,
|
||||
env: {}
|
||||
})).toBe(false)
|
||||
expect(launch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('contains a synchronous launcher failure', () => {
|
||||
const launch = mock(() => { throw new Error('launcher unavailable') })
|
||||
|
||||
expect(() => openExternalUrl('https://skillhub.example.com/device', {
|
||||
platform: 'darwin',
|
||||
launch,
|
||||
env: {}
|
||||
})).not.toThrow()
|
||||
expect(openExternalUrl('https://skillhub.example.com/device', {
|
||||
platform: 'darwin',
|
||||
launch,
|
||||
env: {}
|
||||
})).toBe(false)
|
||||
})
|
||||
})
|
||||
249
cli/test/unit/services/auth-service-device-flow.test.ts
Normal file
249
cli/test/unit/services/auth-service-device-flow.test.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { describe, expect, mock, test } from 'bun:test'
|
||||
import { AuthService } from '../../../src/services/auth-service'
|
||||
import { CliError } from '../../../src/shared/errors'
|
||||
import { EXIT } from '../../../src/shared/constants'
|
||||
|
||||
describe('AuthService device flow', () => {
|
||||
test('polls pending authorization, validates the token, then persists credentials', async () => {
|
||||
const setRegistry = mock(async () => {})
|
||||
const setToken = mock(async () => {})
|
||||
const sleep = mock(async () => {})
|
||||
const pollDeviceToken = mock()
|
||||
.mockResolvedValueOnce({ error: 'authorization_pending' })
|
||||
.mockResolvedValueOnce({ accessToken: 'oauth-secret', tokenType: 'Bearer' })
|
||||
const clientFactory = mock((_registry: string, token?: string) => token
|
||||
? { whoami: async () => ({ handle: 'oauth-user', displayName: 'OAuth User' }) }
|
||||
: {
|
||||
requestDeviceCode: async () => ({
|
||||
deviceCode: 'device-secret',
|
||||
userCode: 'ABCD-2345',
|
||||
verificationUri: '/device',
|
||||
expiresIn: 60,
|
||||
interval: 2
|
||||
}),
|
||||
pollDeviceToken
|
||||
})
|
||||
|
||||
const service = new AuthService(
|
||||
{ setRegistry } as never,
|
||||
{ setToken } as never,
|
||||
{ clientFactory: clientFactory as never, sleep, now: () => 0 }
|
||||
)
|
||||
const onVerification = mock(async () => {})
|
||||
|
||||
const result = await service.loginWithDeviceFlow('https://skillhub.example.com', onVerification)
|
||||
|
||||
expect(result).toEqual({ handle: 'oauth-user' })
|
||||
expect(onVerification).toHaveBeenCalledWith({
|
||||
userCode: 'ABCD-2345',
|
||||
verificationUri: 'https://skillhub.example.com/device',
|
||||
expiresIn: 60
|
||||
})
|
||||
expect(sleep).toHaveBeenCalledTimes(2)
|
||||
expect(sleep).toHaveBeenCalledWith(2_000)
|
||||
expect(setRegistry).toHaveBeenCalledWith('https://skillhub.example.com')
|
||||
expect(setToken).toHaveBeenCalledWith('https://skillhub.example.com', 'oauth-secret')
|
||||
})
|
||||
|
||||
test('stops at expiry without persisting credentials', async () => {
|
||||
const setRegistry = mock(async () => {})
|
||||
const setToken = mock(async () => {})
|
||||
let now = 0
|
||||
const service = new AuthService(
|
||||
{ setRegistry } as never,
|
||||
{ setToken } as never,
|
||||
{
|
||||
clientFactory: (() => ({
|
||||
requestDeviceCode: async () => ({
|
||||
deviceCode: 'device-secret',
|
||||
userCode: 'ABCD-2345',
|
||||
verificationUri: '/device',
|
||||
expiresIn: 1,
|
||||
interval: 1
|
||||
}),
|
||||
pollDeviceToken: async () => ({ error: 'authorization_pending' })
|
||||
})) as never,
|
||||
sleep: async (milliseconds: number) => { now += milliseconds },
|
||||
now: () => now
|
||||
}
|
||||
)
|
||||
|
||||
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
|
||||
.rejects.toMatchObject({ message: 'device authorization expired', exitCode: 2 })
|
||||
expect(setRegistry).not.toHaveBeenCalled()
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('stops when the authorization server denies the request', async () => {
|
||||
const setToken = mock(async () => {})
|
||||
const service = new AuthService(
|
||||
{ setRegistry: mock(async () => {}) } as never,
|
||||
{ setToken } as never,
|
||||
{
|
||||
clientFactory: (() => ({
|
||||
requestDeviceCode: async () => ({
|
||||
deviceCode: 'device-secret',
|
||||
userCode: 'ABCD-2345',
|
||||
verificationUri: '/device',
|
||||
expiresIn: 60,
|
||||
interval: 1
|
||||
}),
|
||||
pollDeviceToken: async () => ({ error: 'access_denied' })
|
||||
})) as never,
|
||||
sleep: async () => {},
|
||||
now: () => 0
|
||||
}
|
||||
)
|
||||
|
||||
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
|
||||
.rejects.toMatchObject({ message: 'device authorization failed: access_denied', exitCode: 2 })
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('rejects a non-positive polling interval from the registry', async () => {
|
||||
const service = new AuthService(
|
||||
{ setRegistry: mock(async () => {}) } as never,
|
||||
{ setToken: mock(async () => {}) } as never,
|
||||
{
|
||||
clientFactory: (() => ({
|
||||
requestDeviceCode: async () => ({
|
||||
deviceCode: 'device-secret',
|
||||
userCode: 'ABCD-2345',
|
||||
verificationUri: '/device',
|
||||
expiresIn: 60,
|
||||
interval: 0
|
||||
})
|
||||
})) as never
|
||||
}
|
||||
)
|
||||
|
||||
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
|
||||
.rejects.toMatchObject({ message: 'registry returned invalid device authorization data', exitCode: 2 })
|
||||
})
|
||||
|
||||
test('rejects a non-HTTP verification URL from the registry', async () => {
|
||||
const service = new AuthService(
|
||||
{ setRegistry: mock(async () => {}) } as never,
|
||||
{ setToken: mock(async () => {}) } as never,
|
||||
{
|
||||
clientFactory: (() => ({
|
||||
requestDeviceCode: async () => ({
|
||||
deviceCode: 'device-secret',
|
||||
userCode: 'ABCD-2345',
|
||||
verificationUri: 'javascript:alert(1)',
|
||||
expiresIn: 60,
|
||||
interval: 1
|
||||
})
|
||||
})) as never
|
||||
}
|
||||
)
|
||||
|
||||
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
|
||||
.rejects.toMatchObject({ message: 'registry returned invalid device verification URL', exitCode: 2 })
|
||||
})
|
||||
|
||||
test.each([
|
||||
['missing device code', { deviceCode: '', userCode: 'ABCD-2345', verificationUri: '/device', expiresIn: 60, interval: 1 }],
|
||||
['missing user code', { deviceCode: 'device-secret', userCode: '', verificationUri: '/device', expiresIn: 60, interval: 1 }],
|
||||
['missing verification URI', { deviceCode: 'device-secret', userCode: 'ABCD-2345', verificationUri: '', expiresIn: 60, interval: 1 }],
|
||||
['zero expiry', { deviceCode: 'device-secret', userCode: 'ABCD-2345', verificationUri: '/device', expiresIn: 0, interval: 1 }],
|
||||
['negative expiry', { deviceCode: 'device-secret', userCode: 'ABCD-2345', verificationUri: '/device', expiresIn: -1, interval: 1 }],
|
||||
['non-finite expiry', { deviceCode: 'device-secret', userCode: 'ABCD-2345', verificationUri: '/device', expiresIn: Number.NaN, interval: 1 }]
|
||||
])('rejects malformed device authorization data: %s', async (_name, device) => {
|
||||
const service = new AuthService(
|
||||
{ setRegistry: mock(async () => {}) } as never,
|
||||
{ setToken: mock(async () => {}) } as never,
|
||||
{ clientFactory: (() => ({ requestDeviceCode: async () => device })) as never }
|
||||
)
|
||||
|
||||
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
|
||||
.rejects.toMatchObject({ message: 'registry returned invalid device authorization data', exitCode: EXIT.auth })
|
||||
})
|
||||
|
||||
test('propagates a device-code request network failure without persisting credentials', async () => {
|
||||
const setToken = mock(async () => {})
|
||||
const service = new AuthService(
|
||||
{ setRegistry: mock(async () => {}) } as never,
|
||||
{ setToken } as never,
|
||||
{
|
||||
clientFactory: (() => ({
|
||||
requestDeviceCode: async () => { throw new CliError('registry unreachable', EXIT.network) }
|
||||
})) as never
|
||||
}
|
||||
)
|
||||
|
||||
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
|
||||
.rejects.toMatchObject({ message: 'registry unreachable', exitCode: EXIT.network })
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('propagates a polling network failure without persisting credentials', async () => {
|
||||
const setToken = mock(async () => {})
|
||||
const service = new AuthService(
|
||||
{ setRegistry: mock(async () => {}) } as never,
|
||||
{ setToken } as never,
|
||||
{
|
||||
clientFactory: (() => ({
|
||||
requestDeviceCode: async () => ({
|
||||
deviceCode: 'device-secret', userCode: 'ABCD-2345', verificationUri: '/device', expiresIn: 60, interval: 1
|
||||
}),
|
||||
pollDeviceToken: async () => { throw new CliError('registry unreachable', EXIT.network) }
|
||||
})) as never,
|
||||
sleep: async () => {},
|
||||
now: () => 0
|
||||
}
|
||||
)
|
||||
|
||||
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
|
||||
.rejects.toMatchObject({ message: 'registry unreachable', exitCode: EXIT.network })
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('rejects a non-Bearer device token without persisting credentials', async () => {
|
||||
const setToken = mock(async () => {})
|
||||
const service = new AuthService(
|
||||
{ setRegistry: mock(async () => {}) } as never,
|
||||
{ setToken } as never,
|
||||
{
|
||||
clientFactory: (() => ({
|
||||
requestDeviceCode: async () => ({
|
||||
deviceCode: 'device-secret', userCode: 'ABCD-2345', verificationUri: '/device', expiresIn: 60, interval: 1
|
||||
}),
|
||||
pollDeviceToken: async () => ({ accessToken: 'oauth-secret', tokenType: 'MAC' })
|
||||
})) as never,
|
||||
sleep: async () => {},
|
||||
now: () => 0
|
||||
}
|
||||
)
|
||||
|
||||
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
|
||||
.rejects.toMatchObject({ message: 'registry returned unsupported device token type', exitCode: EXIT.auth })
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('does not persist the issued token when whoami validation fails', async () => {
|
||||
const setRegistry = mock(async () => {})
|
||||
const setToken = mock(async () => {})
|
||||
const service = new AuthService(
|
||||
{ setRegistry } as never,
|
||||
{ setToken } as never,
|
||||
{
|
||||
clientFactory: ((_registry: string, token?: string) => token
|
||||
? { whoami: async () => { throw new CliError('authentication failed', EXIT.auth) } }
|
||||
: {
|
||||
requestDeviceCode: async () => ({
|
||||
deviceCode: 'device-secret', userCode: 'ABCD-2345', verificationUri: '/device', expiresIn: 60, interval: 1
|
||||
}),
|
||||
pollDeviceToken: async () => ({ accessToken: 'oauth-secret', tokenType: 'Bearer' })
|
||||
}) as never,
|
||||
sleep: async () => {},
|
||||
now: () => 0
|
||||
}
|
||||
)
|
||||
|
||||
await expect(service.loginWithDeviceFlow('https://skillhub.example.com', async () => {}))
|
||||
.rejects.toMatchObject({ message: 'authentication failed', exitCode: EXIT.auth })
|
||||
expect(setRegistry).not.toHaveBeenCalled()
|
||||
expect(setToken).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -75,7 +75,7 @@ services:
|
|||
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:-}
|
||||
DEVICE_AUTH_VERIFICATION_URI: ${DEVICE_AUTH_VERIFICATION_URI:-/device}
|
||||
SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET: ${SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET:?required}
|
||||
SKILLHUB_STORAGE_PROVIDER: ${SKILLHUB_STORAGE_PROVIDER:-s3}
|
||||
STORAGE_BASE_PATH: /var/lib/skillhub/storage
|
||||
|
|
|
|||
35
design-qa.md
Normal file
35
design-qa.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Suite 发布任务设计验收
|
||||
|
||||
## 验收范围
|
||||
|
||||
- 参考方案:方案 3 的任务列表分层、方案 2 的独立详情页。
|
||||
- 实现页面:`/dashboard/suites?tab=publishing`、`/dashboard/suites/publishing/:operationId`。
|
||||
- 视口:桌面端 1487 × 1058;移动端 390 × 844。
|
||||
|
||||
## 对照结论
|
||||
|
||||
| 检查项 | 结果 | 证据 |
|
||||
|---|---|---|
|
||||
| 控制台任务分层 | 通过 | 保留“需要处理 / 进行中 / 最近完成”,终态记录位于“最近完成” |
|
||||
| 独立任务详情 | 通过 | 查看任务进入独立路由,不再回到创建 Suite 页面 |
|
||||
| 取消后的可追溯性 | 通过 | 任务记录保留,并明确说明已有 SkillVersion 与审核任务不会撤回 |
|
||||
| 成员审核入口 | 通过 | 取消后仍显示“查看技能版本与审核状态” |
|
||||
| 状态表现 | 通过 | 任务使用静态状态图标,没有持续旋转图标 |
|
||||
| 响应式 | 通过 | 390px 视口没有横向溢出,操作与成员信息仍可访问 |
|
||||
| 现有设计系统 | 通过 | 复用 DashboardPageHeader、Card、Tabs、Button、Pagination 与 Lucide 图标 |
|
||||
|
||||
参考图包含多条示例任务;真实预览库只有一条已停止记录,因此实际页面保留空分层并展示真实数据。参考详情图展示到“套件审核”,而当前 Bundle 工作流的职责止于“创建套件草稿”,实际实现按领域状态保留三阶段,避免暗示系统会自动提交 Suite 审核。
|
||||
|
||||
## 缺陷分级
|
||||
|
||||
- P0:无。
|
||||
- P1:无。
|
||||
- P2:无。
|
||||
|
||||
## 自动化结果
|
||||
|
||||
- 桌面端任务列表与详情页完成同视口截图对照。
|
||||
- 真实普通用户会话完成任务列表、详情、返回、成员审核入口和移动端检查。
|
||||
- 浏览器过程无 console error 或 pageerror。
|
||||
|
||||
final result: passed
|
||||
30
docs/ai-changes-cli-oauth-device-flow.md
Normal file
30
docs/ai-changes-cli-oauth-device-flow.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# AI 变更记录:CLI OAuth Device Flow 登录
|
||||
|
||||
## 需求背景
|
||||
|
||||
SkillHub 服务端已经提供 Device Code 申请、授权轮询和浏览器确认能力,但 CLI `0.1.12`
|
||||
在没有 API Token 时仍直接返回 `token is required`,无法使用既有的 OAuth 登录链路。
|
||||
对应社区需求记录在 Issue #856。
|
||||
|
||||
## 实现内容
|
||||
|
||||
- `skillhub login` 未显式提供 Token 时,调用现有 `/api/v1/auth/device/code` 发起授权。
|
||||
- 展示服务端返回的验证地址和用户码,并在受支持的平台上尝试打开默认浏览器。
|
||||
- 增加 `--no-open`,支持远程服务器和无图形界面的终端。
|
||||
- 按服务端返回的 `interval` 和 `expiresIn` 轮询 `/api/v1/auth/device/token`,处理等待、拒绝、过期和网络失败。
|
||||
- 获取 Bearer Token 后先调用 `whoami` 验证,成功后才写入现有配置与凭据存储。
|
||||
- 保留 `--token`、`SKILLHUB_TOKEN` 和已存 Token 的兼容行为,供 CI 与自动化使用。
|
||||
- JSON 模式只输出验证地址、用户码、有效期和浏览器打开状态,不输出 device code 或 access token。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- 只允许打开 HTTP/HTTPS 验证地址,不通过 shell 拼接 URL。
|
||||
- access token 不进入标准输出或错误输出。
|
||||
- Device Flow 失败、拒绝或超时时不更新本地凭据。
|
||||
- 浏览器打开失败不会中断流程,用户仍可复制终端中的验证地址继续授权。
|
||||
|
||||
## 测试策略
|
||||
|
||||
- 单元测试覆盖轮询成功、等待、拒绝、过期、非法服务端参数和跨平台浏览器启动。
|
||||
- 客户端测试验证 Device Flow 使用公开端点且不携带已有 Bearer Token。
|
||||
- 集成测试覆盖普通输出、JSON 输出、`--no-open`、Token 不泄漏及原有 Token 登录兼容性。
|
||||
|
|
@ -39,6 +39,7 @@ export default defineConfig({
|
|||
text: '核心功能',
|
||||
items: [
|
||||
{ text: 'Skill 发布与版本管理', link: '/guide/skill-publish' },
|
||||
{ text: 'Suite Bundle 批量导入', link: '/guide/suite-bundle' },
|
||||
{ text: 'Skill 搜索与发现', link: '/guide/skill-discovery' },
|
||||
{ text: '命名空间与团队管理', link: '/guide/namespace' },
|
||||
{ text: '审核与治理', link: '/guide/review' },
|
||||
|
|
@ -86,6 +87,7 @@ export default defineConfig({
|
|||
text: 'Core Features',
|
||||
items: [
|
||||
{ text: 'Skill Publishing & Versioning', link: '/en/guide/skill-publish' },
|
||||
{ text: 'Suite Bundle Import', link: '/en/guide/suite-bundle' },
|
||||
{ text: 'Skill Search & Discovery', link: '/en/guide/skill-discovery' },
|
||||
{ text: 'Namespace & Team Management', link: '/en/guide/namespace' },
|
||||
{ text: 'Review & Governance', link: '/en/guide/review' },
|
||||
|
|
|
|||
|
|
@ -157,6 +157,9 @@ skillhub install pdf-parser --agent codex
|
|||
# Install to AStudio's fixed user-level directory
|
||||
skillhub install pdf-parser --agent astudio
|
||||
|
||||
# Install to Pi's user-level directory (use --scope project for the project directory)
|
||||
skillhub install pdf-parser --agent pi
|
||||
|
||||
# Install to multiple Agents
|
||||
skillhub install pdf-parser --agent codex --agent claude-code
|
||||
|
||||
|
|
@ -204,6 +207,7 @@ Most Agents have both project-level and user-level skills directories. Use `--sc
|
|||
| `openclaw` | `<project>/.openclaw/skills/` | `~/.openclaw/skills/` |
|
||||
| `opencode` | `<project>/.opencode/skills/` | `~/.opencode/skills/` |
|
||||
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
|
||||
| `pi` (Pi) | `<project>/.pi/skills/` | `~/.pi/agent/skills/` |
|
||||
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |
|
||||
|
||||
For a custom path or an unsupported Agent directory, use `--dir` to specify the installation path. In interactive user scope, the `generic` target is offered alongside detected Agent targets. AStudio appears in that selector when `~/.acode/skills/` exists. When `--scope user|project` finds no matching agent directory, the CLI falls back to the `_fallback_` row above.
|
||||
|
|
|
|||
|
|
@ -49,10 +49,28 @@ New version has serious issues, need to point `latest` tag to previous stable ve
|
|||
|
||||
1. **Prepare Skill Package**
|
||||
|
||||
Ensure skill package conforms to SkillHub specification:
|
||||
- Contains `skill.md` (skill description)
|
||||
- Contains `package.json` or `SKILL.md` (metadata)
|
||||
- Clear file structure, no sensitive information
|
||||
Prepare a skill directory with `SKILL.md` at its root:
|
||||
|
||||
- Put both YAML frontmatter and the skill instructions in `SKILL.md`.
|
||||
- The frontmatter must contain `name` and `description`. Set `version` there when you need an explicit version.
|
||||
- `package.json` does not supply or replace this skill metadata.
|
||||
- Keep the package structure clear and exclude sensitive information.
|
||||
|
||||
For example, save this as `my-skill/SKILL.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: Summarize a short text as numbered steps.
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
Summarize the supplied text as numbered steps.
|
||||
```
|
||||
|
||||
When packaging an existing skill, keep its required scripts, references,
|
||||
`agents/` metadata, and license files together. Pass that skill directory
|
||||
(`./my-skill` in this example) to the publish command below.
|
||||
|
||||
2. **Publish via CLI (Recommended)**
|
||||
|
||||
|
|
|
|||
50
docs/skillhub/en/guide/suite-bundle.md
Normal file
50
docs/skillhub/en/guide/suite-bundle.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Suite Bundle Import
|
||||
|
||||
A Suite Bundle uploads one ZIP or folder to create a Suite or create a new version from a published base. SkillHub first shows the member diff. Member publication and review start only after explicit confirmation.
|
||||
|
||||
## Archive format
|
||||
|
||||
The archive root must contain exactly one `SUITE.yaml`. Every packaged member has its own directory with a root `SKILL.md`. Undeclared skills, overlapping directories, unsafe or duplicate paths, and configured file limits block preview.
|
||||
|
||||
```yaml
|
||||
apiVersion: skillhub.iflytek.com/v1alpha1
|
||||
kind: SkillSuiteBundle
|
||||
metadata:
|
||||
namespace: global
|
||||
slug: clinical-workflow
|
||||
spec:
|
||||
mode: CREATE
|
||||
version: 1.0.0
|
||||
displayName: Clinical workflow
|
||||
summary: Validate intake data and produce a summary
|
||||
overview: The entry Skill validates and dispatches input; the summary Skill produces the result.
|
||||
visibility: PUBLIC
|
||||
entry: "@global/intake"
|
||||
members:
|
||||
- skill: "@global/intake"
|
||||
package:
|
||||
path: skills/intake
|
||||
visibility: PUBLIC
|
||||
- skill: "@global/shared-dictionary"
|
||||
reference:
|
||||
version: 2.3.1
|
||||
```
|
||||
|
||||
Use exactly one source per member: `package` publishes the uploaded Skill directory; `reference` pins an existing accessible `PUBLISHED` version without copying or changing it. Creating or updating a packaged Skill requires the normal Namespace and lifecycle permissions.
|
||||
|
||||
## Create, update, and review
|
||||
|
||||
Use `mode: CREATE` for a new coordinate. For `mode: UPDATE`, set `baseVersion` and describe the complete desired member order. Preview reports `ADDED`, `UPDATED`, `UNCHANGED`, and `REMOVED`; order and Entry changes count as updates, while a completely unchanged update cannot be confirmed.
|
||||
|
||||
Choose local import from the Suite create or new-version page, upload once, inspect the preview, acknowledge warnings per affected member and removals separately, then confirm. Preview creates no SkillVersion, review task, or SuiteVersion. Confirmation requires an `Idempotency-Key` and rechecks permissions, versions, references, and staged hashes. A Suite draft is created only after every required member completes its normal publication and review workflow.
|
||||
|
||||
## Deployment flags
|
||||
|
||||
Confirmation is disabled by default. Enable both server-side write paths for the complete workflow:
|
||||
|
||||
```bash
|
||||
SKILLHUB_SUITE_BUNDLE_CONFIRMATION_ENABLED=true
|
||||
SKILLHUB_SUITE_REVIEW_WRITES_ENABLED=true
|
||||
```
|
||||
|
||||
The same protocol works against a self-hosted Registry and does not depend on a SaaS endpoint.
|
||||
|
|
@ -153,6 +153,9 @@ skillhub install pdf-parser --agent codex
|
|||
# 安装到 AStudio 的固定用户级目录
|
||||
skillhub install pdf-parser --agent astudio
|
||||
|
||||
# 安装到 Pi 的用户级目录(添加 --scope project 可安装到项目级目录)
|
||||
skillhub install pdf-parser --agent pi
|
||||
|
||||
# 安装到多个 Agent
|
||||
skillhub install pdf-parser --agent codex --agent claude-code
|
||||
|
||||
|
|
@ -200,6 +203,7 @@ CLI 按以下逻辑确定安装位置:
|
|||
| `openclaw` | `<project>/.openclaw/skills/` | `~/.openclaw/skills/` |
|
||||
| `opencode` | `<project>/.opencode/skills/` | `~/.opencode/skills/` |
|
||||
| `kilo` | `<project>/.kilo/skills/` | `~/.kilo/skills/` |
|
||||
| `pi`(Pi) | `<project>/.pi/skills/` | `~/.pi/agent/skills/` |
|
||||
| _fallback_ | `<project>/.agents/skills/` | `~/.agents/skills/` |
|
||||
|
||||
对于自定义路径或不在列表中的 Agent 目录,使用 `--dir` 显式指定安装路径。交互式 user scope 下会与已探测 Agent 目标一同提供 `generic` 目标;当 `~/.acode/skills/` 存在时,选择器会显示 AStudio。当 `--scope user|project` 找不到匹配的 agent 目录时,CLI 会回退到上表的 `_fallback_` 行。
|
||||
|
|
|
|||
|
|
@ -49,10 +49,27 @@ SkillHub 提供了类似 npm 的发布体验,但增加了企业级的权限控
|
|||
|
||||
1. **准备技能包**
|
||||
|
||||
确保技能包符合 SkillHub 规范:
|
||||
- 包含 `skill.md`(技能描述)
|
||||
- 包含 `package.json` 或 `SKILL.md`(元数据)
|
||||
- 文件结构清晰,无敏感信息
|
||||
准备一个根目录包含 `SKILL.md` 的技能目录:
|
||||
|
||||
- 在 `SKILL.md` 中同时编写 YAML frontmatter 和技能指令。
|
||||
- frontmatter 必须包含 `name` 和 `description`;需要指定版本时,在其中设置 `version`。
|
||||
- `package.json` 不会提供或替代这些技能元数据。
|
||||
- 保持文件结构清晰,不包含敏感信息。
|
||||
|
||||
例如,将以下内容保存为 `my-skill/SKILL.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: Summarize a short text as numbered steps.
|
||||
version: 1.0.0
|
||||
---
|
||||
|
||||
Summarize the supplied text as numbered steps.
|
||||
```
|
||||
|
||||
打包已有技能时,应一并保留其所需的脚本、参考文件、`agents/` 元数据和许可证文件。
|
||||
下一步发布命令的路径应指向该技能目录(本例为 `./my-skill`)。
|
||||
|
||||
2. **使用 CLI 发布(推荐)**
|
||||
|
||||
|
|
|
|||
75
docs/skillhub/guide/suite-bundle.md
Normal file
75
docs/skillhub/guide/suite-bundle.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Suite Bundle 批量导入
|
||||
|
||||
Suite Bundle 用一次 ZIP 或文件夹上传,为 Suite 创建首个版本,或基于一个已发布版本创建新版本。系统先展示成员差异,只有用户明确确认后才进入成员 Skill 的正常发布和审核流程。
|
||||
|
||||
## 归档结构
|
||||
|
||||
归档根目录必须且只能有一个 `SUITE.yaml`。每个携带包的成员使用独立目录,目录根部必须包含 `SKILL.md`。未声明的 `SKILL.md`、目录重叠、路径穿越、重复路径、超出大小或文件数限制都会阻止预览。
|
||||
|
||||
```text
|
||||
SUITE.yaml
|
||||
skills/
|
||||
intake/
|
||||
SKILL.md
|
||||
summarizer/
|
||||
SKILL.md
|
||||
```
|
||||
|
||||
```yaml
|
||||
apiVersion: skillhub.iflytek.com/v1alpha1
|
||||
kind: SkillSuiteBundle
|
||||
metadata:
|
||||
namespace: global
|
||||
slug: clinical-workflow
|
||||
spec:
|
||||
mode: CREATE
|
||||
version: 1.0.0
|
||||
displayName: 临床工作流
|
||||
summary: 串联病历接收与摘要生成
|
||||
overview: |
|
||||
接收结构化病历,完成字段校验后生成摘要。
|
||||
入口技能负责接收和分派,摘要技能负责输出最终结果。
|
||||
visibility: PUBLIC
|
||||
entry: "@global/intake"
|
||||
members:
|
||||
- skill: "@global/intake"
|
||||
package:
|
||||
path: skills/intake
|
||||
visibility: PUBLIC
|
||||
- skill: "@global/shared-dictionary"
|
||||
reference:
|
||||
version: 2.3.1
|
||||
```
|
||||
|
||||
成员只能选择一种来源:
|
||||
|
||||
- `package`:发布本次上传的 Skill 文件夹。新 Skill 必须明确填写 `visibility`;已有 Skill 只能由其所有者、Namespace 管理员或超级管理员更新。
|
||||
- `reference`:精确引用市场中已发布且当前用户可访问的版本,不复制、不修改该 Skill。
|
||||
|
||||
## 创建与更新
|
||||
|
||||
创建 Suite 时使用 `mode: CREATE`,目标坐标必须不存在。更新时使用 `mode: UPDATE`,并增加 `baseVersion`;Manifest 必须描述新版本的完整成员顺序,基准成员未出现时会被标记为移除。
|
||||
|
||||
更新预览会显示 `ADDED`、`UPDATED`、`UNCHANGED` 和 `REMOVED`。成员顺序或 Entry 身份变化也属于更新;展示内容和成员均无变化的 Bundle 不能确认。`summary` 和 `overview` 在更新时可以继承基准版本,但最终值必须非空。
|
||||
|
||||
## 预览、确认与审核
|
||||
|
||||
1. 在“创建 Suite”或“创建新版本”页面选择“从本地导入”。
|
||||
2. 选择 ZIP,或选择包含 `SUITE.yaml` 的根文件夹。
|
||||
3. 检查每个成员的来源、目标版本、关系变化、发布动作、错误和警告。
|
||||
4. 对每个存在警告的成员分别确认;更新时还要单独确认移除成员。
|
||||
5. 确认后查看持久化操作进度。刷新页面后仍可恢复;权限撤销或 Namespace 冻结时可在恢复条件后重试。
|
||||
6. 所有需要发布的成员都完成原有审核后,系统才创建 Suite 草稿;再按 Suite 审核流程提交。
|
||||
|
||||
预览不会创建 SkillVersion、审核任务或 SuiteVersion。确认请求使用 `Idempotency-Key` 防止网络重试重复创建操作,并重新检查权限、版本、引用和暂存文件摘要。
|
||||
|
||||
## 部署开关
|
||||
|
||||
Bundle 确认默认关闭。启用完整流程需要服务端同时允许 Bundle 确认和 Suite 审核写入:
|
||||
|
||||
```bash
|
||||
SKILLHUB_SUITE_BUNDLE_CONFIRMATION_ENABLED=true
|
||||
SKILLHUB_SUITE_REVIEW_WRITES_ENABLED=true
|
||||
```
|
||||
|
||||
可以先保持确认关闭,仅验证上传和差异预览。私有化部署使用相同协议和本地 Registry 数据,不依赖 SaaS 地址。
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-09-11
|
||||
10
openspec/changes/add-suite-bundle-publishing/README.md
Normal file
10
openspec/changes/add-suite-bundle-publishing/README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
# add-suite-bundle-publishing
|
||||
|
||||
本变更将 Issue #847 整理为建立在 Issue #715 / PR #828 Suite 模型之上的三组交付能力:
|
||||
创建与更新共用的 Suite 导入编排、任意成员 Skill 的所属 Suite 展示,以及 Suite 展示信息与标签。
|
||||
|
||||
提案明确记录 #847 中哪些需求继续保留、哪些需要调整。用户可以从技能市场组合精确版本,也可以
|
||||
通过一个 ZIP 或受支持浏览器中的根目录,一次提交多个 Skill 文件夹来创建或更新 Suite。导入可以
|
||||
在有权限的 Namespace 中创建新 Skill,也可以更新操作者有权发布的已有 Skill;非本人所有 Skill
|
||||
只能作为精确 PUBLISHED 版本引用。技能市场和套件专区继续分开。Suite 拥有自己的展示信息和标签,
|
||||
但不修改成员 Skill 的标签或版本 Tag。
|
||||
305
openspec/changes/add-suite-bundle-publishing/design.md
Normal file
305
openspec/changes/add-suite-bundle-publishing/design.md
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
## Context
|
||||
|
||||
需求背景与范围见 `proposal.md`。当前 Web 可以手工创建 Suite、编辑 DRAFT,并基于已有快照创建
|
||||
新 SuiteVersion,但不能一次提交多个本地 Skill 文件夹、自动比较内容并统一跟踪成员发布。当前
|
||||
Suite 使用不可变 SuiteVersion 快照,成员必须是精确的
|
||||
PUBLISHED SkillVersion,并允许引用符合可见性要求的跨 Namespace PUBLIC Skill。普通 Skill 发布
|
||||
可能涉及对象存储、异步扫描和独立人工审核。技能市场已经使用 `/search`,套件专区已经使用
|
||||
`/suites`、类型化资源接口和 Suite 卡片。Skill 详情目前只有在该 Skill 是 Entry Skill 时才展示
|
||||
所属 Suite。Suite 的 summary 和 overview 当前可为空,因此已发布 Suite 可能只能显示空内容提示。
|
||||
现有 Label 用于 Skill 容器分类,Tag 是后端版本别名。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**目标:**
|
||||
|
||||
- 将一次 ZIP 或根目录上传转换为稳定、可检查的 Suite 创建或更新计划。
|
||||
- 允许在操作者具备 Skill 创建权限的 Namespace 中,从携带包创建新 Skill。
|
||||
- 允许继续精确引用合规的非本人所有 Skill,且不尝试发布它们。
|
||||
- 只发布操作者已经具备独立发布权限的携带包成员。
|
||||
- 扫描和审核期间不创建临时或部分有效的 SuiteVersion。
|
||||
- 让重试、异步审核、权限变化和终止失败都可观察。
|
||||
- 在任意成员 Skill 上安全展示当前用户可见的所属 Suite。
|
||||
- 让 Suite 拥有自己的 Label,并要求新发布版本具备有效摘要和概述。
|
||||
- 限制归档处理、数据库查询、扫描和反向引用投影的资源消耗。
|
||||
|
||||
**非目标:**
|
||||
|
||||
- 为操作者只能引用、不能管理的 Skill 发布新内容。
|
||||
- 替代单 Skill 的独立扫描/审核或 Suite 自身审核。
|
||||
- 合并技能市场和套件专区,或者改变两个入口的路由与产品定位。
|
||||
- 批量修改成员 Label、增加 Suite Tag、迁移 Namespace、按成员关键词参与排名、CLI 上传
|
||||
Bundle、嵌套 Suite 或跨 Registry 成员。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 控制台套件清单:采用方案 2(2026-09-16 用户确认)
|
||||
|
||||
移除“我的套件”的独立发布任务页签。套件、尚未形成套件的创建操作统一服务端分页;按坐标归并,
|
||||
生成套件后不重复列出成功操作。顶部采用 288px 搜索框、状态下拉和轻量“需处理”数量提示。
|
||||
数量针对当前搜索所有页,而非本页;筛选、分页和计数在数据库中完成。新工作台接口保留旧列表接口
|
||||
兼容性,每次固定两条 SQL,不读取成员、执行计划 JSON 或包内容;每页 12 条,搜索只在点击搜索
|
||||
或按 Enter 后执行,输入与已提交条件分离,取消过时请求,仅存在运行/等待操作时每 5 秒轮询。正式套件版本审核复用现有“我的审核进度”。
|
||||
|
||||
### 1. 创建和更新共用 Manifest,成员分为“携带包”和“精确引用”
|
||||
|
||||
Manifest 描述完整目标成员集合和顺序,每个成员只能选择一种形式:
|
||||
|
||||
- **携带包成员**:指向一个以 `SKILL.md` 为根的包目录。Skill 不存在时,操作者必须在目标
|
||||
Namespace 具备创建权限;Skill 已存在时,操作者必须具备该 Skill 的发布权限。通过规范化包
|
||||
fingerprint 判断创建 Skill、创建 SkillVersion,还是复用现有精确版本。
|
||||
- **精确引用成员**:指向一个已有、精确、PUBLISHED 的 SkillVersion,不携带包,Bundle 不修改它。
|
||||
操作者只需满足现有 Suite 编排和读取权限,不需要拥有该 Skill。
|
||||
|
||||
创建模式没有基准快照,全部成员均为新增。更新模式以明确的 SuiteVersion 为基准,只有基准成员
|
||||
未出现在 Manifest 中时才视为移除,不能因为没有包目录就视为移除。引用成员可以新增,也可以
|
||||
显式重新固定到另一个合规 PUBLISHED 版本。Entry Skill 可以使用任一成员形式。
|
||||
|
||||
实现协议使用归档根级唯一文件 `SUITE.yaml`。首版结构固定为:
|
||||
|
||||
```yaml
|
||||
apiVersion: skillhub.iflytek.com/v1alpha1
|
||||
kind: SkillSuiteBundle
|
||||
metadata:
|
||||
namespace: global
|
||||
slug: clinical-workflow
|
||||
spec:
|
||||
mode: CREATE # 或 UPDATE
|
||||
baseVersion: 1.0.0 # 仅 UPDATE 必填,CREATE 禁止提供
|
||||
version: 1.1.0
|
||||
displayName: 临床工作流
|
||||
summary: 套件摘要
|
||||
overview: 套件 Markdown 概述
|
||||
visibility: PUBLIC
|
||||
changelog: 本次更新说明
|
||||
entry: "@global/intake"
|
||||
members:
|
||||
- skill: "@global/intake"
|
||||
package:
|
||||
path: skills/intake
|
||||
visibility: PUBLIC # 仅新 Skill 必填
|
||||
- skill: "@global/shared-dictionary"
|
||||
reference:
|
||||
version: 2.3.1
|
||||
```
|
||||
|
||||
协议采用严格字段集合,不识别的字段直接报错,避免拼写错误被静默忽略。`metadata` 坐标和
|
||||
`spec.version` 是目标 Suite 身份;成员 `skill` 是唯一 Skill 身份;`SKILL.md` 的 `name`、
|
||||
`description` 和可选 `version` 继续作为 Skill 自身发布元数据。若携带包的 `SKILL.md` 解析出的
|
||||
slug 与成员 `skill` 的 slug 不同,或显式版本与预览解析出的目标版本冲突,预览阻塞,不设置覆盖
|
||||
优先级。引用成员只能提供精确 `version`,不能同时携带包。已有 Skill 的 `package.visibility`
|
||||
可以省略并继承当前值;若显式提供则必须与当前值一致。新 Skill 必须明确提供该字段。
|
||||
|
||||
**备选方案:要求每个成员都有包目录。** 不采用。Suite 可以合法引用其他用户的公开 Skill,强制
|
||||
打包会错误暗示操作者可以重新发布或复制这些内容。
|
||||
|
||||
### 2. Suite 创建和更新使用同一个持久化 Saga
|
||||
|
||||
预览阶段保存有期限且归属当前操作者的 `PreviewSession`、临时归档和完整计划,但不占用 Suite 坐标
|
||||
或版本。`PREVIEW_READY` 是 PreviewSession 状态,不是执行操作状态。确认事务先创建持久化
|
||||
`ExecutionOperation` 并原子获取目标 Suite 坐标或版本占用;获取失败时必须在任何成员生命周期
|
||||
副作用前结束,并要求重新预览。
|
||||
|
||||
ExecutionOperation 保存创建/更新模式、目标 Namespace/Suite/版本、操作者、归档摘要、完整成员计划、
|
||||
已创建 Skill/SkillVersion ID、精确引用 ID 和失败原因。对外状态为 `RUNNING`、
|
||||
`WAITING_FOR_MEMBERS`、`BLOCKED_RETRYABLE`、`REPREVIEW_REQUIRED`、`SUITE_DRAFT_CREATED` 和
|
||||
`CANCELLED`。只有 PreviewSession 使用 `PREVIEW_READY`、`CONFIRMED` 和 `EXPIRED`;等待人工审核的
|
||||
ExecutionOperation 不受 PreviewSession TTL 影响。
|
||||
|
||||
只有需要创建或发生变化且有权限的携带包成员进入现有 Skill 发布流程。所有新版本成为 PUBLISHED,
|
||||
并且复用成员和引用成员最终仍符合要求后,更新模式才创建 SuiteVersion DRAFT;创建模式原子创建
|
||||
Suite 容器及首个 DRAFT。任一成员失败都会阻止生成 Suite 草稿,但不会生成部分 SuiteVersion。
|
||||
领域事件触发状态协调,同时使用有界定时恢复处理事件丢失或乱序。
|
||||
|
||||
**备选方案:SuiteVersion 暂时引用未发布成员。** 不采用。这会破坏 #828 的精确 PUBLISHED
|
||||
版本不变量,并把临时成员处理扩散到 Suite 校验、审核、详情、删除和安装全链路。
|
||||
|
||||
### 3. 文件只提交一次,先预览再确认
|
||||
|
||||
Web 接受一个 ZIP;支持目录选择的浏览器还可以选择一个根目录,并以同一归档协议提交。服务端将
|
||||
归档流式写入临时对象存储,校验 Manifest 和携带包成员,计算 fingerprint,批量读取基准快照、
|
||||
精确引用和操作者权限。预览为每个成员分别返回成员关系变化 `ADDED`、`UPDATED`、`UNCHANGED`、
|
||||
`REMOVED`,以及发布动作 `CREATE_SKILL`、`CREATE_VERSION`、`REUSE_VERSION`、`REFERENCE_VERSION`、
|
||||
`NONE`,避免混淆“加入 Suite”和“创建 Skill”。预览不产生任何生命周期副作用。
|
||||
|
||||
确认使用不透明 token,并绑定操作者、模式、目标坐标、归档摘要、目标 Suite 版本和完整计划。
|
||||
只有确认成功创建的非终态 ExecutionOperation 才独占目标 Suite 坐标或版本。预览阶段解析出的包
|
||||
版本和引用 ID 在确认与重试过程中保持稳定。创建模式在成员就绪前只通过执行操作占用目标坐标,
|
||||
不创建对普通用户可见的空 Suite。
|
||||
|
||||
### 4. 每个异步边界都保留独立权限
|
||||
|
||||
预览明确区分四种权限:
|
||||
|
||||
- 在目标 Namespace 创建 Suite,或管理已有 Suite 并创建新版本的权限;
|
||||
- 在目标 Namespace 创建新 Skill 的权限;
|
||||
- 读取和编排精确引用版本的权限;
|
||||
- 发布携带包 Skill 的权限。
|
||||
|
||||
服务端在预览、确认、每次创建 Skill/SkillVersion 和 Suite 草稿创建时重新检查对应权限。新 Skill
|
||||
坐标冲突、目标 Namespace 不可写、失去包发布权限或引用失效都会阻止对应写入或最终创建 Suite
|
||||
草稿。Suite 所有权不会扩大成员 Skill 权限,错误响应不得泄露无权读取的资源元数据。
|
||||
|
||||
### 5. 部分成员发布必须可观察且不可破坏性补偿
|
||||
|
||||
确认前尽量发现包、版本、所有权、可见性和策略等确定性错误。但运行期仍可能出现某个成员版本
|
||||
已经创建、另一个成员失败的情况。重试只处理未完成工作。取消会停止后续编排并阻止创建
|
||||
SuiteVersion,但不会删除独立创建的 SkillVersion 或已经形成的审核历史。
|
||||
|
||||
Bundle 不得调用会自动撤回其他待审版本或删除替换已有版本的发布路径。预览发现同一 Skill 已有
|
||||
`PENDING_REVIEW` 版本,或目标版本号已经对应任一非 `PUBLISHED` 版本时,直接阻塞并要求用户先在
|
||||
现有 Skill 流程中处理。这里复用的是普通 Skill 的校验、存储、扫描、审核和审计规则,不是无条件
|
||||
复用当前具有替换副作用的内存型发布方法。
|
||||
|
||||
成员状态收敛规则如下:
|
||||
|
||||
| SkillVersion 状态或事件 | Bundle 状态 | 可执行动作 | ID 处理 |
|
||||
|---|---|---|---|
|
||||
| `SCANNING`、`PENDING_REVIEW` | `WAITING_FOR_MEMBERS` | 查看扫描或审核进度 | 保持预览绑定 ID |
|
||||
| PRIVATE 新版本进入 `UPLOADED` | `RUNNING` | 使用 Bundle 确认中已明确授予的私有发布授权,重新鉴权后执行现有 confirm-publish 转换 | 保持 ID |
|
||||
| `PUBLISHED` | 该成员完成 | 等待其他成员或创建 Suite 草稿 | 保持 ID |
|
||||
| `SCAN_FAILED` | 能以同一 ID 重扫时为 `BLOCKED_RETRYABLE`,否则为 `REPREVIEW_REQUIRED` | 重扫或重新上传预览 | 不得静默换 ID |
|
||||
| `REJECTED` | `REPREVIEW_REQUIRED` | 修改内容后重新上传并预览 | 原 ID 不再自动恢复 |
|
||||
| 待审版本被撤回为 `UPLOADED` | `REPREVIEW_REQUIRED` | 重新预览 | 不自动重新提交审核 |
|
||||
| 已绑定版本被删除、替换、下架或引用身份变化 | `REPREVIEW_REQUIRED` | 重新预览 | 不跟随新 ID |
|
||||
| 权限暂时撤销或 Namespace 冻结且计划身份未变化 | `BLOCKED_RETRYABLE` | 恢复权限或状态后重试,也可由有权角色取消 | 保持 ID |
|
||||
|
||||
`BLOCKED_RETRYABLE` 保留坐标/版本占用,只允许同一 ExecutionOperation 按原计划和原 ID 重试。
|
||||
`REPREVIEW_REQUIRED` 是终态,进入时与坐标/版本占用在同一事务中释放。`CANCELLED` 和
|
||||
`SUITE_DRAFT_CREATED` 同样在终态事务中释放占用。数据库使用仅覆盖占用状态的唯一约束,防止释放
|
||||
与新确认并发时出现双写。等待审核和可重试阻塞不会因 PreviewSession 过期而自动释放;原操作者或
|
||||
当前治理角色可以按权限取消长期操作。
|
||||
|
||||
状态读取只允许原操作者或当前有权治理目标 Suite/Namespace 的角色。读取时仍按当前权限过滤成员
|
||||
元数据;重试和取消必须重新授权。最终创建 Suite 草稿前再次检查 Namespace 可写、Suite 创建或
|
||||
管理权限、已有 Suite 仍为 ACTIVE、目标坐标/版本占用和全部成员资格。权限或 Namespace 状态暂时
|
||||
不可用且计划身份未变化时进入 `BLOCKED_RETRYABLE`;资源身份或目标计划失效时进入
|
||||
`REPREVIEW_REQUIRED`。
|
||||
|
||||
新 Skill 的目标可见性必须由 Manifest 明确提供。已有 Skill 始终继承当前 Skill 可见性,本功能不
|
||||
承担可见性迁移;Manifest 提供不同值时阻止确认。预览展示每个携带包成员的最终可见性以及审核或
|
||||
PRIVATE 直接发布路径,并在确认、成员写入和 Suite 草稿创建前使用现有受众兼容规则重新检查。
|
||||
|
||||
预览还保存每个成员当前 warning 集合及其摘要。确认页逐成员展示 warning,用户必须对 warning
|
||||
执行独立于通用 Bundle 确认的明确确认;确认请求绑定已展示的 warning 摘要。warning 变化会使
|
||||
预览失效,服务端不得因为用户只点击通用确认就静默启用普通发布的 `confirmWarnings`。
|
||||
|
||||
### 6. 保持现有 Skill fingerprint 和 Suite 快照模型
|
||||
|
||||
每个包目录都规范化为以自身 `SKILL.md` 为根的普通 Skill 包,并使用现有校验器和 fingerprint
|
||||
算法。ZIP 文件顺序、压缩元数据、压缩级别和外层目录不参与内容身份。纯引用成员只比较精确
|
||||
SkillVersion 身份和有效性,不下载或计算其包内容。
|
||||
|
||||
Manifest 提供目标 Suite/SuiteVersion 的展示信息、完整顺序和 Entry Skill。新建和变化包继续
|
||||
使用现有发布版本规则;自动解析的版本在预览时固定。最终结果仍是一个只包含精确 PUBLISHED
|
||||
引用的普通 DRAFT SuiteVersion。
|
||||
|
||||
### 7. 限制资源成本并保证归档安全
|
||||
|
||||
归档采用流式解析,拒绝危险路径、链接、重复规范化名称、过度解压以及超过配置的归档/成员/文件
|
||||
限制。预览把成员文件保存为临时对象定位信息、大小、内容类型和已计算摘要,而不是把最大 Bundle
|
||||
展开为一组常驻内存的 `byte[]`。确认后的发布边界从暂存对象流式读取并复用预览摘要,不重新解压
|
||||
或计算 fingerprint;普通发布需要的文件 hash、包归档和存储对象也从该流式输入生成。当前成员、
|
||||
引用、版本和权限批量读取。纯引用和未变化成员不复制对象、不扫描、不审核。
|
||||
|
||||
首版复用 `skillhub.publish.max-package-size` 同时限制 ZIP 压缩体积和整个 Bundle 的解压后总量,
|
||||
单文件限制继续复用 `skillhub.publish.max-single-file-size`;Bundle 总文件数上限为普通单 Skill 文件数
|
||||
上限乘以协议允许的 100 个成员。上传流先写入独立临时目录,每个 ZIP 文件只在解压时计算一次
|
||||
SHA-256 并写入临时对象,分析阶段只读取本地暂存文件,返回计划不保留文件字节。
|
||||
|
||||
外层解析和成员解析分开执行。外层只负责唯一 Manifest、成员目录边界和文件归属;成员目录去除
|
||||
自身前缀后,必须成为一个普通的、根部含 `SKILL.md` 的 Skill 包,并复用现有
|
||||
`SkillPackageValidator`、元数据解析、合规规则和错误/警告等级。成员目录不得重叠或嵌套,未声明的
|
||||
`SKILL.md` 和无法归属的普通文件均阻止确认,避免服务端猜测用户意图。Manifest 与 `SKILL.md`
|
||||
重复字段的优先级必须在协议中唯一确定;必须一致的字段发生冲突时直接报错。
|
||||
|
||||
格式校验、fingerprint 和权限分析都发生在预览阶段;Scanner 仍属于确认后的普通 Skill 生命周期,
|
||||
不为了预览创建扫描任务。任一成员存在阻塞性格式错误时,页面可以展示全部已发现问题,但整个
|
||||
Bundle 不可确认。
|
||||
|
||||
协调任务依靠带索引的操作/成员状态,不重新读取归档。反向引用使用集合式、有上限或分页的查询,
|
||||
不得逐条查询 Suite。
|
||||
|
||||
### 8. 技能市场与套件专区保持分开
|
||||
|
||||
技能市场不增加类型切换。`/search` 继续只展示 Skill,并保留 Skill Label、收藏和排序。
|
||||
`/suites` 继续作为 Suite 专区,使用类型化资源接口和现有 Suite 卡片。
|
||||
|
||||
唯一新增的发现能力是反向成员关系:Skill 详情查询最新、ACTIVE、非隐藏、PUBLISHED 且包含当前
|
||||
Skill 的 SuiteVersion,不再要求当前 Skill 必须是 Entry。结果标明 Entry 身份,只返回用户有权
|
||||
读取的兄弟成员摘要;无权读取的成员仅显示数量。成员关键词暂不影响 Suite 排名。
|
||||
|
||||
### 9. Suite 拥有自己的 Label,但不向成员级联
|
||||
|
||||
复用 `LabelDefinition`、本地化、`visibleInFilter`、排序以及 NORMAL/PRIVILEGED 权限语义,但使用
|
||||
独立的 Suite-to-Label 关联。Suite Label 用于 Suite 容器展示和套件专区筛选。增删 Label 不创建
|
||||
SuiteVersion,也不修改任何成员 Skill Label。
|
||||
|
||||
**备选方案:把现有 Skill Label 关联迁移为通用多态资源表。** 暂不采用。这会迁移稳定的 Skill
|
||||
数据并削弱数据库外键约束,当前收益不足。独立关联可以复用 Label 定义,同时保持所有权清晰。
|
||||
|
||||
Tag 继续属于 SkillVersion。SuiteVersion 已经有显式版本,因此本次不增加 Suite Tag,也不向
|
||||
成员批量设置 Tag。
|
||||
|
||||
### 10. 在发布边界要求有效摘要和概述
|
||||
|
||||
DRAFT SuiteVersion 可以在 summary 或 overview 尚未完成时保存。提交审核、PRIVATE 直接发布和
|
||||
最终批准时,两者都必须非空。summary 是套件专区、卡片和引用使用的简短说明;overview 是
|
||||
Markdown,说明用途、成员职责或顺序、预期输入输出和使用边界。服务端负责强制校验,不自动写入
|
||||
“精选技能组合”之类的泛化文案。
|
||||
|
||||
已有字段为空的 PUBLISHED SuiteVersion 仍可读取和安装,避免破坏历史数据;创建下一个版本时
|
||||
必须补齐。Bundle 可以显式提供新值,也可以继承当前 SuiteVersion 的非空值,预览必须展示最终
|
||||
解析结果。
|
||||
|
||||
### 11. Suite 概述和 Entry Skill 说明各自承担职责
|
||||
|
||||
Suite overview 说明组合用途、适用场景、成员分工或调用顺序、预期输入输出和使用边界。编辑器提供
|
||||
这些章节的结构化模板和完整性提示,但不以机械字数门槛鼓励填充内容,也不自动拿成员文档冒充
|
||||
Suite 作者的概述。
|
||||
|
||||
Suite 详情可以在独立折叠区域按需展示 Entry Skill 固定 SkillVersion 的 `SKILL.md`。内容必须延迟
|
||||
加载、复用该精确版本现有读取权限和安全 Markdown 渲染,并明确标注来源坐标与版本。Entry Skill
|
||||
说明不能替代 Suite overview;无权访问、版本失效或加载失败时,只保留合规的成员状态或跳转入口。
|
||||
|
||||
### 12. Web 将导入作为现有创建新版本流程的一种方式
|
||||
|
||||
“创建 Suite”和“创建新版本”均先选择“从技能市场组合”或“从本地导入”。手工组合保留现有页面
|
||||
和 API;本地导入进入上传、差异预览、明确确认和操作进度。更新预览必须突出显示因未出现在完整
|
||||
Manifest 中而被移除的成员。确认文案列出将创建的 Skill/SkillVersion 数量、复用和引用数量、
|
||||
移除数量以及最终只创建 Suite 草稿这一结果。
|
||||
|
||||
进度页逐成员展示创建、扫描、审核、复用、引用、失败和阻塞状态,并提供允许范围内的重试、取消、
|
||||
审核详情和最终草稿入口。页面刷新或重新登录后可以依靠操作 ID 恢复进度,不依赖仍保留在浏览器
|
||||
内存中的文件。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **引用的公开 Skill 后续可能失效** → 创建 Suite 草稿前重新校验;发布后继续使用现有 degraded
|
||||
机制。
|
||||
- **Bundle 阻塞后可能留下独立成员版本** → 展示每个成员结果;重试和取消不得破坏成员生命周期。
|
||||
- **事件可能重复、丢失或乱序** → 使用幂等协调和有界恢复任务。
|
||||
- **预览后权限可能变化** → 每个异步边界重新检查对应权限,失败时阻止而不是放宽权限。
|
||||
- **并发创建可能争用坐标,更新可能争用版本** → 操作记录独占目标 Suite 坐标和版本,最终创建
|
||||
草稿时重新检查唯一性并保留乐观锁。
|
||||
- **创建模式的某些 Skill 已发布、Suite 最终失败** → 独立 Skill 生命周期不回滚;进度页明确展示,
|
||||
重试只继续未完成工作。
|
||||
- **恶意归档可能消耗 CPU 或内存** → 流式处理、限制解压与文件规模,每个包只计算一次 hash。
|
||||
- **反向引用可能泄露私有成员构成** → 服务端过滤 Suite 和兄弟成员,只暴露不可访问成员数量。
|
||||
- **新必填展示信息可能阻塞历史 Suite 更新** → 历史发布快照保持可读,仅在提交新版本时要求补齐。
|
||||
- **普通作者可能尝试自助配置特权 Label** → 复用现有 Label 权限类型并由服务端执行。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 增加操作表和索引,不修改现有 Skill/Suite 生命周期表。
|
||||
2. 先部署预览和状态读取,再启用确认接口与协调任务。
|
||||
3. 所有实例均能识别操作记录和协调事件后,再开启确认。
|
||||
4. Bundle 进度、Suite Label/展示信息校验和任意成员反向引用可以独立部署;`/search` 与
|
||||
`/suites` 路由保持不变。
|
||||
5. 回滚时关闭新增接口和任务。已有 SkillVersion、SuiteVersion 继续有效;非终态操作保留,供兼容
|
||||
版本取消或过期处理。
|
||||
|
||||
预览默认有效 30 分钟,可通过 `skillhub.suite.bundle.preview-ttl`(环境变量
|
||||
`SKILLHUB_SUITE_BUNDLE_PREVIEW_TTL`)调整。确认入口默认关闭,待所有实例完成升级后通过
|
||||
`skillhub.suite.bundle.confirmation-enabled`(环境变量
|
||||
`SKILLHUB_SUITE_BUNDLE_CONFIRMATION_ENABLED`)显式开启。
|
||||
97
openspec/changes/add-suite-bundle-publishing/proposal.md
Normal file
97
openspec/changes/add-suite-bundle-publishing/proposal.md
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
## Why
|
||||
|
||||
PR #828 已经完成 Suite 版本模型、套件专区搜索和原子安装,但创建或更新包含多个本地 Skill 的
|
||||
Suite 时,维护者仍需逐个创建或上传 Skill,再手工把 Suite 固定到对应版本。Suite 还可能引用
|
||||
维护者并不拥有的公开 Skill,因此 Bundle 发布必须区分“操作者有权创建或发布的包内容”和
|
||||
“操作者只能复用的外部精确引用”。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 为创建和更新 Suite 增加共用的两阶段导入:通过一个 ZIP,或受支持浏览器选择的一个根目录,
|
||||
一次提交多个 Skill 文件夹;先预览成员与发布动作,再明确确认。确认前不得创建 Skill、
|
||||
SkillVersion、Suite、SuiteVersion、扫描或审核任务。
|
||||
- Manifest 使用两种成员形式描述完整的目标 Suite 快照:一种是操作者有权发布的已有 Skill 包,
|
||||
或操作者有权在目标 Namespace 创建的新 Skill 包;另一种是不修改、也不要求归 Suite 维护者
|
||||
所有的精确 PUBLISHED SkillVersion 引用。
|
||||
- Bundle 先校验唯一 Manifest、成员目录边界和文件归属,再将每个目录独立还原为以 `SKILL.md`
|
||||
为根的普通 Skill 包,复用现有路径、大小、扩展名、内容签名、YAML、元数据和合规校验。任一成员
|
||||
存在阻塞错误时不得确认,也不得通过猜测目录或静默选择冲突字段继续。
|
||||
- 新增持久化 Bundle 发布操作。只有具备独立创建或发布权限且携带包的成员进入现有校验、扫描和
|
||||
审核流程;所有包版本均为 PUBLISHED 且全部引用仍然有效后,才原子创建新 Suite 及首个草稿,
|
||||
或为已有 Suite 创建 DRAFT SuiteVersion。
|
||||
- Bundle 确认后进入独立发布任务详情;“我的套件”以“技能套件/发布任务”页签分离结果和过程,
|
||||
发布任务按“需要处理/进行中/最近完成”分层。停止创建 Suite 后保留任务、已创建的 SkillVersion
|
||||
和审核任务,并继续提供成员版本与审核入口。
|
||||
- 未变化成员继续使用原精确版本,不创建版本、扫描或审核;支持添加、移除、调整顺序和显式重新
|
||||
固定引用成员版本。
|
||||
- 任意成员 Skill 均可展示当前用户可见的所属 Suite,不再只支持 Entry Skill。
|
||||
- 技能市场(`/search`)与套件专区(`/suites`)保持独立。#828 已经交付的套件专区和类型化 Suite
|
||||
搜索无需替换。
|
||||
- 新 SuiteVersion 提交或发布前必须具备非空摘要和 Markdown 概述;不完整的 DRAFT 仍可保存,
|
||||
已发布历史数据仍可读取。
|
||||
- Suite 可以配置自己的 Label,并复用 Registry 的 Label 定义和权限类型,用于套件专区筛选。
|
||||
Suite Label 不向成员 Skill 级联。Skill Tag 仍是指向 PUBLISHED SkillVersion 的版本别名,不引入 Suite。
|
||||
- 暂不支持覆盖无权管理的已有 Skill、批量迁移 Namespace、按成员关键词提升 Suite 排名、CLI 上传
|
||||
Bundle、嵌套 Suite 和跨 Registry 成员。
|
||||
|
||||
## 相对 Issue #847 的需求取舍
|
||||
|
||||
| Issue 原提议 | 结论 | OpenSpec 决策 |
|
||||
|---|---|---|
|
||||
| 上传一个多 Skill 归档 | 保留 | 归档只上传一次;预览使用不占位且有期限的 PreviewSession,确认后才创建并占位的 ExecutionOperation。 |
|
||||
| 每个 Suite 成员都必须在归档中有目录 | 调整 | 只有需要发布内容的成员才提供包目录;纯引用成员在 Manifest 中填写精确已发布版本。 |
|
||||
| 被引用 Skill 必须属于 Suite 维护者 | 拒绝 | Suite 可以引用其他人发布的合规公开 Skill;引用权限与发布权限相互独立。 |
|
||||
| 通过 fingerprint 判断包内容变化 | 保留并澄清 | 携带包的成员使用现有 Skill 规范化 fingerprint;纯引用成员比较精确 SkillVersion 身份。 |
|
||||
| 未变化的包不升版、不重新扫描 | 保留 | 继续引用当前精确 SkillVersion。 |
|
||||
| 变化的包沿用现有 Skill 发布流程 | 保留 | 仅限操作者已经具备该 Skill 的独立发布权限。 |
|
||||
| 包成员变化后立刻创建 SuiteVersion | 调整 | 等全部包版本 PUBLISHED、全部引用仍有效后,再创建一个 DRAFT SuiteVersion;创建模式同时原子创建 Suite 容器。 |
|
||||
| 消除 N 次上传和 N 个审核任务 | 调整 | 消除 N 次人工上传,但变化 Skill 仍独立审核,Suite 仍保留自身审核。 |
|
||||
| Bundle 自动创建不存在的 Skill | 保留并收紧 | 仅允许在操作者具备 Skill 创建权限的 Namespace 中创建;坐标冲突、越权或 Namespace 不可写时阻塞整个预览。 |
|
||||
| 批量设置 labels/tags | 调整 | 为 Suite 自身配置 Label,不向成员 Skill 扩散;Suite 不支持 SkillVersion Tag。 |
|
||||
| 把 Suite 结果并入技能市场 | 拒绝 | 技能市场与套件专区保持分开;`/suites` 已提供 Suite 搜索和卡片。 |
|
||||
| 成员关键词提升 Suite 排名 | 延后 | 相关性和隐私安全索引需要单独提案。 |
|
||||
| 任意成员展示所属 Suite | 保留 | 仅返回最新、可见、ACTIVE、非隐藏、PUBLISHED 的 Suite 引用,并标明是否为 Entry。 |
|
||||
| Suite 摘要或概述为空 | 调整 | DRAFT 可暂时不完整,但新提交或直接发布必须同时具备摘要和概述,发布页不能用泛化文案冒充。 |
|
||||
| 保留 #828 精确引用模型 | 保持不变 | SuiteVersion 仍只引用精确 PUBLISHED SkillVersion,发布后保持不可变。 |
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `suite-bundle-publishing`:从一个归档或根目录预览、确认、跟踪、恢复并完成 Suite 创建或更新,
|
||||
同时严格区分有权创建/发布的包内容与非本人所有的精确版本引用。
|
||||
- `suite-member-discovery`:保持技能市场与套件专区独立,同时让任意成员 Skill 展示经过隐私过滤的
|
||||
所属 Suite。
|
||||
- `suite-metadata`:要求 Suite 具备有效展示内容并支持 Suite 自有 Label,不修改成员 Skill 元数据
|
||||
或 SkillVersion Tag。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。现有 `add-skill-suites` 变更尚未归档为主 OpenSpec capability;这些增量 capability 依赖它,
|
||||
但不修改其精确引用和生命周期要求。
|
||||
|
||||
## Impact
|
||||
|
||||
- **领域与持久化**:新增 Bundle 操作、目标坐标占用、成员结果和 Suite-to-Label 关联;不增加
|
||||
SkillVersion 或 SuiteVersion 生命周期状态。
|
||||
- **API / OpenAPI**:新增预览、确认、状态、取消、重试和 Suite Label 接口;扩展 Skill 详情中的
|
||||
Suite 引用和套件专区标签筛选;技能与套件发现 API 继续分开。
|
||||
- **对象存储**:按操作临时保存一个上传归档,支持过期和补偿清理;日志不得保存归档内容。
|
||||
- **安全与治理**:复用包校验、Scanner、Namespace 权限、单 Skill 审核、Suite 审核和审计规则;
|
||||
管理 Suite 不会获得被引用 Skill 的发布权限。
|
||||
- **性能**:每个携带包成员只解压和计算一次 hash;批量读取引用与权限;未变化成员不扫描;限制
|
||||
归档和成员规模;反向引用不得产生 N+1 查询。
|
||||
- **Web**:在“创建 Suite”和“创建新版本”中增加手工组合/本地导入选择,新增 Bundle 差异预览、
|
||||
权限提示和进度页;增加所属 Suite、发布信息校验、Suite Label,以及按需展开的 Entry Skill
|
||||
固定版本说明。技能市场与套件专区导航保持不变。
|
||||
- **兼容性**:现有单 Skill 发布、单 Skill Label/Tag 管理、Suite 管理和安装 API、旧客户端以及
|
||||
已发布 SuiteVersion 快照保持不变。
|
||||
|
||||
## 分阶段交付
|
||||
|
||||
OpenSpec 保留完整产品方向,但实现和 PR 按以下边界拆分,后续阶段不得绕过第一阶段建立的权限、
|
||||
生命周期和性能约束:
|
||||
|
||||
1. Suite 创建/更新 Bundle、成员格式验证、权限重检、生命周期协调和 Web 进度闭环。
|
||||
2. 基于 Suite 当前 `latestVersionId` 的任意成员反向发现。
|
||||
3. Suite Label、展示信息发布校验、概述模板和 Entry Skill 固定版本说明。
|
||||
|
|
@ -0,0 +1,558 @@
|
|||
## Purpose
|
||||
|
||||
让维护者通过一个经过安全校验的 ZIP 或根目录创建或更新 Suite,同时保持 Skill 独立所有权、
|
||||
独立审核以及 Suite 精确版本快照不可变。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: REQ-SBP-01 Bundle 导入 SHALL 支持创建和更新 Suite
|
||||
|
||||
系统 SHALL 使用同一套预览、确认和进度协议支持创建 Suite 或为已有 Suite 创建新版本。创建模式
|
||||
要求目标 Namespace 可写且操作者具备现有 Suite 创建权限;更新模式要求 Suite 为 ACTIVE 且操作者
|
||||
具备创建新版本权限。确认前不得创建 Suite 或 SuiteVersion。
|
||||
|
||||
#### Scenario: 创建新 Suite
|
||||
- **WHEN** 有权限的操作者为尚不存在的 Suite 坐标提交合法 Bundle
|
||||
- **THEN** 系统在没有基准 SuiteVersion 的情况下预览全部目标成员
|
||||
- **AND** 确认及成员发布成功前不创建空 Suite
|
||||
|
||||
#### Scenario: 刷新已有 Suite
|
||||
- **WHEN** 有权限的操作者为 ACTIVE Suite 上传合法 Bundle
|
||||
- **THEN** 系统以用户明确选择的 SuiteVersion 为基准分析 Bundle
|
||||
|
||||
#### Scenario: 创建目标 Suite 坐标已存在
|
||||
- **WHEN** 创建模式指向已经存在或被其他非终态操作占用的 Suite 坐标
|
||||
- **THEN** 预览或确认以可操作冲突拒绝请求
|
||||
- **AND** 不覆盖现有 Suite
|
||||
|
||||
#### Scenario: 操作者不能管理 Suite
|
||||
- **WHEN** 当前用户没有 Suite 管理权限却上传 Bundle
|
||||
- **THEN** 系统拒绝请求,且不泄露其无权访问的成员元数据
|
||||
|
||||
### Requirement: REQ-SBP-02 Bundle Manifest SHALL 区分携带包成员和精确引用成员
|
||||
|
||||
Manifest SHALL 描述完整目标成员顺序和 Entry Skill。每个成员 SHALL 只能是以下一种形式:
|
||||
操作者有权创建或发布的 Skill 包,或者一个合规的精确 PUBLISHED SkillVersion 引用。纯引用成员
|
||||
SHALL NOT 要求操作者拥有该 Skill,也不要求提供包目录。创建模式把全部成员视为新增;更新模式
|
||||
只有基准成员未出现在完整 Manifest 中时,才视为移除。新 Skill 的目标可见性 SHALL 在 Manifest
|
||||
中明确提供;已有 Skill SHALL 继承当前可见性,本功能不得通过 Manifest 改变已有 Skill 可见性。
|
||||
|
||||
#### Scenario: 从文件夹创建新 Skill
|
||||
- **WHEN** 携带包成员指向尚不存在的 Skill 坐标
|
||||
- **AND** 操作者在目标 Namespace 具备 Skill 创建权限且 Namespace 可写
|
||||
- **THEN** 预览把发布动作标记为 `CREATE_SKILL`
|
||||
- **AND** 只有确认后才能创建 Skill 及其首个 SkillVersion
|
||||
|
||||
#### Scenario: 无权在目标 Namespace 创建 Skill
|
||||
- **WHEN** 携带包成员指向不存在的 Skill,但操作者没有对应 Namespace 的创建权限
|
||||
- **THEN** 预览阻塞整个计划
|
||||
- **AND** 不创建 Skill、Suite、版本、扫描或审核任务
|
||||
|
||||
#### Scenario: 更新有权发布的已有 Skill
|
||||
- **WHEN** 携带包成员指向已有 Skill 且内容变化,并且操作者具备独立发布权限
|
||||
- **THEN** 预览把发布动作标记为 `CREATE_VERSION`
|
||||
|
||||
#### Scenario: 新 Skill 坐标与不可管理 Skill 冲突
|
||||
- **WHEN** 携带包坐标已经属于操作者无权发布的 Skill
|
||||
- **THEN** 预览拒绝携带包,而不是将其作为新 Skill 或覆盖现有内容
|
||||
|
||||
#### Scenario: 新 Skill 缺少目标可见性
|
||||
- **WHEN** 携带包成员指向不存在的 Skill 且 Manifest 没有提供目标可见性
|
||||
- **THEN** 预览以阻塞性协议错误拒绝该成员
|
||||
|
||||
#### Scenario: 尝试通过 Bundle 修改已有 Skill 可见性
|
||||
- **WHEN** Manifest 为已有 Skill 提供了不同于当前值的目标可见性
|
||||
- **THEN** 预览阻止确认并要求使用现有 Skill 生命周期独立处理可见性
|
||||
|
||||
#### Scenario: 成员可见性与 Suite 受众不兼容
|
||||
- **WHEN** 新 Skill 的目标可见性或已有 Skill 的继承可见性不能覆盖目标 Suite 受众
|
||||
- **THEN** 预览拒绝该成员
|
||||
- **AND** 确认、成员写入和 Suite 草稿创建前均重新执行同一兼容性检查
|
||||
|
||||
#### Scenario: 保留非本人所有的公开成员
|
||||
- **WHEN** Manifest 以纯引用形式保留其他用户拥有的合规精确 PUBLIC SkillVersion
|
||||
- **THEN** 预览按照现有 Suite 可见性规则接受该引用
|
||||
- **AND** 不复制、发布、扫描、审核、设置 Label/Tag 或以其他方式修改该 Skill
|
||||
|
||||
#### Scenario: 添加已有精确引用
|
||||
- **WHEN** 操作者不提供包,仅添加一个合规精确 PUBLISHED SkillVersion
|
||||
- **THEN** 预览将成员关系标记为 `ADDED`,发布动作标记为 `REFERENCE_VERSION`
|
||||
- **AND** 最终 Suite 草稿可以引用它,但不改变其所有权或生命周期
|
||||
|
||||
#### Scenario: 重新固定引用成员版本
|
||||
- **WHEN** Manifest 将引用成员从一个合规 PUBLISHED SkillVersion 改为另一个
|
||||
- **THEN** 预览将成员关系标记为 `UPDATED`,发布动作标记为 `REFERENCE_VERSION`
|
||||
- **AND** 不发布新的 SkillVersion
|
||||
|
||||
#### Scenario: 为非本人所有 Skill 提供包
|
||||
- **WHEN** 操作者可以引用某个 Skill,但没有独立发布权限
|
||||
- **AND** Manifest 为该 Skill 提供包内容
|
||||
- **THEN** 预览拒绝该携带包成员,并返回不泄露隐私的可操作原因
|
||||
|
||||
#### Scenario: 移除当前成员
|
||||
- **WHEN** 当前成员未出现在完整 Manifest 中
|
||||
- **THEN** 预览将其标记为 `REMOVED`
|
||||
- **AND** 在后续 SuiteVersion 正式发布前,现有已发布 Suite 保持不变
|
||||
|
||||
### Requirement: REQ-SBP-03 Bundle 预览 SHALL 完整且无业务副作用
|
||||
|
||||
确认前,预览 SHALL 返回完整目标 Suite 快照,并区分携带包成员和纯引用成员。预览 SHALL NOT
|
||||
创建 Skill、SkillVersion、Suite、SuiteVersion、扫描、审核任务、Label 或 Tag 变更。临时归档和
|
||||
PreviewSession 不属于生命周期对象,并且 SHALL 受有效期和清理策略约束;预览 SHALL NOT 占用
|
||||
Suite 坐标或目标版本。预览 SHALL 展示每个携带包成员的最终可见性、对应审核/PRIVATE 发布路径和
|
||||
当前 warning 集合。
|
||||
|
||||
#### Scenario: 预览混合变更
|
||||
- **WHEN** 合法 Bundle 同时包含未变化/变化的包、新增/更新/未变化的引用以及被移除成员
|
||||
- **THEN** 预览分别返回成员关系变化 `ADDED/UPDATED/UNCHANGED/REMOVED`、发布动作
|
||||
`CREATE_SKILL/CREATE_VERSION/REUSE_VERSION/REFERENCE_VERSION/NONE`、当前/目标版本、顺序、
|
||||
Entry 标记、警告和阻塞原因
|
||||
- **AND** 不产生发布或元数据副作用
|
||||
|
||||
#### Scenario: 预览不存在有效变化
|
||||
- **WHEN** 包 fingerprint、精确引用、顺序、Entry Skill、可见性和 Suite 元数据均与当前快照一致
|
||||
- **THEN** 预览报告没有有效变化
|
||||
- **AND** 确认不能创建冗余 SkillVersion 或 SuiteVersion
|
||||
|
||||
#### Scenario: 成员存在可确认 warning
|
||||
- **WHEN** 普通 Skill 预发布规则为至少一个成员返回非阻塞 warning
|
||||
- **THEN** 预览按成员展示 warning 内容并绑定 warning 集合摘要
|
||||
- **AND** warning 不得被当成已自动确认
|
||||
|
||||
### Requirement: REQ-SBP-04 携带包成员差异 SHALL 使用规范化 Skill fingerprint
|
||||
|
||||
系统 SHALL 使用普通 Skill fingerprint 相同的规范化路径和文件内容 hash 比较携带包成员。ZIP
|
||||
顺序、压缩元数据、压缩级别和外层目录 SHALL NOT 导致版本变化。纯引用成员 SHALL 比较精确
|
||||
SkillVersion 身份,不下载或计算包内容。
|
||||
|
||||
#### Scenario: 重新打包但内容未变化
|
||||
- **WHEN** 携带包成员的规范化路径和文件内容相同,只改变 ZIP 元数据或归档顺序
|
||||
- **THEN** 预览将发布动作标记为 `REUSE_VERSION`
|
||||
- **AND** 继续使用当前精确 SkillVersion
|
||||
|
||||
#### Scenario: 自有成员内容发生变化
|
||||
- **WHEN** 至少一个规范化路径或文件内容 hash 发生变化
|
||||
- **THEN** 预览将发布动作标记为 `CREATE_VERSION`
|
||||
|
||||
#### Scenario: 保留相同精确引用
|
||||
- **WHEN** 纯引用成员指向与当前快照相同的 SkillVersion
|
||||
- **THEN** 预览将成员关系标记为 `UNCHANGED`,发布动作标记为 `REFERENCE_VERSION`
|
||||
- **AND** 不下载引用包,也不计算引用 fingerprint
|
||||
|
||||
### Requirement: REQ-SBP-05 Bundle 归档 SHALL 在发布前完成安全校验
|
||||
|
||||
系统 SHALL 对每个携带包成员执行现有 Skill 校验,并执行配置的归档总量、解压膨胀、成员数、
|
||||
文件数和单文件大小限制。危险路径、链接、重复规范化成员、同时声明包和引用、非法 Manifest 或
|
||||
非法包 SHALL 在产生副作用前拒绝整个预览。
|
||||
|
||||
#### Scenario: 一个携带包成员不合法
|
||||
- **WHEN** 任一包未通过现有 Skill 包校验或预发布校验
|
||||
- **THEN** 预览返回对应成员和可操作原因
|
||||
- **AND** 不发布任何成员
|
||||
|
||||
#### Scenario: 归档尝试路径穿越或过度解压
|
||||
- **WHEN** 归档包含危险路径、不支持的链接或超过配置的安全限制
|
||||
- **THEN** 系统在写入 Skill/Suite 生命周期数据前拒绝归档
|
||||
|
||||
#### Scenario: 一个成员同时声明包和引用
|
||||
- **WHEN** Manifest 中同一个成员同时使用两种形式
|
||||
- **THEN** 预览拒绝 Manifest,而不是猜测发布意图
|
||||
|
||||
### Requirement: REQ-SBP-06 Bundle 外层结构 SHALL 唯一映射每个 Skill 文件夹
|
||||
|
||||
Bundle 根 SHALL 包含且仅包含一个可识别的 Manifest。每个携带包成员 SHALL 在 Manifest 中声明一个
|
||||
规范化相对目录,该目录必须唯一、不得与其他成员目录重叠或互相嵌套。每个声明目录必须且只能把
|
||||
一个位于该目录根部的 `SKILL.md` 识别为成员入口。除明确允许忽略的操作系统元数据外,未归属于
|
||||
Manifest 或任何声明成员目录的文件 SHALL 作为格式错误处理。
|
||||
|
||||
#### Scenario: 一个合法的多 Skill Bundle
|
||||
- **WHEN** Manifest 声明三个互不重叠的成员目录,且每个目录根部各有一个 `SKILL.md`
|
||||
- **THEN** 解析器生成三个彼此隔离的成员包
|
||||
- **AND** 任一成员的文件不会进入另一个成员的校验或 fingerprint
|
||||
|
||||
#### Scenario: 成员目录缺少根部 SKILL.md
|
||||
- **WHEN** 声明目录没有 `SKILL.md`,或者只在更深层级出现 `SKILL.md`
|
||||
- **THEN** 预览把该成员报告为阻塞性格式错误
|
||||
- **AND** 不通过猜测目录结构自动选择入口
|
||||
|
||||
#### Scenario: 成员目录重叠或嵌套
|
||||
- **WHEN** 两个 Manifest 成员指向相同目录,或者一个成员目录位于另一个成员目录内
|
||||
- **THEN** 系统拒绝整个 Bundle
|
||||
- **AND** 不允许同一文件归属于多个 Skill
|
||||
|
||||
#### Scenario: 存在未声明的 Skill 文件夹
|
||||
- **WHEN** 归档中出现包含 `SKILL.md`、但未被 Manifest 声明的目录
|
||||
- **THEN** 系统报告该目录未被声明并阻止确认
|
||||
|
||||
#### Scenario: ZIP 与目录选择产生相同内容
|
||||
- **WHEN** 用户分别通过 ZIP 和浏览器根目录选择提交相同的规范化文件树
|
||||
- **THEN** 服务端得到相同的 Manifest、成员边界和 fingerprint 结果
|
||||
|
||||
#### Scenario: 不同目录解析为同一逻辑 Skill
|
||||
- **WHEN** 两个携带包目录、或携带包与精确引用,在路径大小写、slug 规范化和元数据解析后指向同一 Skill 身份
|
||||
- **THEN** 系统以重复成员阻止整个 Bundle
|
||||
- **AND** 目标 Suite 快照中的同一 Skill 最多出现一次
|
||||
|
||||
### Requirement: REQ-SBP-07 每个成员包 SHALL 独立通过现有 Skill 协议校验
|
||||
|
||||
解析外层结构后,系统 SHALL 把每个成员目录去除自身前缀,并作为以 `SKILL.md` 为根的普通 Skill
|
||||
包交给现有 SkillPackageValidator、元数据解析和合规校验。路径、扩展名、内容签名、文件数量、
|
||||
单文件大小、总大小、YAML 安全限制和必填字段 SHALL 复用普通 Skill 发布的同一策略及错误/警告
|
||||
等级,不得为 Bundle 放宽。Manifest 与 `SKILL.md` 重复表达的信息 SHALL 按协议定义的唯一优先级
|
||||
解析;互相矛盾时必须阻止确认。
|
||||
|
||||
#### Scenario: 一个成员 SKILL.md frontmatter 无效
|
||||
- **WHEN** 任一成员缺少必填字段、YAML 语法错误或超过解析安全限制
|
||||
- **THEN** 预览在对应成员下返回定位明确的错误
|
||||
- **AND** 整个 Bundle 不能确认
|
||||
|
||||
#### Scenario: 两个路径规范化后冲突
|
||||
- **WHEN** 同一成员内两个原始路径规范化或规范化 `SKILL.md` 大小写后得到相同路径
|
||||
- **THEN** 成员校验以重复路径错误失败
|
||||
|
||||
#### Scenario: Manifest 与 SKILL.md 元数据冲突
|
||||
- **WHEN** Manifest 和成员 `SKILL.md` 对协议规定必须一致的坐标或版本信息给出不同值
|
||||
- **THEN** 预览展示冲突字段并阻止确认
|
||||
- **AND** 服务端不静默选择其中一个值
|
||||
|
||||
#### Scenario: 一个成员超过普通 Skill 包限制
|
||||
- **WHEN** 单个成员的文件数、单文件大小或总大小超过普通 Skill 发布限制
|
||||
- **THEN** 即使整个 Bundle 未超过总限制,该成员仍然校验失败
|
||||
|
||||
#### Scenario: 所有成员格式校验通过
|
||||
- **WHEN** 外层结构和每个成员包均通过阻塞性格式校验
|
||||
- **THEN** 预览才继续执行 fingerprint、权限和目标版本分析
|
||||
- **AND** 普通安全扫描仍在用户确认后的现有 Skill 生命周期中独立执行
|
||||
|
||||
### Requirement: REQ-SBP-08 Bundle 确认 SHALL 绑定已检查的预览并重新授权
|
||||
|
||||
确认 SHALL 使用带有效期的不透明预览标识,并绑定操作者、创建/更新模式、目标 Namespace/Suite
|
||||
坐标、归档摘要、目标 Suite 版本和完整成员计划。确认和重试 SHALL 保持预览阶段解析出的包版本
|
||||
和精确引用 ID,并满足幂等性。确认时 SHALL 重新检查 Suite、Namespace、Skill 和引用权限,并在
|
||||
一个事务中创建 ExecutionOperation、原子获取目标 Suite 坐标或版本占用。获取占用失败时,SHALL
|
||||
在创建任何 Skill 或 SkillVersion 前结束。存在 warning 时,确认 SHALL 额外携带用户对已展示
|
||||
warning 集合的明确确认并匹配预览摘要;通用 Bundle 确认不得隐式代替 warning 确认。
|
||||
|
||||
#### Scenario: 确认已检查计划
|
||||
- **WHEN** 同一有权限操作者确认仍有效且相关状态未变化的预览
|
||||
- **THEN** 系统针对该精确计划启动一个持久化 Bundle 操作
|
||||
- **AND** 返回操作 ID
|
||||
|
||||
#### Scenario: 预览过期或相关状态变化
|
||||
- **WHEN** 确认使用过期预览,或者 Suite、权限、包、引用相关状态已经变化
|
||||
- **THEN** 不产生新的发布副作用
|
||||
- **AND** 要求重新预览
|
||||
|
||||
#### Scenario: 确认前权限被撤销
|
||||
- **WHEN** 操作者在预览后失去 Suite 创建/管理、Skill 创建/发布或引用读取权限
|
||||
- **THEN** 确认拒绝对应计划且不启动成员发布
|
||||
- **AND** 错误不泄露操作者已经无权读取的资源元数据
|
||||
|
||||
#### Scenario: 响应丢失后重试
|
||||
- **WHEN** 操作者使用相同幂等标识重试确认
|
||||
- **THEN** 系统返回已有操作
|
||||
- **AND** 不重复创建成员版本或审核任务
|
||||
|
||||
#### Scenario: 两个预览针对同一目标
|
||||
- **WHEN** 多个用户同时预览同一 Suite 坐标或目标版本
|
||||
- **THEN** 系统允许生成彼此隔离且有期限的 PreviewSession
|
||||
- **AND** 只有确认事务中成功获得占用的一个计划可以执行
|
||||
|
||||
#### Scenario: warning 未确认或发生变化
|
||||
- **WHEN** 用户没有明确确认全部当前 warning,或者确认时 warning 集合摘要与预览不一致
|
||||
- **THEN** 系统不创建 ExecutionOperation 或成员版本
|
||||
- **AND** warning 变化时要求重新预览
|
||||
|
||||
### Requirement: REQ-SBP-09 Bundle 预览 SHALL 解析可发布的 Suite 展示信息
|
||||
|
||||
预览 SHALL 展示目标 SuiteVersion 最终使用的 summary 和 overview。更新模式可以使用 Manifest
|
||||
提供的值,也可以继承基准 SuiteVersion 的非空值;创建模式必须由 Manifest 提供。任一最终值为空
|
||||
时 SHALL 阻止确认。Bundle 处理 SHALL NOT
|
||||
增加、删除或向成员扩散 Suite/Skill Label 与 Tag。
|
||||
|
||||
#### Scenario: 继承当前完整展示信息
|
||||
- **WHEN** Manifest 未填写 summary 和 overview,且当前 SuiteVersion 两者均非空
|
||||
- **THEN** 预览展示继承后的最终值
|
||||
- **AND** 最终 DRAFT 保留这些值
|
||||
|
||||
#### Scenario: 展示信息仍不完整
|
||||
- **WHEN** Manifest 和当前 SuiteVersion 无法得到非空 summary 或 overview
|
||||
- **THEN** 预览返回可操作的阻塞错误
|
||||
- **AND** 在发布包内容前阻止确认
|
||||
|
||||
#### Scenario: Bundle 只修改成员组成
|
||||
- **WHEN** 已确认 Bundle 更新包或引用
|
||||
- **THEN** Suite Label 及全部成员 Skill Label/Tag 保持不变
|
||||
|
||||
### Requirement: REQ-SBP-10 携带包成员 SHALL 保持独立 Skill 权限和生命周期
|
||||
|
||||
只有操作者在对应 Namespace 具备创建权限的新 Skill,或已经具备独立发布权限的已有 Skill,才能
|
||||
进入普通所有权、版本、校验、存储、扫描、可见性、审核和审计流程。未变化的包和全部纯引用成员
|
||||
SHALL 不产生 Skill 发布副作用。服务端 SHALL 在每次实际创建 Skill 或 SkillVersion 前重新授权。
|
||||
Bundle SHALL 复用普通发布的规则与生命周期,但 SHALL NOT 自动撤回其他 `PENDING_REVIEW` 版本,
|
||||
也不得删除或替换已有非 PUBLISHED 版本。
|
||||
|
||||
#### Scenario: 确认后创建新 Skill
|
||||
- **WHEN** 已确认计划包含通过预览的新 Skill 包,且写入时权限仍然有效
|
||||
- **THEN** 系统通过现有 Skill 创建和首版发布流程处理该成员
|
||||
- **AND** 操作记录新 Skill、SkillVersion、最终可见性及其状态
|
||||
|
||||
#### Scenario: 发布有权限的变化包
|
||||
- **WHEN** 已确认计划包含操作者有权发布的变化包
|
||||
- **THEN** 独立 SkillVersion 进入现有扫描和审核流程
|
||||
- **AND** 操作记录每个版本及其状态
|
||||
|
||||
#### Scenario: 新 Skill 使用显式可见性
|
||||
- **WHEN** 已确认计划创建新 Skill
|
||||
- **THEN** 创建动作使用预览绑定且重新校验过的 Manifest 目标可见性
|
||||
- **AND** 进度页展示该成员进入审核还是 PRIVATE 直接发布路径
|
||||
|
||||
#### Scenario: 复用未变化包
|
||||
- **WHEN** 携带包成员的发布动作被判断为 `REUSE_VERSION`
|
||||
- **THEN** 继续使用当前精确 PUBLISHED SkillVersion
|
||||
- **AND** 不复制对象、不扫描、不审核、不创建版本
|
||||
|
||||
#### Scenario: Suite 管理权限不授予 Skill 发布权限
|
||||
- **WHEN** 操作者可以管理 Suite,但不能发布某个携带包 Skill
|
||||
- **THEN** 该包不能进入发布流程
|
||||
- **AND** Suite 所有权不会扩大 Skill 权限
|
||||
|
||||
#### Scenario: 异步写入前 Skill 权限被撤销
|
||||
- **WHEN** 操作者在确认后、创建成员版本前失去对应权限
|
||||
- **THEN** 计划身份未变化时操作进入 `BLOCKED_RETRYABLE` 并保留目标占用,且不创建新版本
|
||||
- **AND** 其他已创建成员保持独立生命周期
|
||||
|
||||
#### Scenario: 已有 Skill 存在待审版本
|
||||
- **WHEN** 预览发现目标 Skill 存在任意 `PENDING_REVIEW` 版本
|
||||
- **THEN** 计划阻塞并要求用户先完成或撤回现有审核
|
||||
- **AND** Bundle 不自动撤回该版本或删除审核任务
|
||||
|
||||
#### Scenario: 目标版本号已存在但未发布
|
||||
- **WHEN** 预览解析出的目标版本号已经对应 DRAFT、UPLOADED、SCANNING、SCAN_FAILED、PENDING_REVIEW 或 REJECTED 版本
|
||||
- **THEN** 计划阻塞并要求用户通过现有 Skill 流程处理该版本
|
||||
- **AND** Bundle 不删除、替换或改写已有版本 ID
|
||||
|
||||
### Requirement: REQ-SBP-11 纯引用成员 SHALL 保持独立所有权和生命周期
|
||||
|
||||
系统 SHALL 只校验引用身份、当前操作者可见性、目标 Suite 受众兼容性和可安装性。系统 SHALL NOT
|
||||
修改引用成员的包、版本、所有者、Label、Tag、审核、扫描或生命周期。
|
||||
|
||||
#### Scenario: 引用其他所有者的公开 Skill
|
||||
- **WHEN** Suite 维护者选择其他用户拥有的合规精确 PUBLIC SkillVersion
|
||||
- **THEN** 系统不要求管理权限即可接受该引用
|
||||
|
||||
#### Scenario: 操作者个人可见但目标受众不兼容
|
||||
- **WHEN** 操作者可以读取精确 SkillVersion,但 Suite 目标受众不能读取
|
||||
- **THEN** 系统按照现有 Suite 可见性规则拒绝引用
|
||||
|
||||
### Requirement: REQ-SBP-12 Suite 草稿创建 SHALL 等待包发布成功和引用最终有效
|
||||
|
||||
只有全部变化包版本均为 PUBLISHED,且全部未变化/纯引用成员仍然有效后,系统 SHALL 创建且仅创建
|
||||
一个 DRAFT SuiteVersion。更新模式在已有 Suite 下创建草稿;创建模式 SHALL 原子创建 Suite 容器
|
||||
和首个 DRAFT。草稿 SHALL 保存已确认的精确引用、顺序、Entry Skill、元数据和移除结果,并继续
|
||||
执行现有 Suite 审核生命周期。创建前 SHALL 重新检查目标 Namespace 可写、操作者仍具备 Suite
|
||||
创建或管理权限、更新目标 Suite 仍为 ACTIVE、坐标/版本占用仍归当前操作,以及全部成员最终资格。
|
||||
|
||||
#### Scenario: 全部包已发布且引用仍有效
|
||||
- **WHEN** 所有需要发布的包均达到 PUBLISHED,且最终成员检查通过
|
||||
- **THEN** 系统根据已确认快照创建一个 SuiteVersion DRAFT
|
||||
- **AND** 操作状态变为 `SUITE_DRAFT_CREATED`
|
||||
|
||||
#### Scenario: 创建模式全部成员就绪
|
||||
- **WHEN** 创建模式的全部包版本均为 PUBLISHED,引用最终检查通过,目标坐标仍可用
|
||||
- **THEN** 系统在一个事务中创建 Suite 及其首个 DRAFT SuiteVersion
|
||||
- **AND** 不向普通发现入口暴露无版本的空 Suite
|
||||
|
||||
#### Scenario: 包仍在扫描或审核
|
||||
- **WHEN** 至少一个必要包仍处于扫描或审核中
|
||||
- **THEN** 操作状态为 `WAITING_FOR_MEMBERS`
|
||||
- **AND** 不创建临时 SuiteVersion
|
||||
|
||||
#### Scenario: 引用成员失效
|
||||
- **WHEN** 创建草稿前精确引用被下架、隐藏、删除或不再符合受众可见性
|
||||
- **THEN** 操作状态为 `REPREVIEW_REQUIRED`,并在同一事务中释放目标占用
|
||||
- **AND** 不创建 SuiteVersion
|
||||
|
||||
#### Scenario: 异步完成前 Suite 权限或状态变化
|
||||
- **WHEN** 成员就绪前 Namespace 变为不可写、操作者失去 Suite 创建/管理权限,或更新目标 Suite 不再 ACTIVE
|
||||
- **THEN** 计划身份未变化时操作状态变为 `BLOCKED_RETRYABLE` 并保留目标占用
|
||||
- **AND** 不创建 Suite 或 SuiteVersion
|
||||
|
||||
### Requirement: REQ-SBP-13 Bundle 恢复 SHALL 幂等且不破坏成员
|
||||
|
||||
系统 SHALL 展示成员级进度,并能处理重复、延迟或丢失的生命周期通知。`BLOCKED_RETRYABLE`
|
||||
SHALL 保留目标占用,只允许同一操作按原版本 ID 重试;`REPREVIEW_REQUIRED` SHALL 作为终态释放
|
||||
占用并要求新预览。取消 SHALL 停止后续编排和 SuiteVersion 创建,但 SHALL NOT 修改已创建
|
||||
SkillVersion、精确引用或已完成审核。状态读取只允许原操作者或当前具备目标 Suite/Namespace 治理
|
||||
权限的角色;重试和取消 SHALL 重新检查当前操作权限,且响应 SHALL 对当前无权读取的成员信息脱敏。
|
||||
|
||||
#### Scenario: 生命周期通知重复到达
|
||||
- **WHEN** 同一成员生命周期通知被重复处理
|
||||
- **THEN** 成员结果和 Suite 草稿数量保持不变
|
||||
|
||||
#### Scenario: 恢复丢失的通知
|
||||
- **WHEN** 所有必要包已经 PUBLISHED,但对应事件丢失
|
||||
- **THEN** 有界恢复任务发现最终状态
|
||||
- **AND** 最多创建一个 Suite 草稿
|
||||
|
||||
#### Scenario: 取消等待中的操作
|
||||
- **WHEN** 有权限操作者在 Suite 草稿创建前取消操作
|
||||
- **THEN** 操作变为 `CANCELLED`,后续不能再创建 SuiteVersion
|
||||
- **AND** 成员 Skill 保持独立状态
|
||||
|
||||
#### Scenario: 成员扫描失败
|
||||
- **WHEN** 已绑定成员版本进入 `SCAN_FAILED`
|
||||
- **THEN** 现有重扫动作保留同一版本 ID 时,操作变为 `BLOCKED_RETRYABLE` 并保留占用
|
||||
- **AND** 否则操作变为 `REPREVIEW_REQUIRED` 并释放占用
|
||||
|
||||
#### Scenario: 成员审核被拒绝
|
||||
- **WHEN** 已绑定成员版本进入 `REJECTED`
|
||||
- **THEN** 操作变为 `REPREVIEW_REQUIRED`,释放占用并要求修改内容后重新上传预览
|
||||
- **AND** 不自动删除被拒绝版本或改绑新版本 ID
|
||||
|
||||
#### Scenario: PRIVATE 成员完成上传
|
||||
- **WHEN** Bundle 确认已明确包含 PRIVATE 成员发布影响,且该成员进入 `UPLOADED`
|
||||
- **THEN** 协调器重新鉴权后使用现有 PRIVATE confirm-publish 转换推进到 PUBLISHED
|
||||
- **AND** 不要求用户对同一计划重复确认
|
||||
|
||||
#### Scenario: 已绑定成员发生外部状态漂移
|
||||
- **WHEN** 待审成员被其他操作撤回、删除、替换、下架或变为当前操作者不可读
|
||||
- **THEN** 操作变为 `REPREVIEW_REQUIRED`,释放占用并要求重新预览
|
||||
- **AND** 不自动提交、恢复或跟随另一个 SkillVersion ID
|
||||
|
||||
#### Scenario: 未授权用户读取或操作进度
|
||||
- **WHEN** 非原操作者且不具备当前治理权限的用户读取、重试或取消操作
|
||||
- **THEN** 系统拒绝请求且不泄露成员坐标、版本、审核状态或错误详情
|
||||
|
||||
#### Scenario: 可重试阻塞恢复
|
||||
- **WHEN** 权限或 Namespace 可写状态恢复,且绑定资源身份和版本计划未变化
|
||||
- **THEN** 有权操作者可以重试同一 `BLOCKED_RETRYABLE` 操作
|
||||
- **AND** 系统保持原目标占用和成员版本 ID
|
||||
|
||||
#### Scenario: 执行操作进入终态
|
||||
- **WHEN** 操作变为 `REPREVIEW_REQUIRED`、`CANCELLED` 或 `SUITE_DRAFT_CREATED`
|
||||
- **THEN** 系统在同一状态事务中释放 Suite 坐标或版本占用
|
||||
- **AND** 后续确认可以按照唯一约束重新竞争该目标
|
||||
|
||||
#### Scenario: 等待审核超过预览有效期
|
||||
- **WHEN** ExecutionOperation 正在等待成员审核且原 PreviewSession 已到期
|
||||
- **THEN** 执行操作和目标占用保持有效
|
||||
- **AND** 不因 PreviewSession TTL 自动过期或释放占用
|
||||
|
||||
### Requirement: REQ-SBP-14 并发导入 SHALL NOT 占用相同 Suite 坐标或目标版本
|
||||
|
||||
系统 SHALL 阻止两个非终态创建操作占用相同 Suite 坐标,并阻止两个非终态更新操作或已有
|
||||
SuiteVersion 使用同一 Suite 和目标版本。最终创建草稿时 SHALL 重新检查坐标、版本和当前状态。
|
||||
|
||||
#### Scenario: 并发创建相同 Suite 坐标
|
||||
- **WHEN** 两个操作者同时确认创建相同 Namespace 和 Suite slug
|
||||
- **THEN** 最多一个操作获得目标坐标占用
|
||||
- **AND** 另一个操作在创建任何 Skill 前失败或必须重新预览
|
||||
|
||||
#### Scenario: 并发确认指向相同版本
|
||||
- **WHEN** 两个操作者同时确认同一 Suite 和目标版本的计划
|
||||
- **THEN** 最多一个操作获得目标版本占用
|
||||
- **AND** 另一个操作必须重新预览或选择新版本
|
||||
|
||||
### Requirement: REQ-SBP-15 Bundle 处理 SHALL 控制资源消耗
|
||||
|
||||
系统 SHALL 流式校验归档,每次预览对每个携带包成员最多解压和计算一次 fingerprint,批量解析成员、
|
||||
引用和权限,并避免读取、扫描或写入纯引用和未变化成员。预览 SHALL 将确认后发布所需的临时对象
|
||||
定位、大小、内容类型和文件摘要绑定到计划;发布 SHALL 从暂存对象流式读取并复用摘要,不把最大
|
||||
Bundle 展开为常驻内存字节数组。状态轮询 SHALL NOT 重新读取归档。
|
||||
|
||||
#### Scenario: 预览最大合法 Suite
|
||||
- **WHEN** Bundle 使用包和精确引用描述允许的最大成员数
|
||||
- **THEN** 预览使用有界归档处理和集合查询
|
||||
- **AND** 返回完整计划且不产生逐成员查询放大
|
||||
|
||||
#### Scenario: 轮询等待中的操作
|
||||
- **WHEN** 客户端反复查询操作状态
|
||||
- **THEN** 系统直接读取持久化进度
|
||||
- **AND** 不解压归档、不计算 hash、不下载引用、不重复扫描包
|
||||
|
||||
### Requirement: REQ-SBP-16 现有 Skill 和 Suite 契约 SHALL 保持兼容
|
||||
|
||||
Bundle 接口 SHALL 是增量接口。单 Skill 发布、精确 Suite 管理和安装、技能市场、套件专区、
|
||||
单 Skill Label/Tag API 和旧客户端 SHALL 保持现有行为。已发布 SuiteVersion SHALL 继续是不可变的
|
||||
精确 PUBLISHED SkillVersion 快照。
|
||||
|
||||
#### Scenario: 使用现有客户端和页面
|
||||
- **WHEN** 用户从不调用 Bundle 接口
|
||||
- **THEN** 现有 Skill 和 Suite 行为保持不变
|
||||
|
||||
#### Scenario: Bundle 操作完成
|
||||
- **WHEN** Bundle 操作创建 SuiteVersion DRAFT
|
||||
- **THEN** 现有 Suite API 可以查看、在允许时编辑、提交、审核和发布该草稿
|
||||
- **AND** 安装客户端不需要理解 Bundle 归档
|
||||
|
||||
### Requirement: REQ-SBP-17 Web 导入 SHALL 复用现有 Suite 创建和新版本入口
|
||||
|
||||
“创建 Suite”和“创建新版本”页面 SHALL 同时提供“从技能市场组合”和“从本地导入”。本地导入
|
||||
SHALL 支持上传一个 ZIP;浏览器支持安全目录选择时,也可以选择一个根目录并按同一归档协议提交。
|
||||
更新模式 SHALL 把未出现在完整 Manifest 中的基准成员作为高风险移除项单独展示。
|
||||
|
||||
#### Scenario: 创建 Suite 时选择本地导入
|
||||
- **WHEN** 用户在创建入口选择本地导入并提交合法文件
|
||||
- **THEN** 页面展示没有基准版本的创建预览
|
||||
- **AND** 不要求用户先逐个进入 Skill 发布页
|
||||
|
||||
#### Scenario: 更新 Suite 时选择本地导入
|
||||
- **WHEN** 用户从已有 Suite 的“创建新版本”入口选择本地导入
|
||||
- **THEN** 页面展示相对于明确基准版本的成员和发布动作差异
|
||||
- **AND** 现有手工组合入口保持可用
|
||||
|
||||
#### Scenario: 预览包含移除成员
|
||||
- **WHEN** 更新 Manifest 未包含至少一个基准成员
|
||||
- **THEN** 页面在确认区单独列出移除项及数量
|
||||
- **AND** 确认文案说明当前已发布 Suite 不受影响
|
||||
|
||||
### Requirement: REQ-SBP-18 Web 确认和进度 SHALL 准确展示副作用
|
||||
|
||||
确认页面 SHALL 展示将创建的 Skill、将创建的 SkillVersion、复用版本、纯引用和移除成员数量,
|
||||
每个携带包成员的最终可见性、发布路径和 warning,并说明成员就绪后只生成 Suite 草稿。warning
|
||||
必须逐成员明确确认。确认后,进度页 SHALL 展示每个成员的创建、扫描、审核、复用、引用、失败或
|
||||
阻塞状态,并能通过操作 ID 在刷新或重新登录后恢复。“我的套件” SHALL 使用统一分页清单,将当前
|
||||
创建或更新进度归入对应套件行,不再设置独立“发布任务”页签。尚未生成 Suite 的操作 SHALL 保留为
|
||||
临时行,生成 Suite 后合并为正式行;取消的新建操作仍可在清单中找到。清单 SHALL 提供紧凑搜索、
|
||||
状态筛选和覆盖搜索结果所有页的“需处理”数量提示,不另外堆待办卡片。点击过程操作 SHALL 进入独立发布详情页,不得
|
||||
返回创建或新版本编辑页面。公开套件专区 SHALL 继续只展示已发布 Suite,不得把执行操作伪装成已经
|
||||
创建的 Suite。清单 SHALL 在服务端合并、筛选、计数和分页,不得逐套件请求详情或加载所有操作后
|
||||
在浏览器分页;搜索 SHALL 仅在点击搜索或按 Enter 后执行,输入期间不得更新查询条件,且取消过时请求。只有仍可能自动变化的运行或等待状态才进行轮询。
|
||||
正式 SuiteVersion 审核 SHALL 在现有“我的审核进度”中查看,名称、资源类型和详情链接必须正确。
|
||||
清单和详情首屏 SHALL 使用文本明确展示整体状态及与该状态对应的下一步,
|
||||
不得仅依赖颜色或图标表达。需要重新预览的更新操作 SHALL 保持更新语义并返回同一 Suite 的新版本
|
||||
入口;原基准版本仍存在时预选该版本,基准版本已删除时不预选,但不得降级为创建新 Suite。
|
||||
|
||||
#### Scenario: 用户确认混合计划
|
||||
- **WHEN** 预览同时包含创建 Skill、创建版本、复用、引用和移除
|
||||
- **THEN** 确认界面分别列出每类数量和关键成员
|
||||
- **AND** 不使用仅包含“是否继续”的笼统确认
|
||||
|
||||
#### Scenario: 成员等待审核
|
||||
- **WHEN** 已确认操作至少有一个成员等待独立审核
|
||||
- **THEN** 进度页展示 `WAITING_FOR_MEMBERS` 和成员审核入口
|
||||
- **AND** 不把操作展示为 Suite 已更新
|
||||
|
||||
#### Scenario: 从我的套件继续查看发布
|
||||
- **WHEN** 当前用户发起的 Bundle 操作仍为 `RUNNING`、`WAITING_FOR_MEMBERS` 或 `BLOCKED_RETRYABLE`
|
||||
- **THEN** “我的套件”统一清单展示对应套件或临时创建行的目标版本、当前状态及下一步操作
|
||||
- **AND** 用户进入独立任务详情页继续查看或处理,不进入 Suite 创建或新版本编辑页
|
||||
- **AND** 操作数量超过单页容量时仍可通过分页访问
|
||||
- **AND** 公开套件专区不展示该未完成操作
|
||||
|
||||
#### Scenario: 取消后保留发布记录和成员审核入口
|
||||
- **WHEN** 用户停止一个已经创建部分 SkillVersion 或审核任务的 Bundle 操作
|
||||
- **THEN** 尚未形成 Suite 的操作以 `CANCELLED` 保留为临时记录,已有 Suite 的历史操作保留在套件内记录中;详情明确说明不会再创建 Suite 草稿
|
||||
- **AND** 已创建的 SkillVersion 和审核任务不撤回、不删除,仍可从成员行进入对应版本处理
|
||||
- **AND** 长时间等待状态使用静态状态图标而不是持续旋转的加载图标
|
||||
|
||||
#### Scenario: 操作创建 Suite 草稿
|
||||
- **WHEN** 操作达到 `SUITE_DRAFT_CREATED`
|
||||
- **THEN** 页面提供新 Suite 草稿入口
|
||||
- **AND** 下一步说明根据可见性区分提交审核或直接发布
|
||||
|
||||
#### Scenario: 更新操作需要重新预览且原基准版本已删除
|
||||
- **WHEN** 更新操作达到 `REPREVIEW_REQUIRED` 且原基准 SuiteVersion 已删除
|
||||
- **THEN** 页面返回同一 Suite 的创建新版本入口且不预选已删除版本
|
||||
- **AND** 不跳转到创建新 Suite 入口
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
## Purpose
|
||||
|
||||
保持技能市场和套件专区两个独立入口,同时让任意成员 Skill 都能安全展示所属 Suite,包括不是
|
||||
Suite 入口的普通成员。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: REQ-SMD-01 技能市场与套件专区 SHALL 保持独立
|
||||
|
||||
技能市场 SHALL 继续通过 `/search` 发现 Skill,并保留 Skill 搜索、Label、收藏和排序行为。
|
||||
套件专区 SHALL 继续通过 `/suites` 发现 Suite,并使用类型化 Suite 搜索和 Suite 卡片。本变更
|
||||
SHALL NOT 在技能市场增加 Skill/Suite 类型切换。
|
||||
|
||||
#### Scenario: 搜索技能市场
|
||||
- **WHEN** 用户在 `/search` 搜索
|
||||
- **THEN** 搜索结果仍然是 Skill
|
||||
- **AND** 现有 Skill 筛选语义保持不变
|
||||
|
||||
#### Scenario: 搜索套件专区
|
||||
- **WHEN** 用户在 `/suites` 搜索
|
||||
- **THEN** 搜索结果仍然是当前用户可见的 Suite
|
||||
- **AND** 结果链接到 Suite 详情
|
||||
|
||||
### Requirement: REQ-SMD-02 任意成员 Skill SHALL 展示当前用户可见的所属 Suite
|
||||
|
||||
Skill 详情 SHALL 只依据每个 ACTIVE、非隐藏 Suite 当前 `latestVersionId` 指向的 PUBLISHED
|
||||
SuiteVersion 判断是否包含当前 Skill,不再只查询当前 Skill 是 Entry Skill 的情况。每条引用
|
||||
SHALL 标明当前 Skill 是否为 Entry Skill;已从当前 latestVersionId 快照移除的 Skill 不得继续展示
|
||||
该 Suite,即使历史版本仍包含它。
|
||||
|
||||
#### Scenario: 当前 Skill 是普通成员
|
||||
- **WHEN** 当前用户可见的最新 SuiteVersion 将该 Skill 作为非 Entry 成员
|
||||
- **THEN** Skill 详情展示该 Suite 和精确 Suite 版本
|
||||
- **AND** 标明当前 Skill 是普通成员
|
||||
|
||||
#### Scenario: 当前 Skill 是 Entry 成员
|
||||
- **WHEN** 当前 Skill 是当前用户可见 SuiteVersion 的 Entry Skill
|
||||
- **THEN** 所属 Suite 信息保留明确的 Entry 标记
|
||||
|
||||
#### Scenario: 引用该 Skill 的 Suite 不可见
|
||||
- **WHEN** Suite 属于其他人的 PRIVATE 内容,或者已隐藏、归档、未发布、当前用户无权访问
|
||||
- **THEN** Skill 详情既不暴露 Suite 坐标,也不暴露成员关系
|
||||
|
||||
#### Scenario: Skill 只存在于 Suite 历史版本
|
||||
- **WHEN** 当前 Skill 存在于历史 PUBLISHED SuiteVersion,但不在 Suite 当前 latestVersionId 快照中
|
||||
- **THEN** Skill 详情不再把该 Suite 显示为当前所属 Suite
|
||||
|
||||
### Requirement: REQ-SMD-03 所属 Suite 信息 SHALL 保护兄弟成员元数据
|
||||
|
||||
所属 Suite 信息 SHALL 保护兄弟成员元数据。它可以展示紧凑的兄弟成员列表,但只允许返回当前用户有权读取的 Skill。不可访问或已删除
|
||||
的兄弟成员只能按数量表示,不得暴露坐标、名称、摘要、版本、fingerprint 或下载信息。
|
||||
|
||||
#### Scenario: 所有兄弟成员均可见
|
||||
- **WHEN** 当前用户可以读取所属 Suite 的全部成员
|
||||
- **THEN** 所属 Suite 信息可以按顺序展示紧凑成员列表和 Entry 标记
|
||||
|
||||
#### Scenario: 存在不可访问的兄弟成员
|
||||
- **WHEN** 当前用户不能读取至少一个兄弟成员
|
||||
- **THEN** 返回结果省略该成员的身份信息
|
||||
- **AND** 只能选择性展示受限或不可用成员数量
|
||||
|
||||
### Requirement: REQ-SMD-04 Suite 成员反向发现 SHALL 控制查询规模
|
||||
|
||||
所属 Suite 引用 SHALL 分页或设置明确上限,并通过集合查询完成,不得为每个 Suite 或兄弟成员
|
||||
分别发起查询。
|
||||
|
||||
#### Scenario: 一个 Skill 属于大量 Suite
|
||||
- **WHEN** 引用该 Skill 的 Suite 数量超过详情响应上限
|
||||
- **THEN** 响应使用确定性上限或分页契约
|
||||
- **AND** 提供继续查看其他结果所需的信息
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
## Purpose
|
||||
|
||||
确保每个新发布 Suite 都有可理解、可发现的摘要、概述和自有 Label,同时不修改被引用成员 Skill
|
||||
的元数据或版本别名。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: REQ-SMT-01 新 Suite 发布 SHALL 要求摘要和概述
|
||||
|
||||
SuiteVersion DRAFT 可以在展示信息未完成时保存,但提交审核、PRIVATE 直接发布和审核批准时,
|
||||
summary 与 Markdown overview SHALL 均为非空。服务端 SHALL 独立于 Web 校验执行该规则。
|
||||
|
||||
#### Scenario: 保存展示信息不完整的草稿
|
||||
- **WHEN** 有权限的作者尚未完成 summary 或 overview
|
||||
- **THEN** DRAFT 可以保存并继续编辑
|
||||
- **AND** 作者可以看到其未完成状态
|
||||
|
||||
#### Scenario: 提交缺少摘要的 Suite
|
||||
- **WHEN** 作者提交或直接发布 summary 为空的 SuiteVersion
|
||||
- **THEN** 系统以可操作的摘要必填提示拒绝该动作
|
||||
- **AND** SuiteVersion 保持 DRAFT
|
||||
|
||||
#### Scenario: 提交缺少概述的 Suite
|
||||
- **WHEN** 作者提交或直接发布 overview 为空的 SuiteVersion
|
||||
- **THEN** 系统以可操作的概述必填提示拒绝该动作
|
||||
- **AND** SuiteVersion 保持 DRAFT
|
||||
|
||||
#### Scenario: 审核批准时展示信息已不合规
|
||||
- **WHEN** PENDING_REVIEW SuiteVersion 在批准时不再满足展示信息要求
|
||||
- **THEN** 系统拒绝批准且不发布该 SuiteVersion
|
||||
|
||||
### Requirement: REQ-SMT-02 摘要和概述 SHALL 承担不同展示职责
|
||||
|
||||
summary SHALL 为套件专区、卡片和所属 Suite 引用提供简明说明。overview SHALL 说明 Suite 用途、
|
||||
成员职责或使用顺序、预期输入输出和重要使用边界。发布后的展示 SHALL 使用作者提供的内容,不得
|
||||
静默使用泛化生成文案冒充真实内容。
|
||||
|
||||
#### Scenario: 展示信息完整的已发布 Suite
|
||||
- **WHEN** 用户在套件专区或详情查看新发布 Suite
|
||||
- **THEN** 卡片和引用展示 summary
|
||||
- **AND** 详情将 Markdown overview 与成员列表分开呈现
|
||||
|
||||
#### Scenario: 通用兜底文案不算作者内容
|
||||
- **WHEN** SuiteVersion 没有作者提供的 summary 或 overview
|
||||
- **THEN** “精选技能组合”等通用文案不能通过发布校验
|
||||
|
||||
#### Scenario: 编辑 Suite 概述
|
||||
- **WHEN** 作者创建或编辑 SuiteVersion
|
||||
- **THEN** 编辑器提供适用场景、使用准备、成员分工或顺序、输入输出和注意事项的结构化写作提示
|
||||
- **AND** 系统不以机械字数门槛替代内容完整性,也不自动复制成员文档充当概述
|
||||
|
||||
### Requirement: REQ-SMT-03 历史空展示信息 SHALL 保持兼容
|
||||
|
||||
本要求上线前已经发布的 SuiteVersion 即使 summary 或 overview 为空,仍 SHALL 保持可读取和可安装。
|
||||
该 Suite 后续提交的新版本 SHALL 满足新的展示信息要求。
|
||||
|
||||
#### Scenario: 读取历史空概述 Suite
|
||||
- **WHEN** 已有 PUBLISHED SuiteVersion 的展示信息为空
|
||||
- **THEN** 用户仍可按照现有可见性和可用性规则查看与安装
|
||||
- **AND** UI 如实提示缺少内容,不得编造说明
|
||||
|
||||
#### Scenario: 创建历史 Suite 的下一个版本
|
||||
- **WHEN** 作者基于展示信息为空的历史快照提交新 SuiteVersion
|
||||
- **THEN** 发布前必须补齐非空 summary 和 overview
|
||||
|
||||
### Requirement: REQ-SMT-04 Bundle 导入 SHALL 解析完整展示信息
|
||||
|
||||
Bundle 预览 SHALL 展示目标 SuiteVersion 最终使用的 summary 和 overview。Manifest 可以提供新值,
|
||||
也可以继承当前 SuiteVersion 的非空值;最终任一字段为空时 SHALL NOT 允许确认。
|
||||
|
||||
#### Scenario: 继承当前完整展示信息
|
||||
- **WHEN** Bundle 未填写展示信息,且当前 SuiteVersion 的 summary 和 overview 均非空
|
||||
- **THEN** 预览将继承值作为目标展示信息展示
|
||||
|
||||
#### Scenario: 当前 Suite 的概述为空
|
||||
- **WHEN** Bundle 未提供 overview,且当前 SuiteVersion overview 也为空
|
||||
- **THEN** 预览报告阻塞性展示信息错误
|
||||
- **AND** 用户必须更新 Manifest 或 Suite 草稿后才能确认
|
||||
|
||||
#### Scenario: 通过 Bundle 创建 Suite
|
||||
- **WHEN** 创建模式的 Manifest 没有提供非空 summary 或 overview
|
||||
- **THEN** 预览报告阻塞性展示信息错误
|
||||
- **AND** 不从 Entry Skill 的 `SKILL.md` 自动生成或复制 Suite 概述
|
||||
|
||||
### Requirement: REQ-SMT-05 Suite SHALL 支持自己的 Label
|
||||
|
||||
系统 SHALL 允许有权限的用户把已有 Registry LabelDefinition 关联到 Suite 容器或解除关联。
|
||||
Suite Label SHALL 使用独立于成员 Skill Label 的关联,并 SHALL NOT 创建或修改 SuiteVersion。
|
||||
|
||||
#### Scenario: 为 Suite 添加普通 Label
|
||||
- **WHEN** 有权限的 Suite 管理者添加一个允许使用的普通 Label
|
||||
- **THEN** 该 Label 出现在 Suite 及其发现投影中
|
||||
- **AND** 所有成员 Skill Label 保持不变
|
||||
|
||||
#### Scenario: 删除 Suite Label
|
||||
- **WHEN** 有权限的 Suite 管理者解除一个 Suite Label
|
||||
- **THEN** 只删除 Suite-to-Label 关联
|
||||
- **AND** SuiteVersion 和成员元数据保持不变
|
||||
|
||||
#### Scenario: Suite 引用了其他人的 Skill
|
||||
- **WHEN** 带 Label 的 Suite 包含非本人所有的引用 Skill
|
||||
- **THEN** 除非该 Label 在 Skill 上被独立配置,否则不会出现在这些 Skill 上
|
||||
|
||||
### Requirement: REQ-SMT-06 Suite Label 权限 SHALL 复用现有 Label 权限类型
|
||||
|
||||
Suite Label 变更 SHALL 同时检查 Suite 管理权限和现有 LabelDefinition 权限类型。PRIVILEGED Label
|
||||
继续仅允许现有 Label 策略授权的平台角色操作。读取响应 SHALL 按现有可见规则过滤 LabelDefinition。
|
||||
|
||||
#### Scenario: 普通管理者尝试配置特权 Label
|
||||
- **WHEN** Suite 管理者没有使用 PRIVILEGED Label 的平台权限
|
||||
- **THEN** 系统拒绝变更且不修改 Suite 或成员
|
||||
|
||||
#### Scenario: 平台管理员配置特权 Label
|
||||
- **WHEN** 获得授权的平台角色为可管理 Suite 添加 PRIVILEGED Label
|
||||
- **THEN** 系统建立关联并记录实际操作者
|
||||
|
||||
### Requirement: REQ-SMT-07 套件专区 SHALL 展示并筛选 Suite Label
|
||||
|
||||
套件专区卡片和详情 SHALL 展示 Suite 直接关联的可见 Label。套件专区 SHALL 支持按 Suite Label
|
||||
筛选,不得修改技能市场筛选,也不得把成员 Skill Label 当成 Suite Label。
|
||||
|
||||
#### Scenario: 按 Label 筛选套件专区
|
||||
- **WHEN** 用户在套件专区选择一个可见 Label
|
||||
- **THEN** 结果只匹配直接关联该 Label 的可见 Suite
|
||||
- **AND** 不会因为某个成员 Skill 有该 Label 就匹配 Suite
|
||||
|
||||
#### Scenario: Skill 与 Suite 使用同一个 LabelDefinition
|
||||
- **WHEN** 同一个 LabelDefinition 分别关联 Skill 和 Suite
|
||||
- **THEN** 技能市场筛选 Skill 关联
|
||||
- **AND** 套件专区筛选 Suite 关联
|
||||
|
||||
### Requirement: REQ-SMT-08 Suite SHALL NOT 支持 SkillVersion Tag
|
||||
|
||||
本变更 SHALL NOT 创建 Suite Tag,也不向成员批量设置 Tag。现有 Skill Tag 继续指向精确 PUBLISHED
|
||||
SkillVersion,并保留系统现有 `latest` 行为。
|
||||
|
||||
#### Scenario: 管理 Suite 元数据
|
||||
- **WHEN** 作者修改 Suite Label、summary 或 overview
|
||||
- **THEN** 不创建、移动或删除任何 Skill Tag
|
||||
|
||||
### Requirement: REQ-SMT-09 Suite Label 读取 SHALL 批量完成并可审计
|
||||
|
||||
Suite 列表和搜索 SHALL 以集合方式加载 Label 投影,不得逐 Suite 查询。每次 Suite Label 变更
|
||||
SHALL 记录操作者、Suite ID、Label、动作和请求关联信息,不得记录成员包内容。
|
||||
|
||||
#### Scenario: 展示一页带 Label 的 Suite
|
||||
- **WHEN** 套件专区展示一页结果
|
||||
- **THEN** 可见 LabelDefinition 和关联通过有界集合查询完成
|
||||
|
||||
#### Scenario: 审计 Suite Label 变更
|
||||
- **WHEN** Suite 添加或删除 Label
|
||||
- **THEN** 审计记录指向 Suite,而不是任何成员 Skill
|
||||
|
||||
### Requirement: REQ-SMT-10 Entry Skill 说明 SHALL 与 Suite 概述分开展示
|
||||
|
||||
Suite 详情 MAY 在独立折叠区域按需展示 Entry Skill 固定 SkillVersion 的 `SKILL.md`,但 SHALL NOT
|
||||
将其作为 Suite overview 的替代或兜底。内容 SHALL 标明精确坐标和版本,并复用现有版本读取权限、
|
||||
内容安全策略和 Markdown 渲染。
|
||||
|
||||
#### Scenario: 用户展开可读的 Entry Skill 说明
|
||||
- **WHEN** 当前用户有权读取 Entry Skill 固定版本并主动展开说明
|
||||
- **THEN** 页面延迟加载并展示该精确 SkillVersion 的 `SKILL.md`
|
||||
- **AND** 明确标注内容来源坐标和版本
|
||||
|
||||
#### Scenario: Entry Skill 存在更新版本
|
||||
- **WHEN** Suite 固定的 Entry SkillVersion 不是该 Skill 的最新版本
|
||||
- **THEN** 页面仍读取 Suite 固定版本的说明
|
||||
- **AND** 不使用 `latest` 内容替换固定快照
|
||||
|
||||
#### Scenario: Entry Skill 不可读或内容加载失败
|
||||
- **WHEN** 当前用户无权读取、固定版本失效或说明加载失败
|
||||
- **THEN** 页面不展示 `SKILL.md` 内容
|
||||
- **AND** Suite 作者编写的 overview 和经过权限过滤的成员状态仍可正常展示
|
||||
49
openspec/changes/add-suite-bundle-publishing/tasks.md
Normal file
49
openspec/changes/add-suite-bundle-publishing/tasks.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
## 1. 协议与操作基础
|
||||
|
||||
- [x] 1.1 `[REQ-SBP-02, REQ-SBP-06, REQ-SBP-07]` 确定同时支持创建/更新模式、“携带包成员”和“精确引用成员”的 Manifest,包括唯一根 Manifest、成员相对目录、新 Skill 必填可见性、已有 Skill 可见性继承、重复字段优先级和冲突规则;通过 fixture 验证新 Skill、自有已有 Skill、非本人所有的公开引用、重新固定版本、移除、歧义定义、危险路径和非法可见性。
|
||||
- [x] 1.2 `[REQ-SBP-01, REQ-SBP-08, REQ-SBP-13, REQ-SBP-14]` 新增有期限且不占位的 PreviewSession,以及确认后才创建的 ExecutionOperation、目标 Suite 坐标/版本占用、成员结果、幂等字段和索引;通过 PostgreSQL 集成测试验证并发创建/更新唯一性、重启恢复和回滚不会修改现有生命周期数据。
|
||||
- [x] 1.3 `[REQ-SBP-08, REQ-SBP-13, REQ-SBP-17, REQ-SBP-18]` 新增预览、确认、状态、重试和取消的 API 契约,明确状态读取脱敏和操作重新授权;运行 `make generate-api` 和 `scripts/check-openapi-generated.sh` 验证生成类型无漂移。
|
||||
|
||||
## 2. Bundle 预览
|
||||
|
||||
- [x] 2.1 `[REQ-SBP-05, REQ-SBP-06, REQ-SBP-07]` 实现流式两层解析:先验证唯一 Manifest、目录不重叠、文件唯一归属、逻辑 Skill 唯一和未声明内容,再把每个目录去前缀后交给现有 SkillPackageValidator、元数据与合规校验;通过 fixture 覆盖缺失/嵌套 `SKILL.md`、重复规范化路径/坐标、目录嵌套、未声明目录、元数据冲突和单成员限制。
|
||||
- [x] 2.2 `[REQ-SBP-04, REQ-SBP-15]` 复用现有 fingerprint,并以临时对象定位和摘要连接预览与发布,保证每个文件最多解压和计算一次;通过测试证明 ZIP 元数据、合法外层目录以及 ZIP/目录选择不影响成员边界和比较结果,最大合法 Bundle 不常驻内存。
|
||||
- [x] 2.3 `[REQ-SBP-01, REQ-SBP-02, REQ-SBP-08, REQ-SBP-10, REQ-SBP-11, REQ-SBP-14]` 批量解析基准成员、精确引用、Suite 创建/管理权限、新 Skill 创建权限、已有 Skill 发布权限、最终可见性/受众兼容、warning、已有待审/未发布版本和目标坐标/版本冲突;通过测试验证 100 个成员时查询次数仍受控,且不会自动确认 warning、撤回或替换现有版本。
|
||||
- [x] 2.4 `[REQ-SBP-03, REQ-SBP-09]` 分别生成成员关系变化和发布动作,且不产生生命周期副作用;通过测试确认预览不占用目标坐标,也不会创建 Skill、SkillVersion、Suite、SuiteVersion、扫描、审核、Label 或 Tag 变更。
|
||||
- [x] 2.5 `[REQ-SBP-08]` 将带有效期的预览与操作者、创建/更新模式、目标坐标、归档摘要、精确引用、已解析包版本和计划绑定;通过测试确认过期或相关状态变化后必须重新预览,多个预览可以并存且只有确认事务获取占用。
|
||||
- [x] 2.6 `[REQ-SBP-05, REQ-SBP-06, REQ-SBP-07, REQ-SBP-15, REQ-SBP-17]` 实现 ZIP 上传和受支持浏览器的根目录选择,保证两者生成同一协议并只上传一次;通过大包、取消、重试和兼容性测试验证内存与网络消耗受控。
|
||||
- [x] 2.7 `[REQ-SBP-03, REQ-SBP-17, REQ-SBP-18]` 实现 Web 差异预览,按成员目录展示格式错误、warning、最终可见性和审核/PRIVATE 发布路径,分别展示新增/更新/不变/移除和创建 Skill/创建版本/复用/引用,突出高风险移除、已有待审版本、加载、过期和无变化状态;通过 Vitest 和浏览器用例验证。
|
||||
|
||||
## 3. Bundle 发布编排
|
||||
|
||||
- [x] 3.1 `[REQ-SBP-08, REQ-SBP-14]` 实现幂等确认事务,并在任何成员写入前原子获取目标 Suite 坐标/版本占用;通过并发创建、并发更新和响应丢失测试确认最多创建一个操作且不重复创建包版本。
|
||||
- [x] 3.2 `[REQ-SBP-10, REQ-SBP-11]` 将有 Namespace 创建权限的新 Skill 和有独立发布权限的变化包送入非破坏性的现有 Skill 规则/生命周期边界,并在每次写入前重新授权;验证 Suite 权限不能扩大 Skill 权限,不会撤回或替换已有版本,纯引用和未变化成员不会产生发布副作用。
|
||||
- [x] 3.3 `[REQ-SBP-12, REQ-SBP-13]` 记录包和引用结果,并按规范状态矩阵通过领域事件与有界恢复任务完成协调;覆盖 PRIVATE `UPLOADED`、`SCANNING`、`SCAN_FAILED`、`PENDING_REVIEW`、`REJECTED`、外部撤回/删除/下架、`BLOCKED_RETRYABLE` 与 `REPREVIEW_REQUIRED`,验证占用随终态原子释放,重复、延迟、丢失和乱序时按原 ID 收敛或明确要求重新预览。
|
||||
- [x] 3.4 `[REQ-SBP-12]` 仅在包发布成功、引用最终有效且最终权限/状态检查通过时,原子创建新 Suite 及首个 DRAFT,或为已有 Suite 创建一个 SuiteVersion DRAFT;验证权限撤销、Namespace 冻结、待审核、被拒绝、失败包和失效引用不会产生空 Suite 或部分草稿。
|
||||
- [x] 3.5 `[REQ-SBP-13, REQ-SBP-15]` 实现带当前授权和响应脱敏的状态读取、原 ID 重试、非破坏性取消、终态占用释放、PreviewSession/暂存对象过期和限定范围补偿清理;验证等待审核的 ExecutionOperation 不随预览过期,已有 SkillVersion 和引用保持不变,清理失败仍有可操作记录。
|
||||
- [x] 3.6 `[REQ-SBP-17, REQ-SBP-18]` 在“创建 Suite”和“创建新版本”入口提供手工组合/本地导入选择,并实现准确列出副作用、最终可见性、发布路径和逐成员 warning 确认的确认页;确认后统一进入独立发布任务详情页,展示包/引用状态、Skill 审核链接、阻塞原因、重试/停止和生成的 Suite 草稿。“我的套件”以“技能套件/发布任务”页签分离创作结果和过程记录,发布任务通过单次分页数据库聚合按“需要处理/进行中/最近完成”分层展示,保留取消记录,只对运行或等待状态短间隔轮询;任务列表和详情首屏明确展示文本状态及下一步,需要重新预览的更新操作始终返回同一 Suite 的新版本入口。通过正常、等待、可重试阻塞、必须重预览、基准版本已删除、取消后保留记录和成员审核、权限变化、刷新、重新登录、超过单页容量和离开导入页后恢复用例验证。
|
||||
|
||||
## 4. 任意成员的所属 Suite
|
||||
|
||||
- [x] 4.1 `[REQ-SMD-01]` 保持技能市场和套件专区的路由、API、筛选和卡片相互独立;验证现有 `/search` 和 `/suites` 测试不变。
|
||||
- [x] 4.2 `[REQ-SMD-02, REQ-SMD-03, REQ-SMD-04]` 将 Entry-only 查询改为基于 Suite 当前 `latestVersionId` 的集合式、隐私过滤任意成员查询,返回 Entry 标记和有界兄弟成员摘要;验证历史已移除成员、PUBLIC、NAMESPACE_ONLY、PRIVATE、隐藏、归档、删除和无权访问场景,且无信息泄露或 N+1 查询。
|
||||
- [x] 4.3 `[REQ-SMD-02, REQ-SMD-03]` 更新 Skill 详情中的所属 Suite 和可见兄弟成员展示;通过浏览器验证加载、空态、历史移除、降级、受限、Entry/非 Entry、多 Suite 和响应式状态。
|
||||
|
||||
## 5. Suite 展示信息与 Label
|
||||
|
||||
- [x] 5.1 `[REQ-SMT-01, REQ-SMT-02, REQ-SMT-03]` 在 Suite 提交、直接发布和批准边界要求非空摘要和概述,同时允许不完整 DRAFT 和历史发布数据读取;通过生命周期测试覆盖所有边界和兼容场景。
|
||||
- [x] 5.2 `[REQ-SMT-04]` Bundle 从 Manifest 显式值或当前非空值解析展示内容;验证创建/更新模式展示信息不完整时,在发布包副作用发生前阻止确认。
|
||||
- [x] 5.3 `[REQ-SMT-05, REQ-SMT-06, REQ-SMT-08, REQ-SMT-09]` 新增 Suite-to-Label 关联并复用 Label 定义、本地化和权限类型;通过持久化与权限测试覆盖普通、特权、重复、数量上限和删除行为,确保不修改成员或 Skill Tag。
|
||||
- [x] 5.4 `[REQ-SMT-05, REQ-SMT-06, REQ-SMT-07, REQ-SMT-09]` 新增 Suite Label 查询、变更和筛选 API 并重新生成 OpenAPI 类型;验证批量投影、Suite 专属审计和搜索筛选不会使用成员 Skill 关联。
|
||||
- [x] 5.5 `[REQ-SMT-01, REQ-SMT-02, REQ-SMT-03, REQ-SMT-05, REQ-SMT-06, REQ-SMT-07]` 在 Suite 编辑、详情、卡片和套件专区增加必填展示信息与 Suite Label;通过测试验证草稿校验、历史缺失信息、国际化标签、筛选、权限和响应式状态。
|
||||
- [x] 5.6 `[REQ-SMT-02, REQ-SMT-03, REQ-SMT-10]` 为 Suite 概述编辑器增加结构化提示,并在详情页增加 Entry Skill 固定版本说明的独立按需展开区域;通过测试验证精确版本、延迟加载、读取权限、安全 Markdown、无权访问和加载失败不会替代或泄露概述内容。
|
||||
|
||||
## 6. 兼容性、安全、性能与交付验证
|
||||
|
||||
- [ ] 6.6 `[REQ-SBP-18]` 2026-09-16 方案 2:统一分页套件清单、紧凑搜索/筛选与轻量需处理提示;验证临时记录合并、取消保留、审核跳转、跨页计数、确认后搜索/取消、固定两条 SQL 和仅变化状态轮询。执行结果见本变更的 `workspace-validation.md`。
|
||||
|
||||
- [x] 6.1 `[REQ-SBP-16, REQ-SMD-01, REQ-SMT-03, REQ-SMT-08]` 增加单 Skill 发布、Suite 草稿/审核/安装、技能与套件独立发现、单 Skill Label/Tag API 和旧客户端回归覆盖,验证现有契约保持兼容。
|
||||
- [x] 6.2 `[REQ-SBP-03, REQ-SBP-04, REQ-SBP-05, REQ-SBP-06, REQ-SBP-07, REQ-SBP-08, REQ-SBP-09, REQ-SBP-10, REQ-SBP-11, REQ-SBP-12, REQ-SBP-13, REQ-SBP-14, REQ-SBP-15, REQ-SMD-02, REQ-SMD-03, REQ-SMD-04, REQ-SMT-05, REQ-SMT-06, REQ-SMT-07, REQ-SMT-08, REQ-SMT-09, REQ-SMT-10]` 增加目录/ZIP 等价、归档限制、ZIP traversal/bomb、fingerprint 等价、100 成员批量解析、状态轮询、新 Skill 坐标冲突、重复逻辑 Skill、跨所有者引用、多阶段权限撤销、成员状态矩阵、Label 权限、Suite Label 批量读取和私有信息保护测试,并以确定性断言验证边界。
|
||||
- [x] 6.3 `[REQ-SBP-01, REQ-SBP-02, REQ-SBP-03, REQ-SBP-04, REQ-SBP-05, REQ-SBP-06, REQ-SBP-07, REQ-SBP-08, REQ-SBP-09, REQ-SBP-10, REQ-SBP-11, REQ-SBP-12, REQ-SBP-13, REQ-SBP-14, REQ-SBP-15, REQ-SBP-16, REQ-SBP-17, REQ-SBP-18, REQ-SMD-01, REQ-SMD-02, REQ-SMD-03, REQ-SMD-04, REQ-SMT-01, REQ-SMT-02, REQ-SMT-03, REQ-SMT-04, REQ-SMT-05, REQ-SMT-06, REQ-SMT-07, REQ-SMT-08, REQ-SMT-09, REQ-SMT-10]` 运行后端、前端、CLI、OpenAPI 漂移检查和 `openspec validate add-suite-bundle-publishing --strict`,按精确 feature SHA 记录结果。
|
||||
- [x] 6.4 `[REQ-SBP-01, REQ-SBP-03, REQ-SBP-08, REQ-SBP-10, REQ-SBP-11, REQ-SBP-12, REQ-SBP-13, REQ-SBP-14, REQ-SBP-15, REQ-SBP-17, REQ-SBP-18, REQ-SMD-02, REQ-SMD-03, REQ-SMD-04, REQ-SMT-01, REQ-SMT-04, REQ-SMT-05, REQ-SMT-06, REQ-SMT-07, REQ-SMT-09, REQ-SMT-10]` 构建精确 SHA 的 Server/Web 镜像并运行带认证的 release Compose 场景,覆盖创建 Suite、更新 Suite、新建 Skill、已有 Skill 发布/审核、非本人所有引用、协调恢复、Suite 草稿/审核、展示信息校验、Entry Skill 说明权限、Suite Label 和反向引用,同时使用本地 S3 与 Scanner 路径。
|
||||
- [x] 6.5 `[REQ-SBP-01..18, REQ-SMD-01..04, REQ-SMT-01..10]` 完成独立实现评审、测试设计评审、CI/隐私/就绪检查、人工 Web 复测说明和中文合并报告,再申请合并授权;最终追踪矩阵必须展开每个完整 REQ ID,不得只保留范围缩写。
|
||||
1
openspec/config.yaml
Normal file
1
openspec/config.yaml
Normal file
|
|
@ -0,0 +1 @@
|
|||
schema: spec-driven
|
||||
|
|
@ -23,6 +23,7 @@ POSTGRES_IMAGE_VALUE="${POSTGRES_IMAGE:-}"
|
|||
REDIS_IMAGE_VALUE="${REDIS_IMAGE:-}"
|
||||
DISABLE_SCANNER=false
|
||||
USE_ALIYUN=false
|
||||
EXPLICIT_REF=false
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
|
|
@ -53,6 +54,7 @@ while [ "$#" -gt 0 ]; do
|
|||
--ref)
|
||||
[ "$#" -ge 2 ] || { echo "Missing value for --ref" >&2; exit 1; }
|
||||
SKILLHUB_REF="$2"
|
||||
EXPLICIT_REF=true
|
||||
shift 2
|
||||
;;
|
||||
--server-image)
|
||||
|
|
@ -119,12 +121,10 @@ done
|
|||
if [ "$USE_ALIYUN" = "true" ]; then
|
||||
SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://imageless.oss-cn-beijing.aliyuncs.com}"
|
||||
RUNTIME_SCRIPT_URL="$SKILLHUB_RAW_BASE/runtime.sh"
|
||||
RUNTIME_SOURCE_ARG=" --aliyun"
|
||||
echo "Using Aliyun OSS for runtime files: $SKILLHUB_RAW_BASE"
|
||||
else
|
||||
SKILLHUB_RAW_BASE="${SKILLHUB_RAW_BASE:-https://raw.githubusercontent.com/iflytek/skillhub/$SKILLHUB_REF}"
|
||||
RUNTIME_SCRIPT_URL="$SKILLHUB_RAW_BASE/scripts/runtime.sh"
|
||||
RUNTIME_SOURCE_ARG=""
|
||||
echo "Using GitHub raw for runtime files: $SKILLHUB_RAW_BASE"
|
||||
fi
|
||||
COMPOSE_FILE="$SKILLHUB_HOME/compose.release.yml"
|
||||
|
|
@ -376,6 +376,85 @@ run_compose() {
|
|||
$compose_cmd --env-file "$ENV_FILE" -f "$COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
shell_quote() {
|
||||
case "$1" in
|
||||
*[!A-Za-z0-9_./:=@%+-]*)
|
||||
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
|
||||
;;
|
||||
*)
|
||||
printf '%s' "$1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
append_runtime_flag() {
|
||||
RUNTIME_LIFECYCLE_ARGS="$RUNTIME_LIFECYCLE_ARGS $1"
|
||||
}
|
||||
|
||||
append_runtime_option() {
|
||||
RUNTIME_LIFECYCLE_ARGS="$RUNTIME_LIFECYCLE_ARGS $1 $(shell_quote "$2")"
|
||||
}
|
||||
|
||||
build_runtime_lifecycle_args() {
|
||||
RUNTIME_LIFECYCLE_ARGS=""
|
||||
|
||||
if [ "$USE_ALIYUN" = "true" ]; then
|
||||
append_runtime_flag "--aliyun"
|
||||
elif [ "$EXPLICIT_REF" = "true" ] || [ "$SKILLHUB_REF" != "main" ]; then
|
||||
append_runtime_option "--ref" "$SKILLHUB_REF"
|
||||
fi
|
||||
|
||||
if [ -n "$SKILLHUB_VERSION_VALUE" ]; then
|
||||
append_runtime_option "--version" "$SKILLHUB_VERSION_VALUE"
|
||||
fi
|
||||
|
||||
if [ "$SKILLHUB_HOME" != "$SKILLHUB_HOME_DEFAULT" ]; then
|
||||
append_runtime_option "--home" "$SKILLHUB_HOME"
|
||||
fi
|
||||
|
||||
if [ -n "$SKILLHUB_PUBLIC_BASE_URL_VALUE" ]; then
|
||||
append_runtime_option "--public-url" "$SKILLHUB_PUBLIC_BASE_URL_VALUE"
|
||||
fi
|
||||
|
||||
if [ -n "$SKILLHUB_MIRROR_REGISTRY_VALUE" ] && [ "$USE_ALIYUN" != "true" ]; then
|
||||
append_runtime_option "--mirror-registry" "$SKILLHUB_MIRROR_REGISTRY_VALUE"
|
||||
fi
|
||||
|
||||
if [ -n "$SKILLHUB_SERVER_IMAGE_VALUE" ]; then
|
||||
append_runtime_option "--server-image" "$SKILLHUB_SERVER_IMAGE_VALUE"
|
||||
fi
|
||||
|
||||
if [ -n "$SKILLHUB_WEB_IMAGE_VALUE" ]; then
|
||||
append_runtime_option "--web-image" "$SKILLHUB_WEB_IMAGE_VALUE"
|
||||
fi
|
||||
|
||||
if [ -n "$SKILLHUB_SCANNER_IMAGE_VALUE" ]; then
|
||||
append_runtime_option "--scanner-image" "$SKILLHUB_SCANNER_IMAGE_VALUE"
|
||||
fi
|
||||
|
||||
if [ -n "$POSTGRES_IMAGE_VALUE" ]; then
|
||||
append_runtime_option "--postgres-image" "$POSTGRES_IMAGE_VALUE"
|
||||
fi
|
||||
|
||||
if [ -n "$REDIS_IMAGE_VALUE" ]; then
|
||||
append_runtime_option "--redis-image" "$REDIS_IMAGE_VALUE"
|
||||
fi
|
||||
|
||||
if [ "$DISABLE_SCANNER" = "true" ]; then
|
||||
append_runtime_flag "--no-scanner"
|
||||
fi
|
||||
}
|
||||
|
||||
print_runtime_command() {
|
||||
lifecycle_command="$1"
|
||||
printf ' curl -fsSL %s | sh -s -- %s%s\n' \
|
||||
"$RUNTIME_SCRIPT_URL" \
|
||||
"$lifecycle_command" \
|
||||
"$RUNTIME_LIFECYCLE_ARGS"
|
||||
}
|
||||
|
||||
build_runtime_lifecycle_args
|
||||
|
||||
prepare_runtime_files
|
||||
|
||||
case "$COMMAND" in
|
||||
|
|
@ -390,17 +469,17 @@ case "$COMMAND" in
|
|||
run_compose up -d
|
||||
fi
|
||||
PUBLIC_URL="${SKILLHUB_PUBLIC_BASE_URL_VALUE:-http://localhost}"
|
||||
HOME_ARG=""
|
||||
if [ "$SKILLHUB_HOME" != "$SKILLHUB_HOME_DEFAULT" ]; then
|
||||
HOME_ARG=" --home $SKILLHUB_HOME"
|
||||
fi
|
||||
cat <<EOF
|
||||
SkillHub runtime started.
|
||||
Web UI: $PUBLIC_URL
|
||||
Backend API: http://localhost:8080
|
||||
Runtime dir: $SKILLHUB_HOME
|
||||
Stop with:
|
||||
curl -fsSL $RUNTIME_SCRIPT_URL | sh -s -- down$RUNTIME_SOURCE_ARG$HOME_ARG
|
||||
Lifecycle commands:
|
||||
$(print_runtime_command up)
|
||||
$(print_runtime_command down)
|
||||
$(print_runtime_command ps)
|
||||
$(print_runtime_command logs)
|
||||
$(print_runtime_command pull)
|
||||
EOF
|
||||
;;
|
||||
down)
|
||||
|
|
|
|||
407
scripts/suite-bundle-smoke-test.sh
Executable file
407
scripts/suite-bundle-smoke-test.sh
Executable file
|
|
@ -0,0 +1,407 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${1:-http://localhost:8080}"
|
||||
ADMIN_USERNAME="${SMOKE_ADMIN_USERNAME:-}"
|
||||
ADMIN_PASSWORD="${SMOKE_ADMIN_PASSWORD:-}"
|
||||
WORK_DIR="$(mktemp -d)"
|
||||
ADMIN_COOKIE="$(mktemp)"
|
||||
RECOVERY_COOKIE="$(mktemp)"
|
||||
USER_COOKIE="$(mktemp)"
|
||||
TOKEN="$(date +%s)${RANDOM}"
|
||||
ENTRY_SLUG="bundle-entry-${TOKEN}"
|
||||
REFERENCE_SLUG="bundle-reference-${TOKEN}"
|
||||
SUITE_SLUG="bundle-suite-${TOKEN}"
|
||||
LABEL_SLUG="bundle-label-${TOKEN}"
|
||||
USER_NAME="bundle_user_${TOKEN}"
|
||||
USER_PASSWORD="BundleUser${TOKEN}!Aa9"
|
||||
SUITE_ID=""
|
||||
ENTRY_SKILL_ID=""
|
||||
REFERENCE_SKILL_ID=""
|
||||
LABEL_CREATED=false
|
||||
|
||||
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 response="$2"
|
||||
local expected="${3:-0}"
|
||||
local actual
|
||||
actual="$(json_field "$response" code)"
|
||||
if [[ "$actual" != "$expected" ]]; then
|
||||
echo "FAIL: $description (expected code $expected, got $actual)" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: $description"
|
||||
}
|
||||
|
||||
csrf_token() {
|
||||
awk '$6 == "XSRF-TOKEN" { print $7 }' "$1" | tail -n 1
|
||||
}
|
||||
|
||||
bootstrap_cookie() {
|
||||
curl -sS -c "$1" "$BASE_URL/api/v1/auth/me" >/dev/null
|
||||
}
|
||||
|
||||
login_admin() {
|
||||
local cookie_file="$1"
|
||||
bootstrap_cookie "$cookie_file"
|
||||
local csrf
|
||||
csrf="$(csrf_token "$cookie_file")"
|
||||
local response
|
||||
response="$(curl -fsS -b "$cookie_file" -c "$cookie_file" \
|
||||
-H "X-XSRF-TOKEN: $csrf" -H "Content-Type: application/json" \
|
||||
-X POST "$BASE_URL/api/v1/auth/local/login" \
|
||||
-d "{\"username\":\"$ADMIN_USERNAME\",\"password\":\"$ADMIN_PASSWORD\"}")"
|
||||
assert_code "authenticate local administrator" "$response"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local csrf=""
|
||||
csrf="$(csrf_token "$ADMIN_COOKIE" || true)"
|
||||
if [[ -n "$csrf" ]]; then
|
||||
if [[ -n "$SUITE_ID" ]]; then
|
||||
curl -sS -o /dev/null -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $csrf" \
|
||||
-X DELETE "$BASE_URL/api/web/suites/$SUITE_ID" || true
|
||||
fi
|
||||
if [[ -n "$ENTRY_SKILL_ID" ]]; then
|
||||
curl -sS -o /dev/null -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $csrf" \
|
||||
-X DELETE "$BASE_URL/api/v1/skills/id/$ENTRY_SKILL_ID" || true
|
||||
fi
|
||||
if [[ -n "$REFERENCE_SKILL_ID" ]]; then
|
||||
curl -sS -o /dev/null -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $csrf" \
|
||||
-X DELETE "$BASE_URL/api/v1/skills/id/$REFERENCE_SKILL_ID" || true
|
||||
fi
|
||||
if [[ "$LABEL_CREATED" == true ]]; then
|
||||
curl -sS -o /dev/null -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $csrf" \
|
||||
-X DELETE "$BASE_URL/api/v1/admin/labels/$LABEL_SLUG" || true
|
||||
fi
|
||||
fi
|
||||
rm -f "$ADMIN_COOKIE" "$RECOVERY_COOKIE" "$USER_COOKIE"
|
||||
rm -rf "$WORK_DIR"
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ -z "$ADMIN_USERNAME" || -z "$ADMIN_PASSWORD" ]]; then
|
||||
echo "FAIL: SMOKE_ADMIN_USERNAME and SMOKE_ADMIN_PASSWORD are required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
make_skill_zip() {
|
||||
local slug="$1"
|
||||
local version="$2"
|
||||
local output="$3"
|
||||
SLUG="$slug" VERSION="$version" OUTPUT="$output" python3 - "$WORK_DIR" <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
skill_md = root / f"{os.environ['SLUG']}-{os.environ['VERSION']}.md"
|
||||
skill_md.write_text(
|
||||
"---\n"
|
||||
f"name: {os.environ['SLUG']}\n"
|
||||
f"description: Release Compose Bundle smoke member {os.environ['SLUG']}\n"
|
||||
f"version: {os.environ['VERSION']}\n"
|
||||
"---\n\n# Bundle smoke member\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
with zipfile.ZipFile(os.environ["OUTPUT"], "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.write(skill_md, "SKILL.md")
|
||||
PY
|
||||
}
|
||||
|
||||
make_bundle_zip() {
|
||||
local mode="$1"
|
||||
local version="$2"
|
||||
local base_version="$3"
|
||||
local output="$4"
|
||||
MODE="$mode" VERSION="$version" BASE_VERSION="$base_version" OUTPUT="$output" \
|
||||
SUITE_SLUG="$SUITE_SLUG" ENTRY_SLUG="$ENTRY_SLUG" REFERENCE_SLUG="$REFERENCE_SLUG" \
|
||||
python3 - "$WORK_DIR" <<'PY'
|
||||
from pathlib import Path
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
root = Path(sys.argv[1])
|
||||
member = root / f"entry-{os.environ['VERSION']}.md"
|
||||
member.write_text(
|
||||
"---\n"
|
||||
f"name: {os.environ['ENTRY_SLUG']}\n"
|
||||
"description: Entry member created and updated through Suite Bundle smoke\n"
|
||||
f"version: {os.environ['VERSION']}\n"
|
||||
"---\n\n# Bundle entry\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
base = "" if not os.environ["BASE_VERSION"] else f" baseVersion: {os.environ['BASE_VERSION']}\n"
|
||||
manifest = (
|
||||
"apiVersion: skillhub.iflytek.com/v1alpha1\n"
|
||||
"kind: SkillSuiteBundle\n"
|
||||
"metadata:\n"
|
||||
" namespace: global\n"
|
||||
f" slug: {os.environ['SUITE_SLUG']}\n"
|
||||
"spec:\n"
|
||||
f" mode: {os.environ['MODE']}\n"
|
||||
f" version: {os.environ['VERSION']}\n"
|
||||
f"{base}"
|
||||
" displayName: Release Compose Bundle smoke\n"
|
||||
" summary: Authenticated release Compose Bundle smoke workflow\n"
|
||||
" overview: |\n"
|
||||
" # Release Compose Bundle smoke\n\n"
|
||||
" Creates and updates a Suite with one package and one exact reference.\n"
|
||||
" visibility: PUBLIC\n"
|
||||
f" entry: \"@global/{os.environ['ENTRY_SLUG']}\"\n"
|
||||
" members:\n"
|
||||
f" - skill: \"@global/{os.environ['ENTRY_SLUG']}\"\n"
|
||||
" package:\n"
|
||||
" path: skills/entry\n"
|
||||
" visibility: PUBLIC\n"
|
||||
f" - skill: \"@global/{os.environ['REFERENCE_SLUG']}\"\n"
|
||||
" reference:\n"
|
||||
" version: 1.0.0\n"
|
||||
)
|
||||
with zipfile.ZipFile(os.environ["OUTPUT"], "w", zipfile.ZIP_DEFLATED) as archive:
|
||||
archive.writestr("SUITE.yaml", manifest)
|
||||
archive.write(member, "skills/entry/SKILL.md")
|
||||
PY
|
||||
}
|
||||
|
||||
poll_skill_status() {
|
||||
local cookie_file="$1"
|
||||
local slug="$2"
|
||||
local expected="$3"
|
||||
local response=""
|
||||
for _ in $(seq 1 120); do
|
||||
response="$(curl -fsS -b "$cookie_file" "$BASE_URL/api/web/skills/global/$slug")"
|
||||
if JSON_INPUT="$response" EXPECTED="$expected" 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 {}, data.get("publishedVersion") or {}]
|
||||
raise SystemExit(0 if any(item.get("status") == os.environ["EXPECTED"] for item in versions) else 1)
|
||||
PY
|
||||
then
|
||||
printf '%s' "$response"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "FAIL: $slug did not reach $expected" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
poll_operation() {
|
||||
local cookie_file="$1"
|
||||
local operation_id="$2"
|
||||
local response=""
|
||||
for _ in $(seq 1 120); do
|
||||
response="$(curl -fsS -b "$cookie_file" "$BASE_URL/api/web/suite-bundles/operations/$operation_id")"
|
||||
if [[ "$(json_field "$response" data.status)" == "SUITE_DRAFT_CREATED" ]]; then
|
||||
printf '%s' "$response"
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "FAIL: Bundle operation $operation_id did not create a Suite draft" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "=== Suite Bundle Release Compose Smoke Test ==="
|
||||
echo "Target: $BASE_URL"
|
||||
echo "Suite: @global/$SUITE_SLUG"
|
||||
|
||||
login_admin "$ADMIN_COOKIE"
|
||||
ADMIN_CSRF="$(csrf_token "$ADMIN_COOKIE")"
|
||||
|
||||
bootstrap_cookie "$USER_COOKIE"
|
||||
USER_CSRF="$(csrf_token "$USER_COOKIE")"
|
||||
REGISTER_RESPONSE="$(curl -fsS -b "$USER_COOKIE" -c "$USER_COOKIE" \
|
||||
-H "X-XSRF-TOKEN: $USER_CSRF" -H "Content-Type: application/json" \
|
||||
-X POST "$BASE_URL/api/v1/auth/local/register" \
|
||||
-d "{\"username\":\"$USER_NAME\",\"password\":\"$USER_PASSWORD\",\"email\":\"$USER_NAME@example.test\"}")"
|
||||
assert_code "register a non-admin Skill owner" "$REGISTER_RESPONSE"
|
||||
USER_CSRF="$(csrf_token "$USER_COOKIE")"
|
||||
|
||||
make_skill_zip "$REFERENCE_SLUG" 1.0.0 "$WORK_DIR/reference.zip"
|
||||
REFERENCE_PUBLISH="$(curl -fsS -b "$USER_COOKIE" -c "$USER_COOKIE" \
|
||||
-H "X-XSRF-TOKEN: $USER_CSRF" \
|
||||
-F "file=@$WORK_DIR/reference.zip;type=application/zip" -F "visibility=PUBLIC" \
|
||||
"$BASE_URL/api/web/skills/global/publish")"
|
||||
assert_code "non-admin publishes reference Skill for review" "$REFERENCE_PUBLISH"
|
||||
REFERENCE_SKILL_ID="$(json_field "$REFERENCE_PUBLISH" data.skillId)"
|
||||
poll_skill_status "$USER_COOKIE" "$REFERENCE_SLUG" PENDING_REVIEW >/dev/null
|
||||
echo "PASS: reference Skill reaches PENDING_REVIEW"
|
||||
|
||||
GLOBAL_NAMESPACE="$(curl -fsS -b "$ADMIN_COOKIE" "$BASE_URL/api/web/namespaces/global")"
|
||||
GLOBAL_NAMESPACE_ID="$(json_field "$GLOBAL_NAMESPACE" data.id)"
|
||||
REVIEWS="$(curl -fsS -b "$ADMIN_COOKIE" "$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$GLOBAL_NAMESPACE_ID")"
|
||||
REVIEW_ID="$(JSON_INPUT="$REVIEWS" SLUG="$REFERENCE_SLUG" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
|
||||
match = next((item for item in items if item["skillSlug"] == os.environ["SLUG"]), None)
|
||||
print(match["id"] if match else "")
|
||||
PY
|
||||
)"
|
||||
[[ -n "$REVIEW_ID" ]] || { echo "FAIL: pending review was not found" >&2; exit 1; }
|
||||
APPROVE="$(curl -fsS -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $ADMIN_CSRF" \
|
||||
-H "Content-Type: application/json" -X POST \
|
||||
"$BASE_URL/api/web/reviews/$REVIEW_ID/approve" -d '{"comment":"release compose Bundle smoke"}')"
|
||||
assert_code "administrator approves the foreign-owned reference Skill" "$APPROVE"
|
||||
poll_skill_status "$USER_COOKIE" "$REFERENCE_SLUG" PUBLISHED >/dev/null
|
||||
echo "PASS: foreign-owned reference Skill is PUBLISHED"
|
||||
|
||||
run_bundle() {
|
||||
local mode="$1"
|
||||
local version="$2"
|
||||
local base_version="$3"
|
||||
local archive="$WORK_DIR/bundle-${version}.zip"
|
||||
make_bundle_zip "$mode" "$version" "$base_version" "$archive"
|
||||
local preview
|
||||
preview="$(curl -fsS -b "$ADMIN_COOKIE" -c "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $ADMIN_CSRF" \
|
||||
-F "file=@$archive;type=application/zip" "$BASE_URL/api/web/suite-bundles/preview")"
|
||||
assert_code "$mode Bundle preview" "$preview"
|
||||
JSON_INPUT="$preview" MODE="$mode" VERSION="$version" ENTRY="$ENTRY_SLUG" REFERENCE="$REFERENCE_SLUG" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
data = json.loads(os.environ["JSON_INPUT"])["data"]
|
||||
members = {item["coordinate"]: item for item in data["members"]}
|
||||
assert data["confirmable"] is True
|
||||
assert data["target"]["mode"] == os.environ["MODE"]
|
||||
assert data["target"]["targetVersion"] == os.environ["VERSION"]
|
||||
entry = members[f"@global/{os.environ['ENTRY']}"]
|
||||
reference = members[f"@global/{os.environ['REFERENCE']}"]
|
||||
assert entry["sourceType"] == "PACKAGE"
|
||||
assert entry["packagePath"] == "skills/entry"
|
||||
assert entry["publishAction"] in {"CREATE_SKILL", "CREATE_VERSION"}
|
||||
assert reference["sourceType"] == "REFERENCE"
|
||||
assert reference["publishAction"] == "REFERENCE_VERSION"
|
||||
PY
|
||||
echo "PASS: $mode preview exposes package path, actions, and exact foreign reference"
|
||||
local token digest confirmation operation_id
|
||||
token="$(json_field "$preview" data.previewToken)"
|
||||
digest="$(json_field "$preview" data.warningDigest)"
|
||||
confirmation="$(curl -fsS -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $ADMIN_CSRF" \
|
||||
-H "Content-Type: application/json" -H "Idempotency-Key: $mode-$TOKEN" \
|
||||
-X POST "$BASE_URL/api/web/suite-bundles/previews/$token/confirm" \
|
||||
-d "{\"warningDigest\":\"$digest\"}")"
|
||||
assert_code "$mode Bundle confirmation" "$confirmation"
|
||||
operation_id="$(json_field "$confirmation" data.operationId)"
|
||||
|
||||
login_admin "$RECOVERY_COOKIE"
|
||||
local recovered
|
||||
recovered="$(poll_operation "$RECOVERY_COOKIE" "$operation_id")"
|
||||
assert_code "$mode operation is recoverable after a fresh login" "$recovered"
|
||||
echo "PASS: $mode operation reaches SUITE_DRAFT_CREATED"
|
||||
}
|
||||
|
||||
publish_public_suite() {
|
||||
local version_id="$1"
|
||||
local submit reviews review_id approve
|
||||
submit="$(curl -fsS -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $ADMIN_CSRF" \
|
||||
-X POST "$BASE_URL/api/web/suites/$SUITE_ID/versions/$version_id/submit")"
|
||||
assert_code "submit public Suite version for review" "$submit"
|
||||
reviews="$(curl -fsS -b "$ADMIN_COOKIE" \
|
||||
"$BASE_URL/api/web/reviews?status=PENDING&namespaceId=$GLOBAL_NAMESPACE_ID")"
|
||||
review_id="$(JSON_INPUT="$reviews" VERSION_ID="$version_id" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
items = json.loads(os.environ["JSON_INPUT"])["data"]["items"]
|
||||
match = next((item for item in items
|
||||
if item.get("subjectType") == "SUITE_VERSION"
|
||||
and str(item.get("subjectVersionId")) == os.environ["VERSION_ID"]), None)
|
||||
print(match["id"] if match else "")
|
||||
PY
|
||||
)"
|
||||
[[ -n "$review_id" ]] || { echo "FAIL: pending Suite review was not found" >&2; exit 1; }
|
||||
approve="$(curl -fsS -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $ADMIN_CSRF" \
|
||||
-H "Content-Type: application/json" -X POST \
|
||||
"$BASE_URL/api/web/suites/reviews/$review_id/approve" \
|
||||
-d '{"comment":"release compose Bundle smoke"}')"
|
||||
assert_code "approve public Suite version review" "$approve"
|
||||
}
|
||||
|
||||
run_bundle CREATE 1.0.0 ""
|
||||
CREATE_DETAIL="$(curl -fsS -b "$ADMIN_COOKIE" "$BASE_URL/api/web/suites/global/$SUITE_SLUG?version=1.0.0")"
|
||||
assert_code "load created Suite draft" "$CREATE_DETAIL"
|
||||
SUITE_ID="$(json_field "$CREATE_DETAIL" data.id)"
|
||||
SUITE_VERSION_ID="$(json_field "$CREATE_DETAIL" data.versionId)"
|
||||
ENTRY_SKILL_ID="$(json_field "$CREATE_DETAIL" data.members.0.skillId)"
|
||||
|
||||
LABEL_CREATE="$(curl -fsS -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $ADMIN_CSRF" \
|
||||
-H "Content-Type: application/json" -X POST "$BASE_URL/api/v1/admin/labels" \
|
||||
-d "{\"slug\":\"$LABEL_SLUG\",\"type\":\"RECOMMENDED\",\"visibleInFilter\":true,\"sortOrder\":10,\"translations\":[{\"locale\":\"en\",\"displayName\":\"Bundle smoke\"}]}")"
|
||||
assert_code "create Suite smoke label definition" "$LABEL_CREATE"
|
||||
LABEL_CREATED=true
|
||||
LABEL_ATTACH="$(curl -fsS -b "$ADMIN_COOKIE" -H "X-XSRF-TOKEN: $ADMIN_CSRF" \
|
||||
-X PUT "$BASE_URL/api/web/suites/global/$SUITE_SLUG/labels/$LABEL_SLUG")"
|
||||
assert_code "attach direct Suite label" "$LABEL_ATTACH"
|
||||
|
||||
publish_public_suite "$SUITE_VERSION_ID"
|
||||
|
||||
ENTRY_DETAIL="$(curl -fsS -b "$ADMIN_COOKIE" "$BASE_URL/api/web/skills/global/$ENTRY_SLUG")"
|
||||
JSON_INPUT="$ENTRY_DETAIL" SUITE="$SUITE_SLUG" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
items = json.loads(os.environ["JSON_INPUT"])["data"]["memberOfSuites"]["items"]
|
||||
match = next(item for item in items if item["slug"] == os.environ["SUITE"])
|
||||
assert match["currentSkillEntry"] is True
|
||||
PY
|
||||
echo "PASS: Entry Skill reverse discovery identifies the published Suite"
|
||||
|
||||
REFERENCE_DETAIL="$(curl -fsS -b "$USER_COOKIE" "$BASE_URL/api/web/skills/global/$REFERENCE_SLUG")"
|
||||
JSON_INPUT="$REFERENCE_DETAIL" SUITE="$SUITE_SLUG" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
items = json.loads(os.environ["JSON_INPUT"])["data"]["memberOfSuites"]["items"]
|
||||
match = next(item for item in items if item["slug"] == os.environ["SUITE"])
|
||||
assert match["currentSkillEntry"] is False
|
||||
PY
|
||||
echo "PASS: non-entry foreign Skill reverse discovery identifies the published Suite"
|
||||
|
||||
ENTRY_FILE="$(curl -fsS -b "$ADMIN_COOKIE" \
|
||||
"$BASE_URL/api/web/skills/global/$ENTRY_SLUG/versions/1.0.0/file?path=SKILL.md")"
|
||||
if [[ "$ENTRY_FILE" != *"# Bundle entry"* ]]; then
|
||||
echo "FAIL: pinned Entry Skill instructions do not contain the expected content" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: read the pinned Entry Skill instructions"
|
||||
|
||||
run_bundle UPDATE 1.1.0 1.0.0
|
||||
UPDATE_DETAIL="$(curl -fsS -b "$ADMIN_COOKIE" "$BASE_URL/api/web/suites/global/$SUITE_SLUG?version=1.1.0")"
|
||||
assert_code "load updated Suite draft" "$UPDATE_DETAIL"
|
||||
UPDATE_VERSION_ID="$(json_field "$UPDATE_DETAIL" data.versionId)"
|
||||
publish_public_suite "$UPDATE_VERSION_ID"
|
||||
|
||||
LABELS="$(curl -fsS -b "$ADMIN_COOKIE" "$BASE_URL/api/web/suites/global/$SUITE_SLUG/labels")"
|
||||
JSON_INPUT="$LABELS" LABEL="$LABEL_SLUG" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
items = json.loads(os.environ["JSON_INPUT"])["data"]
|
||||
assert any(item["slug"] == os.environ["LABEL"] for item in items)
|
||||
PY
|
||||
echo "PASS: Suite label persists after Bundle version update"
|
||||
|
||||
echo "=== Suite Bundle Release Compose Smoke Test Passed ==="
|
||||
|
|
@ -216,6 +216,7 @@ print(json.dumps({
|
|||
"slug": sys.argv[1],
|
||||
"displayName": "Suite smoke test",
|
||||
"summary": "Temporary private Suite",
|
||||
"overview": "## Smoke test\n\nValidates exact member publication and installation.",
|
||||
"version": "1.0.0",
|
||||
"visibility": "PRIVATE",
|
||||
"changelog": "Initial smoke version",
|
||||
|
|
@ -233,6 +234,15 @@ payload.pop("entrySkill")
|
|||
print(json.dumps(payload))
|
||||
PY
|
||||
)"
|
||||
INCOMPLETE_METADATA_PAYLOAD="$(JSON_INPUT="$SUITE_PAYLOAD" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
payload = json.loads(os.environ["JSON_INPUT"])
|
||||
payload.pop("overview")
|
||||
print(json.dumps(payload))
|
||||
PY
|
||||
)"
|
||||
MISSING_ENTRY_STATUS="$(curl -sS -o "$WORK_DIR/missing-entry.json" -w '%{http_code}' \
|
||||
-b "$COOKIE_FILE" -c "$COOKIE_FILE" "${AUTH_HEADERS[@]}" \
|
||||
-H "X-XSRF-TOKEN: $CSRF_TOKEN" -H "Content-Type: application/json" \
|
||||
|
|
@ -246,11 +256,32 @@ echo "PASS: creating a Suite without an Entry Skill is rejected"
|
|||
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
|
||||
-d "$INCOMPLETE_METADATA_PAYLOAD")"
|
||||
assert_code "save an incomplete 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)"
|
||||
|
||||
INCOMPLETE_PUBLISH_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 "reject publishing a Suite draft without an overview" "$INCOMPLETE_PUBLISH_RESPONSE" 400
|
||||
|
||||
INCOMPLETE_DETAIL="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
|
||||
"${AUTH_HEADERS[@]}" "$BASE_URL/api/web/suites/global/$SUITE_SLUG?version=1.0.0")"
|
||||
assert_code "reload the incomplete Suite draft" "$INCOMPLETE_DETAIL" 0
|
||||
if [[ "$(json_field "$INCOMPLETE_DETAIL" data.status)" != "DRAFT" ]]; then
|
||||
echo "FAIL: incomplete Suite should remain DRAFT"
|
||||
exit 1
|
||||
fi
|
||||
echo "PASS: incomplete Suite remains DRAFT"
|
||||
|
||||
UPDATE_RESPONSE="$(curl -sS -b "$COOKIE_FILE" -c "$COOKIE_FILE" \
|
||||
"${AUTH_HEADERS[@]}" -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
|
||||
-H "Content-Type: application/json" -X PUT \
|
||||
"$BASE_URL/api/web/suites/$SUITE_ID/versions/$SUITE_VERSION_ID" \
|
||||
-d "$SUITE_PAYLOAD")"
|
||||
assert_code "complete the Suite draft metadata" "$UPDATE_RESPONSE" 0
|
||||
|
||||
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")"
|
||||
|
|
|
|||
|
|
@ -92,20 +92,38 @@ grep -Fq "Generated SKILLHUB_DOWNLOAD_ANON_COOKIE_SECRET" "$stdout_generated" \
|
|||
if grep -Fq "$generated_secret" "$stdout_generated"; then
|
||||
fail "runtime must not print the generated secret value"
|
||||
fi
|
||||
grep -Fq "curl -fsSL file://$REPO_ROOT/scripts/runtime.sh | sh -s -- down --home $home_generated" "$stdout_generated" \
|
||||
|| fail "GitHub runtime should print the scripts/runtime.sh stop URL"
|
||||
grep -Fq "curl -fsSL file://$REPO_ROOT/scripts/runtime.sh | sh -s -- down --version sha-test --home $home_generated --public-url http://localhost" "$stdout_generated" \
|
||||
|| fail "GitHub runtime should print a reproducible down command"
|
||||
grep -Fq "curl -fsSL file://$REPO_ROOT/scripts/runtime.sh | sh -s -- up --version sha-test --home $home_generated --public-url http://localhost" "$stdout_generated" \
|
||||
|| fail "GitHub runtime should print a reproducible up command"
|
||||
grep -Fq "curl -fsSL file://$REPO_ROOT/scripts/runtime.sh | sh -s -- ps --version sha-test --home $home_generated --public-url http://localhost" "$stdout_generated" \
|
||||
|| fail "GitHub runtime should print a reproducible ps command"
|
||||
grep -Fq "curl -fsSL file://$REPO_ROOT/scripts/runtime.sh | sh -s -- logs --version sha-test --home $home_generated --public-url http://localhost" "$stdout_generated" \
|
||||
|| fail "GitHub runtime should print a reproducible logs command"
|
||||
grep -Fq "curl -fsSL file://$REPO_ROOT/scripts/runtime.sh | sh -s -- pull --version sha-test --home $home_generated --public-url http://localhost" "$stdout_generated" \
|
||||
|| fail "GitHub runtime should print a reproducible pull command"
|
||||
|
||||
home_aliyun="$tmp/aliyun"
|
||||
stdout_aliyun="$tmp/aliyun.out"
|
||||
mkdir -p "$home_aliyun"
|
||||
run_runtime "$home_aliyun" "$bin_dir" "$stdout_aliyun" --aliyun
|
||||
|
||||
grep -Fq "curl -fsSL file://$REPO_ROOT/runtime.sh | sh -s -- down --aliyun --home $home_aliyun" "$stdout_aliyun" \
|
||||
grep -Fq "curl -fsSL file://$REPO_ROOT/runtime.sh | sh -s -- down --aliyun --version sha-test --home $home_aliyun --public-url http://localhost" "$stdout_aliyun" \
|
||||
|| fail "Aliyun runtime should preserve the root runtime.sh URL and --aliyun source mode"
|
||||
if grep -Fq "file://$REPO_ROOT/scripts/runtime.sh" "$stdout_aliyun"; then
|
||||
fail "Aliyun runtime must not print the GitHub scripts/runtime.sh path"
|
||||
fi
|
||||
|
||||
home_ref="$tmp/ref"
|
||||
stdout_ref="$tmp/ref.out"
|
||||
mkdir -p "$home_ref"
|
||||
run_runtime "$home_ref" "$bin_dir" "$stdout_ref" --ref 36de54157bff59c18c5eff255d2c158df45a3e2a --no-scanner
|
||||
|
||||
grep -Fq "curl -fsSL file://$REPO_ROOT/scripts/runtime.sh | sh -s -- down --ref 36de54157bff59c18c5eff255d2c158df45a3e2a --version sha-test --home $home_ref --public-url http://localhost --no-scanner" "$stdout_ref" \
|
||||
|| fail "GitHub runtime should preserve pinned --ref and --no-scanner in down command"
|
||||
grep -Fq "curl -fsSL file://$REPO_ROOT/scripts/runtime.sh | sh -s -- up --ref 36de54157bff59c18c5eff255d2c158df45a3e2a --version sha-test --home $home_ref --public-url http://localhost --no-scanner" "$stdout_ref" \
|
||||
|| fail "GitHub runtime should preserve pinned --ref and --no-scanner in up command"
|
||||
|
||||
home_preserved="$tmp/preserved"
|
||||
stdout_preserved="$tmp/preserved.out"
|
||||
mkdir -p "$home_preserved"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.iflytek.skillhub.config;
|
|||
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
|
||||
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifestParser;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
|
|
@ -25,6 +26,11 @@ public class DomainBeanConfig {
|
|||
return new SkillMetadataParser();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SkillSuiteBundleManifestParser skillSuiteBundleManifestParser() {
|
||||
return new SkillSuiteBundleManifestParser();
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SkillPackageValidator skillPackageValidator(SkillMetadataParser skillMetadataParser,
|
||||
SkillPublishProperties skillPublishProperties) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
package com.iflytek.skillhub.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/** Runtime controls for staged Suite Bundle previews and execution rollout. */
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "skillhub.suite.bundle")
|
||||
public class SkillSuiteBundleProperties {
|
||||
|
||||
private Duration previewTtl = Duration.ofMinutes(30);
|
||||
private boolean confirmationEnabled;
|
||||
|
||||
public Duration getPreviewTtl() {
|
||||
return previewTtl;
|
||||
}
|
||||
|
||||
public void setPreviewTtl(Duration previewTtl) {
|
||||
if (previewTtl == null || previewTtl.isZero() || previewTtl.isNegative()) {
|
||||
throw new IllegalArgumentException("skillhub.suite.bundle.preview-ttl must be positive");
|
||||
}
|
||||
this.previewTtl = previewTtl;
|
||||
}
|
||||
|
||||
public boolean isConfirmationEnabled() {
|
||||
return confirmationEnabled;
|
||||
}
|
||||
|
||||
public void setConfirmationEnabled(boolean confirmationEnabled) {
|
||||
this.confirmationEnabled = confirmationEnabled;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ 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.MySkillSuiteWorkspaceResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.service.SkillSuiteAppService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
|
@ -42,4 +43,18 @@ public class MySkillSuiteController extends BaseApiController {
|
|||
return ok("response.success.read", appService.listMine(
|
||||
userId, roles == null ? Map.of() : roles, q, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/workspace")
|
||||
@Operation(operationId = "listMySkillSuiteWorkspace", summary = "List owner workbench with merged Suite creation progress")
|
||||
public ApiResponse<MySkillSuiteWorkspaceResponse> workspace(
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) String state,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "12") int size,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles
|
||||
) {
|
||||
return ok("response.success.read", appService.workspace(
|
||||
userId, roles == null ? Map.of() : roles, q, state, page, size));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ 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 java.util.List;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
|
@ -43,10 +44,11 @@ public class ResourceDiscoveryController extends BaseApiController {
|
|||
@RequestParam(defaultValue = "newest") String sort,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestParam(name = "label", required = false) List<String> labels,
|
||||
@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()));
|
||||
roles == null ? java.util.Set.of() : roles.keySet(), labels));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -143,6 +143,7 @@ public class ReviewController extends BaseApiController {
|
|||
|
||||
@GetMapping("/my-progress")
|
||||
public ApiResponse<ReviewProgressPageResponse> listMyProgress(
|
||||
@RequestParam(required = false) String subjectType,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "") String q,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
|
|
@ -150,7 +151,7 @@ public class ReviewController extends BaseApiController {
|
|||
@RequestAttribute("userId") String userId) {
|
||||
return ok(
|
||||
"response.success.read",
|
||||
governanceWorkflowAppService.listMyReviewProgress(status, q, page, size, userId)
|
||||
governanceWorkflowAppService.listMyReviewProgress(subjectType, status, q, page, size, userId)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import com.iflytek.skillhub.dto.ResolveVersionResponse;
|
|||
import com.iflytek.skillhub.dto.SkillDetailResponse;
|
||||
import com.iflytek.skillhub.dto.SkillFileResponse;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteReferenceResponse;
|
||||
import com.iflytek.skillhub.dto.SkillVersionCompareFileResponse;
|
||||
import com.iflytek.skillhub.dto.SkillVersionCompareHunkResponse;
|
||||
import com.iflytek.skillhub.dto.SkillVersionCompareLineResponse;
|
||||
|
|
@ -89,8 +90,13 @@ public class SkillController extends BaseApiController {
|
|||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
|
||||
Set<String> platformRoles = principal == null || principal.platformRoles() == null
|
||||
? Set.of() : principal.platformRoles();
|
||||
SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail(
|
||||
namespace, slug, userId, namespaceRoles);
|
||||
PageResponse<SkillSuiteReferenceResponse> memberOfSuites =
|
||||
skillSuiteAppService.findVisibleMemberships(
|
||||
detail.id(), userId, namespaceRoles, platformRoles, 0, 20);
|
||||
|
||||
SkillDetailResponse response = new SkillDetailResponse(
|
||||
detail.id(),
|
||||
|
|
@ -118,15 +124,36 @@ public class SkillController extends BaseApiController {
|
|||
toLifecycleVersion(detail.ownerPreviewVersion()),
|
||||
detail.ownerPreviewReviewComment(),
|
||||
detail.resolutionMode(),
|
||||
skillSuiteAppService.findVisibleEntryReferences(
|
||||
detail.id(), userId, namespaceRoles,
|
||||
principal == null || principal.platformRoles() == null
|
||||
? Set.of() : principal.platformRoles())
|
||||
memberOfSuites.items().stream()
|
||||
.filter(SkillSuiteReferenceResponse::currentSkillEntry)
|
||||
.toList(),
|
||||
memberOfSuites
|
||||
);
|
||||
|
||||
return ok("response.success.read", response);
|
||||
}
|
||||
|
||||
/** Returns a bounded page of visible current Suite snapshots containing this Skill. */
|
||||
@GetMapping("/{namespace}/{slug}/suite-memberships")
|
||||
public ApiResponse<PageResponse<SkillSuiteReferenceResponse>>
|
||||
listSuiteMemberships(
|
||||
@PathVariable String namespace,
|
||||
@PathVariable String slug,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false)
|
||||
Map<Long, NamespaceRole> userNsRoles,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal) {
|
||||
Map<Long, NamespaceRole> namespaceRoles = userNsRoles != null ? userNsRoles : Map.of();
|
||||
Set<String> platformRoles = principal == null || principal.platformRoles() == null
|
||||
? Set.of() : principal.platformRoles();
|
||||
SkillQueryService.SkillDetailDTO detail = skillQueryService.getSkillDetail(
|
||||
namespace, slug, userId, namespaceRoles);
|
||||
return ok("response.success.read", skillSuiteAppService.findVisibleMemberships(
|
||||
detail.id(), userId, namespaceRoles, platformRoles, page, size));
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists versions visible to the caller rather than every persisted version
|
||||
* of the skill.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,164 @@
|
|||
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.PageResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteBundleConfirmRequest;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationDetailResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationPageResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteBundlePreviewResponse;
|
||||
import com.iflytek.skillhub.ratelimit.RateLimit;
|
||||
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleConfirmationAppService;
|
||||
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleOperationCommandService;
|
||||
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleOperationQueryService;
|
||||
import com.iflytek.skillhub.service.bundle.SkillSuiteBundlePreviewAppService;
|
||||
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleResponseMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestPart;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Transport-only endpoints for the two-stage Suite Bundle import workflow. */
|
||||
@RestController
|
||||
@Tag(name = "Skill Suite Bundles")
|
||||
@RequestMapping({"/api/v1/suite-bundles", "/api/web/suite-bundles"})
|
||||
public class SkillSuiteBundleController extends BaseApiController {
|
||||
|
||||
private final SkillSuiteBundlePreviewAppService previewService;
|
||||
private final SkillSuiteBundleConfirmationAppService confirmationService;
|
||||
private final SkillSuiteBundleOperationQueryService operationQueryService;
|
||||
private final SkillSuiteBundleOperationCommandService operationCommandService;
|
||||
private final SkillSuiteBundleResponseMapper responseMapper;
|
||||
|
||||
public SkillSuiteBundleController(
|
||||
SkillSuiteBundlePreviewAppService previewService,
|
||||
SkillSuiteBundleConfirmationAppService confirmationService,
|
||||
SkillSuiteBundleOperationQueryService operationQueryService,
|
||||
SkillSuiteBundleOperationCommandService operationCommandService,
|
||||
SkillSuiteBundleResponseMapper responseMapper,
|
||||
ApiResponseFactory responseFactory
|
||||
) {
|
||||
super(responseFactory);
|
||||
this.previewService = previewService;
|
||||
this.confirmationService = confirmationService;
|
||||
this.operationQueryService = operationQueryService;
|
||||
this.operationCommandService = operationCommandService;
|
||||
this.responseMapper = responseMapper;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/preview", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
operationId = "previewSkillSuiteBundle",
|
||||
summary = "Validate and preview one Suite Bundle archive",
|
||||
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(required = true)
|
||||
)
|
||||
@RateLimit(category = "publish", authenticated = 10, anonymous = 0)
|
||||
public ApiResponse<SkillSuiteBundlePreviewResponse> preview(
|
||||
@RequestPart("file") MultipartFile file,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal
|
||||
) throws IOException {
|
||||
return ok("response.success.read", responseMapper.toResponse(previewService.preview(
|
||||
file, userId, roles == null ? Map.of() : roles, platformRoles(principal))));
|
||||
}
|
||||
|
||||
@PostMapping("/previews/{previewToken}/confirm")
|
||||
@Operation(operationId = "confirmSkillSuiteBundle", summary = "Confirm one exact Suite Bundle preview")
|
||||
@RateLimit(category = "publish", authenticated = 10, anonymous = 0)
|
||||
public ApiResponse<SkillSuiteBundleOperationResponse> confirm(
|
||||
@PathVariable String previewToken,
|
||||
@RequestHeader("Idempotency-Key") String clientRequestId,
|
||||
@Valid @RequestBody SkillSuiteBundleConfirmRequest request,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal
|
||||
) {
|
||||
return ok("response.success.created", responseMapper.toResponse(confirmationService.confirm(
|
||||
previewToken, clientRequestId, request.warningDigest(), userId,
|
||||
roles == null ? Map.of() : roles, platformRoles(principal))));
|
||||
}
|
||||
|
||||
@GetMapping("/operations/{operationId}")
|
||||
@Operation(operationId = "getSkillSuiteBundleOperation", summary = "Get one authorized Suite Bundle operation")
|
||||
public ApiResponse<SkillSuiteBundleOperationDetailResponse> getOperation(
|
||||
@PathVariable String operationId,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal
|
||||
) {
|
||||
return ok("response.success.read", operationQueryService.get(
|
||||
operationId, userId, roles == null ? Map.of() : roles, platformRoles(principal)));
|
||||
}
|
||||
|
||||
@GetMapping("/operations/active")
|
||||
@Operation(operationId = "listActiveSkillSuiteBundleOperations", summary = "List active Bundle operations started by the current user")
|
||||
public ApiResponse<PageResponse<SkillSuiteBundleOperationSummaryResponse>> listActiveOperations(
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "12") int size
|
||||
) {
|
||||
return ok("response.success.read", operationQueryService.listActive(userId, page, size));
|
||||
}
|
||||
|
||||
@GetMapping("/operations/mine")
|
||||
@Operation(operationId = "listMySkillSuiteBundleOperations", summary = "List current and completed Bundle operations started by the current user")
|
||||
public ApiResponse<SkillSuiteBundleOperationPageResponse> listMyOperations(
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "12") int size
|
||||
) {
|
||||
return ok("response.success.read", operationQueryService.listMine(userId, page, size));
|
||||
}
|
||||
|
||||
@PostMapping("/operations/{operationId}/cancel")
|
||||
@Operation(operationId = "cancelSkillSuiteBundleOperation", summary = "Cancel one active Suite Bundle operation")
|
||||
public ApiResponse<SkillSuiteBundleOperationResponse> cancelOperation(
|
||||
@PathVariable String operationId,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal
|
||||
) {
|
||||
return ok("response.success.updated", operationCommandService.cancel(
|
||||
operationId, userId, roles == null ? Map.of() : roles, platformRoles(principal)));
|
||||
}
|
||||
|
||||
@PostMapping("/operations/{operationId}/retry")
|
||||
@Operation(operationId = "retrySkillSuiteBundleOperation", summary = "Retry one blocked Suite Bundle operation")
|
||||
public ApiResponse<SkillSuiteBundleOperationResponse> retryOperation(
|
||||
@PathVariable String operationId,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> roles,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal
|
||||
) {
|
||||
return ok("response.success.updated", operationCommandService.retry(
|
||||
operationId, userId, roles == null ? Map.of() : roles, platformRoles(principal)));
|
||||
}
|
||||
|
||||
private Set<String> platformRoles(PlatformPrincipal principal) {
|
||||
return principal == null || principal.platformRoles() == null
|
||||
? Set.of()
|
||||
: principal.platformRoles();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
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.SkillLabelDto;
|
||||
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||
import com.iflytek.skillhub.service.SkillSuiteLabelAppService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestAttribute;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Transport-only endpoints for direct Suite-to-Label associations. */
|
||||
@RestController
|
||||
@Tag(name = "Skill Suite Labels")
|
||||
@RequestMapping({
|
||||
"/api/v1/suites/{namespace}/{slug}/labels",
|
||||
"/api/web/suites/{namespace}/{slug}/labels"
|
||||
})
|
||||
public class SkillSuiteLabelController extends BaseApiController {
|
||||
|
||||
private final SkillSuiteLabelAppService appService;
|
||||
|
||||
public SkillSuiteLabelController(
|
||||
SkillSuiteLabelAppService appService,
|
||||
ApiResponseFactory responseFactory
|
||||
) {
|
||||
super(responseFactory);
|
||||
this.appService = appService;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(operationId = "listSkillSuiteLabels", summary = "List direct labels on one visible Suite")
|
||||
public ApiResponse<List<SkillLabelDto>> listLabels(
|
||||
@PathVariable String namespace,
|
||||
@PathVariable String slug,
|
||||
@RequestAttribute(value = "userId", required = false) String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal
|
||||
) {
|
||||
return ok("response.success.read", appService.listLabels(
|
||||
namespace, slug, userId, roles(namespaceRoles), platformRoles(principal)));
|
||||
}
|
||||
|
||||
@PutMapping("/{labelSlug}")
|
||||
@Operation(operationId = "attachSkillSuiteLabel", summary = "Attach an existing Registry label to a Suite")
|
||||
public ApiResponse<SkillLabelDto> attachLabel(
|
||||
@PathVariable String namespace,
|
||||
@PathVariable String slug,
|
||||
@PathVariable String labelSlug,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
return ok("response.success.updated", appService.attachLabel(
|
||||
namespace, slug, labelSlug, userId, roles(namespaceRoles),
|
||||
platformRoles(principal), AuditRequestContext.from(request)));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{labelSlug}")
|
||||
@Operation(operationId = "detachSkillSuiteLabel", summary = "Detach a Registry label from a Suite")
|
||||
public ApiResponse<MessageResponse> detachLabel(
|
||||
@PathVariable String namespace,
|
||||
@PathVariable String slug,
|
||||
@PathVariable String labelSlug,
|
||||
@RequestAttribute("userId") String userId,
|
||||
@RequestAttribute(value = "userNsRoles", required = false) Map<Long, NamespaceRole> namespaceRoles,
|
||||
@AuthenticationPrincipal PlatformPrincipal principal,
|
||||
HttpServletRequest request
|
||||
) {
|
||||
return ok("response.success.deleted", appService.detachLabel(
|
||||
namespace, slug, labelSlug, userId, roles(namespaceRoles),
|
||||
platformRoles(principal), AuditRequestContext.from(request)));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Paginated owner workbench, including creation operations before a Suite exists. */
|
||||
public record MySkillSuiteWorkspaceResponse(
|
||||
List<Item> items, long total, int page, int size,
|
||||
long attentionCount, boolean hasChangingOperations
|
||||
) {
|
||||
public record Item(
|
||||
Long suiteId, String namespace, String slug, String displayName, String summary,
|
||||
String version, String suiteVersion, String state, Instant updatedAt,
|
||||
String operationId, String operationStatus, String failureCode
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Type-explicit discovery item used by new clients without changing the legacy Skill search API. */
|
||||
public record ResourceSummaryResponse(
|
||||
|
|
@ -15,6 +16,7 @@ public record ResourceSummaryResponse(
|
|||
String visibility,
|
||||
long installCount,
|
||||
boolean available,
|
||||
Instant updatedAt
|
||||
Instant updatedAt,
|
||||
List<SkillLabelDto> labels
|
||||
) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,5 +29,6 @@ public record SkillDetailResponse(
|
|||
SkillLifecycleVersionResponse ownerPreviewVersion,
|
||||
String ownerPreviewReviewComment,
|
||||
String resolutionMode,
|
||||
List<SkillSuiteReferenceResponse> entryForSuites
|
||||
List<SkillSuiteReferenceResponse> entryForSuites,
|
||||
PageResponse<SkillSuiteReferenceResponse> memberOfSuites
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record SkillSuiteBundleConfirmRequest(
|
||||
@NotBlank String warningDigest
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record SkillSuiteBundleOperationDetailResponse(
|
||||
String operationId,
|
||||
SkillSuiteBundleOperationStatus status,
|
||||
SkillSuiteBundleMode mode,
|
||||
String targetCoordinate,
|
||||
Long targetNamespaceId,
|
||||
Long targetSuiteId,
|
||||
String targetVersion,
|
||||
String baseVersion,
|
||||
String failureCode,
|
||||
Long resultSuiteId,
|
||||
Long resultSuiteVersionId,
|
||||
Instant createdAt,
|
||||
Instant updatedAt,
|
||||
Instant completedAt,
|
||||
List<OperationMember> members
|
||||
) {
|
||||
public record OperationMember(
|
||||
int position,
|
||||
boolean redacted,
|
||||
String coordinate,
|
||||
SkillSuiteBundleMemberSourceType sourceType,
|
||||
String packagePath,
|
||||
SkillSuiteBundleRelationshipChange relationship,
|
||||
SkillSuiteBundlePublishAction publishAction,
|
||||
SkillSuiteBundleMemberResultStatus status,
|
||||
SkillVisibility visibility,
|
||||
String version,
|
||||
Long skillId,
|
||||
Long skillVersionId,
|
||||
List<String> errors,
|
||||
List<String> warnings
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** One prioritized page of Bundle tasks plus collection-wide polling state. */
|
||||
public record SkillSuiteBundleOperationPageResponse(
|
||||
List<SkillSuiteBundleOperationSummaryResponse> items,
|
||||
long total,
|
||||
int page,
|
||||
int size,
|
||||
boolean hasChangingOperations
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
public record SkillSuiteBundleOperationResponse(
|
||||
String operationId,
|
||||
String status,
|
||||
boolean replayed
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Bundle operation summary shown in the current user's publishing task list. */
|
||||
public record SkillSuiteBundleOperationSummaryResponse(
|
||||
String operationId,
|
||||
SkillSuiteBundleMode mode,
|
||||
String targetCoordinate,
|
||||
String targetVersion,
|
||||
SkillSuiteBundleOperationStatus status,
|
||||
String failureCode,
|
||||
String baseVersion,
|
||||
int totalMembers,
|
||||
int completedMembers,
|
||||
int waitingMembers,
|
||||
Instant updatedAt
|
||||
) {
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record SkillSuiteBundlePreviewResponse(
|
||||
String previewToken,
|
||||
Instant expiresAt,
|
||||
boolean confirmable,
|
||||
Target target,
|
||||
List<PreviewMember> members,
|
||||
List<RemovedMember> removedMembers,
|
||||
List<String> errors,
|
||||
List<String> warnings,
|
||||
String warningDigest
|
||||
) {
|
||||
public record Target(
|
||||
SkillSuiteBundleMode mode,
|
||||
String coordinate,
|
||||
Long namespaceId,
|
||||
Long suiteId,
|
||||
Long baseSuiteVersionId,
|
||||
String targetVersion,
|
||||
String displayName,
|
||||
String summary,
|
||||
String overview,
|
||||
SkillVisibility visibility
|
||||
) {
|
||||
}
|
||||
|
||||
public record PreviewMember(
|
||||
String coordinate,
|
||||
SkillSuiteBundleMemberSourceType sourceType,
|
||||
String packagePath,
|
||||
SkillSuiteBundleRelationshipChange relationship,
|
||||
SkillSuiteBundlePublishAction publishAction,
|
||||
Long skillId,
|
||||
Long skillVersionId,
|
||||
SkillVisibility finalVisibility,
|
||||
String resolvedVersion,
|
||||
String fingerprint,
|
||||
List<String> errors,
|
||||
List<String> warnings
|
||||
) {
|
||||
}
|
||||
|
||||
public record RemovedMember(
|
||||
String coordinate,
|
||||
Long skillId,
|
||||
Long skillVersionId,
|
||||
String version,
|
||||
boolean entry
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,18 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
/** One currently visible published Suite that uses this Skill as its orchestration entry. */
|
||||
import java.util.List;
|
||||
|
||||
/** One currently visible published Suite whose latest snapshot contains this Skill. */
|
||||
public record SkillSuiteReferenceResponse(
|
||||
Long suiteId,
|
||||
String namespace,
|
||||
String slug,
|
||||
String displayName,
|
||||
String version,
|
||||
int memberCount
|
||||
int memberCount,
|
||||
boolean currentSkillEntry,
|
||||
List<SkillSuiteSiblingMemberResponse> visibleSiblingMembers,
|
||||
int restrictedMemberCount,
|
||||
int omittedVisibleMemberCount
|
||||
) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.iflytek.skillhub.dto;
|
|||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.suite.SkillSuiteAllowedAction;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
|
|
@ -15,6 +16,12 @@ public record SkillSuiteResponse(
|
|||
String displayName,
|
||||
String summary,
|
||||
String overview,
|
||||
String changelog,
|
||||
String createdBy,
|
||||
String createdByName,
|
||||
Instant createdAt,
|
||||
Instant publishedAt,
|
||||
Instant yankedAt,
|
||||
String version,
|
||||
String status,
|
||||
SkillVisibility visibility,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
package com.iflytek.skillhub.dto;
|
||||
|
||||
/** Compact metadata for one Suite sibling Skill visible to the current viewer. */
|
||||
public record SkillSuiteSiblingMemberResponse(
|
||||
Long skillId,
|
||||
String namespace,
|
||||
String slug,
|
||||
String displayName,
|
||||
String version,
|
||||
boolean entry,
|
||||
boolean available
|
||||
) {
|
||||
}
|
||||
|
|
@ -10,6 +10,9 @@ public record SkillSuiteVersionSummaryResponse(
|
|||
String version,
|
||||
String status,
|
||||
SkillVisibility visibility,
|
||||
String changelog,
|
||||
String createdBy,
|
||||
String createdByName,
|
||||
Instant publishedAt,
|
||||
Instant yankedAt,
|
||||
Instant createdAt
|
||||
|
|
|
|||
|
|
@ -25,11 +25,13 @@ import org.springframework.web.HttpMediaTypeNotAcceptableException;
|
|||
import org.springframework.web.HttpMediaTypeNotSupportedException;
|
||||
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.MissingRequestHeaderException;
|
||||
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.multipart.support.MissingServletRequestPartException;
|
||||
|
||||
/**
|
||||
* Translates application, domain, auth, and infrastructure exceptions into the platform's JSON API
|
||||
|
|
@ -102,7 +104,9 @@ public class GlobalExceptionHandler {
|
|||
}
|
||||
|
||||
@ExceptionHandler({
|
||||
MissingRequestHeaderException.class,
|
||||
MissingServletRequestParameterException.class,
|
||||
MissingServletRequestPartException.class,
|
||||
HttpMessageNotReadableException.class,
|
||||
MethodArgumentTypeMismatchException.class
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
package com.iflytek.skillhub.listener;
|
||||
|
||||
import com.iflytek.skillhub.domain.event.ReviewRejectedEvent;
|
||||
import com.iflytek.skillhub.domain.event.SkillPublishedEvent;
|
||||
import com.iflytek.skillhub.domain.event.SkillSuiteBundleAdvanceRequestedEvent;
|
||||
import com.iflytek.skillhub.domain.event.SkillVersionYankedEvent;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
|
||||
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleCoordinator;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.event.TransactionPhase;
|
||||
import org.springframework.transaction.event.TransactionalEventListener;
|
||||
|
||||
/** Wakes durable Bundle coordination after confirmation and member lifecycle events. */
|
||||
@Component
|
||||
public class SkillSuiteBundleEventListener {
|
||||
|
||||
private final SkillSuiteBundleMemberResultRepository memberRepository;
|
||||
private final SkillSuiteBundleCoordinator coordinator;
|
||||
|
||||
public SkillSuiteBundleEventListener(
|
||||
SkillSuiteBundleMemberResultRepository memberRepository,
|
||||
SkillSuiteBundleCoordinator coordinator
|
||||
) {
|
||||
this.memberRepository = memberRepository;
|
||||
this.coordinator = coordinator;
|
||||
}
|
||||
|
||||
@Async("skillhubEventExecutor")
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void onAdvanceRequested(SkillSuiteBundleAdvanceRequestedEvent event) {
|
||||
coordinator.advance(event.operationId());
|
||||
}
|
||||
|
||||
@Async("skillhubEventExecutor")
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void onSkillPublished(SkillPublishedEvent event) {
|
||||
advanceBoundOperations(event.versionId());
|
||||
}
|
||||
|
||||
@Async("skillhubEventExecutor")
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void onReviewRejected(ReviewRejectedEvent event) {
|
||||
advanceBoundOperations(event.versionId());
|
||||
}
|
||||
|
||||
@Async("skillhubEventExecutor")
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
|
||||
public void onSkillVersionYanked(SkillVersionYankedEvent event) {
|
||||
advanceBoundOperations(event.versionId());
|
||||
}
|
||||
|
||||
private void advanceBoundOperations(Long skillVersionId) {
|
||||
memberRepository.findBySkillVersionId(skillVersionId).stream()
|
||||
.map(member -> member.getOperationId())
|
||||
.distinct()
|
||||
.forEach(coordinator::advance);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
||||
import com.iflytek.skillhub.domain.review.ReviewSubjectType;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressResponse;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressStatusCounts;
|
||||
|
|
@ -79,15 +80,20 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
|
|||
OR LOWER(namespace.slug) LIKE :queryPattern
|
||||
)
|
||||
AND (:status = '' OR latest.status = :status)
|
||||
AND (:subjectType = '' OR latest.subject_type = :subjectType)
|
||||
ORDER BY latest.submitted_at DESC, latest.id DESC
|
||||
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY
|
||||
""";
|
||||
|
||||
private static final String MY_PROGRESS_SUMMARY_SQL = RANKED_CTE + """
|
||||
SELECT COUNT(*) FILTER (WHERE :status = '' OR latest.status = :status) AS filtered_total,
|
||||
COUNT(*) FILTER (WHERE latest.status = 'PENDING') AS pending_count,
|
||||
COUNT(*) FILTER (WHERE latest.status = 'APPROVED') AS approved_count,
|
||||
COUNT(*) FILTER (WHERE latest.status = 'REJECTED') AS rejected_count
|
||||
SELECT COUNT(*) FILTER (WHERE (:status = '' OR latest.status = :status)
|
||||
AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS filtered_total,
|
||||
COUNT(*) FILTER (WHERE latest.status = 'PENDING'
|
||||
AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS pending_count,
|
||||
COUNT(*) FILTER (WHERE latest.status = 'APPROVED'
|
||||
AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS approved_count,
|
||||
COUNT(*) FILTER (WHERE latest.status = 'REJECTED'
|
||||
AND (:subjectType = '' OR latest.subject_type = :subjectType)) AS rejected_count
|
||||
FROM latest
|
||||
LEFT JOIN skill
|
||||
ON latest.subject_type = 'SKILL_VERSION' AND skill.id = latest.subject_id
|
||||
|
|
@ -109,16 +115,19 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
|
|||
@Transactional(readOnly = true)
|
||||
public ReviewProgressPageResponse findMyProgress(
|
||||
String userId,
|
||||
ReviewSubjectType subjectType,
|
||||
ReviewTaskStatus status,
|
||||
String query,
|
||||
int page,
|
||||
int size) {
|
||||
String normalizedQuery = query == null ? "" : query.trim().toLowerCase(java.util.Locale.ROOT);
|
||||
String statusName = status != null ? status.name() : "";
|
||||
String subjectTypeName = subjectType != null ? subjectType.name() : "";
|
||||
String queryPattern = "%" + normalizedQuery + "%";
|
||||
Query nativeQuery = bindFilters(
|
||||
entityManager.createNativeQuery(MY_PROGRESS_SQL),
|
||||
userId,
|
||||
subjectTypeName,
|
||||
statusName,
|
||||
normalizedQuery,
|
||||
queryPattern)
|
||||
|
|
@ -127,6 +136,7 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
|
|||
Query summaryQuery = bindFilters(
|
||||
entityManager.createNativeQuery(MY_PROGRESS_SUMMARY_SQL),
|
||||
userId,
|
||||
subjectTypeName,
|
||||
statusName,
|
||||
normalizedQuery,
|
||||
queryPattern);
|
||||
|
|
@ -147,11 +157,13 @@ public class JpaReviewProgressQueryRepository implements ReviewProgressQueryRepo
|
|||
private Query bindFilters(
|
||||
Query query,
|
||||
String userId,
|
||||
String subjectType,
|
||||
String status,
|
||||
String normalizedQuery,
|
||||
String queryPattern) {
|
||||
return query
|
||||
.setParameter("userId", userId)
|
||||
.setParameter("subjectType", subjectType)
|
||||
.setParameter("status", status)
|
||||
.setParameter("query", normalizedQuery)
|
||||
.setParameter("queryPattern", queryPattern);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.dto.MySkillSuiteSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.MySkillSuiteWorkspaceResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Query;
|
||||
|
|
@ -29,7 +30,8 @@ public class MySkillSuiteQueryRepository {
|
|||
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
|
||||
version.visibility, suite.hidden, suite.updated_at,
|
||||
version.created_at AS version_created_at
|
||||
FROM skill_suite suite
|
||||
JOIN namespace ON namespace.id = suite.namespace_id
|
||||
JOIN skill_suite_version version ON version.suite_id = suite.id
|
||||
|
|
@ -47,6 +49,98 @@ public class MySkillSuiteQueryRepository {
|
|||
|
||||
private final EntityManager entityManager;
|
||||
|
||||
// The actor's newest operation is merged by coordinate, not fetched once per Suite. Operations
|
||||
// without a Suite remain temporary rows. Page selection and metrics each require one SQL query;
|
||||
// neither query loads plans, member results, or package contents.
|
||||
private static final String WORKSPACE_CTE = CTE.stripTrailing() + """
|
||||
, latest_operation AS (
|
||||
SELECT DISTINCT ON (operation.namespace_id, operation.target_suite_slug)
|
||||
operation.operation_id, operation.namespace_id, operation.target_suite_slug,
|
||||
operation.target_version, operation.status, operation.failure_code,
|
||||
operation.created_at, operation.updated_at, namespace.slug AS namespace_slug
|
||||
FROM skill_suite_bundle_operation operation
|
||||
JOIN namespace ON namespace.id = operation.namespace_id
|
||||
WHERE operation.actor_id = :userId
|
||||
AND operation.namespace_id IN (:memberNamespaceIds)
|
||||
ORDER BY operation.namespace_id, operation.target_suite_slug,
|
||||
operation.created_at DESC, operation.operation_id DESC
|
||||
), workspace AS (
|
||||
SELECT suite.id AS suite_id, suite.namespace_slug, suite.slug,
|
||||
suite.display_name, suite.summary,
|
||||
COALESCE(operation.target_version, suite.version) AS version,
|
||||
suite.version AS suite_version,
|
||||
CASE WHEN operation.status IN ('BLOCKED_RETRYABLE', 'REPREVIEW_REQUIRED') THEN 'ATTENTION'
|
||||
WHEN operation.status IS NOT NULL THEN 'PREPARING'
|
||||
WHEN suite.suite_status = 'ARCHIVED' THEN 'ARCHIVED'
|
||||
ELSE suite.version_status END AS state,
|
||||
GREATEST(suite.updated_at, operation.updated_at) AS updated_at,
|
||||
operation.operation_id, operation.status AS operation_status, operation.failure_code
|
||||
FROM manageable suite
|
||||
LEFT JOIN latest_operation operation
|
||||
ON operation.namespace_slug = suite.namespace_slug AND operation.target_suite_slug = suite.slug
|
||||
AND operation.status IN ('RUNNING', 'WAITING_FOR_MEMBERS', 'BLOCKED_RETRYABLE', 'REPREVIEW_REQUIRED')
|
||||
AND operation.created_at >= suite.version_created_at
|
||||
UNION ALL
|
||||
SELECT CAST(NULL AS bigint), operation.namespace_slug, operation.target_suite_slug,
|
||||
operation.target_suite_slug, CAST(NULL AS text), operation.target_version, CAST(NULL AS text),
|
||||
CASE WHEN operation.status IN ('BLOCKED_RETRYABLE', 'REPREVIEW_REQUIRED') THEN 'ATTENTION'
|
||||
WHEN operation.status = 'CANCELLED' THEN 'CANCELLED'
|
||||
ELSE 'PREPARING' END,
|
||||
operation.updated_at, operation.operation_id, operation.status, operation.failure_code
|
||||
FROM latest_operation operation
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM skill_suite suite
|
||||
WHERE suite.namespace_id = operation.namespace_id AND suite.slug = operation.target_suite_slug
|
||||
)
|
||||
), searched AS (
|
||||
SELECT * FROM workspace
|
||||
WHERE (:query = '' OR LOWER(namespace_slug || '/' || slug) LIKE :pattern ESCAPE '!'
|
||||
OR LOWER(display_name) LIKE :pattern ESCAPE '!'
|
||||
OR LOWER(COALESCE(summary, '')) LIKE :pattern ESCAPE '!')
|
||||
)
|
||||
""";
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public MySkillSuiteWorkspaceResponse findWorkspace(
|
||||
String userId, Set<Long> memberNamespaceIds, Set<Long> adminNamespaceIds,
|
||||
String keyword, String state, int page, int size
|
||||
) {
|
||||
String queryText = keyword == null ? "" : keyword.trim().toLowerCase(Locale.ROOT);
|
||||
String pattern = "%" + queryText.replace("!", "!!").replace("%", "!%")
|
||||
.replace("_", "!_") + "%";
|
||||
Query select = bind(entityManager.createNativeQuery(WORKSPACE_CTE + """
|
||||
SELECT suite_id, namespace_slug, slug, display_name, summary, version,
|
||||
suite_version, state, updated_at, operation_id, operation_status, failure_code
|
||||
FROM searched
|
||||
WHERE (:state = '' OR state = :state
|
||||
OR (:state = 'OTHER' AND state NOT IN ('ATTENTION', 'DRAFT', 'PENDING_REVIEW', 'PUBLISHED')))
|
||||
ORDER BY CASE WHEN state = 'ATTENTION' THEN 0 WHEN state = 'PREPARING' THEN 1 ELSE 2 END,
|
||||
updated_at DESC, namespace_slug, slug
|
||||
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY
|
||||
"""), userId, memberNamespaceIds, adminNamespaceIds, queryText);
|
||||
select.setParameter("pattern", pattern).setParameter("state", state)
|
||||
.setParameter("offset", (long) page * size).setParameter("size", size);
|
||||
Query metrics = bind(entityManager.createNativeQuery(WORKSPACE_CTE + """
|
||||
SELECT COUNT(*) FILTER (WHERE :state = '' OR state = :state
|
||||
OR (:state = 'OTHER' AND state NOT IN ('ATTENTION', 'DRAFT', 'PENDING_REVIEW', 'PUBLISHED'))),
|
||||
COUNT(*) FILTER (WHERE state = 'ATTENTION'),
|
||||
COALESCE(BOOL_OR(operation_status IN ('RUNNING', 'WAITING_FOR_MEMBERS')), FALSE)
|
||||
FROM searched
|
||||
"""), userId, memberNamespaceIds, adminNamespaceIds, queryText);
|
||||
metrics.setParameter("pattern", pattern).setParameter("state", state);
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object[]> rows = select.getResultList();
|
||||
Object[] counts = (Object[]) metrics.getSingleResult();
|
||||
return new MySkillSuiteWorkspaceResponse(rows.stream().map(row ->
|
||||
new MySkillSuiteWorkspaceResponse.Item(
|
||||
row[0] == null ? null : ((Number) row[0]).longValue(),
|
||||
(String) row[1], (String) row[2], (String) row[3], (String) row[4],
|
||||
(String) row[5], (String) row[6], (String) row[7], instant(row[8]),
|
||||
(String) row[9], (String) row[10], (String) row[11])).toList(),
|
||||
((Number) counts[0]).longValue(), page, size,
|
||||
((Number) counts[1]).longValue(), (Boolean) counts[2]);
|
||||
}
|
||||
|
||||
public MySkillSuiteQueryRepository(EntityManager entityManager) {
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.review.ReviewTaskStatus;
|
||||
import com.iflytek.skillhub.domain.review.ReviewSubjectType;
|
||||
import com.iflytek.skillhub.dto.ReviewProgressPageResponse;
|
||||
|
||||
/**
|
||||
|
|
@ -10,6 +11,7 @@ public interface ReviewProgressQueryRepository {
|
|||
|
||||
ReviewProgressPageResponse findMyProgress(
|
||||
String userId,
|
||||
ReviewSubjectType subjectType,
|
||||
ReviewTaskStatus status,
|
||||
String query,
|
||||
int page,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationPageResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationSummaryResponse;
|
||||
import jakarta.persistence.EntityManager;
|
||||
import jakarta.persistence.Query;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Dashboard read model for Bundle operations owned by one actor.
|
||||
*
|
||||
* <p>The native query pages operations before joining member rows, then computes status counts in
|
||||
* PostgreSQL. This keeps each poll bounded and avoids loading member errors and warnings.</p>
|
||||
*/
|
||||
@Repository
|
||||
public class SkillSuiteBundleOperationQueryRepository {
|
||||
|
||||
private static final List<String> ACTIVE_STATUSES = List.of(
|
||||
SkillSuiteBundleOperationStatus.RUNNING.name(),
|
||||
SkillSuiteBundleOperationStatus.WAITING_FOR_MEMBERS.name(),
|
||||
SkillSuiteBundleOperationStatus.BLOCKED_RETRYABLE.name());
|
||||
|
||||
private final EntityManager entityManager;
|
||||
|
||||
public SkillSuiteBundleOperationQueryRepository(EntityManager entityManager) {
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<SkillSuiteBundleOperationSummaryResponse> findActive(
|
||||
String actorId,
|
||||
int page,
|
||||
int size
|
||||
) {
|
||||
Query select = entityManager.createNativeQuery("""
|
||||
WITH active_operation AS (
|
||||
SELECT operation.operation_id, operation.mode,
|
||||
'@' || namespace.slug || '/' || operation.target_suite_slug AS target_coordinate,
|
||||
operation.target_version, operation.status, operation.failure_code,
|
||||
base_version.version AS base_version, operation.updated_at
|
||||
FROM skill_suite_bundle_operation operation
|
||||
JOIN namespace ON namespace.id = operation.namespace_id
|
||||
LEFT JOIN skill_suite_version base_version
|
||||
ON base_version.id = operation.base_suite_version_id
|
||||
WHERE operation.actor_id = :actorId
|
||||
AND operation.status IN (:statuses)
|
||||
ORDER BY operation.updated_at DESC, operation.operation_id DESC
|
||||
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY
|
||||
)
|
||||
SELECT operation.operation_id, operation.mode, operation.target_coordinate,
|
||||
operation.target_version, operation.status, operation.failure_code,
|
||||
operation.base_version, operation.updated_at,
|
||||
COUNT(member.id) AS total_members,
|
||||
COUNT(member.id) FILTER (WHERE member.status = 'COMPLETED') AS completed_members,
|
||||
COUNT(member.id) FILTER (WHERE member.status = 'WAITING_FOR_MEMBER') AS waiting_members
|
||||
FROM active_operation operation
|
||||
LEFT JOIN skill_suite_bundle_member_result member
|
||||
ON member.operation_id = operation.operation_id
|
||||
GROUP BY operation.operation_id, operation.mode, operation.target_coordinate,
|
||||
operation.target_version, operation.status, operation.failure_code,
|
||||
operation.base_version, operation.updated_at
|
||||
ORDER BY operation.updated_at DESC, operation.operation_id DESC
|
||||
""");
|
||||
bind(select, actorId)
|
||||
.setParameter("offset", (long) page * size)
|
||||
.setParameter("size", size);
|
||||
Query count = bind(entityManager.createNativeQuery("""
|
||||
SELECT COUNT(*)
|
||||
FROM skill_suite_bundle_operation operation
|
||||
WHERE operation.actor_id = :actorId
|
||||
AND operation.status IN (:statuses)
|
||||
"""), actorId);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object[]> rows = select.getResultList();
|
||||
return new PageResponse<>(
|
||||
rows.stream().map(this::map).toList(),
|
||||
((Number) count.getSingleResult()).longValue(), page, size);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SkillSuiteBundleOperationPageResponse findMine(
|
||||
String actorId,
|
||||
int page,
|
||||
int size
|
||||
) {
|
||||
Query select = entityManager.createNativeQuery("""
|
||||
WITH selected_operation AS (
|
||||
SELECT operation.operation_id, operation.mode,
|
||||
'@' || namespace.slug || '/' || operation.target_suite_slug AS target_coordinate,
|
||||
operation.target_version, operation.status, operation.failure_code,
|
||||
base_version.version AS base_version, operation.updated_at
|
||||
FROM skill_suite_bundle_operation operation
|
||||
JOIN namespace ON namespace.id = operation.namespace_id
|
||||
LEFT JOIN skill_suite_version base_version
|
||||
ON base_version.id = operation.base_suite_version_id
|
||||
WHERE operation.actor_id = :actorId
|
||||
ORDER BY CASE
|
||||
WHEN operation.status IN ('BLOCKED_RETRYABLE', 'REPREVIEW_REQUIRED') THEN 0
|
||||
WHEN operation.status IN ('RUNNING', 'WAITING_FOR_MEMBERS') THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
operation.updated_at DESC, operation.operation_id DESC
|
||||
OFFSET :offset ROWS FETCH NEXT :size ROWS ONLY
|
||||
)
|
||||
SELECT operation.operation_id, operation.mode, operation.target_coordinate,
|
||||
operation.target_version, operation.status, operation.failure_code,
|
||||
operation.base_version, operation.updated_at,
|
||||
COUNT(member.id) AS total_members,
|
||||
COUNT(member.id) FILTER (WHERE member.status = 'COMPLETED') AS completed_members,
|
||||
COUNT(member.id) FILTER (WHERE member.status = 'WAITING_FOR_MEMBER') AS waiting_members
|
||||
FROM selected_operation operation
|
||||
LEFT JOIN skill_suite_bundle_member_result member
|
||||
ON member.operation_id = operation.operation_id
|
||||
GROUP BY operation.operation_id, operation.mode, operation.target_coordinate,
|
||||
operation.target_version, operation.status, operation.failure_code,
|
||||
operation.base_version, operation.updated_at
|
||||
ORDER BY CASE
|
||||
WHEN operation.status IN ('BLOCKED_RETRYABLE', 'REPREVIEW_REQUIRED') THEN 0
|
||||
WHEN operation.status IN ('RUNNING', 'WAITING_FOR_MEMBERS') THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
operation.updated_at DESC, operation.operation_id DESC
|
||||
""");
|
||||
select.setParameter("actorId", actorId)
|
||||
.setParameter("offset", (long) page * size)
|
||||
.setParameter("size", size);
|
||||
Query count = entityManager.createNativeQuery("""
|
||||
SELECT (SELECT COUNT(*)
|
||||
FROM skill_suite_bundle_operation operation
|
||||
WHERE operation.actor_id = :actorId),
|
||||
EXISTS(SELECT 1
|
||||
FROM skill_suite_bundle_operation changing
|
||||
WHERE changing.actor_id = :actorId
|
||||
AND changing.status IN ('RUNNING', 'WAITING_FOR_MEMBERS'))
|
||||
""").setParameter("actorId", actorId);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Object[]> rows = select.getResultList();
|
||||
Object[] metrics = (Object[]) count.getSingleResult();
|
||||
return new SkillSuiteBundleOperationPageResponse(
|
||||
rows.stream().map(this::map).toList(),
|
||||
((Number) metrics[0]).longValue(), page, size, (Boolean) metrics[1]);
|
||||
}
|
||||
|
||||
private Query bind(Query query, String actorId) {
|
||||
return query.setParameter("actorId", actorId).setParameter("statuses", ACTIVE_STATUSES);
|
||||
}
|
||||
|
||||
private SkillSuiteBundleOperationSummaryResponse map(Object[] row) {
|
||||
return new SkillSuiteBundleOperationSummaryResponse(
|
||||
(String) row[0], SkillSuiteBundleMode.valueOf(String.valueOf(row[1])),
|
||||
(String) row[2], (String) row[3],
|
||||
SkillSuiteBundleOperationStatus.valueOf(String.valueOf(row[4])),
|
||||
(String) row[5], (String) row[6],
|
||||
((Number) row[8]).intValue(), ((Number) row[9]).intValue(),
|
||||
((Number) row[10]).intValue(), instant(row[7]));
|
||||
}
|
||||
|
||||
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 Bundle operation update timestamp, got " + value);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.label.LabelDefinition;
|
||||
import com.iflytek.skillhub.domain.label.LabelDefinitionService;
|
||||
import com.iflytek.skillhub.domain.label.LabelTranslation;
|
||||
import com.iflytek.skillhub.domain.label.SkillSuiteLabel;
|
||||
import com.iflytek.skillhub.domain.label.SkillSuiteLabelService;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.service.LabelLocalizationService;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/** Assembles localized direct Suite labels for a page of Suites with bounded query count. */
|
||||
@Repository
|
||||
public class SkillSuiteLabelQueryRepository {
|
||||
|
||||
private final SkillSuiteLabelService suiteLabelService;
|
||||
private final LabelDefinitionService labelDefinitionService;
|
||||
private final LabelLocalizationService labelLocalizationService;
|
||||
|
||||
public SkillSuiteLabelQueryRepository(
|
||||
SkillSuiteLabelService suiteLabelService,
|
||||
LabelDefinitionService labelDefinitionService,
|
||||
LabelLocalizationService labelLocalizationService
|
||||
) {
|
||||
this.suiteLabelService = suiteLabelService;
|
||||
this.labelDefinitionService = labelDefinitionService;
|
||||
this.labelLocalizationService = labelLocalizationService;
|
||||
}
|
||||
|
||||
public Map<Long, List<SkillLabelDto>> labelsBySuiteIds(List<Long> suiteIds) {
|
||||
if (suiteIds == null || suiteIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Long> distinctSuiteIds = suiteIds.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
if (distinctSuiteIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<SkillSuiteLabel> assignments =
|
||||
suiteLabelService.listSuiteLabelsBySuiteIds(distinctSuiteIds);
|
||||
if (assignments.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Long> labelIds = assignments.stream()
|
||||
.map(SkillSuiteLabel::getLabelId)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, LabelDefinition> definitionsById = labelDefinitionService.listByIds(labelIds).stream()
|
||||
.collect(Collectors.toMap(LabelDefinition::getId, Function.identity()));
|
||||
Map<Long, List<LabelTranslation>> translationsByLabelId =
|
||||
labelDefinitionService.listTranslationsByLabelIds(labelIds);
|
||||
|
||||
return assignments.stream()
|
||||
.filter(assignment -> definitionsById.containsKey(assignment.getLabelId()))
|
||||
.collect(Collectors.groupingBy(
|
||||
SkillSuiteLabel::getSuiteId,
|
||||
Collectors.collectingAndThen(
|
||||
Collectors.toList(),
|
||||
suiteAssignments -> suiteAssignments.stream()
|
||||
.map(assignment -> toDto(
|
||||
definitionsById.get(assignment.getLabelId()),
|
||||
translationsByLabelId))
|
||||
.sorted(Comparator.comparing(SkillLabelDto::type)
|
||||
.thenComparing(SkillLabelDto::slug))
|
||||
.toList())));
|
||||
}
|
||||
|
||||
private SkillLabelDto toDto(
|
||||
LabelDefinition definition,
|
||||
Map<Long, List<LabelTranslation>> translationsByLabelId
|
||||
) {
|
||||
return new SkillLabelDto(
|
||||
definition.getSlug(),
|
||||
definition.getType().name(),
|
||||
labelLocalizationService.resolveDisplayName(
|
||||
definition.getSlug(),
|
||||
translationsByLabelId.getOrDefault(definition.getId(), List.of())));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +1,221 @@
|
|||
package com.iflytek.skillhub.repository;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteReferenceResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteSiblingMemberResponse;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Skill-detail read model for current Suite entry references.
|
||||
* Skill-detail read model for current Suite membership references.
|
||||
*
|
||||
* <p>The query starts from each Suite's latest published snapshot so historical Suite versions do
|
||||
* not look like current installation recommendations. Visibility filtering happens in SQL to avoid
|
||||
* leaking private Suite coordinates through a public Skill page.</p>
|
||||
* <p>Only each Suite's latest published snapshot participates. Suite visibility and sibling Skill
|
||||
* visibility are evaluated in bounded collection queries so callers cannot infer private
|
||||
* coordinates and the projection does not perform an N+1 lookup.</p>
|
||||
*/
|
||||
@Repository
|
||||
public class SkillSuiteReferenceQueryRepository {
|
||||
|
||||
public static final int MAX_PAGE_SIZE = 20;
|
||||
private static final int MAX_VISIBLE_SIBLINGS = 8;
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbcTemplate;
|
||||
|
||||
public SkillSuiteReferenceQueryRepository(NamedParameterJdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
/** Legacy entry-only projection retained for older clients. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<SkillSuiteReferenceResponse> findVisibleEntryReferences(
|
||||
Long skillId,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
return findVisibleMemberships(
|
||||
skillId, userId, namespaceRoles, platformRoles, 0, MAX_PAGE_SIZE).items().stream()
|
||||
.filter(SkillSuiteReferenceResponse::currentSkillEntry)
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<SkillSuiteReferenceResponse> findVisibleMemberships(
|
||||
Long skillId,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles,
|
||||
int page,
|
||||
int size
|
||||
) {
|
||||
int boundedPage = Math.max(0, page);
|
||||
int boundedSize = Math.min(Math.max(1, size), MAX_PAGE_SIZE);
|
||||
MapSqlParameterSource parameters = viewerParameters(
|
||||
skillId, userId, namespaceRoles, platformRoles)
|
||||
.addValue("limit", boundedSize)
|
||||
.addValue("offset", boundedPage * boundedSize);
|
||||
|
||||
List<SuiteRow> suites = jdbcTemplate.query("""
|
||||
SELECT suite.id,
|
||||
version.id AS suite_version_id,
|
||||
suite_namespace.slug AS namespace_slug,
|
||||
suite.slug,
|
||||
version.display_name,
|
||||
version.version,
|
||||
current_member.entry AS current_skill_entry,
|
||||
COUNT(all_members.id) AS member_count,
|
||||
COUNT(*) OVER() AS total_count
|
||||
FROM skill_suite suite
|
||||
JOIN namespace suite_namespace ON suite_namespace.id = suite.namespace_id
|
||||
JOIN skill_suite_version version ON version.id = suite.latest_version_id
|
||||
JOIN skill_suite_version_member current_member
|
||||
ON current_member.suite_version_id = version.id
|
||||
AND current_member.skill_id = :skillId
|
||||
JOIN skill_suite_version_member all_members
|
||||
ON all_members.suite_version_id = version.id
|
||||
WHERE %s
|
||||
GROUP BY suite.id, version.id, suite_namespace.slug, suite.slug,
|
||||
version.display_name, version.version, current_member.entry
|
||||
ORDER BY current_member.entry DESC, LOWER(version.display_name), suite.id
|
||||
LIMIT :limit OFFSET :offset
|
||||
""".formatted(visibleSuitePredicate()), parameters, (resultSet, rowNumber) -> new SuiteRow(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getLong("suite_version_id"),
|
||||
resultSet.getString("namespace_slug"),
|
||||
resultSet.getString("slug"),
|
||||
resultSet.getString("display_name"),
|
||||
resultSet.getString("version"),
|
||||
resultSet.getBoolean("current_skill_entry"),
|
||||
resultSet.getInt("member_count"),
|
||||
resultSet.getLong("total_count")));
|
||||
|
||||
if (suites.isEmpty()) {
|
||||
long total = boundedPage == 0 ? 0 : countVisibleMemberships(parameters);
|
||||
return new PageResponse<>(List.of(), total, boundedPage, boundedSize);
|
||||
}
|
||||
|
||||
Map<Long, List<MemberRow>> membersBySuiteVersion = loadMembers(
|
||||
suites.stream().map(SuiteRow::suiteVersionId).toList(),
|
||||
skillId, userId, namespaceRoles, platformRoles);
|
||||
List<SkillSuiteReferenceResponse> items = suites.stream()
|
||||
.map(suite -> toResponse(suite, membersBySuiteVersion.getOrDefault(
|
||||
suite.suiteVersionId(), List.of())))
|
||||
.toList();
|
||||
return new PageResponse<>(items, suites.getFirst().totalCount(), boundedPage, boundedSize);
|
||||
}
|
||||
|
||||
private long countVisibleMemberships(MapSqlParameterSource parameters) {
|
||||
Long counted = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*)
|
||||
FROM skill_suite suite
|
||||
JOIN namespace suite_namespace ON suite_namespace.id = suite.namespace_id
|
||||
JOIN skill_suite_version version ON version.id = suite.latest_version_id
|
||||
JOIN skill_suite_version_member current_member
|
||||
ON current_member.suite_version_id = version.id
|
||||
AND current_member.skill_id = :skillId
|
||||
WHERE %s
|
||||
""".formatted(visibleSuitePredicate()), parameters, Long.class);
|
||||
return counted == null ? 0 : counted;
|
||||
}
|
||||
|
||||
private Map<Long, List<MemberRow>> loadMembers(
|
||||
List<Long> suiteVersionIds,
|
||||
Long currentSkillId,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
MapSqlParameterSource parameters = viewerParameters(
|
||||
currentSkillId, userId, namespaceRoles, platformRoles)
|
||||
.addValue("suiteVersionIds", suiteVersionIds);
|
||||
List<MemberRow> rows = jdbcTemplate.query("""
|
||||
SELECT member.suite_version_id,
|
||||
member.position,
|
||||
member.entry,
|
||||
skill.id AS skill_id,
|
||||
skill_namespace.slug AS namespace_slug,
|
||||
skill.slug,
|
||||
COALESCE(skill.display_name, skill.slug) AS display_name,
|
||||
skill_version.version,
|
||||
CASE WHEN skill.id IS NULL
|
||||
OR skill_version.id IS NULL
|
||||
OR skill_namespace.id IS NULL THEN FALSE
|
||||
WHEN :superAdmin = TRUE THEN TRUE
|
||||
WHEN skill.hidden = TRUE THEN (
|
||||
skill.owner_id = :userId
|
||||
OR skill.namespace_id IN (:adminNamespaceIds))
|
||||
WHEN skill.latest_version_id IS NULL THEN skill.owner_id = :userId
|
||||
WHEN skill.visibility = 'PUBLIC' THEN TRUE
|
||||
WHEN skill.visibility = 'NAMESPACE_ONLY' THEN
|
||||
skill.namespace_id IN (:memberNamespaceIds)
|
||||
WHEN skill.visibility = 'PRIVATE' THEN (
|
||||
skill.owner_id = :userId
|
||||
OR skill.namespace_id IN (:adminNamespaceIds))
|
||||
ELSE FALSE
|
||||
END AS visible,
|
||||
CASE WHEN skill.id IS NOT NULL
|
||||
AND skill_version.id IS NOT NULL
|
||||
AND skill_namespace.status = 'ACTIVE'
|
||||
AND skill.status = 'ACTIVE'
|
||||
AND skill.hidden = FALSE
|
||||
AND skill_version.status = 'PUBLISHED'
|
||||
AND skill_version.download_ready = TRUE
|
||||
AND skill_version.yanked_at IS NULL
|
||||
THEN TRUE ELSE FALSE
|
||||
END AS available
|
||||
FROM skill_suite_version_member member
|
||||
LEFT JOIN skill ON skill.id = member.skill_id
|
||||
LEFT JOIN skill_version
|
||||
ON skill_version.id = member.skill_version_id
|
||||
AND skill_version.skill_id = member.skill_id
|
||||
LEFT JOIN namespace skill_namespace ON skill_namespace.id = skill.namespace_id
|
||||
WHERE member.suite_version_id IN (:suiteVersionIds)
|
||||
AND (member.skill_id IS NULL OR member.skill_id <> :skillId)
|
||||
ORDER BY member.suite_version_id, member.position
|
||||
""", parameters, (resultSet, rowNumber) -> new MemberRow(
|
||||
resultSet.getLong("suite_version_id"),
|
||||
resultSet.getObject("skill_id", Long.class),
|
||||
resultSet.getString("namespace_slug"),
|
||||
resultSet.getString("slug"),
|
||||
resultSet.getString("display_name"),
|
||||
resultSet.getString("version"),
|
||||
resultSet.getBoolean("entry"),
|
||||
resultSet.getBoolean("visible"),
|
||||
resultSet.getBoolean("available")));
|
||||
Map<Long, List<MemberRow>> grouped = new LinkedHashMap<>();
|
||||
rows.forEach(row -> grouped.computeIfAbsent(row.suiteVersionId(), ignored -> new ArrayList<>()).add(row));
|
||||
return grouped;
|
||||
}
|
||||
|
||||
private SkillSuiteReferenceResponse toResponse(SuiteRow suite, List<MemberRow> members) {
|
||||
List<MemberRow> visible = members.stream().filter(MemberRow::visible).toList();
|
||||
List<SkillSuiteSiblingMemberResponse> summaries = visible.stream()
|
||||
.limit(MAX_VISIBLE_SIBLINGS)
|
||||
.map(member -> new SkillSuiteSiblingMemberResponse(
|
||||
member.skillId(), member.namespace(), member.slug(), member.displayName(),
|
||||
member.version(), member.entry(), member.available()))
|
||||
.toList();
|
||||
int restrictedCount = (int) members.stream().filter(member -> !member.visible()).count();
|
||||
int omittedVisibleCount = Math.max(0, visible.size() - summaries.size());
|
||||
return new SkillSuiteReferenceResponse(
|
||||
suite.suiteId(), suite.namespace(), suite.slug(), suite.displayName(), suite.version(),
|
||||
suite.memberCount(), suite.currentSkillEntry(), summaries,
|
||||
restrictedCount, omittedVisibleCount);
|
||||
}
|
||||
|
||||
private MapSqlParameterSource viewerParameters(
|
||||
Long skillId,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
List<Long> memberNamespaceIds = namespaceRoles.keySet().stream().toList();
|
||||
List<Long> adminNamespaceIds = namespaceRoles.entrySet().stream()
|
||||
|
|
@ -40,32 +223,20 @@ public class SkillSuiteReferenceQueryRepository {
|
|||
|| entry.getValue() == NamespaceRole.ADMIN)
|
||||
.map(Map.Entry::getKey)
|
||||
.toList();
|
||||
MapSqlParameterSource parameters = new MapSqlParameterSource()
|
||||
return new MapSqlParameterSource()
|
||||
.addValue("skillId", skillId)
|
||||
.addValue("userId", userId)
|
||||
.addValue("memberNamespaceIds", nonEmpty(memberNamespaceIds))
|
||||
.addValue("adminNamespaceIds", nonEmpty(adminNamespaceIds))
|
||||
.addValue("authenticated", userId != null)
|
||||
.addValue("superAdmin", platformRoles.contains("SUPER_ADMIN"));
|
||||
}
|
||||
|
||||
return jdbcTemplate.query("""
|
||||
SELECT suite.id,
|
||||
namespace.slug AS namespace_slug,
|
||||
suite.slug,
|
||||
version.display_name,
|
||||
version.version,
|
||||
COUNT(all_members.id) AS member_count
|
||||
FROM skill_suite suite
|
||||
JOIN namespace ON namespace.id = suite.namespace_id
|
||||
JOIN skill_suite_version version ON version.id = suite.latest_version_id
|
||||
JOIN skill_suite_version_member entry_member
|
||||
ON entry_member.suite_version_id = version.id AND entry_member.entry = TRUE
|
||||
JOIN skill_suite_version_member all_members
|
||||
ON all_members.suite_version_id = version.id
|
||||
WHERE entry_member.skill_id = :skillId
|
||||
AND suite.status = 'ACTIVE'
|
||||
private String visibleSuitePredicate() {
|
||||
return """
|
||||
suite.status = 'ACTIVE'
|
||||
AND suite.hidden = FALSE
|
||||
AND namespace.status = 'ACTIVE'
|
||||
AND suite_namespace.status = 'ACTIVE'
|
||||
AND version.status = 'PUBLISHED'
|
||||
AND (
|
||||
:superAdmin = TRUE
|
||||
|
|
@ -79,18 +250,36 @@ public class SkillSuiteReferenceQueryRepository {
|
|||
AND suite.namespace_id IN (:memberNamespaceIds))
|
||||
))
|
||||
)
|
||||
GROUP BY suite.id, namespace.slug, suite.slug, version.display_name, version.version
|
||||
ORDER BY LOWER(version.display_name), suite.id
|
||||
""", parameters, (resultSet, rowNumber) -> new SkillSuiteReferenceResponse(
|
||||
resultSet.getLong("id"),
|
||||
resultSet.getString("namespace_slug"),
|
||||
resultSet.getString("slug"),
|
||||
resultSet.getString("display_name"),
|
||||
resultSet.getString("version"),
|
||||
resultSet.getInt("member_count")));
|
||||
""";
|
||||
}
|
||||
|
||||
private List<Long> nonEmpty(List<Long> values) {
|
||||
return values.isEmpty() ? List.of(-1L) : values;
|
||||
}
|
||||
|
||||
private record SuiteRow(
|
||||
Long suiteId,
|
||||
Long suiteVersionId,
|
||||
String namespace,
|
||||
String slug,
|
||||
String displayName,
|
||||
String version,
|
||||
boolean currentSkillEntry,
|
||||
int memberCount,
|
||||
long totalCount
|
||||
) {
|
||||
}
|
||||
|
||||
private record MemberRow(
|
||||
Long suiteVersionId,
|
||||
Long skillId,
|
||||
String namespace,
|
||||
String slug,
|
||||
String displayName,
|
||||
String version,
|
||||
boolean entry,
|
||||
boolean visible,
|
||||
boolean available
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,12 +99,13 @@ public class GovernanceWorkflowAppService {
|
|||
}
|
||||
|
||||
public ReviewProgressPageResponse listMyReviewProgress(
|
||||
String subjectType,
|
||||
String status,
|
||||
String query,
|
||||
int page,
|
||||
int size,
|
||||
String userId) {
|
||||
return reviewPortalAppService.listMyProgress(status, query, page, size, userId);
|
||||
return reviewPortalAppService.listMyProgress(subjectType, status, query, page, size, userId);
|
||||
}
|
||||
|
||||
public List<ReviewTaskResponse> listMyReviewAttempts(Long reviewTaskId, String userId) {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.repository.SkillSuiteLabelQueryRepository;
|
||||
|
||||
import com.iflytek.skillhub.dto.ResourceSearchResponse;
|
||||
import com.iflytek.skillhub.dto.ResourceSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.search.ResourceDiscoveryQueryService;
|
||||
import com.iflytek.skillhub.search.ResourceDiscoveryQueryService.ResourceQuery;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
|
|
@ -12,9 +18,14 @@ import org.springframework.stereotype.Service;
|
|||
public class ResourceDiscoveryAppService {
|
||||
|
||||
private final ResourceDiscoveryQueryService queryService;
|
||||
private final SkillSuiteLabelQueryRepository suiteLabelProjectionService;
|
||||
|
||||
public ResourceDiscoveryAppService(ResourceDiscoveryQueryService queryService) {
|
||||
public ResourceDiscoveryAppService(
|
||||
ResourceDiscoveryQueryService queryService,
|
||||
SkillSuiteLabelQueryRepository suiteLabelProjectionService
|
||||
) {
|
||||
this.queryService = queryService;
|
||||
this.suiteLabelProjectionService = suiteLabelProjectionService;
|
||||
}
|
||||
|
||||
public ResourceSearchResponse search(
|
||||
|
|
@ -25,11 +36,30 @@ public class ResourceDiscoveryAppService {
|
|||
int page,
|
||||
int size,
|
||||
Set<Long> memberNamespaceIds
|
||||
) {
|
||||
return search(keyword, namespace, resourceType, sort, page, size, memberNamespaceIds, List.of());
|
||||
}
|
||||
|
||||
public ResourceSearchResponse search(
|
||||
String keyword,
|
||||
String namespace,
|
||||
String resourceType,
|
||||
String sort,
|
||||
int page,
|
||||
int size,
|
||||
Set<Long> memberNamespaceIds,
|
||||
List<String> labelSlugs
|
||||
) {
|
||||
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));
|
||||
keyword, namespace, resourceType, sort, safePage, safeSize, memberNamespaceIds,
|
||||
normalizeLabelSlugs(labelSlugs)));
|
||||
Map<Long, List<SkillLabelDto>> labelsBySuiteId =
|
||||
suiteLabelProjectionService.labelsBySuiteIds(result.items().stream()
|
||||
.filter(item -> "SUITE".equals(item.resourceType()))
|
||||
.map(ResourceDiscoveryQueryService.ResourceHit::id)
|
||||
.toList());
|
||||
return new ResourceSearchResponse(
|
||||
result.items().stream().map(item -> new ResourceSummaryResponse(
|
||||
item.resourceType(),
|
||||
|
|
@ -44,7 +74,21 @@ public class ResourceDiscoveryAppService {
|
|||
item.visibility(),
|
||||
item.installCount(),
|
||||
item.available(),
|
||||
item.updatedAt())).toList(),
|
||||
item.updatedAt(),
|
||||
"SUITE".equals(item.resourceType())
|
||||
? labelsBySuiteId.getOrDefault(item.id(), List.of())
|
||||
: List.of())).toList(),
|
||||
result.total(), result.page(), result.size());
|
||||
}
|
||||
|
||||
private List<String> normalizeLabelSlugs(List<String> labelSlugs) {
|
||||
if (labelSlugs == null || labelSlugs.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
return labelSlugs.stream()
|
||||
.filter(value -> value != null && !value.isBlank())
|
||||
.map(value -> value.trim().toLowerCase(Locale.ROOT))
|
||||
.distinct()
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -241,11 +241,15 @@ public class ReviewPortalAppService {
|
|||
}
|
||||
|
||||
public ReviewProgressPageResponse listMyProgress(
|
||||
String subjectType,
|
||||
String status,
|
||||
String query,
|
||||
int page,
|
||||
int size,
|
||||
String userId) {
|
||||
ReviewSubjectType reviewSubjectType = subjectType == null || subjectType.isBlank()
|
||||
? null
|
||||
: ReviewSubjectType.valueOf(subjectType.toUpperCase(java.util.Locale.ROOT));
|
||||
ReviewTaskStatus reviewStatus = status == null || status.isBlank()
|
||||
? null
|
||||
: ReviewTaskStatus.valueOf(status.toUpperCase(java.util.Locale.ROOT));
|
||||
|
|
@ -253,6 +257,7 @@ public class ReviewPortalAppService {
|
|||
int safeSize = Math.min(Math.max(size, 1), 100);
|
||||
return reviewProgressQueryRepository.findMyProgress(
|
||||
userId,
|
||||
reviewSubjectType,
|
||||
reviewStatus,
|
||||
query != null ? query : "",
|
||||
safePage,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import com.iflytek.skillhub.domain.skill.SkillVersion;
|
|||
import com.iflytek.skillhub.domain.skill.service.SkillDownloadService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillQueryService;
|
||||
import com.iflytek.skillhub.dto.ReviewSkillDetailResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.SkillDetailResponse;
|
||||
import com.iflytek.skillhub.dto.SkillFileResponse;
|
||||
import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse;
|
||||
|
|
@ -85,7 +86,8 @@ public class ReviewSkillDetailAppService {
|
|||
toLifecycleVersion(snapshot.activeVersion()),
|
||||
null,
|
||||
"REVIEW_TASK",
|
||||
List.of()
|
||||
List.of(),
|
||||
new PageResponse<>(List.of(), 0, 0, 20)
|
||||
);
|
||||
|
||||
List<SkillVersionResponse> versions = snapshot.versions().stream()
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ 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.domain.user.UserAccount;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteCreateRequest;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteMemberRequest;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteMemberResponse;
|
||||
|
|
@ -36,6 +38,7 @@ import com.iflytek.skillhub.repository.SkillSuiteCandidateQueryRepository;
|
|||
import com.iflytek.skillhub.repository.SkillSuiteReferenceQueryRepository;
|
||||
import com.iflytek.skillhub.repository.MySkillSuiteQueryRepository;
|
||||
import com.iflytek.skillhub.dto.MySkillSuiteSummaryResponse;
|
||||
import com.iflytek.skillhub.dto.MySkillSuiteWorkspaceResponse;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
|
|
@ -48,11 +51,13 @@ import java.security.GeneralSecurityException;
|
|||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Application boundary for resolving Suite inputs and invoking domain workflows. */
|
||||
@Service
|
||||
|
|
@ -72,6 +77,7 @@ public class SkillSuiteAppService {
|
|||
private final SkillSuiteCandidateQueryRepository candidateQueryRepository;
|
||||
private final MySkillSuiteQueryRepository mySkillSuiteQueryRepository;
|
||||
private final SkillSuiteReferenceQueryRepository referenceQueryRepository;
|
||||
private final UserAccountRepository userAccountRepository;
|
||||
|
||||
public SkillSuiteAppService(
|
||||
NamespaceRepository namespaceRepository,
|
||||
|
|
@ -85,7 +91,8 @@ public class SkillSuiteAppService {
|
|||
RequestIdAccessor requestIdAccessor,
|
||||
SkillSuiteCandidateQueryRepository candidateQueryRepository,
|
||||
MySkillSuiteQueryRepository mySkillSuiteQueryRepository,
|
||||
SkillSuiteReferenceQueryRepository referenceQueryRepository
|
||||
SkillSuiteReferenceQueryRepository referenceQueryRepository,
|
||||
UserAccountRepository userAccountRepository
|
||||
) {
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillQueryService = skillQueryService;
|
||||
|
|
@ -99,6 +106,7 @@ public class SkillSuiteAppService {
|
|||
this.candidateQueryRepository = candidateQueryRepository;
|
||||
this.mySkillSuiteQueryRepository = mySkillSuiteQueryRepository;
|
||||
this.referenceQueryRepository = referenceQueryRepository;
|
||||
this.userAccountRepository = userAccountRepository;
|
||||
}
|
||||
|
||||
public PageResponse<MySkillSuiteSummaryResponse> listMine(
|
||||
|
|
@ -118,6 +126,21 @@ public class SkillSuiteAppService {
|
|||
Math.max(0, page), Math.min(Math.max(1, size), 100));
|
||||
}
|
||||
|
||||
public MySkillSuiteWorkspaceResponse workspace(
|
||||
String userId, Map<Long, NamespaceRole> namespaceRoles,
|
||||
String query, String state, int page, int size
|
||||
) {
|
||||
String filter = state == null ? "" : state;
|
||||
if (!Set.of("", "ATTENTION", "DRAFT", "PENDING_REVIEW", "PUBLISHED", "OTHER").contains(filter)) {
|
||||
throw new DomainBadRequestException("error.suite.workspace.invalidFilter");
|
||||
}
|
||||
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.findWorkspace(userId, namespaceRoles.keySet(), adminNamespaceIds,
|
||||
query, filter, Math.max(0, page), Math.min(Math.max(1, size), 100));
|
||||
}
|
||||
|
||||
public List<SkillSuiteMemberCandidateResponse> searchCandidates(
|
||||
String suiteNamespace,
|
||||
SkillVisibility visibility,
|
||||
|
|
@ -159,6 +182,18 @@ public class SkillSuiteAppService {
|
|||
skillId, userId, namespaceRoles, platformRoles);
|
||||
}
|
||||
|
||||
public PageResponse<SkillSuiteReferenceResponse> findVisibleMemberships(
|
||||
Long skillId,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles,
|
||||
int page,
|
||||
int size
|
||||
) {
|
||||
return referenceQueryRepository.findVisibleMemberships(
|
||||
skillId, userId, namespaceRoles, platformRoles, page, size);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillSuiteInstallPlanResponse createInstallPlan(
|
||||
String namespace,
|
||||
|
|
@ -353,7 +388,9 @@ public class SkillSuiteAppService {
|
|||
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().getOverview(), detail.version().getChangelog(), detail.version().getCreatedBy(),
|
||||
creatorName(detail.version().getCreatedBy()),
|
||||
detail.version().getCreatedAt(), detail.version().getPublishedAt(), detail.version().getYankedAt(),
|
||||
detail.version().getVersion(), detail.version().getStatus().name(),
|
||||
detail.version().getVisibility(), detail.suite().getStatus().name(),
|
||||
detail.suite().isHidden(), allowedActions, detail.available(), members);
|
||||
|
|
@ -417,9 +454,18 @@ public class SkillSuiteAppService {
|
|||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
return queryService.listVersions(namespace, slug, userId, namespaceRoles, platformRoles).stream()
|
||||
List<SkillSuiteQueryService.VersionSummary> versions =
|
||||
queryService.listVersions(namespace, slug, userId, namespaceRoles, platformRoles);
|
||||
Map<String, String> creatorNames = userAccountRepository.findByIdIn(versions.stream()
|
||||
.map(SkillSuiteQueryService.VersionSummary::createdBy)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList()).stream()
|
||||
.collect(Collectors.toMap(UserAccount::getId, UserAccount::getDisplayName));
|
||||
return versions.stream()
|
||||
.map(version -> new SkillSuiteVersionSummaryResponse(
|
||||
version.id(), version.version(), version.status().name(), version.visibility(),
|
||||
version.changelog(), version.createdBy(), creatorNames.get(version.createdBy()),
|
||||
version.publishedAt(), version.yankedAt(), version.createdAt()))
|
||||
.toList();
|
||||
}
|
||||
|
|
@ -559,14 +605,13 @@ public class SkillSuiteAppService {
|
|||
Set<String> platformRoles
|
||||
) {
|
||||
List<SkillSuiteMemberSelection> selections = new ArrayList<>(request.members().size());
|
||||
List<String> invalidMembers = new ArrayList<>();
|
||||
Set<String> invalidMembers = new LinkedHashSet<>();
|
||||
for (SkillSuiteMemberRequest member : request.members()) {
|
||||
try {
|
||||
selections.add(resolve(member, userId, namespaceRoles, platformRoles));
|
||||
} catch (LocalizedDomainException exception) {
|
||||
invalidMembers.add(String.format(
|
||||
"@%s/%s@%s (%s)", member.namespace(), member.slug(), member.version(),
|
||||
exception.messageCode()));
|
||||
"@%s/%s@%s", member.namespace(), member.slug(), member.version()));
|
||||
}
|
||||
}
|
||||
if (!invalidMembers.isEmpty()) {
|
||||
|
|
@ -632,12 +677,20 @@ public class SkillSuiteAppService {
|
|||
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().getOverview(), created.version().getChangelog(), created.version().getCreatedBy(),
|
||||
creatorName(created.version().getCreatedBy()),
|
||||
created.version().getCreatedAt(), created.version().getPublishedAt(), created.version().getYankedAt(),
|
||||
created.version().getVersion(), created.version().getStatus().name(),
|
||||
created.version().getVisibility(), created.suite().getStatus().name(),
|
||||
created.suite().isHidden(), allowedActions, false, members);
|
||||
}
|
||||
|
||||
private String creatorName(String userId) {
|
||||
return userId == null ? null : userAccountRepository.findById(userId)
|
||||
.map(UserAccount::getDisplayName)
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private SkillSuiteActionContext authorizationContext(
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
package com.iflytek.skillhub.service;
|
||||
|
||||
import com.iflytek.skillhub.repository.SkillSuiteLabelQueryRepository;
|
||||
|
||||
import com.iflytek.skillhub.domain.audit.AuditDetail;
|
||||
import com.iflytek.skillhub.domain.audit.AuditLogService;
|
||||
import com.iflytek.skillhub.domain.label.SkillSuiteLabel;
|
||||
import com.iflytek.skillhub.domain.label.SkillSuiteLabelService;
|
||||
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.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.suite.SkillSuite;
|
||||
import com.iflytek.skillhub.domain.suite.SkillSuiteQueryService;
|
||||
import com.iflytek.skillhub.domain.suite.SkillSuiteRepository;
|
||||
import com.iflytek.skillhub.dto.MessageResponse;
|
||||
import com.iflytek.skillhub.dto.SkillLabelDto;
|
||||
import com.iflytek.skillhub.observability.RequestIdAccessor;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Coordinates Suite label visibility, mutations, localization, and Suite-scoped audit. */
|
||||
@Service
|
||||
public class SkillSuiteLabelAppService {
|
||||
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillSuiteRepository suiteRepository;
|
||||
private final SkillSuiteQueryService suiteQueryService;
|
||||
private final SkillSuiteLabelService suiteLabelService;
|
||||
private final SkillSuiteLabelQueryRepository projectionService;
|
||||
private final AuditLogService auditLogService;
|
||||
private final RequestIdAccessor requestIdAccessor;
|
||||
|
||||
public SkillSuiteLabelAppService(
|
||||
NamespaceRepository namespaceRepository,
|
||||
SkillSuiteRepository suiteRepository,
|
||||
SkillSuiteQueryService suiteQueryService,
|
||||
SkillSuiteLabelService suiteLabelService,
|
||||
SkillSuiteLabelQueryRepository projectionService,
|
||||
AuditLogService auditLogService,
|
||||
RequestIdAccessor requestIdAccessor
|
||||
) {
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.suiteRepository = suiteRepository;
|
||||
this.suiteQueryService = suiteQueryService;
|
||||
this.suiteLabelService = suiteLabelService;
|
||||
this.projectionService = projectionService;
|
||||
this.auditLogService = auditLogService;
|
||||
this.requestIdAccessor = requestIdAccessor;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<SkillLabelDto> listLabels(
|
||||
String namespaceSlug,
|
||||
String suiteSlug,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
SkillSuite suite = resolveSuite(namespaceSlug, suiteSlug);
|
||||
Map<Long, NamespaceRole> safeNamespaceRoles = roles(namespaceRoles);
|
||||
Set<String> safePlatformRoles = roles(platformRoles);
|
||||
if (!canManageContainer(suite, userId, safeNamespaceRoles, safePlatformRoles)) {
|
||||
suiteQueryService.getDetail(
|
||||
namespaceSlug, suiteSlug, null, userId, safeNamespaceRoles, safePlatformRoles);
|
||||
}
|
||||
return projectionService.labelsBySuiteIds(List.of(suite.getId()))
|
||||
.getOrDefault(suite.getId(), List.of());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillLabelDto attachLabel(
|
||||
String namespaceSlug,
|
||||
String suiteSlug,
|
||||
String labelSlug,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles,
|
||||
AuditRequestContext auditContext
|
||||
) {
|
||||
SkillSuite suite = resolveSuite(namespaceSlug, suiteSlug);
|
||||
SkillSuiteLabel attached = suiteLabelService.attachLabel(
|
||||
suite.getId(), labelSlug, userId, roles(namespaceRoles), roles(platformRoles));
|
||||
recordAudit("SKILL_SUITE_LABEL_ATTACH", userId, suite.getId(), labelSlug, auditContext);
|
||||
return projectionService.labelsBySuiteIds(List.of(attached.getSuiteId()))
|
||||
.getOrDefault(attached.getSuiteId(), List.of()).stream()
|
||||
.filter(label -> label.slug().equalsIgnoreCase(labelSlug.trim()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException("Attached Suite label projection is missing"));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MessageResponse detachLabel(
|
||||
String namespaceSlug,
|
||||
String suiteSlug,
|
||||
String labelSlug,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles,
|
||||
AuditRequestContext auditContext
|
||||
) {
|
||||
SkillSuite suite = resolveSuite(namespaceSlug, suiteSlug);
|
||||
suiteLabelService.detachLabel(
|
||||
suite.getId(), labelSlug, userId, roles(namespaceRoles), roles(platformRoles));
|
||||
recordAudit("SKILL_SUITE_LABEL_DETACH", userId, suite.getId(), labelSlug, auditContext);
|
||||
return new MessageResponse("Suite label detached");
|
||||
}
|
||||
|
||||
private SkillSuite resolveSuite(String namespaceSlug, String suiteSlug) {
|
||||
Namespace namespace = namespaceRepository.findBySlug(namespaceSlug)
|
||||
.orElseThrow(() -> new DomainNotFoundException("namespace.not_found", namespaceSlug));
|
||||
return suiteRepository.findByNamespaceIdAndSlug(namespace.getId(), suiteSlug)
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.suite.notFound", suiteSlug));
|
||||
}
|
||||
|
||||
private boolean canManageContainer(
|
||||
SkillSuite suite,
|
||||
String userId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
if (platformRoles.contains("SUPER_ADMIN")) {
|
||||
return true;
|
||||
}
|
||||
NamespaceRole role = namespaceRoles.get(suite.getNamespaceId());
|
||||
return role == NamespaceRole.OWNER
|
||||
|| role == NamespaceRole.ADMIN
|
||||
|| (role != null && userId != null && userId.equals(suite.getCreatedBy()));
|
||||
}
|
||||
|
||||
private void recordAudit(
|
||||
String action,
|
||||
String userId,
|
||||
Long suiteId,
|
||||
String labelSlug,
|
||||
AuditRequestContext auditContext
|
||||
) {
|
||||
auditLogService.record(
|
||||
userId,
|
||||
action,
|
||||
"SKILL_SUITE",
|
||||
suiteId,
|
||||
requestIdAccessor.current(),
|
||||
auditContext != null ? auditContext.clientIp() : null,
|
||||
auditContext != null ? auditContext.userAgent() : null,
|
||||
AuditDetail.of("labelSlug", labelSlug));
|
||||
}
|
||||
|
||||
private Map<Long, NamespaceRole> roles(Map<Long, NamespaceRole> roles) {
|
||||
return roles == null ? Map.of() : roles;
|
||||
}
|
||||
|
||||
private Set<String> roles(Set<String> roles) {
|
||||
return roles == null ? Set.of() : roles;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.iflytek.skillhub.auth.rbac.RbacService;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
|
||||
import com.iflytek.skillhub.domain.user.UserAccountRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Resolves fresh persisted authorization for asynchronous Bundle boundaries. */
|
||||
@Service
|
||||
public class SkillSuiteBundleActorContextService {
|
||||
|
||||
private final UserAccountRepository userRepository;
|
||||
private final NamespaceMemberRepository namespaceMemberRepository;
|
||||
private final RbacService rbacService;
|
||||
|
||||
public SkillSuiteBundleActorContextService(
|
||||
UserAccountRepository userRepository,
|
||||
NamespaceMemberRepository namespaceMemberRepository,
|
||||
RbacService rbacService
|
||||
) {
|
||||
this.userRepository = userRepository;
|
||||
this.namespaceMemberRepository = namespaceMemberRepository;
|
||||
this.rbacService = rbacService;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public ActorContext requireCurrent(String actorId) {
|
||||
if (userRepository.findById(actorId).filter(user -> user.isActive()).isEmpty()) {
|
||||
throw new DomainForbiddenException("error.suite.bundle.actor.inactive");
|
||||
}
|
||||
Map<Long, NamespaceRole> namespaceRoles = namespaceMemberRepository.findByUserId(actorId).stream()
|
||||
.collect(Collectors.toUnmodifiableMap(
|
||||
member -> member.getNamespaceId(), member -> member.getRole()));
|
||||
Set<String> platformRoles = Set.copyOf(rbacService.getUserRoleCodes(actorId));
|
||||
return new ActorContext(namespaceRoles, platformRoles);
|
||||
}
|
||||
|
||||
public record ActorContext(
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.iflytek.skillhub.config.SkillPublishProperties;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.DigestInputStream;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
/** Stages and analyzes one uploaded Bundle without retaining the expanded archive in heap memory. */
|
||||
@Service
|
||||
public class SkillSuiteBundleArchiveService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SkillSuiteBundleArchiveService.class);
|
||||
private static final int BUFFER_SIZE = 8192;
|
||||
private static final int MAX_MEMBERS = 100;
|
||||
private static final int ZIP_CENTRAL_HEADER_SIZE = 46;
|
||||
private static final int ZIP_EOCD_MIN_SIZE = 22;
|
||||
private static final int ZIP_EOCD_MAX_SEARCH = 65_557;
|
||||
private static final int UNIX_FILE_TYPE_MASK = 0170000;
|
||||
private static final int UNIX_SYMBOLIC_LINK = 0120000;
|
||||
|
||||
private final SkillSuiteBundlePackageAnalyzer analyzer;
|
||||
private final ObjectStorageService objectStorageService;
|
||||
private final long maxArchiveSize;
|
||||
private final long maxExpandedSize;
|
||||
private final long maxSingleFileSize;
|
||||
private final int maxFileCount;
|
||||
|
||||
public SkillSuiteBundleArchiveService(
|
||||
SkillSuiteBundlePackageAnalyzer analyzer,
|
||||
ObjectStorageService objectStorageService,
|
||||
SkillPublishProperties properties
|
||||
) {
|
||||
this.analyzer = analyzer;
|
||||
this.objectStorageService = objectStorageService;
|
||||
this.maxArchiveSize = properties.getMaxPackageSize();
|
||||
this.maxExpandedSize = properties.getMaxPackageSize();
|
||||
this.maxSingleFileSize = properties.getMaxSingleFileSize();
|
||||
this.maxFileCount = Math.multiplyExact(properties.getMaxFileCount(), MAX_MEMBERS);
|
||||
}
|
||||
|
||||
public StagedBundleAnalysis stageAndAnalyze(MultipartFile upload) throws IOException {
|
||||
if (upload == null || upload.isEmpty()) {
|
||||
throw invalid("Bundle archive is empty");
|
||||
}
|
||||
if (upload.getSize() > maxArchiveSize) {
|
||||
throw invalid("Bundle archive exceeds max compressed size " + maxArchiveSize);
|
||||
}
|
||||
|
||||
String stagingId = UUID.randomUUID().toString();
|
||||
String prefix = "temporary/suite-bundles/" + stagingId;
|
||||
String archiveObjectKey = prefix + "/bundle.zip";
|
||||
Path tempDirectory = Files.createTempDirectory("skillhub-suite-bundle-");
|
||||
Path archivePath = tempDirectory.resolve("bundle.zip");
|
||||
List<Path> localEntries = new ArrayList<>();
|
||||
List<String> uploadedObjectKeys = new ArrayList<>();
|
||||
|
||||
try {
|
||||
String archiveSha256 = copyUpload(upload, archivePath);
|
||||
rejectSymbolicLinks(archivePath);
|
||||
try (InputStream archiveInput = Files.newInputStream(archivePath)) {
|
||||
objectStorageService.putObject(
|
||||
archiveObjectKey, archiveInput, Files.size(archivePath), "application/zip");
|
||||
}
|
||||
uploadedObjectKeys.add(archiveObjectKey);
|
||||
|
||||
List<SkillSuiteBundleStagedEntry> stagedEntries = extractEntries(
|
||||
archivePath, tempDirectory, prefix, localEntries, uploadedObjectKeys);
|
||||
SkillSuiteBundlePackageAnalyzer.BundleAnalysis analysis = analyzer.analyze(stagedEntries);
|
||||
if (!analysis.confirmable()) {
|
||||
cleanupStagedObjects(uploadedObjectKeys);
|
||||
return new StagedBundleAnalysis(null, null, analysis, List.of());
|
||||
}
|
||||
Set<String> retainedKeys = analysis.packageMembers().stream()
|
||||
.flatMap(member -> member.files().stream())
|
||||
.map(SkillSuiteBundlePackageAnalyzer.StagedMemberFile::objectKey)
|
||||
.collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
|
||||
retainedKeys.add(archiveObjectKey);
|
||||
List<String> unusedKeys = uploadedObjectKeys.stream()
|
||||
.filter(key -> !retainedKeys.contains(key))
|
||||
.toList();
|
||||
cleanupStagedObjects(unusedKeys);
|
||||
return new StagedBundleAnalysis(
|
||||
archiveObjectKey, archiveSha256, analysis, List.copyOf(retainedKeys));
|
||||
} catch (IOException | RuntimeException exception) {
|
||||
cleanupStagedObjects(uploadedObjectKeys);
|
||||
throw exception;
|
||||
} finally {
|
||||
for (Path localEntry : localEntries) {
|
||||
Files.deleteIfExists(localEntry);
|
||||
}
|
||||
Files.deleteIfExists(archivePath);
|
||||
Files.deleteIfExists(tempDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
private String copyUpload(MultipartFile upload, Path archivePath) throws IOException {
|
||||
MessageDigest digest = sha256();
|
||||
long copied;
|
||||
try (InputStream raw = upload.getInputStream();
|
||||
DigestInputStream input = new DigestInputStream(raw, digest);
|
||||
OutputStream output = new BufferedOutputStream(Files.newOutputStream(archivePath))) {
|
||||
copied = copyBounded(input, output, maxArchiveSize, "Bundle archive");
|
||||
}
|
||||
if (upload.getSize() >= 0 && upload.getSize() != copied) {
|
||||
throw invalid("Bundle archive size changed during upload");
|
||||
}
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
}
|
||||
|
||||
private List<SkillSuiteBundleStagedEntry> extractEntries(
|
||||
Path archivePath,
|
||||
Path tempDirectory,
|
||||
String objectPrefix,
|
||||
List<Path> localEntries,
|
||||
List<String> uploadedObjectKeys
|
||||
) throws IOException {
|
||||
List<SkillSuiteBundleStagedEntry> staged = new ArrayList<>();
|
||||
long expandedSize = 0L;
|
||||
try (ZipInputStream zip = new ZipInputStream(Files.newInputStream(archivePath))) {
|
||||
ZipEntry entry;
|
||||
while ((entry = zip.getNextEntry()) != null) {
|
||||
if (entry.isDirectory() || entry.getName().endsWith("/") || entry.getName().endsWith("\\")) {
|
||||
zip.closeEntry();
|
||||
continue;
|
||||
}
|
||||
if (isOsMetadata(entry.getName())) {
|
||||
zip.closeEntry();
|
||||
continue;
|
||||
}
|
||||
if (staged.size() >= maxFileCount) {
|
||||
throw invalid("Bundle contains more than " + maxFileCount + " files");
|
||||
}
|
||||
|
||||
int position = staged.size();
|
||||
Path localPath = tempDirectory.resolve("entry-" + position);
|
||||
localEntries.add(localPath);
|
||||
MessageDigest digest = sha256();
|
||||
long size;
|
||||
try (OutputStream output = new BufferedOutputStream(Files.newOutputStream(localPath))) {
|
||||
size = copyZipEntry(zip, output, digest, entry.getName());
|
||||
}
|
||||
expandedSize = Math.addExact(expandedSize, size);
|
||||
if (expandedSize > maxExpandedSize) {
|
||||
throw invalid("Bundle expanded content exceeds max size " + maxExpandedSize);
|
||||
}
|
||||
|
||||
String objectKey = objectPrefix + "/entries/" + position;
|
||||
String contentType = contentType(entry.getName());
|
||||
try (InputStream entryInput = Files.newInputStream(localPath)) {
|
||||
objectStorageService.putObject(objectKey, entryInput, size, contentType);
|
||||
}
|
||||
uploadedObjectKeys.add(objectKey);
|
||||
String sha256 = HexFormat.of().formatHex(digest.digest());
|
||||
staged.add(new SkillSuiteBundleStagedEntry(
|
||||
entry.getName(), size, contentType, sha256, objectKey,
|
||||
() -> Files.newInputStream(localPath)));
|
||||
zip.closeEntry();
|
||||
}
|
||||
} catch (ArithmeticException exception) {
|
||||
throw invalid("Bundle expanded content size overflow");
|
||||
}
|
||||
return staged;
|
||||
}
|
||||
|
||||
private long copyZipEntry(
|
||||
InputStream input, OutputStream output, MessageDigest digest, String path
|
||||
) throws IOException {
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
long copied = 0L;
|
||||
int read;
|
||||
while ((read = input.read(buffer)) != -1) {
|
||||
copied += read;
|
||||
if (copied > maxSingleFileSize) {
|
||||
throw invalid("Bundle file exceeds max size: " + path);
|
||||
}
|
||||
digest.update(buffer, 0, read);
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
private long copyBounded(
|
||||
InputStream input, OutputStream output, long maximum, String subject
|
||||
) throws IOException {
|
||||
byte[] buffer = new byte[BUFFER_SIZE];
|
||||
long copied = 0L;
|
||||
int read;
|
||||
while ((read = input.read(buffer)) != -1) {
|
||||
copied += read;
|
||||
if (copied > maximum) {
|
||||
throw invalid(subject + " exceeds max size " + maximum);
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return copied;
|
||||
}
|
||||
|
||||
/** java.util.zip does not expose Unix modes, so inspect central-directory attributes directly. */
|
||||
private void rejectSymbolicLinks(Path archivePath) throws IOException {
|
||||
try (FileChannel channel = FileChannel.open(archivePath, StandardOpenOption.READ)) {
|
||||
long fileSize = channel.size();
|
||||
int tailLength = (int) Math.min(fileSize, ZIP_EOCD_MAX_SEARCH);
|
||||
ByteBuffer tail = ByteBuffer.allocate(tailLength).order(ByteOrder.LITTLE_ENDIAN);
|
||||
readFully(channel, tail, fileSize - tailLength);
|
||||
int eocd = findEndOfCentralDirectory(tail);
|
||||
if (eocd < 0 || eocd + ZIP_EOCD_MIN_SIZE > tailLength) {
|
||||
throw invalid("Invalid ZIP end-of-central-directory record");
|
||||
}
|
||||
int entryCount = Short.toUnsignedInt(tail.getShort(eocd + 10));
|
||||
long centralSize = Integer.toUnsignedLong(tail.getInt(eocd + 12));
|
||||
long centralOffset = Integer.toUnsignedLong(tail.getInt(eocd + 16));
|
||||
if (entryCount == 0xffff || centralSize == 0xffffffffL || centralOffset == 0xffffffffL) {
|
||||
throw invalid("ZIP64 Bundle archives are not supported");
|
||||
}
|
||||
if (centralOffset + centralSize > fileSize) {
|
||||
throw invalid("Invalid ZIP central-directory bounds");
|
||||
}
|
||||
|
||||
long cursor = centralOffset;
|
||||
for (int index = 0; index < entryCount; index++) {
|
||||
ByteBuffer header = ByteBuffer.allocate(ZIP_CENTRAL_HEADER_SIZE).order(ByteOrder.LITTLE_ENDIAN);
|
||||
readFully(channel, header, cursor);
|
||||
if (header.getInt(0) != 0x02014b50) {
|
||||
throw invalid("Invalid ZIP central-directory entry");
|
||||
}
|
||||
int creatorSystem = Byte.toUnsignedInt(header.get(5));
|
||||
int nameLength = Short.toUnsignedInt(header.getShort(28));
|
||||
int extraLength = Short.toUnsignedInt(header.getShort(30));
|
||||
int commentLength = Short.toUnsignedInt(header.getShort(32));
|
||||
long externalAttributes = Integer.toUnsignedLong(header.getInt(38));
|
||||
int unixMode = (int) (externalAttributes >>> 16);
|
||||
if (creatorSystem == 3 && (unixMode & UNIX_FILE_TYPE_MASK) == UNIX_SYMBOLIC_LINK) {
|
||||
throw invalid("Bundle archive must not contain symbolic links");
|
||||
}
|
||||
cursor += ZIP_CENTRAL_HEADER_SIZE + nameLength + extraLength + commentLength;
|
||||
if (cursor > centralOffset + centralSize) {
|
||||
throw invalid("Invalid ZIP central-directory entry bounds");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void readFully(FileChannel channel, ByteBuffer buffer, long position) throws IOException {
|
||||
while (buffer.hasRemaining()) {
|
||||
int read = channel.read(buffer, position + buffer.position());
|
||||
if (read < 0) {
|
||||
throw invalid("Unexpected end of ZIP archive");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private int findEndOfCentralDirectory(ByteBuffer tail) {
|
||||
byte[] bytes = tail.array();
|
||||
int signature = 0x06054b50;
|
||||
for (int index = bytes.length - ZIP_EOCD_MIN_SIZE; index >= 0; index--) {
|
||||
if ((bytes[index] & 0xff) == (signature & 0xff)
|
||||
&& (bytes[index + 1] & 0xff) == ((signature >>> 8) & 0xff)
|
||||
&& (bytes[index + 2] & 0xff) == ((signature >>> 16) & 0xff)
|
||||
&& (bytes[index + 3] & 0xff) == ((signature >>> 24) & 0xff)) {
|
||||
int commentLength = Short.toUnsignedInt(tail.getShort(index + 20));
|
||||
if (index + ZIP_EOCD_MIN_SIZE + commentLength == bytes.length) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private String contentType(String path) {
|
||||
String lower = path.toLowerCase();
|
||||
if (lower.endsWith(".py")) return "text/x-python";
|
||||
if (lower.endsWith(".json")) return "application/json";
|
||||
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "application/x-yaml";
|
||||
if (lower.endsWith(".txt")) return "text/plain";
|
||||
if (lower.endsWith(".md")) return "text/markdown";
|
||||
if (lower.endsWith(".html")) return "text/html";
|
||||
if (lower.endsWith(".css")) return "text/css";
|
||||
if (lower.endsWith(".csv")) return "text/csv";
|
||||
if (lower.endsWith(".xml")) return "application/xml";
|
||||
if (lower.endsWith(".js") || lower.endsWith(".cjs") || lower.endsWith(".mjs")) {
|
||||
return "text/javascript";
|
||||
}
|
||||
if (lower.endsWith(".ts")) return "text/typescript";
|
||||
if (lower.endsWith(".sh") || lower.endsWith(".bash") || lower.endsWith(".zsh")) {
|
||||
return "text/x-shellscript";
|
||||
}
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".gif")) return "image/gif";
|
||||
if (lower.endsWith(".svg")) return "image/svg+xml";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".ico")) return "image/x-icon";
|
||||
if (lower.endsWith(".pdf")) return "application/pdf";
|
||||
if (lower.endsWith(".toml")) return "application/toml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
private boolean isOsMetadata(String path) {
|
||||
String normalized = path.replace('\\', '/');
|
||||
if (normalized.equals("__MACOSX") || normalized.startsWith("__MACOSX/")) {
|
||||
return true;
|
||||
}
|
||||
int slash = normalized.lastIndexOf('/');
|
||||
String fileName = slash < 0 ? normalized : normalized.substring(slash + 1);
|
||||
return fileName.equals(".DS_Store") || fileName.startsWith("._");
|
||||
}
|
||||
|
||||
private MessageDigest sha256() {
|
||||
try {
|
||||
return MessageDigest.getInstance("SHA-256");
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void cleanupStagedObjects(List<String> objectKeys) {
|
||||
if (objectKeys.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
objectStorageService.deleteObjects(List.copyOf(objectKeys));
|
||||
} catch (RuntimeException exception) {
|
||||
log.warn("Failed to clean staged Suite Bundle objects: count={}", objectKeys.size(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private DomainBadRequestException invalid(String detail) {
|
||||
return new DomainBadRequestException("error.suite.bundle.manifest.invalid", detail);
|
||||
}
|
||||
|
||||
public record StagedBundleAnalysis(
|
||||
String archiveObjectKey,
|
||||
String archiveSha256,
|
||||
SkillSuiteBundlePackageAnalyzer.BundleAnalysis analysis,
|
||||
List<String> objectKeys
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,177 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.iflytek.skillhub.config.SkillSuiteBundleProperties;
|
||||
import com.iflytek.skillhub.domain.event.SkillSuiteBundleAdvanceRequestedEvent;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifest;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMember;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
|
||||
import com.iflytek.skillhub.observability.RequestIdAccessor;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Atomically confirms one exact PreviewSession and acquires its Suite target reservation. */
|
||||
@Service
|
||||
public class SkillSuiteBundleConfirmationAppService {
|
||||
|
||||
private final SkillSuiteBundlePreviewSessionRepository previewRepository;
|
||||
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
|
||||
private final SkillSuiteBundleMemberResultRepository memberRepository;
|
||||
private final SkillSuiteBundlePreviewRevalidationService revalidationService;
|
||||
private final SkillSuiteBundleProperties properties;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final Clock clock;
|
||||
|
||||
public SkillSuiteBundleConfirmationAppService(
|
||||
SkillSuiteBundlePreviewSessionRepository previewRepository,
|
||||
SkillSuiteBundleExecutionOperationRepository operationRepository,
|
||||
SkillSuiteBundleMemberResultRepository memberRepository,
|
||||
SkillSuiteBundlePreviewRevalidationService revalidationService,
|
||||
SkillSuiteBundleProperties properties,
|
||||
ApplicationEventPublisher eventPublisher,
|
||||
Clock clock
|
||||
) {
|
||||
this.previewRepository = previewRepository;
|
||||
this.operationRepository = operationRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.revalidationService = revalidationService;
|
||||
this.properties = properties;
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public ConfirmationOutcome confirm(
|
||||
String previewToken,
|
||||
String clientRequestId,
|
||||
String confirmedWarningDigest,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
requireEnabled();
|
||||
String normalizedRequestId = normalizeRequestId(clientRequestId);
|
||||
ConfirmationOutcome existing = findIdempotent(actorId, normalizedRequestId, previewToken);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
SkillSuiteBundlePreviewSession preview = previewRepository.findByIdForUpdate(previewToken)
|
||||
.orElseThrow(() -> new DomainNotFoundException("error.suite.bundle.preview.notFound"));
|
||||
|
||||
// A concurrent confirmation may have committed while this transaction waited for the row lock.
|
||||
existing = findIdempotent(actorId, normalizedRequestId, previewToken);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
Instant now = clock.instant();
|
||||
preview.requireConfirmableBy(actorId, confirmedWarningDigest, now);
|
||||
SkillSuiteBundlePreviewRevalidationService.ValidatedPreview validated =
|
||||
revalidationService.requireUnchanged(preview, actorId, namespaceRoles, platformRoles);
|
||||
SkillSuiteBundlePreviewPlanner.PreviewPlan previewPlan = validated.plan();
|
||||
SkillSuiteBundleManifest manifest = validated.manifest();
|
||||
|
||||
String operationId = UUID.randomUUID().toString();
|
||||
SkillSuiteBundleExecutionOperation operation = new SkillSuiteBundleExecutionOperation(
|
||||
operationId, previewToken, normalizedRequestId, actorId, preview.getMode(),
|
||||
preview.getNamespaceId(), preview.getTargetSuiteSlug(), preview.getTargetSuiteId(),
|
||||
preview.getBaseSuiteVersionId(), preview.getTargetVersion(), preview.getArchiveObjectKey(),
|
||||
preview.getArchiveSha256(), preview.getPlan(), preview.getWarningDigest(), now);
|
||||
try {
|
||||
operationRepository.save(operation);
|
||||
// The partial unique index must win before any member lifecycle work can start.
|
||||
operationRepository.flush();
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new DomainConflictException("error.suite.bundle.confirmation.operationConflict");
|
||||
}
|
||||
try {
|
||||
memberRepository.saveAll(toMemberResults(operationId, manifest, previewPlan, now));
|
||||
memberRepository.flush();
|
||||
preview.markConfirmed(now);
|
||||
previewRepository.save(preview);
|
||||
previewRepository.flush();
|
||||
} catch (DataIntegrityViolationException exception) {
|
||||
throw new DomainBadRequestException("error.suite.bundle.preview.stateChanged");
|
||||
}
|
||||
eventPublisher.publishEvent(new SkillSuiteBundleAdvanceRequestedEvent(operationId));
|
||||
return new ConfirmationOutcome(operationId, operation.getStatus().name(), false);
|
||||
}
|
||||
|
||||
private ConfirmationOutcome findIdempotent(
|
||||
String actorId, String requestId, String previewToken
|
||||
) {
|
||||
SkillSuiteBundleExecutionOperation existing = operationRepository
|
||||
.findByActorIdAndClientRequestId(actorId, requestId).orElse(null);
|
||||
if (existing == null) {
|
||||
return null;
|
||||
}
|
||||
if (!existing.getPreviewToken().equals(previewToken)) {
|
||||
throw new DomainConflictException("error.suite.bundle.confirmation.operationConflict");
|
||||
}
|
||||
return new ConfirmationOutcome(existing.getOperationId(), existing.getStatus().name(), true);
|
||||
}
|
||||
|
||||
private List<SkillSuiteBundleMemberResult> toMemberResults(
|
||||
String operationId,
|
||||
SkillSuiteBundleManifest manifest,
|
||||
SkillSuiteBundlePreviewPlanner.PreviewPlan plan,
|
||||
Instant now
|
||||
) {
|
||||
Map<SkillSuiteBundleCoordinate, SkillSuiteBundleMember> manifestMembers =
|
||||
manifest.spec().members().stream().collect(Collectors.toMap(
|
||||
SkillSuiteBundleMember::coordinate, Function.identity()));
|
||||
List<SkillSuiteBundleMemberResult> results = new ArrayList<>(plan.members().size());
|
||||
for (int position = 0; position < plan.members().size(); position++) {
|
||||
SkillSuiteBundlePreviewPlanner.MemberPlan member = plan.members().get(position);
|
||||
var declared = manifestMembers.get(member.coordinate());
|
||||
String packagePath = declared.packageSource() == null ? null : declared.packageSource().path();
|
||||
results.add(new SkillSuiteBundleMemberResult(
|
||||
operationId, position, member.coordinate(), member.sourceType(), packagePath,
|
||||
member.finalVisibility(), member.resolvedVersion(), member.relationship(),
|
||||
member.publishAction(), member.fingerprint(), member.skillId(), member.skillVersionId(),
|
||||
member.errors(), member.warnings(), now));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private String normalizeRequestId(String requestId) {
|
||||
if (requestId == null || requestId.isBlank()) {
|
||||
throw new DomainBadRequestException("error.suite.bundle.confirmation.idempotencyKey.invalid");
|
||||
}
|
||||
if (!RequestIdAccessor.isValid(requestId)) {
|
||||
throw new DomainBadRequestException("error.suite.bundle.confirmation.idempotencyKey.invalid");
|
||||
}
|
||||
return requestId;
|
||||
}
|
||||
|
||||
private void requireEnabled() {
|
||||
if (!properties.isConfirmationEnabled()) {
|
||||
throw new DomainBadRequestException("error.suite.bundle.confirmation.disabled");
|
||||
}
|
||||
}
|
||||
|
||||
public record ConfirmationOutcome(String operationId, String status, boolean replayed) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.LocalizedDomainException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/** Advances one durable Bundle operation through bounded, independently committed steps. */
|
||||
@Service
|
||||
public class SkillSuiteBundleCoordinator {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SkillSuiteBundleCoordinator.class);
|
||||
private static final int MAX_MEMBER_STEPS = 100;
|
||||
|
||||
private final SkillSuiteBundleMemberExecutionService executionService;
|
||||
private final SkillSuiteBundleMemberProgressService progressService;
|
||||
private final SkillSuiteBundleDraftCreationService draftCreationService;
|
||||
private final SkillSuiteBundleOperationStateService stateService;
|
||||
|
||||
public SkillSuiteBundleCoordinator(
|
||||
SkillSuiteBundleMemberExecutionService executionService,
|
||||
SkillSuiteBundleMemberProgressService progressService,
|
||||
SkillSuiteBundleDraftCreationService draftCreationService,
|
||||
SkillSuiteBundleOperationStateService stateService
|
||||
) {
|
||||
this.executionService = executionService;
|
||||
this.progressService = progressService;
|
||||
this.draftCreationService = draftCreationService;
|
||||
this.stateService = stateService;
|
||||
}
|
||||
|
||||
public void advance(String operationId) {
|
||||
try {
|
||||
for (int step = 0; step < MAX_MEMBER_STEPS; step++) {
|
||||
if (executionService.executeNext(operationId)
|
||||
== SkillSuiteBundleMemberExecutionService.ExecutionOutcome.NONE) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
SkillSuiteBundleMemberProgressService.ProgressOutcome outcome =
|
||||
progressService.reconcile(operationId);
|
||||
if (outcome == SkillSuiteBundleMemberProgressService.ProgressOutcome.READY_FOR_DRAFT) {
|
||||
draftCreationService.create(operationId);
|
||||
}
|
||||
} catch (LocalizedDomainException exception) {
|
||||
if (isRetryableBlock(exception)) {
|
||||
stateService.markBlockedRetryable(
|
||||
operationId, "AUTHORIZATION_OR_NAMESPACE_BLOCKED", exception.messageCode());
|
||||
log.info("Suite Bundle is temporarily blocked [operationId={}, reason={}]",
|
||||
operationId, exception.messageCode());
|
||||
} else {
|
||||
stateService.markRepreviewRequired(operationId, "BUNDLE_PLAN_CHANGED");
|
||||
log.info("Suite Bundle requires a new preview [operationId={}, reason={}]",
|
||||
operationId, exception.messageCode());
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
stateService.markBlockedRetryable(
|
||||
operationId, "MEMBER_EXECUTION_FAILED", exception.getClass().getSimpleName());
|
||||
log.error("Suite Bundle execution blocked [operationId={}]", operationId, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isRetryableBlock(LocalizedDomainException exception) {
|
||||
if ("error.namespace.frozen".equals(exception.messageCode())
|
||||
|| "error.skill.publish.publisher.notMember".equals(exception.messageCode())
|
||||
|| "error.skill.lifecycle.noPermission".equals(exception.messageCode())
|
||||
|| "error.suite.lifecycle.noPermission".equals(exception.messageCode())
|
||||
|| "error.suite.bundle.actor.inactive".equals(exception.messageCode())) {
|
||||
return true;
|
||||
}
|
||||
return "error.suite.namespace.notWritable".equals(exception.messageCode())
|
||||
&& exception.messageArgs().length > 0
|
||||
&& "FROZEN".equals(String.valueOf(exception.messageArgs()[0]));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
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.SkillSuiteMemberSelection;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifest;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/** Atomically creates the Suite draft only after every bound member is published. */
|
||||
@Service
|
||||
public class SkillSuiteBundleDraftCreationService {
|
||||
|
||||
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
|
||||
private final SkillSuiteBundleMemberResultRepository memberRepository;
|
||||
private final SkillSuiteBundlePreviewSessionRepository previewRepository;
|
||||
private final SkillSuiteBundleActorContextService actorContextService;
|
||||
private final SkillSuiteDraftService draftService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Clock clock;
|
||||
|
||||
public SkillSuiteBundleDraftCreationService(
|
||||
SkillSuiteBundleExecutionOperationRepository operationRepository,
|
||||
SkillSuiteBundleMemberResultRepository memberRepository,
|
||||
SkillSuiteBundlePreviewSessionRepository previewRepository,
|
||||
SkillSuiteBundleActorContextService actorContextService,
|
||||
SkillSuiteDraftService draftService,
|
||||
ObjectMapper objectMapper,
|
||||
Clock clock
|
||||
) {
|
||||
this.operationRepository = operationRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.previewRepository = previewRepository;
|
||||
this.actorContextService = actorContextService;
|
||||
this.draftService = draftService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public boolean create(String operationId) {
|
||||
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
|
||||
.orElse(null);
|
||||
if (operation == null || operation.getStatus() == SkillSuiteBundleOperationStatus.CANCELLED
|
||||
|| operation.getStatus() == SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED) {
|
||||
return false;
|
||||
}
|
||||
if (operation.getStatus() == SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED) {
|
||||
return true;
|
||||
}
|
||||
List<SkillSuiteBundleMemberResult> members =
|
||||
memberRepository.findByOperationIdOrderByPositionForUpdate(operationId);
|
||||
if (members.isEmpty() || members.stream().anyMatch(
|
||||
member -> member.getStatus() != SkillSuiteBundleMemberResultStatus.COMPLETED)) {
|
||||
return false;
|
||||
}
|
||||
SkillSuiteBundlePreviewSession preview = previewRepository.findById(operation.getPreviewToken())
|
||||
.orElseThrow(this::stateChanged);
|
||||
SkillSuiteBundleManifest manifest = objectMapper.convertValue(
|
||||
preview.getManifest(), SkillSuiteBundleManifest.class);
|
||||
SkillSuiteBundlePreviewPlanner.PreviewPlan plan = objectMapper.convertValue(
|
||||
operation.getPlan(), SkillSuiteBundlePreviewPlanner.PreviewPlan.class);
|
||||
if (manifest.spec().mode() != operation.getMode()
|
||||
|| plan.mode() != operation.getMode()
|
||||
|| !manifest.metadata().coordinate().equals(plan.target())
|
||||
|| !manifest.metadata().coordinate().slug().equals(operation.getTargetSuiteSlug())
|
||||
|| !manifest.spec().version().equals(operation.getTargetVersion())
|
||||
|| !Objects.equals(plan.targetNamespaceId(), operation.getNamespaceId())
|
||||
|| !Objects.equals(plan.targetSuiteId(), operation.getTargetSuiteId())
|
||||
|| !Objects.equals(plan.baseSuiteVersionId(), operation.getBaseSuiteVersionId())
|
||||
|| !Objects.equals(plan.targetVersion(), operation.getTargetVersion())) {
|
||||
throw stateChanged();
|
||||
}
|
||||
SkillSuiteBundleActorContextService.ActorContext actor =
|
||||
actorContextService.requireCurrent(operation.getActorId());
|
||||
Long entryVersionId = members.stream()
|
||||
.filter(member -> member.getNamespaceSlug().equals(manifest.spec().entry().namespace()))
|
||||
.filter(member -> member.getSkillSlug().equals(manifest.spec().entry().slug()))
|
||||
.map(SkillSuiteBundleMemberResult::getSkillVersionId)
|
||||
.findFirst()
|
||||
.orElseThrow(this::stateChanged);
|
||||
List<SkillSuiteMemberSelection> selections = members.stream()
|
||||
.map(member -> new SkillSuiteMemberSelection(
|
||||
member.getSkillId(), member.getSkillVersionId(), member.getNamespaceSlug(),
|
||||
member.getSkillSlug(), member.getRequestedVersion(), member.getFingerprint()))
|
||||
.toList();
|
||||
CreateSkillSuiteDraftCommand command = new CreateSkillSuiteDraftCommand(
|
||||
operation.getNamespaceId(), operation.getTargetSuiteSlug(), plan.displayName(),
|
||||
plan.summary(), plan.overview(), operation.getTargetVersion(), plan.visibility(),
|
||||
manifest.spec().changelog(), entryVersionId, selections);
|
||||
SkillSuiteActionContext context = new SkillSuiteActionContext(
|
||||
operation.getActorId(), actor.namespaceRoles(), actor.platformRoles(),
|
||||
operationId, null, null);
|
||||
SkillSuiteDraftService.CreatedDraft created = operation.getMode() == SkillSuiteBundleMode.CREATE
|
||||
? draftService.create(command, context)
|
||||
: draftService.createVersion(operation.getTargetSuiteId(), command, context);
|
||||
operation.markSuiteDraftCreated(
|
||||
created.suite().getId(), created.version().getId(), clock.instant());
|
||||
operationRepository.save(operation);
|
||||
operationRepository.flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
private DomainBadRequestException stateChanged() {
|
||||
return new DomainBadRequestException("error.suite.bundle.member.stateChanged");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.namespace.Namespace;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceStatus;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillPublishService;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillReviewSubmitService;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Executes at most one confirmed Bundle member in its own transaction. */
|
||||
@Service
|
||||
public class SkillSuiteBundleMemberExecutionService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SkillSuiteBundleMemberExecutionService.class);
|
||||
|
||||
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
|
||||
private final SkillSuiteBundleMemberResultRepository memberRepository;
|
||||
private final SkillSuiteBundleActorContextService actorContextService;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final VisibilityChecker visibilityChecker;
|
||||
private final SkillPublishService skillPublishService;
|
||||
private final SkillReviewSubmitService skillReviewSubmitService;
|
||||
private final ObjectStorageService objectStorageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Clock clock;
|
||||
|
||||
public SkillSuiteBundleMemberExecutionService(
|
||||
SkillSuiteBundleExecutionOperationRepository operationRepository,
|
||||
SkillSuiteBundleMemberResultRepository memberRepository,
|
||||
SkillSuiteBundleActorContextService actorContextService,
|
||||
NamespaceRepository namespaceRepository,
|
||||
SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
VisibilityChecker visibilityChecker,
|
||||
SkillPublishService skillPublishService,
|
||||
SkillReviewSubmitService skillReviewSubmitService,
|
||||
ObjectStorageService objectStorageService,
|
||||
ObjectMapper objectMapper,
|
||||
Clock clock
|
||||
) {
|
||||
this.operationRepository = operationRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.actorContextService = actorContextService;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.visibilityChecker = visibilityChecker;
|
||||
this.skillPublishService = skillPublishService;
|
||||
this.skillReviewSubmitService = skillReviewSubmitService;
|
||||
this.objectStorageService = objectStorageService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public ExecutionOutcome executeNext(String operationId) {
|
||||
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
|
||||
.orElse(null);
|
||||
if (operation == null || operation.getStatus() != SkillSuiteBundleOperationStatus.RUNNING) {
|
||||
return ExecutionOutcome.NONE;
|
||||
}
|
||||
List<SkillSuiteBundleMemberResult> members =
|
||||
memberRepository.findByOperationIdOrderByPositionForUpdate(operationId);
|
||||
SkillSuiteBundleMemberResult member = members.stream()
|
||||
.filter(candidate -> candidate.getStatus()
|
||||
== com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus.PLANNED)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (member == null) {
|
||||
return ExecutionOutcome.NONE;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
if (!member.start(now)) {
|
||||
return ExecutionOutcome.NONE;
|
||||
}
|
||||
|
||||
SkillSuiteBundleActorContextService.ActorContext actor =
|
||||
actorContextService.requireCurrent(operation.getActorId());
|
||||
SkillSuiteBundlePreviewPlanner.PreviewPlan plan = objectMapper.convertValue(
|
||||
operation.getPlan(), SkillSuiteBundlePreviewPlanner.PreviewPlan.class);
|
||||
SkillSuiteBundlePreviewPlanner.MemberPlan planned = plan.members().stream()
|
||||
.filter(candidate -> candidate.coordinate().namespace().equals(member.getNamespaceSlug()))
|
||||
.filter(candidate -> candidate.coordinate().slug().equals(member.getSkillSlug()))
|
||||
.findFirst()
|
||||
.orElseThrow(this::stateChanged);
|
||||
assertPlanBinding(member, planned);
|
||||
|
||||
if (planned.publishAction() == SkillSuiteBundlePublishAction.REUSE_VERSION
|
||||
|| planned.publishAction() == SkillSuiteBundlePublishAction.REFERENCE_VERSION) {
|
||||
completeExisting(operation, member, actor, now);
|
||||
} else if (planned.publishAction() == SkillSuiteBundlePublishAction.CREATE_SKILL
|
||||
|| planned.publishAction() == SkillSuiteBundlePublishAction.CREATE_VERSION) {
|
||||
publishPackage(operation, member, planned, actor, now);
|
||||
} else {
|
||||
throw stateChanged();
|
||||
}
|
||||
memberRepository.saveAll(List.of(member));
|
||||
memberRepository.flush();
|
||||
return ExecutionOutcome.PROGRESSED;
|
||||
}
|
||||
|
||||
private void publishPackage(
|
||||
SkillSuiteBundleExecutionOperation operation,
|
||||
SkillSuiteBundleMemberResult member,
|
||||
SkillSuiteBundlePreviewPlanner.MemberPlan planned,
|
||||
SkillSuiteBundleActorContextService.ActorContext actor,
|
||||
Instant now
|
||||
) {
|
||||
if (planned.files().isEmpty() || member.getRequestedVisibility() == null) {
|
||||
throw stateChanged();
|
||||
}
|
||||
Path localDirectory = null;
|
||||
try {
|
||||
localDirectory = Files.createTempDirectory("skillhub-suite-member-");
|
||||
List<PackageEntry> entries = readEntries(planned.files(), localDirectory);
|
||||
SkillPublishService.PublishResult result = skillPublishService.publishBundleMemberFromEntries(
|
||||
member.getNamespaceSlug(), member.getSkillId(), member.getSkillSlug(),
|
||||
member.getRequestedVersion(), entries, planned.files().stream().collect(Collectors.toUnmodifiableMap(
|
||||
SkillSuiteBundlePackageAnalyzer.StagedMemberFile::relativePath,
|
||||
SkillSuiteBundlePackageAnalyzer.StagedMemberFile::sha256)),
|
||||
operation.getActorId(),
|
||||
member.getRequestedVisibility(), actor.namespaceRoles(), actor.platformRoles(), true);
|
||||
member.bindVersion(result.skillId(), result.version().getId(), now);
|
||||
advanceCreatedVersion(operation, member, result.version(), actor, now);
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Failed to stage Bundle member locally", exception);
|
||||
} finally {
|
||||
deleteLocalDirectory(localDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
private void advanceCreatedVersion(
|
||||
SkillSuiteBundleExecutionOperation operation,
|
||||
SkillSuiteBundleMemberResult member,
|
||||
SkillVersion version,
|
||||
SkillSuiteBundleActorContextService.ActorContext actor,
|
||||
Instant now
|
||||
) {
|
||||
if (version.getStatus() == SkillVersionStatus.UPLOADED
|
||||
&& member.getRequestedVisibility()
|
||||
== com.iflytek.skillhub.domain.skill.SkillVisibility.PRIVATE) {
|
||||
skillReviewSubmitService.confirmPublish(
|
||||
member.getSkillId(), version.getId(), operation.getActorId(),
|
||||
actor.namespaceRoles(), actor.platformRoles());
|
||||
member.markCompleted(now);
|
||||
return;
|
||||
}
|
||||
if (version.getStatus() == SkillVersionStatus.PUBLISHED) {
|
||||
member.markCompleted(now);
|
||||
return;
|
||||
}
|
||||
if (version.getStatus() == SkillVersionStatus.SCANNING
|
||||
|| version.getStatus() == SkillVersionStatus.PENDING_REVIEW) {
|
||||
member.markWaiting(now);
|
||||
return;
|
||||
}
|
||||
throw stateChanged();
|
||||
}
|
||||
|
||||
private void completeExisting(
|
||||
SkillSuiteBundleExecutionOperation operation,
|
||||
SkillSuiteBundleMemberResult member,
|
||||
SkillSuiteBundleActorContextService.ActorContext actor,
|
||||
Instant now
|
||||
) {
|
||||
Skill skill = skillRepository.findById(Objects.requireNonNull(member.getSkillId()))
|
||||
.orElseThrow(this::stateChanged);
|
||||
SkillVersion version = skillVersionRepository.findById(Objects.requireNonNull(member.getSkillVersionId()))
|
||||
.orElseThrow(this::stateChanged);
|
||||
Namespace namespace = namespaceRepository.findBySlug(member.getNamespaceSlug())
|
||||
.orElseThrow(this::stateChanged);
|
||||
if (!skill.getNamespaceId().equals(namespace.getId())
|
||||
|| namespace.getStatus() != NamespaceStatus.ACTIVE
|
||||
|| !skill.getSlug().equals(member.getSkillSlug())
|
||||
|| !version.getSkillId().equals(skill.getId())
|
||||
|| !version.getVersion().equals(member.getRequestedVersion())
|
||||
|| version.getStatus() != SkillVersionStatus.PUBLISHED
|
||||
|| !version.isDownloadReady()
|
||||
|| version.getYankedAt() != null
|
||||
|| skill.getStatus() != SkillStatus.ACTIVE
|
||||
|| skill.isHidden()
|
||||
|| skill.getVisibility() != member.getRequestedVisibility()
|
||||
|| !visibilityChecker.canAccess(
|
||||
skill, operation.getActorId(), actor.namespaceRoles(), actor.platformRoles())) {
|
||||
throw stateChanged();
|
||||
}
|
||||
member.markCompleted(now);
|
||||
}
|
||||
|
||||
private List<PackageEntry> readEntries(
|
||||
List<SkillSuiteBundlePackageAnalyzer.StagedMemberFile> files, Path localDirectory
|
||||
) throws IOException {
|
||||
List<PackageEntry> entries = new ArrayList<>(files.size());
|
||||
for (int index = 0; index < files.size(); index++) {
|
||||
SkillSuiteBundlePackageAnalyzer.StagedMemberFile file = files.get(index);
|
||||
if (file.size() < 0 || file.size() >= Integer.MAX_VALUE) {
|
||||
throw stateChanged();
|
||||
}
|
||||
Path localFile = localDirectory.resolve("entry-" + index);
|
||||
long copied = 0;
|
||||
try (InputStream input = objectStorageService.getObject(file.objectKey());
|
||||
OutputStream output = Files.newOutputStream(localFile)) {
|
||||
byte[] buffer = new byte[64 * 1024];
|
||||
int read;
|
||||
while ((read = input.read(buffer)) != -1) {
|
||||
copied += read;
|
||||
if (copied > file.size()) {
|
||||
throw stateChanged();
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
if (copied != file.size()) {
|
||||
throw stateChanged();
|
||||
}
|
||||
entries.add(PackageEntry.streaming(
|
||||
file.relativePath(), file.size(), file.contentType(),
|
||||
() -> Files.newInputStream(localFile)));
|
||||
}
|
||||
return List.copyOf(entries);
|
||||
}
|
||||
|
||||
private void deleteLocalDirectory(Path directory) {
|
||||
if (directory == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
List<Path> localFiles;
|
||||
try (var files = Files.list(directory)) {
|
||||
localFiles = files.toList();
|
||||
}
|
||||
for (Path path : localFiles) {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException exception) {
|
||||
log.warn("Failed to delete staged Bundle member file {}", path, exception);
|
||||
}
|
||||
}
|
||||
Files.deleteIfExists(directory);
|
||||
} catch (IOException exception) {
|
||||
log.warn("Failed to delete staged Bundle member directory {}", directory, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertPlanBinding(
|
||||
SkillSuiteBundleMemberResult member,
|
||||
SkillSuiteBundlePreviewPlanner.MemberPlan planned
|
||||
) {
|
||||
if (planned.publishAction() != member.getPublishAction()
|
||||
|| planned.sourceType() != member.getSourceType()
|
||||
|| !Objects.equals(planned.skillId(), member.getSkillId())
|
||||
|| !Objects.equals(planned.skillVersionId(), member.getSkillVersionId())
|
||||
|| !Objects.equals(planned.resolvedVersion(), member.getRequestedVersion())
|
||||
|| !Objects.equals(planned.fingerprint(), member.getFingerprint())) {
|
||||
throw stateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private DomainBadRequestException stateChanged() {
|
||||
return new DomainBadRequestException("error.suite.bundle.member.stateChanged");
|
||||
}
|
||||
|
||||
public enum ExecutionOutcome {
|
||||
PROGRESSED,
|
||||
NONE
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
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.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.service.SkillReviewSubmitService;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Converges bound member versions without changing their identity. */
|
||||
@Service
|
||||
public class SkillSuiteBundleMemberProgressService {
|
||||
|
||||
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
|
||||
private final SkillSuiteBundleMemberResultRepository memberRepository;
|
||||
private final SkillSuiteBundleActorContextService actorContextService;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillReviewSubmitService skillReviewSubmitService;
|
||||
private final Clock clock;
|
||||
|
||||
public SkillSuiteBundleMemberProgressService(
|
||||
SkillSuiteBundleExecutionOperationRepository operationRepository,
|
||||
SkillSuiteBundleMemberResultRepository memberRepository,
|
||||
SkillSuiteBundleActorContextService actorContextService,
|
||||
NamespaceRepository namespaceRepository,
|
||||
SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillReviewSubmitService skillReviewSubmitService,
|
||||
Clock clock
|
||||
) {
|
||||
this.operationRepository = operationRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.actorContextService = actorContextService;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillReviewSubmitService = skillReviewSubmitService;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public ProgressOutcome reconcile(String operationId) {
|
||||
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
|
||||
.orElse(null);
|
||||
if (operation == null || terminal(operation.getStatus())) {
|
||||
return ProgressOutcome.TERMINAL;
|
||||
}
|
||||
SkillSuiteBundleActorContextService.ActorContext actor =
|
||||
actorContextService.requireCurrent(operation.getActorId());
|
||||
List<SkillSuiteBundleMemberResult> members =
|
||||
memberRepository.findByOperationIdOrderByPositionForUpdate(operationId);
|
||||
Instant now = clock.instant();
|
||||
boolean waiting = false;
|
||||
for (SkillSuiteBundleMemberResult member : members) {
|
||||
if (member.getStatus() == SkillSuiteBundleMemberResultStatus.PLANNED
|
||||
|| member.getStatus() == SkillSuiteBundleMemberResultStatus.RUNNING) {
|
||||
operation.markRunning(now);
|
||||
operationRepository.save(operation);
|
||||
return ProgressOutcome.HAS_PLANNED;
|
||||
}
|
||||
if (member.getStatus() == SkillSuiteBundleMemberResultStatus.BLOCKED_RETRYABLE) {
|
||||
operation.markBlockedRetryable("MEMBER_RETRY_REQUIRED", null, now);
|
||||
operationRepository.save(operation);
|
||||
return ProgressOutcome.TERMINAL;
|
||||
}
|
||||
if (member.getStatus() != SkillSuiteBundleMemberResultStatus.WAITING_FOR_MEMBER) {
|
||||
continue;
|
||||
}
|
||||
MemberState state = loadState(member, operation, actor);
|
||||
switch (state.version().getStatus()) {
|
||||
case PUBLISHED -> {
|
||||
if (!state.version().isDownloadReady() || state.version().getYankedAt() != null) {
|
||||
throw stateChanged();
|
||||
}
|
||||
member.markCompleted(now);
|
||||
}
|
||||
case SCANNING, PENDING_REVIEW -> waiting = true;
|
||||
case UPLOADED -> {
|
||||
if (state.skill().getVisibility() != SkillVisibility.PRIVATE) {
|
||||
throw stateChanged();
|
||||
}
|
||||
skillReviewSubmitService.confirmPublish(
|
||||
state.skill().getId(), state.version().getId(), operation.getActorId(),
|
||||
actor.namespaceRoles(), actor.platformRoles());
|
||||
member.markCompleted(now);
|
||||
}
|
||||
case SCAN_FAILED -> {
|
||||
member.markBlockedRetryable("MEMBER_SCAN_FAILED", now);
|
||||
operation.markBlockedRetryable("MEMBER_SCAN_FAILED", null, now);
|
||||
memberRepository.saveAll(members);
|
||||
operationRepository.save(operation);
|
||||
return ProgressOutcome.TERMINAL;
|
||||
}
|
||||
case REJECTED, DRAFT, YANKED -> throw stateChanged();
|
||||
}
|
||||
}
|
||||
memberRepository.saveAll(members);
|
||||
if (waiting) {
|
||||
operation.markWaitingForMembers(now);
|
||||
operationRepository.save(operation);
|
||||
return ProgressOutcome.WAITING;
|
||||
}
|
||||
boolean allCompleted = members.stream().allMatch(
|
||||
member -> member.getStatus() == SkillSuiteBundleMemberResultStatus.COMPLETED);
|
||||
if (!allCompleted) {
|
||||
throw stateChanged();
|
||||
}
|
||||
operation.markRunning(now);
|
||||
operationRepository.save(operation);
|
||||
return ProgressOutcome.READY_FOR_DRAFT;
|
||||
}
|
||||
|
||||
private MemberState loadState(
|
||||
SkillSuiteBundleMemberResult member,
|
||||
SkillSuiteBundleExecutionOperation operation,
|
||||
SkillSuiteBundleActorContextService.ActorContext actor
|
||||
) {
|
||||
Skill skill = skillRepository.findById(Objects.requireNonNull(member.getSkillId()))
|
||||
.orElseThrow(this::stateChanged);
|
||||
SkillVersion version = skillVersionRepository.findById(Objects.requireNonNull(member.getSkillVersionId()))
|
||||
.orElseThrow(this::stateChanged);
|
||||
Namespace namespace = namespaceRepository.findBySlug(member.getNamespaceSlug())
|
||||
.orElseThrow(this::stateChanged);
|
||||
if (!skill.getNamespaceId().equals(namespace.getId())
|
||||
|| namespace.getStatus() != NamespaceStatus.ACTIVE
|
||||
|| !skill.getSlug().equals(member.getSkillSlug())
|
||||
|| skill.getStatus() != SkillStatus.ACTIVE
|
||||
|| skill.isHidden()
|
||||
|| !version.getSkillId().equals(skill.getId())
|
||||
|| !version.getVersion().equals(member.getRequestedVersion())
|
||||
|| skill.getVisibility() != member.getRequestedVisibility()
|
||||
|| !canManage(skill, operation.getActorId(), actor.namespaceRoles(), actor.platformRoles())) {
|
||||
throw stateChanged();
|
||||
}
|
||||
return new MemberState(skill, version);
|
||||
}
|
||||
|
||||
private boolean canManage(
|
||||
Skill skill,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
NamespaceRole role = namespaceRoles.get(skill.getNamespaceId());
|
||||
return skill.getOwnerId().equals(actorId)
|
||||
|| role == NamespaceRole.OWNER
|
||||
|| role == NamespaceRole.ADMIN
|
||||
|| platformRoles.contains("SUPER_ADMIN");
|
||||
}
|
||||
|
||||
private boolean terminal(SkillSuiteBundleOperationStatus status) {
|
||||
return status == SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED
|
||||
|| status == SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED
|
||||
|| status == SkillSuiteBundleOperationStatus.CANCELLED;
|
||||
}
|
||||
|
||||
private DomainBadRequestException stateChanged() {
|
||||
return new DomainBadRequestException("error.suite.bundle.member.stateChanged");
|
||||
}
|
||||
|
||||
private record MemberState(Skill skill, SkillVersion version) {
|
||||
}
|
||||
|
||||
public enum ProgressOutcome {
|
||||
HAS_PLANNED,
|
||||
WAITING,
|
||||
READY_FOR_DRAFT,
|
||||
TERMINAL
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.event.SkillSuiteBundleAdvanceRequestedEvent;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationAuthorizationPolicy;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationResponse;
|
||||
import com.iflytek.skillhub.service.AuditRequestContext;
|
||||
import com.iflytek.skillhub.service.SecurityScanRetryAppService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Applies non-destructive control commands to a locked Bundle operation. */
|
||||
@Service
|
||||
public class SkillSuiteBundleOperationCommandService {
|
||||
|
||||
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
|
||||
private final SkillSuiteBundleMemberResultRepository memberRepository;
|
||||
private final SkillSuiteBundlePreviewSessionRepository previewRepository;
|
||||
private final SkillSuiteBundlePreviewRevalidationService revalidationService;
|
||||
private final SecurityScanRetryAppService securityScanRetryAppService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
private final Clock clock;
|
||||
|
||||
public SkillSuiteBundleOperationCommandService(
|
||||
SkillSuiteBundleExecutionOperationRepository operationRepository,
|
||||
SkillSuiteBundleMemberResultRepository memberRepository,
|
||||
SkillSuiteBundlePreviewSessionRepository previewRepository,
|
||||
SkillSuiteBundlePreviewRevalidationService revalidationService,
|
||||
SecurityScanRetryAppService securityScanRetryAppService,
|
||||
ApplicationEventPublisher eventPublisher,
|
||||
Clock clock
|
||||
) {
|
||||
this.operationRepository = operationRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.previewRepository = previewRepository;
|
||||
this.revalidationService = revalidationService;
|
||||
this.securityScanRetryAppService = securityScanRetryAppService;
|
||||
this.eventPublisher = eventPublisher;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillSuiteBundleOperationResponse cancel(
|
||||
String operationId,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
|
||||
.orElseThrow(this::notFound);
|
||||
if (!SkillSuiteBundleOperationAuthorizationPolicy.canAccess(
|
||||
operation, actorId, namespaceRoles, platformRoles)) {
|
||||
throw notFound();
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
boolean changed = operation.cancel(now);
|
||||
if (changed) {
|
||||
List<SkillSuiteBundleMemberResult> members =
|
||||
memberRepository.findByOperationIdOrderByPositionForUpdate(operationId);
|
||||
members.forEach(member -> member.cancelUnlessCompleted(now));
|
||||
memberRepository.saveAll(members);
|
||||
operationRepository.save(operation);
|
||||
operationRepository.flush();
|
||||
}
|
||||
return new SkillSuiteBundleOperationResponse(
|
||||
operation.getOperationId(), operation.getStatus().name(), !changed);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public SkillSuiteBundleOperationResponse retry(
|
||||
String operationId,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
|
||||
.orElseThrow(this::notFound);
|
||||
if (!SkillSuiteBundleOperationAuthorizationPolicy.canAccess(
|
||||
operation, actorId, namespaceRoles, platformRoles)) {
|
||||
throw notFound();
|
||||
}
|
||||
operation.requireRetryable();
|
||||
SkillSuiteBundlePreviewSession preview = previewRepository.findById(operation.getPreviewToken())
|
||||
.orElse(null);
|
||||
Instant now = clock.instant();
|
||||
List<SkillSuiteBundleMemberResult> members =
|
||||
memberRepository.findByOperationIdOrderByPositionForUpdate(operationId);
|
||||
if ("MEMBER_SCAN_FAILED".equals(operation.getFailureCode())) {
|
||||
retryFailedScan(operation, members, actorId, namespaceRoles, platformRoles, now);
|
||||
} else if (preview == null || (!hasProgress(members)
|
||||
&& !planRemainsValid(preview, actorId, namespaceRoles, platformRoles))) {
|
||||
operation.markRepreviewRequired("BUNDLE_PLAN_CHANGED", now);
|
||||
members.forEach(member -> member.requireRepreviewUnlessCompleted(now));
|
||||
} else {
|
||||
operation.retry(now);
|
||||
members.forEach(member -> member.retryUnlessCompleted(now));
|
||||
}
|
||||
memberRepository.saveAll(members);
|
||||
operationRepository.save(operation);
|
||||
operationRepository.flush();
|
||||
if (operation.getStatus() == com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus.RUNNING) {
|
||||
eventPublisher.publishEvent(new SkillSuiteBundleAdvanceRequestedEvent(operationId));
|
||||
}
|
||||
return new SkillSuiteBundleOperationResponse(
|
||||
operation.getOperationId(), operation.getStatus().name(), false);
|
||||
}
|
||||
|
||||
private void retryFailedScan(
|
||||
SkillSuiteBundleExecutionOperation operation,
|
||||
List<SkillSuiteBundleMemberResult> members,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles,
|
||||
Instant now
|
||||
) {
|
||||
SkillSuiteBundleMemberResult failed = members.stream()
|
||||
.filter(member -> member.getStatus()
|
||||
== com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus.BLOCKED_RETRYABLE)
|
||||
.filter(member -> member.getSkillId() != null && member.getSkillVersionId() != null)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new DomainBadRequestException("error.suite.bundle.preview.stateChanged"));
|
||||
securityScanRetryAppService.retry(
|
||||
failed.getSkillId(), failed.getSkillVersionId(), actorId, platformRoles, namespaceRoles,
|
||||
new AuditRequestContext(null, "SkillSuiteBundle"));
|
||||
operation.retry(now);
|
||||
failed.markWaiting(now);
|
||||
}
|
||||
|
||||
private boolean hasProgress(List<SkillSuiteBundleMemberResult> members) {
|
||||
return members.stream().anyMatch(member -> member.getSkillVersionId() != null);
|
||||
}
|
||||
|
||||
private boolean planRemainsValid(
|
||||
SkillSuiteBundlePreviewSession preview,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
try {
|
||||
revalidationService.requireUnchanged(preview, actorId, namespaceRoles, platformRoles);
|
||||
return true;
|
||||
} catch (DomainBadRequestException exception) {
|
||||
if (!"error.suite.bundle.preview.stateChanged".equals(exception.messageCode())) {
|
||||
throw exception;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private DomainNotFoundException notFound() {
|
||||
return new DomainNotFoundException("error.suite.bundle.operation.notFound");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
|
||||
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.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
|
||||
import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationAuthorizationPolicy;
|
||||
import com.iflytek.skillhub.dto.PageResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationDetailResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationPageResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationSummaryResponse;
|
||||
import com.iflytek.skillhub.repository.SkillSuiteBundleOperationQueryRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Reads one Bundle operation through an actor/governance authorization and redaction boundary. */
|
||||
@Service
|
||||
public class SkillSuiteBundleOperationQueryService {
|
||||
|
||||
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
|
||||
private final SkillSuiteBundleMemberResultRepository memberRepository;
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillSuiteVersionRepository suiteVersionRepository;
|
||||
private final VisibilityChecker visibilityChecker;
|
||||
private final SkillSuiteBundleOperationQueryRepository operationQueryRepository;
|
||||
|
||||
public SkillSuiteBundleOperationQueryService(
|
||||
SkillSuiteBundleExecutionOperationRepository operationRepository,
|
||||
SkillSuiteBundleMemberResultRepository memberRepository,
|
||||
NamespaceRepository namespaceRepository,
|
||||
SkillRepository skillRepository,
|
||||
SkillSuiteVersionRepository suiteVersionRepository,
|
||||
VisibilityChecker visibilityChecker,
|
||||
SkillSuiteBundleOperationQueryRepository operationQueryRepository
|
||||
) {
|
||||
this.operationRepository = operationRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.suiteVersionRepository = suiteVersionRepository;
|
||||
this.visibilityChecker = visibilityChecker;
|
||||
this.operationQueryRepository = operationQueryRepository;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public SkillSuiteBundleOperationDetailResponse get(
|
||||
String operationId,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
SkillSuiteBundleExecutionOperation operation = operationRepository.findById(operationId)
|
||||
.orElseThrow(this::notFound);
|
||||
if (!SkillSuiteBundleOperationAuthorizationPolicy.canAccess(
|
||||
operation, actorId, namespaceRoles, platformRoles)) {
|
||||
// Do not reveal whether an operation ID exists to an unrelated caller.
|
||||
throw notFound();
|
||||
}
|
||||
var persistedMembers = memberRepository.findByOperationIdOrderByPosition(operationId);
|
||||
List<Long> skillIds = persistedMembers.stream()
|
||||
.map(member -> member.getSkillId())
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<Long, Skill> skillsById = (skillIds.isEmpty() ? List.<Skill>of() : skillRepository.findByIdIn(skillIds))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(Skill::getId, Function.identity()));
|
||||
List<String> namespaceSlugs = persistedMembers.stream()
|
||||
.map(member -> member.getNamespaceSlug())
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<String, Long> namespaceIdsBySlug = (namespaceSlugs.isEmpty()
|
||||
? List.<com.iflytek.skillhub.domain.namespace.Namespace>of()
|
||||
: namespaceRepository.findBySlugIn(namespaceSlugs)).stream()
|
||||
.collect(Collectors.toMap(namespace -> namespace.getSlug(), namespace -> namespace.getId()));
|
||||
var members = persistedMembers.stream()
|
||||
.map(member -> canReadMember(
|
||||
member.getSkillId() == null ? null : skillsById.get(member.getSkillId()),
|
||||
namespaceIdsBySlug.get(member.getNamespaceSlug()), member.getRequestedVisibility(),
|
||||
operation, actorId, namespaceRoles, platformRoles)
|
||||
? visibleMember(member)
|
||||
: redactedMember(member))
|
||||
.toList();
|
||||
String namespaceSlug = namespaceRepository.findById(operation.getNamespaceId())
|
||||
.map(namespace -> namespace.getSlug())
|
||||
.orElseThrow(this::notFound);
|
||||
String baseVersion = operation.getBaseSuiteVersionId() == null
|
||||
? null
|
||||
: suiteVersionRepository.findById(operation.getBaseSuiteVersionId())
|
||||
.map(version -> version.getVersion())
|
||||
.orElse(null);
|
||||
return new SkillSuiteBundleOperationDetailResponse(
|
||||
operation.getOperationId(), operation.getStatus(), operation.getMode(),
|
||||
"@" + namespaceSlug + "/" + operation.getTargetSuiteSlug(), operation.getNamespaceId(),
|
||||
operation.getTargetSuiteId(), operation.getTargetVersion(), baseVersion,
|
||||
operation.getFailureCode(), operation.getResultSuiteId(), operation.getResultSuiteVersionId(),
|
||||
operation.getCreatedAt(), operation.getUpdatedAt(), operation.getCompletedAt(), members);
|
||||
}
|
||||
|
||||
/** Returns one bounded page of the caller's active operations without exposing member metadata. */
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<SkillSuiteBundleOperationSummaryResponse> listActive(
|
||||
String actorId,
|
||||
int page,
|
||||
int size
|
||||
) {
|
||||
return operationQueryRepository.findActive(
|
||||
actorId, Math.max(0, page), Math.min(Math.max(1, size), 50));
|
||||
}
|
||||
|
||||
/** Returns one bounded page of the caller's operations, including terminal history. */
|
||||
@Transactional(readOnly = true)
|
||||
public SkillSuiteBundleOperationPageResponse listMine(
|
||||
String actorId,
|
||||
int page,
|
||||
int size
|
||||
) {
|
||||
return operationQueryRepository.findMine(
|
||||
actorId, Math.max(0, page), Math.min(Math.max(1, size), 50));
|
||||
}
|
||||
|
||||
private boolean canReadMember(
|
||||
Skill skill,
|
||||
Long namespaceId,
|
||||
SkillVisibility visibility,
|
||||
SkillSuiteBundleExecutionOperation operation,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
if (skill != null) {
|
||||
return visibilityChecker.canAccess(skill, actorId, namespaceRoles, platformRoles);
|
||||
}
|
||||
if (platformRoles.contains("SUPER_ADMIN") || visibility == SkillVisibility.PUBLIC) {
|
||||
return true;
|
||||
}
|
||||
NamespaceRole role = namespaceId == null ? null : namespaceRoles.get(namespaceId);
|
||||
if (visibility == SkillVisibility.NAMESPACE_ONLY) {
|
||||
return role != null;
|
||||
}
|
||||
return operation.getActorId().equals(actorId)
|
||||
|| role == NamespaceRole.OWNER
|
||||
|| role == NamespaceRole.ADMIN;
|
||||
}
|
||||
|
||||
private SkillSuiteBundleOperationDetailResponse.OperationMember visibleMember(
|
||||
SkillSuiteBundleMemberResult member
|
||||
) {
|
||||
return new SkillSuiteBundleOperationDetailResponse.OperationMember(
|
||||
member.getPosition(), false, "@" + member.getNamespaceSlug() + "/" + member.getSkillSlug(),
|
||||
member.getSourceType(), member.getPackagePath(), member.getRelationshipChange(), member.getPublishAction(),
|
||||
member.getStatus(), member.getRequestedVisibility(), member.getRequestedVersion(),
|
||||
member.getSkillId(), member.getSkillVersionId(), member.getErrors(), member.getWarnings());
|
||||
}
|
||||
|
||||
private SkillSuiteBundleOperationDetailResponse.OperationMember redactedMember(
|
||||
SkillSuiteBundleMemberResult member
|
||||
) {
|
||||
return new SkillSuiteBundleOperationDetailResponse.OperationMember(
|
||||
member.getPosition(), true, null, null, null, null, null, member.getStatus(),
|
||||
null, null, null, null, List.of(), List.of());
|
||||
}
|
||||
|
||||
private DomainNotFoundException notFound() {
|
||||
return new DomainNotFoundException("error.suite.bundle.operation.notFound");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResult;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
/** Records recoverable or terminal Bundle failures outside a rolled-back member transaction. */
|
||||
@Service
|
||||
public class SkillSuiteBundleOperationStateService {
|
||||
|
||||
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
|
||||
private final SkillSuiteBundleMemberResultRepository memberRepository;
|
||||
private final Clock clock;
|
||||
|
||||
public SkillSuiteBundleOperationStateService(
|
||||
SkillSuiteBundleExecutionOperationRepository operationRepository,
|
||||
SkillSuiteBundleMemberResultRepository memberRepository,
|
||||
Clock clock
|
||||
) {
|
||||
this.operationRepository = operationRepository;
|
||||
this.memberRepository = memberRepository;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void markRepreviewRequired(String operationId, String code) {
|
||||
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
|
||||
.orElse(null);
|
||||
if (operation == null || terminal(operation.getStatus())) {
|
||||
return;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
operation.markRepreviewRequired(code, now);
|
||||
List<SkillSuiteBundleMemberResult> members =
|
||||
memberRepository.findByOperationIdOrderByPositionForUpdate(operationId);
|
||||
members.forEach(member -> {
|
||||
if (member.getStatus()
|
||||
!= com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus.COMPLETED) {
|
||||
member.markRepreviewRequired(code, now);
|
||||
}
|
||||
});
|
||||
memberRepository.saveAll(members);
|
||||
operationRepository.save(operation);
|
||||
operationRepository.flush();
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void markBlockedRetryable(String operationId, String code, String detail) {
|
||||
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
|
||||
.orElse(null);
|
||||
if (operation == null || terminal(operation.getStatus())) {
|
||||
return;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
operation.markBlockedRetryable(code, detail, now);
|
||||
List<SkillSuiteBundleMemberResult> members =
|
||||
memberRepository.findByOperationIdOrderByPositionForUpdate(operationId);
|
||||
members.stream()
|
||||
.filter(member -> member.getStatus()
|
||||
== com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus.PLANNED
|
||||
|| member.getStatus()
|
||||
== com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberResultStatus.RUNNING)
|
||||
.findFirst()
|
||||
.ifPresent(member -> member.markBlockedRetryable(code, now));
|
||||
memberRepository.saveAll(members);
|
||||
operationRepository.save(operation);
|
||||
operationRepository.flush();
|
||||
}
|
||||
|
||||
private boolean terminal(SkillSuiteBundleOperationStatus status) {
|
||||
return status == SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED
|
||||
|| status == SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED
|
||||
|| status == SkillSuiteBundleOperationStatus.CANCELLED;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadataParser;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
|
||||
import com.iflytek.skillhub.domain.skill.validation.NoOpPrePublishValidator;
|
||||
import com.iflytek.skillhub.domain.skill.validation.PrePublishValidator;
|
||||
import com.iflytek.skillhub.domain.skill.validation.SkillPackagePolicy;
|
||||
import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator;
|
||||
import com.iflytek.skillhub.domain.skill.validation.ValidationResult;
|
||||
import com.iflytek.skillhub.domain.namespace.SlugValidator;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifest;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifestParser;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMember;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Resolves the outer Bundle tree and validates each staged package as an independent Skill package.
|
||||
* Only the file currently inspected by a content validator is materialized; returned plans keep
|
||||
* staged locations rather than retaining member packages in heap memory.
|
||||
*/
|
||||
@Service
|
||||
public class SkillSuiteBundlePackageAnalyzer {
|
||||
|
||||
private static final int MAX_MANIFEST_BYTES = 256_000;
|
||||
|
||||
private final SkillSuiteBundleManifestParser manifestParser;
|
||||
private final SkillMetadataParser metadataParser;
|
||||
private final SkillPackageValidator packageValidator;
|
||||
private final PrePublishValidator prePublishValidator;
|
||||
|
||||
@Autowired
|
||||
public SkillSuiteBundlePackageAnalyzer(
|
||||
SkillSuiteBundleManifestParser manifestParser,
|
||||
SkillMetadataParser metadataParser,
|
||||
SkillPackageValidator packageValidator,
|
||||
PrePublishValidator prePublishValidator
|
||||
) {
|
||||
this.manifestParser = manifestParser;
|
||||
this.metadataParser = metadataParser;
|
||||
this.packageValidator = packageValidator;
|
||||
this.prePublishValidator = prePublishValidator;
|
||||
}
|
||||
|
||||
SkillSuiteBundlePackageAnalyzer(
|
||||
SkillSuiteBundleManifestParser manifestParser,
|
||||
SkillMetadataParser metadataParser,
|
||||
SkillPackageValidator packageValidator
|
||||
) {
|
||||
this(manifestParser, metadataParser, packageValidator, new NoOpPrePublishValidator());
|
||||
}
|
||||
|
||||
public BundleAnalysis analyze(List<SkillSuiteBundleStagedEntry> stagedEntries) throws IOException {
|
||||
List<String> errors = new ArrayList<>();
|
||||
List<IndexedEntry> indexed = indexEntries(stagedEntries, errors);
|
||||
IndexedEntry manifestEntry = findManifest(indexed);
|
||||
String outerPrefix = parentPrefix(manifestEntry.normalizedPath());
|
||||
List<IndexedEntry> rooted = removeOuterPrefix(indexed, outerPrefix, errors);
|
||||
|
||||
IndexedEntry rootedManifest = rooted.stream()
|
||||
.filter(entry -> SkillSuiteBundleManifest.FILE_NAME.equals(entry.rootPath()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> invalid("SUITE.yaml must be at the Bundle root"));
|
||||
SkillSuiteBundleManifest manifest = manifestParser.parse(readManifest(rootedManifest.staged()));
|
||||
|
||||
Map<String, List<IndexedEntry>> filesByDirectory = new LinkedHashMap<>();
|
||||
for (SkillSuiteBundleMember member : manifest.spec().members()) {
|
||||
if (member.packageSource() != null) {
|
||||
filesByDirectory.put(member.packageSource().path(), new ArrayList<>());
|
||||
}
|
||||
}
|
||||
|
||||
for (IndexedEntry entry : rooted) {
|
||||
if (SkillSuiteBundleManifest.FILE_NAME.equals(entry.rootPath())) {
|
||||
continue;
|
||||
}
|
||||
String owner = owningDirectory(entry.rootPath(), filesByDirectory.keySet());
|
||||
if (owner == null) {
|
||||
errors.add("Unclaimed archive entry: " + entry.rootPath());
|
||||
} else {
|
||||
filesByDirectory.get(owner).add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
List<MemberPackageAnalysis> packageMembers = new ArrayList<>();
|
||||
for (SkillSuiteBundleMember member : manifest.spec().members()) {
|
||||
if (member.packageSource() == null) {
|
||||
continue;
|
||||
}
|
||||
packageMembers.add(analyzeMember(
|
||||
member.coordinate(), member.packageSource().path(),
|
||||
filesByDirectory.get(member.packageSource().path())));
|
||||
}
|
||||
return new BundleAnalysis(manifest, List.copyOf(packageMembers), List.copyOf(errors));
|
||||
}
|
||||
|
||||
private List<IndexedEntry> indexEntries(
|
||||
List<SkillSuiteBundleStagedEntry> stagedEntries, List<String> errors
|
||||
) {
|
||||
List<IndexedEntry> indexed = new ArrayList<>();
|
||||
Set<String> paths = new HashSet<>();
|
||||
for (SkillSuiteBundleStagedEntry staged : stagedEntries) {
|
||||
if (isOsMetadata(staged.path())) {
|
||||
continue;
|
||||
}
|
||||
if (staged.path().contains("\\")) {
|
||||
errors.add("Archive entry must use '/' separators: " + staged.path());
|
||||
continue;
|
||||
}
|
||||
String normalized;
|
||||
try {
|
||||
normalized = SkillPackagePolicy.normalizeEntryPath(staged.path());
|
||||
} catch (IllegalArgumentException exception) {
|
||||
errors.add(exception.getMessage());
|
||||
continue;
|
||||
}
|
||||
if (!paths.add(normalized)) {
|
||||
errors.add("Duplicate archive path: " + normalized);
|
||||
continue;
|
||||
}
|
||||
indexed.add(new IndexedEntry(staged, normalized, normalized));
|
||||
}
|
||||
return indexed;
|
||||
}
|
||||
|
||||
private IndexedEntry findManifest(List<IndexedEntry> entries) {
|
||||
List<IndexedEntry> manifests = entries.stream()
|
||||
.filter(entry -> entry.normalizedPath().equals(SkillSuiteBundleManifest.FILE_NAME)
|
||||
|| entry.normalizedPath().endsWith("/" + SkillSuiteBundleManifest.FILE_NAME))
|
||||
.toList();
|
||||
if (manifests.size() != 1) {
|
||||
throw invalid("Bundle must contain exactly one SUITE.yaml manifest");
|
||||
}
|
||||
return manifests.getFirst();
|
||||
}
|
||||
|
||||
private List<IndexedEntry> removeOuterPrefix(
|
||||
List<IndexedEntry> entries, String prefix, List<String> errors
|
||||
) {
|
||||
if (prefix.isEmpty()) {
|
||||
return entries;
|
||||
}
|
||||
List<IndexedEntry> rooted = new ArrayList<>();
|
||||
for (IndexedEntry entry : entries) {
|
||||
if (!entry.normalizedPath().startsWith(prefix)) {
|
||||
errors.add("Archive entry is outside the Bundle root: " + entry.normalizedPath());
|
||||
continue;
|
||||
}
|
||||
rooted.add(new IndexedEntry(
|
||||
entry.staged(), entry.normalizedPath(), entry.normalizedPath().substring(prefix.length())));
|
||||
}
|
||||
return rooted;
|
||||
}
|
||||
|
||||
private MemberPackageAnalysis analyzeMember(
|
||||
SkillSuiteBundleCoordinate coordinate, String directory, List<IndexedEntry> indexedEntries
|
||||
) throws IOException {
|
||||
List<PackageEntry> packageEntries = new ArrayList<>();
|
||||
List<StagedMemberFile> stagedFiles = new ArrayList<>();
|
||||
String prefix = directory + "/";
|
||||
for (IndexedEntry indexed : indexedEntries) {
|
||||
String relativePath = indexed.rootPath().substring(prefix.length());
|
||||
packageEntries.add(PackageEntry.streaming(
|
||||
relativePath, indexed.staged().size(), indexed.staged().contentType(),
|
||||
indexed.staged().content()::open));
|
||||
stagedFiles.add(new StagedMemberFile(
|
||||
relativePath, indexed.staged().size(), indexed.staged().contentType(),
|
||||
indexed.staged().sha256(), indexed.staged().objectKey()));
|
||||
}
|
||||
packageEntries.sort(Comparator.comparing(PackageEntry::path));
|
||||
stagedFiles.sort(Comparator.comparing(StagedMemberFile::relativePath));
|
||||
|
||||
ValidationResult base = packageValidator.validate(packageEntries);
|
||||
List<String> memberErrors = new ArrayList<>(base.errors());
|
||||
List<String> memberWarnings = new ArrayList<>(base.warnings());
|
||||
packageEntries.stream()
|
||||
.map(PackageEntry::path)
|
||||
.filter(path -> path.endsWith("/" + SkillPackagePolicy.SKILL_MD_PATH))
|
||||
.forEach(path -> memberErrors.add("Nested SKILL.md is not allowed: " + path));
|
||||
|
||||
SkillMetadata metadata = null;
|
||||
PackageEntry skillMd = packageEntries.stream()
|
||||
.filter(entry -> SkillPackagePolicy.SKILL_MD_PATH.equals(entry.path()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (skillMd != null && base.passed()) {
|
||||
metadata = metadataParser.parse(new String(skillMd.content(), StandardCharsets.UTF_8));
|
||||
String metadataSlug = SlugValidator.slugify(metadata.name());
|
||||
if (!coordinate.slug().equals(metadataSlug)) {
|
||||
memberErrors.add("SKILL.md name resolves to " + metadataSlug
|
||||
+ " and does not match manifest skill " + coordinate.canonical());
|
||||
}
|
||||
ValidationResult prePublish = prePublishValidator.validate(
|
||||
new PrePublishValidator.SkillPackageContext(packageEntries, metadata, null, null));
|
||||
memberErrors.addAll(prePublish.errors());
|
||||
memberWarnings.addAll(prePublish.warnings());
|
||||
}
|
||||
|
||||
ValidationResult validation = ValidationResult.of(memberErrors, memberWarnings);
|
||||
return new MemberPackageAnalysis(
|
||||
coordinate, directory, metadata, validation, List.copyOf(stagedFiles),
|
||||
fingerprint(stagedFiles));
|
||||
}
|
||||
|
||||
private String owningDirectory(String path, Set<String> directories) {
|
||||
for (String directory : directories) {
|
||||
if (path.startsWith(directory + "/")) {
|
||||
return directory;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String readManifest(SkillSuiteBundleStagedEntry entry) throws IOException {
|
||||
try (InputStream input = entry.content().open()) {
|
||||
byte[] bytes = input.readNBytes(MAX_MANIFEST_BYTES + 1);
|
||||
if (bytes.length > MAX_MANIFEST_BYTES) {
|
||||
throw invalid("SUITE.yaml exceeds max size " + MAX_MANIFEST_BYTES);
|
||||
}
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private String fingerprint(List<StagedMemberFile> files) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
files.stream()
|
||||
.sorted(Comparator.comparing(StagedMemberFile::relativePath))
|
||||
.forEach(file -> digest.update((file.relativePath() + ":" + file.sha256() + "\n")
|
||||
.getBytes(StandardCharsets.UTF_8)));
|
||||
return "sha256:" + HexFormat.of().formatHex(digest.digest());
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String parentPrefix(String path) {
|
||||
int slash = path.lastIndexOf('/');
|
||||
return slash < 0 ? "" : path.substring(0, slash + 1);
|
||||
}
|
||||
|
||||
private boolean isOsMetadata(String path) {
|
||||
String normalized = path.replace('\\', '/');
|
||||
if (normalized.equals("__MACOSX") || normalized.startsWith("__MACOSX/")) {
|
||||
return true;
|
||||
}
|
||||
int slash = normalized.lastIndexOf('/');
|
||||
String fileName = slash < 0 ? normalized : normalized.substring(slash + 1);
|
||||
return fileName.equals(".DS_Store") || fileName.startsWith("._");
|
||||
}
|
||||
|
||||
private DomainBadRequestException invalid(String detail) {
|
||||
return new DomainBadRequestException("error.suite.bundle.manifest.invalid", detail);
|
||||
}
|
||||
|
||||
private record IndexedEntry(
|
||||
SkillSuiteBundleStagedEntry staged,
|
||||
String normalizedPath,
|
||||
String rootPath
|
||||
) {
|
||||
}
|
||||
|
||||
public record BundleAnalysis(
|
||||
SkillSuiteBundleManifest manifest,
|
||||
List<MemberPackageAnalysis> packageMembers,
|
||||
List<String> errors
|
||||
) {
|
||||
public boolean confirmable() {
|
||||
return errors.isEmpty() && packageMembers.stream().allMatch(member -> member.validation().passed());
|
||||
}
|
||||
}
|
||||
|
||||
public record MemberPackageAnalysis(
|
||||
SkillSuiteBundleCoordinate coordinate,
|
||||
String directory,
|
||||
SkillMetadata metadata,
|
||||
ValidationResult validation,
|
||||
List<StagedMemberFile> files,
|
||||
String fingerprint
|
||||
) {
|
||||
}
|
||||
|
||||
public record StagedMemberFile(
|
||||
String relativePath,
|
||||
long size,
|
||||
String contentType,
|
||||
String sha256,
|
||||
String objectKey
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.config.SkillSuiteBundleProperties;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Creates actor-bound PreviewSessions after file and semantic checks complete without side effects. */
|
||||
@Service
|
||||
public class SkillSuiteBundlePreviewAppService {
|
||||
|
||||
private static final TypeReference<Map<String, Object>> JSON_OBJECT = new TypeReference<>() { };
|
||||
|
||||
private final SkillSuiteBundleArchiveService archiveService;
|
||||
private final SkillSuiteBundlePreviewPlanner planner;
|
||||
private final SkillSuiteBundlePreviewPersistenceService persistenceService;
|
||||
private final SkillSuiteBundleProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Clock clock;
|
||||
|
||||
public SkillSuiteBundlePreviewAppService(
|
||||
SkillSuiteBundleArchiveService archiveService,
|
||||
SkillSuiteBundlePreviewPlanner planner,
|
||||
SkillSuiteBundlePreviewPersistenceService persistenceService,
|
||||
SkillSuiteBundleProperties properties,
|
||||
ObjectMapper objectMapper,
|
||||
Clock clock
|
||||
) {
|
||||
this.archiveService = archiveService;
|
||||
this.planner = planner;
|
||||
this.persistenceService = persistenceService;
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
public PreviewOutcome preview(
|
||||
MultipartFile upload,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) throws IOException {
|
||||
SkillSuiteBundleArchiveService.StagedBundleAnalysis staged = archiveService.stageAndAnalyze(upload);
|
||||
if (!staged.analysis().confirmable()) {
|
||||
return new PreviewOutcome(null, null, staged.analysis(), null);
|
||||
}
|
||||
|
||||
SkillSuiteBundlePreviewPlanner.PreviewPlan plan = planner.plan(
|
||||
staged.analysis(), actorId, namespaceRoles, platformRoles);
|
||||
if (!plan.confirmable()) {
|
||||
archiveService.cleanupStagedObjects(staged.objectKeys());
|
||||
return new PreviewOutcome(null, null, staged.analysis(), plan);
|
||||
}
|
||||
|
||||
Instant now = clock.instant();
|
||||
Instant expiresAt = now.plus(properties.getPreviewTtl());
|
||||
String token = UUID.randomUUID().toString();
|
||||
try {
|
||||
Map<String, Object> manifestJson = objectMapper.convertValue(
|
||||
staged.analysis().manifest(), JSON_OBJECT);
|
||||
Map<String, Object> planJson = objectMapper.convertValue(plan, JSON_OBJECT);
|
||||
SkillSuiteBundlePreviewSession session = new SkillSuiteBundlePreviewSession(
|
||||
token, actorId, plan.mode(), plan.targetNamespaceId(), plan.target().slug(),
|
||||
plan.targetSuiteId(), plan.baseSuiteVersionId(), plan.targetVersion(),
|
||||
staged.archiveObjectKey(), staged.archiveSha256(), manifestJson, planJson,
|
||||
plan.warningDigest(), expiresAt, now);
|
||||
persistenceService.save(session);
|
||||
return new PreviewOutcome(token, expiresAt, staged.analysis(), plan);
|
||||
} catch (RuntimeException exception) {
|
||||
archiveService.cleanupStagedObjects(staged.objectKeys());
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
public record PreviewOutcome(
|
||||
String previewToken,
|
||||
Instant expiresAt,
|
||||
SkillSuiteBundlePackageAnalyzer.BundleAnalysis packageAnalysis,
|
||||
SkillSuiteBundlePreviewPlanner.PreviewPlan plan
|
||||
) {
|
||||
public boolean confirmable() {
|
||||
return previewToken != null;
|
||||
}
|
||||
|
||||
public List<String> errors() {
|
||||
if (plan != null) {
|
||||
return plan.errors();
|
||||
}
|
||||
return java.util.stream.Stream.concat(
|
||||
packageAnalysis.errors().stream(),
|
||||
packageAnalysis.packageMembers().stream().flatMap(member ->
|
||||
member.validation().errors().stream()
|
||||
.map(error -> member.coordinate().canonical() + ": " + error)))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/** Keeps PreviewSession persistence atomic without holding a transaction during ZIP processing. */
|
||||
@Service
|
||||
public class SkillSuiteBundlePreviewPersistenceService {
|
||||
|
||||
private final SkillSuiteBundlePreviewSessionRepository previewRepository;
|
||||
|
||||
public SkillSuiteBundlePreviewPersistenceService(
|
||||
SkillSuiteBundlePreviewSessionRepository previewRepository
|
||||
) {
|
||||
this.previewRepository = previewRepository;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void save(SkillSuiteBundlePreviewSession preview) {
|
||||
previewRepository.save(preview);
|
||||
// Surface serialization and constraint failures before staged-object compensation runs.
|
||||
previewRepository.flush();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,719 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
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.security.SecurityScanService;
|
||||
import com.iflytek.skillhub.domain.skill.Skill;
|
||||
import com.iflytek.skillhub.domain.skill.SkillFile;
|
||||
import com.iflytek.skillhub.domain.skill.SkillFileRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersion;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionRepository;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVersionStatus;
|
||||
import com.iflytek.skillhub.domain.skill.SkillVisibility;
|
||||
import com.iflytek.skillhub.domain.skill.VisibilityChecker;
|
||||
import com.iflytek.skillhub.domain.suite.SkillSuite;
|
||||
import com.iflytek.skillhub.domain.suite.SkillSuiteActionContext;
|
||||
import com.iflytek.skillhub.domain.suite.SkillSuiteAuthorizationPolicy;
|
||||
import com.iflytek.skillhub.domain.suite.SkillSuiteRepository;
|
||||
import com.iflytek.skillhub.domain.suite.SkillSuiteStatus;
|
||||
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.suite.bundle.SkillSuiteBundleCoordinate;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifest;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMember;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMode;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Clock;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Builds a side-effect-free, batch-loaded publication plan from validated Bundle packages. */
|
||||
@Service
|
||||
public class SkillSuiteBundlePreviewPlanner {
|
||||
|
||||
private static final DateTimeFormatter AUTO_VERSION_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyyMMdd.HHmmss").withZone(ZoneId.systemDefault());
|
||||
|
||||
private final NamespaceRepository namespaceRepository;
|
||||
private final SkillRepository skillRepository;
|
||||
private final SkillVersionRepository skillVersionRepository;
|
||||
private final SkillFileRepository skillFileRepository;
|
||||
private final SkillSuiteRepository suiteRepository;
|
||||
private final SkillSuiteVersionRepository suiteVersionRepository;
|
||||
private final SkillSuiteVersionMemberRepository suiteMemberRepository;
|
||||
private final VisibilityChecker visibilityChecker;
|
||||
private final SecurityScanService securityScanService;
|
||||
private final Clock clock;
|
||||
|
||||
public SkillSuiteBundlePreviewPlanner(
|
||||
NamespaceRepository namespaceRepository,
|
||||
SkillRepository skillRepository,
|
||||
SkillVersionRepository skillVersionRepository,
|
||||
SkillFileRepository skillFileRepository,
|
||||
SkillSuiteRepository suiteRepository,
|
||||
SkillSuiteVersionRepository suiteVersionRepository,
|
||||
SkillSuiteVersionMemberRepository suiteMemberRepository,
|
||||
VisibilityChecker visibilityChecker,
|
||||
SecurityScanService securityScanService,
|
||||
Clock clock
|
||||
) {
|
||||
this.namespaceRepository = namespaceRepository;
|
||||
this.skillRepository = skillRepository;
|
||||
this.skillVersionRepository = skillVersionRepository;
|
||||
this.skillFileRepository = skillFileRepository;
|
||||
this.suiteRepository = suiteRepository;
|
||||
this.suiteVersionRepository = suiteVersionRepository;
|
||||
this.suiteMemberRepository = suiteMemberRepository;
|
||||
this.visibilityChecker = visibilityChecker;
|
||||
this.securityScanService = securityScanService;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PreviewPlan plan(
|
||||
SkillSuiteBundlePackageAnalyzer.BundleAnalysis packageAnalysis,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
SkillSuiteBundleManifest manifest = packageAnalysis.manifest();
|
||||
Map<Long, NamespaceRole> safeNamespaceRoles = namespaceRoles == null ? Map.of() : namespaceRoles;
|
||||
Set<String> safePlatformRoles = platformRoles == null ? Set.of() : platformRoles;
|
||||
List<String> errors = new ArrayList<>(packageAnalysis.errors());
|
||||
List<String> warnings = new ArrayList<>();
|
||||
|
||||
Set<String> namespaceSlugs = manifest.spec().members().stream()
|
||||
.map(member -> member.coordinate().namespace())
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
namespaceSlugs.add(manifest.metadata().coordinate().namespace());
|
||||
Map<String, Namespace> namespaces = namespaceRepository.findBySlugIn(List.copyOf(namespaceSlugs)).stream()
|
||||
.collect(Collectors.toMap(Namespace::getSlug, Function.identity()));
|
||||
for (String namespaceSlug : namespaceSlugs) {
|
||||
if (!namespaces.containsKey(namespaceSlug)) {
|
||||
errors.add("Namespace not found: " + namespaceSlug);
|
||||
}
|
||||
}
|
||||
|
||||
Target target = resolveTarget(
|
||||
manifest, namespaces.get(manifest.metadata().coordinate().namespace()), actorId,
|
||||
safeNamespaceRoles, safePlatformRoles, errors);
|
||||
|
||||
List<Long> namespaceIds = namespaces.values().stream().map(Namespace::getId).distinct().toList();
|
||||
List<String> skillSlugs = manifest.spec().members().stream()
|
||||
.map(member -> member.coordinate().slug()).distinct().toList();
|
||||
List<Skill> skills = namespaceIds.isEmpty() || skillSlugs.isEmpty()
|
||||
? List.of()
|
||||
: skillRepository.findByNamespaceIdInAndSlugIn(namespaceIds, skillSlugs);
|
||||
List<Long> skillIds = skills.stream().map(Skill::getId).distinct().toList();
|
||||
|
||||
List<SkillVersion> pendingVersions = skillIds.isEmpty()
|
||||
? List.of()
|
||||
: skillVersionRepository.findBySkillIdInAndStatus(skillIds, SkillVersionStatus.PENDING_REVIEW);
|
||||
List<String> requestedVersions = requestedVersions(packageAnalysis);
|
||||
List<SkillVersion> namedVersions = skillIds.isEmpty() || requestedVersions.isEmpty()
|
||||
? List.of()
|
||||
: skillVersionRepository.findBySkillIdInAndVersionIn(skillIds, requestedVersions);
|
||||
|
||||
List<Long> latestVersionIds = skills.stream()
|
||||
.map(Skill::getLatestVersionId)
|
||||
.filter(Objects::nonNull)
|
||||
.distinct()
|
||||
.toList();
|
||||
List<SkillVersion> latestVersions = latestVersionIds.isEmpty()
|
||||
? List.of()
|
||||
: skillVersionRepository.findByIdIn(latestVersionIds);
|
||||
Map<Long, SkillVersion> publishCandidatesById = new LinkedHashMap<>();
|
||||
latestVersions.stream()
|
||||
.filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED)
|
||||
.forEach(version -> publishCandidatesById.put(version.getId(), version));
|
||||
namedVersions.stream()
|
||||
.filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED)
|
||||
.forEach(version -> publishCandidatesById.put(version.getId(), version));
|
||||
List<SkillVersion> publishedVersions = List.copyOf(publishCandidatesById.values());
|
||||
|
||||
Map<Long, List<SkillVersion>> publishedBySkill = groupVersions(publishedVersions);
|
||||
Map<Long, List<SkillVersion>> pendingBySkill = groupVersions(pendingVersions);
|
||||
Map<Long, List<SkillVersion>> namedBySkill = groupVersions(namedVersions);
|
||||
List<Long> publishedVersionIds = publishedVersions.stream().map(SkillVersion::getId).distinct().toList();
|
||||
Map<Long, List<SkillFile>> filesByVersion = publishedVersionIds.isEmpty()
|
||||
? Map.of()
|
||||
: skillFileRepository.findByVersionIdIn(publishedVersionIds).stream()
|
||||
.collect(Collectors.groupingBy(SkillFile::getVersionId));
|
||||
Map<Long, String> fingerprints = publishedVersions.stream().collect(Collectors.toMap(
|
||||
SkillVersion::getId,
|
||||
version -> fingerprint(filesByVersion.getOrDefault(version.getId(), List.of())),
|
||||
(left, right) -> left));
|
||||
|
||||
Map<SkillSuiteBundleCoordinate, SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis> packageMembers =
|
||||
packageAnalysis.packageMembers().stream().collect(Collectors.toMap(
|
||||
SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis::coordinate,
|
||||
Function.identity()));
|
||||
Map<SkillSuiteBundleCoordinate, SkillSuiteVersionMember> baseline = baselineMembers(target);
|
||||
List<MemberPlan> members = new ArrayList<>();
|
||||
Set<SkillSuiteBundleCoordinate> desiredCoordinates = new LinkedHashSet<>();
|
||||
|
||||
for (int position = 0; position < manifest.spec().members().size(); position++) {
|
||||
SkillSuiteBundleMember member = manifest.spec().members().get(position);
|
||||
desiredCoordinates.add(member.coordinate());
|
||||
Namespace namespace = namespaces.get(member.coordinate().namespace());
|
||||
List<Skill> coordinateSkills = namespace == null ? List.of() : skills.stream()
|
||||
.filter(skill -> Objects.equals(skill.getNamespaceId(), namespace.getId()))
|
||||
.filter(skill -> skill.getSlug().equals(member.coordinate().slug()))
|
||||
.toList();
|
||||
MemberPlan planned = member.packageSource() != null
|
||||
? planPackageMember(
|
||||
manifest, member, packageMembers.get(member.coordinate()), namespace,
|
||||
coordinateSkills, publishedBySkill, pendingBySkill, namedBySkill,
|
||||
fingerprints, actorId, safeNamespaceRoles, safePlatformRoles)
|
||||
: planReferenceMember(
|
||||
manifest, member, namespace, coordinateSkills, namedBySkill,
|
||||
fingerprints, actorId, safeNamespaceRoles, safePlatformRoles);
|
||||
SkillSuiteVersionMember previous = baseline.get(member.coordinate());
|
||||
boolean requestedEntry = member.coordinate().equals(manifest.spec().entry());
|
||||
SkillSuiteBundleRelationshipChange relationship = previous == null
|
||||
? SkillSuiteBundleRelationshipChange.ADDED
|
||||
: Objects.equals(previous.getSkillVersionId(), planned.skillVersionId())
|
||||
&& previous.getPosition() == position
|
||||
&& previous.isEntry() == requestedEntry
|
||||
? SkillSuiteBundleRelationshipChange.UNCHANGED
|
||||
: SkillSuiteBundleRelationshipChange.UPDATED;
|
||||
planned = planned.withRelationship(relationship);
|
||||
members.add(planned);
|
||||
planned.errors().forEach(error ->
|
||||
errors.add(member.coordinate().canonical() + ": " + error));
|
||||
planned.warnings().forEach(warning ->
|
||||
warnings.add(member.coordinate().canonical() + ": " + warning));
|
||||
}
|
||||
|
||||
List<RemovedMemberPlan> removed = baseline.entrySet().stream()
|
||||
.filter(entry -> !desiredCoordinates.contains(entry.getKey()))
|
||||
.sorted(Comparator.comparingInt(entry -> entry.getValue().getPosition()))
|
||||
.map(entry -> new RemovedMemberPlan(
|
||||
entry.getKey(), entry.getValue().getSkillId(),
|
||||
entry.getValue().getSkillVersionId(), entry.getValue().getSkillVersionSnapshot(),
|
||||
entry.getValue().isEntry(), SkillSuiteBundleRelationshipChange.REMOVED,
|
||||
SkillSuiteBundlePublishAction.NONE))
|
||||
.toList();
|
||||
|
||||
ResolvedPresentation presentation = resolvePresentation(manifest, target, errors);
|
||||
boolean memberRelationshipChanged = !removed.isEmpty() || members.stream()
|
||||
.anyMatch(member -> member.relationship() != SkillSuiteBundleRelationshipChange.UNCHANGED);
|
||||
if (manifest.spec().mode() == SkillSuiteBundleMode.UPDATE
|
||||
&& !memberRelationshipChanged
|
||||
&& !presentationChanged(manifest, target, presentation)) {
|
||||
errors.add("Bundle does not contain an effective change from the base Suite version");
|
||||
}
|
||||
return new PreviewPlan(
|
||||
manifest.spec().mode(), manifest.metadata().coordinate(), target.namespaceId(), target.suiteId(),
|
||||
target.baseVersionId(), manifest.spec().version(), presentation.displayName(),
|
||||
presentation.summary(), presentation.overview(), manifest.spec().visibility(),
|
||||
List.copyOf(members), removed, List.copyOf(errors), List.copyOf(warnings),
|
||||
warningDigest(warnings));
|
||||
}
|
||||
|
||||
private Target resolveTarget(
|
||||
SkillSuiteBundleManifest manifest,
|
||||
Namespace namespace,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles,
|
||||
List<String> errors
|
||||
) {
|
||||
if (namespace == null) {
|
||||
return Target.empty();
|
||||
}
|
||||
if (namespace.getStatus() != NamespaceStatus.ACTIVE) {
|
||||
errors.add("Target Namespace is not writable");
|
||||
}
|
||||
SkillSuiteBundleCoordinate coordinate = manifest.metadata().coordinate();
|
||||
SkillSuite suite = suiteRepository.findByNamespaceIdAndSlug(namespace.getId(), coordinate.slug()).orElse(null);
|
||||
boolean superAdmin = platformRoles.contains("SUPER_ADMIN");
|
||||
if (manifest.spec().mode() == SkillSuiteBundleMode.CREATE) {
|
||||
if (!superAdmin && !namespaceRoles.containsKey(namespace.getId())) {
|
||||
errors.add("No permission to create Suite in target Namespace");
|
||||
}
|
||||
if (suite != null) {
|
||||
errors.add("Target Suite coordinate already exists");
|
||||
return new Target(namespace.getId(), suite.getId(), null, null, suite);
|
||||
}
|
||||
return Target.empty(namespace.getId());
|
||||
}
|
||||
if (suite == null) {
|
||||
errors.add("Target Suite does not exist for UPDATE");
|
||||
return Target.empty();
|
||||
}
|
||||
SkillSuiteActionContext context = new SkillSuiteActionContext(
|
||||
actorId, namespaceRoles, platformRoles, null, null, null);
|
||||
if (!SkillSuiteAuthorizationPolicy.canCreateVersion(suite, context)) {
|
||||
errors.add("No permission to create a version for target Suite");
|
||||
}
|
||||
if (suite.getStatus() != SkillSuiteStatus.ACTIVE) {
|
||||
errors.add("Target Suite is not active");
|
||||
}
|
||||
SkillSuiteVersion base = suiteVersionRepository
|
||||
.findBySuiteIdAndVersion(suite.getId(), manifest.spec().baseVersion()).orElse(null);
|
||||
if (base == null || base.getStatus() != SkillSuiteVersionStatus.PUBLISHED) {
|
||||
errors.add("Base Suite version is not published or does not exist");
|
||||
}
|
||||
if (suiteVersionRepository.findBySuiteIdAndVersion(
|
||||
suite.getId(), manifest.spec().version()).isPresent()) {
|
||||
errors.add("Target Suite version already exists");
|
||||
}
|
||||
return new Target(namespace.getId(), suite.getId(), base == null ? null : base.getId(), base, suite);
|
||||
}
|
||||
|
||||
private MemberPlan planPackageMember(
|
||||
SkillSuiteBundleManifest manifest,
|
||||
SkillSuiteBundleMember member,
|
||||
SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis packageAnalysis,
|
||||
Namespace namespace,
|
||||
List<Skill> coordinateSkills,
|
||||
Map<Long, List<SkillVersion>> publishedBySkill,
|
||||
Map<Long, List<SkillVersion>> pendingBySkill,
|
||||
Map<Long, List<SkillVersion>> namedBySkill,
|
||||
Map<Long, String> fingerprints,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
List<String> warnings = packageAnalysis == null
|
||||
? List.of()
|
||||
: packageAnalysis.validation().warnings();
|
||||
if (packageAnalysis == null) {
|
||||
errors.add("Package analysis is missing");
|
||||
return MemberPlan.invalidPackage(member.coordinate(), errors);
|
||||
}
|
||||
errors.addAll(packageAnalysis.validation().errors());
|
||||
if (namespace == null) {
|
||||
errors.add("Member Namespace does not exist");
|
||||
return MemberPlan.packageResult(member.coordinate(), null, null, null,
|
||||
packageAnalysis.fingerprint(), null, null, packageAnalysis.files(), errors, warnings);
|
||||
}
|
||||
if (namespace.getStatus() != NamespaceStatus.ACTIVE) {
|
||||
errors.add("Member Namespace is not writable");
|
||||
}
|
||||
|
||||
Skill manageable = selectManageableSkill(
|
||||
coordinateSkills, actorId, namespaceRoles.get(namespace.getId()), platformRoles, errors);
|
||||
boolean hasExplicitVersion = packageAnalysis.metadata() != null
|
||||
&& packageAnalysis.metadata().version() != null
|
||||
&& !packageAnalysis.metadata().version().isBlank();
|
||||
String initialResolvedVersion = !hasExplicitVersion
|
||||
? AUTO_VERSION_FORMATTER.format(clock.instant())
|
||||
: packageAnalysis.metadata().version();
|
||||
|
||||
if (coordinateSkills.isEmpty()) {
|
||||
if (!platformRoles.contains("SUPER_ADMIN") && !namespaceRoles.containsKey(namespace.getId())) {
|
||||
errors.add("No permission to create Skill in member Namespace");
|
||||
}
|
||||
SkillVisibility requestedVisibility = member.packageSource().visibility();
|
||||
if (requestedVisibility == null) {
|
||||
errors.add("New Skill requires package.visibility");
|
||||
} else if (!audienceCompatible(
|
||||
manifest.spec().visibility(), manifest.metadata().coordinate().namespace(),
|
||||
requestedVisibility, member.coordinate().namespace())) {
|
||||
errors.add("New Skill visibility is incompatible with Suite audience");
|
||||
}
|
||||
if (requestedVisibility != null
|
||||
&& requestedVisibility != SkillVisibility.PRIVATE
|
||||
&& !securityScanService.isEnabled()) {
|
||||
errors.add("Security scanner is required for non-private Skill publication");
|
||||
}
|
||||
return MemberPlan.packageResult(
|
||||
member.coordinate(), null, null, SkillSuiteBundlePublishAction.CREATE_SKILL,
|
||||
packageAnalysis.fingerprint(), requestedVisibility, initialResolvedVersion,
|
||||
packageAnalysis.files(), errors, warnings);
|
||||
}
|
||||
if (manageable == null) {
|
||||
return MemberPlan.packageResult(
|
||||
member.coordinate(), null, null, null, packageAnalysis.fingerprint(), null,
|
||||
initialResolvedVersion, packageAnalysis.files(), errors, warnings);
|
||||
}
|
||||
if (manageable.getStatus() != SkillStatus.ACTIVE || manageable.isHidden()) {
|
||||
errors.add("Existing Skill is not active and visible");
|
||||
}
|
||||
SkillVisibility requestedVisibility = member.packageSource().visibility();
|
||||
if (requestedVisibility != null && requestedVisibility != manageable.getVisibility()) {
|
||||
errors.add("Existing Skill visibility cannot be changed by Bundle");
|
||||
}
|
||||
SkillVisibility finalVisibility = manageable.getVisibility();
|
||||
if (!audienceCompatible(
|
||||
manifest.spec().visibility(), manifest.metadata().coordinate().namespace(),
|
||||
finalVisibility, member.coordinate().namespace())) {
|
||||
errors.add("Existing Skill visibility is incompatible with Suite audience");
|
||||
}
|
||||
if (!pendingBySkill.getOrDefault(manageable.getId(), List.of()).isEmpty()) {
|
||||
errors.add("Existing Skill has a pending review version");
|
||||
}
|
||||
|
||||
List<SkillVersion> named = namedBySkill.getOrDefault(manageable.getId(), List.of()).stream()
|
||||
.filter(version -> initialResolvedVersion.equals(version.getVersion())).toList();
|
||||
SkillVersion reusable = named.stream()
|
||||
.filter(version -> version.getStatus() == SkillVersionStatus.PUBLISHED)
|
||||
.filter(SkillVersion::isDownloadReady)
|
||||
.filter(version -> version.getYankedAt() == null)
|
||||
.filter(version -> packageAnalysis.fingerprint().equals(fingerprints.get(version.getId())))
|
||||
.findFirst().orElse(null);
|
||||
String resolvedVersion = initialResolvedVersion;
|
||||
if (!hasExplicitVersion && reusable == null) {
|
||||
reusable = publishedBySkill.getOrDefault(manageable.getId(), List.of()).stream()
|
||||
.filter(SkillVersion::isDownloadReady)
|
||||
.filter(version -> version.getYankedAt() == null)
|
||||
.filter(version -> packageAnalysis.fingerprint().equals(fingerprints.get(version.getId())))
|
||||
.findFirst().orElse(null);
|
||||
if (reusable != null) {
|
||||
resolvedVersion = reusable.getVersion();
|
||||
}
|
||||
}
|
||||
SkillSuiteBundlePublishAction action;
|
||||
if (!named.isEmpty() && reusable == null) {
|
||||
errors.add("Target Skill version already exists with different content or non-published status");
|
||||
action = null;
|
||||
} else if (reusable != null) {
|
||||
action = SkillSuiteBundlePublishAction.REUSE_VERSION;
|
||||
} else {
|
||||
action = SkillSuiteBundlePublishAction.CREATE_VERSION;
|
||||
}
|
||||
if ((action == SkillSuiteBundlePublishAction.CREATE_VERSION)
|
||||
&& finalVisibility != SkillVisibility.PRIVATE
|
||||
&& !securityScanService.isEnabled()) {
|
||||
errors.add("Security scanner is required for non-private Skill publication");
|
||||
}
|
||||
return MemberPlan.packageResult(
|
||||
member.coordinate(), manageable.getId(), reusable == null ? null : reusable.getId(),
|
||||
action, packageAnalysis.fingerprint(), finalVisibility, resolvedVersion,
|
||||
packageAnalysis.files(), errors, warnings);
|
||||
}
|
||||
|
||||
private MemberPlan planReferenceMember(
|
||||
SkillSuiteBundleManifest manifest,
|
||||
SkillSuiteBundleMember member,
|
||||
Namespace namespace,
|
||||
List<Skill> coordinateSkills,
|
||||
Map<Long, List<SkillVersion>> namedBySkill,
|
||||
Map<Long, String> fingerprints,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
String requestedVersion = member.referenceSource().version();
|
||||
List<ReferenceCandidate> candidates = new ArrayList<>();
|
||||
if (namespace != null) {
|
||||
for (Skill skill : coordinateSkills) {
|
||||
for (SkillVersion version : namedBySkill.getOrDefault(skill.getId(), List.of())) {
|
||||
if (!requestedVersion.equals(version.getVersion())
|
||||
|| version.getStatus() != SkillVersionStatus.PUBLISHED) {
|
||||
continue;
|
||||
}
|
||||
if (isReferenceEligible(
|
||||
manifest, member.coordinate(), namespace, skill, version,
|
||||
actorId, namespaceRoles, platformRoles)) {
|
||||
candidates.add(new ReferenceCandidate(skill, version));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.size() != 1) {
|
||||
return new MemberPlan(
|
||||
member.coordinate(), SkillSuiteBundleMemberSourceType.REFERENCE,
|
||||
SkillSuiteBundleRelationshipChange.ADDED, null, null, null, null,
|
||||
requestedVersion, null, List.of(),
|
||||
List.of("Exact reference is unavailable or ambiguous"), List.of());
|
||||
}
|
||||
ReferenceCandidate candidate = candidates.getFirst();
|
||||
return new MemberPlan(
|
||||
member.coordinate(), SkillSuiteBundleMemberSourceType.REFERENCE,
|
||||
SkillSuiteBundleRelationshipChange.ADDED,
|
||||
SkillSuiteBundlePublishAction.REFERENCE_VERSION,
|
||||
candidate.skill().getId(), candidate.version().getId(),
|
||||
candidate.skill().getVisibility(), requestedVersion,
|
||||
fingerprints.get(candidate.version().getId()), List.of(), List.of(), List.of());
|
||||
}
|
||||
|
||||
private boolean isReferenceEligible(
|
||||
SkillSuiteBundleManifest manifest,
|
||||
SkillSuiteBundleCoordinate coordinate,
|
||||
Namespace namespace,
|
||||
Skill skill,
|
||||
SkillVersion version,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
return namespace.getStatus() == NamespaceStatus.ACTIVE
|
||||
&& skill.getStatus() == SkillStatus.ACTIVE
|
||||
&& !skill.isHidden()
|
||||
&& version.isDownloadReady()
|
||||
&& version.getYankedAt() == null
|
||||
&& visibilityChecker.canAccess(skill, actorId, namespaceRoles, platformRoles)
|
||||
&& audienceCompatible(
|
||||
manifest.spec().visibility(), manifest.metadata().coordinate().namespace(),
|
||||
skill.getVisibility(), coordinate.namespace());
|
||||
}
|
||||
|
||||
private Skill selectManageableSkill(
|
||||
List<Skill> skills,
|
||||
String actorId,
|
||||
NamespaceRole namespaceRole,
|
||||
Set<String> platformRoles,
|
||||
List<String> errors
|
||||
) {
|
||||
boolean namespaceAdmin = namespaceRole == NamespaceRole.OWNER || namespaceRole == NamespaceRole.ADMIN;
|
||||
boolean superAdmin = platformRoles.contains("SUPER_ADMIN");
|
||||
List<Skill> manageable = skills.stream()
|
||||
.filter(skill -> Objects.equals(skill.getOwnerId(), actorId) || namespaceAdmin || superAdmin)
|
||||
.toList();
|
||||
if (manageable.size() == 1) {
|
||||
return manageable.getFirst();
|
||||
}
|
||||
if (!skills.isEmpty()) {
|
||||
errors.add(manageable.isEmpty()
|
||||
? "No permission to publish existing Skill"
|
||||
: "Skill coordinate is ambiguous across owners");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Map<SkillSuiteBundleCoordinate, SkillSuiteVersionMember> baselineMembers(Target target) {
|
||||
if (target.baseVersionId() == null) {
|
||||
return Map.of();
|
||||
}
|
||||
return suiteMemberRepository.findBySuiteVersionIdOrderByPosition(target.baseVersionId()).stream()
|
||||
.collect(Collectors.toMap(
|
||||
member -> new SkillSuiteBundleCoordinate(
|
||||
member.getNamespaceSlugSnapshot(), member.getSkillSlugSnapshot()),
|
||||
Function.identity(),
|
||||
(left, right) -> left,
|
||||
LinkedHashMap::new));
|
||||
}
|
||||
|
||||
private ResolvedPresentation resolvePresentation(
|
||||
SkillSuiteBundleManifest manifest, Target target, List<String> errors
|
||||
) {
|
||||
SkillSuiteBundleManifest.Spec spec = manifest.spec();
|
||||
String displayName = spec.displayName();
|
||||
String summary = spec.summary() != null
|
||||
? spec.summary()
|
||||
: target.baseVersion() == null ? null : target.baseVersion().getSummary();
|
||||
String overview = spec.overview() != null
|
||||
? spec.overview()
|
||||
: target.baseVersion() == null ? null : target.baseVersion().getOverview();
|
||||
if (summary == null || summary.isBlank()) {
|
||||
errors.add("Suite summary is required after inheritance");
|
||||
}
|
||||
if (overview == null || overview.isBlank()) {
|
||||
errors.add("Suite overview is required after inheritance");
|
||||
}
|
||||
return new ResolvedPresentation(displayName, summary, overview);
|
||||
}
|
||||
|
||||
private boolean presentationChanged(
|
||||
SkillSuiteBundleManifest manifest, Target target, ResolvedPresentation presentation
|
||||
) {
|
||||
SkillSuiteVersion base = target.baseVersion();
|
||||
return base == null
|
||||
|| !Objects.equals(base.getDisplayName(), presentation.displayName())
|
||||
|| !Objects.equals(base.getSummary(), presentation.summary())
|
||||
|| !Objects.equals(base.getOverview(), presentation.overview())
|
||||
|| base.getVisibility() != manifest.spec().visibility();
|
||||
}
|
||||
|
||||
private List<String> requestedVersions(
|
||||
SkillSuiteBundlePackageAnalyzer.BundleAnalysis packageAnalysis
|
||||
) {
|
||||
LinkedHashSet<String> versions = new LinkedHashSet<>();
|
||||
packageAnalysis.manifest().spec().members().stream()
|
||||
.filter(member -> member.referenceSource() != null)
|
||||
.map(member -> member.referenceSource().version())
|
||||
.forEach(versions::add);
|
||||
packageAnalysis.packageMembers().stream()
|
||||
.map(SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis::metadata)
|
||||
.filter(Objects::nonNull)
|
||||
.map(metadata -> metadata.version())
|
||||
.filter(Objects::nonNull)
|
||||
.filter(version -> !version.isBlank())
|
||||
.forEach(versions::add);
|
||||
versions.add(AUTO_VERSION_FORMATTER.format(clock.instant()));
|
||||
return List.copyOf(versions);
|
||||
}
|
||||
|
||||
private Map<Long, List<SkillVersion>> groupVersions(List<SkillVersion> versions) {
|
||||
return versions.stream().collect(Collectors.groupingBy(SkillVersion::getSkillId));
|
||||
}
|
||||
|
||||
private String fingerprint(List<SkillFile> files) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
files.stream().sorted(Comparator.comparing(SkillFile::getFilePath)).forEach(file ->
|
||||
digest.update((file.getFilePath() + ":" + file.getSha256() + "\n")
|
||||
.getBytes(StandardCharsets.UTF_8)));
|
||||
return "sha256:" + HexFormat.of().formatHex(digest.digest());
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String warningDigest(List<String> warnings) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
warnings.stream().sorted().forEach(warning -> {
|
||||
digest.update(warning.getBytes(StandardCharsets.UTF_8));
|
||||
digest.update((byte) '\n');
|
||||
});
|
||||
return HexFormat.of().formatHex(digest.digest());
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean audienceCompatible(
|
||||
SkillVisibility suiteVisibility,
|
||||
String suiteNamespace,
|
||||
SkillVisibility memberVisibility,
|
||||
String memberNamespace
|
||||
) {
|
||||
if (memberVisibility == SkillVisibility.PUBLIC) {
|
||||
return true;
|
||||
}
|
||||
if (suiteVisibility == SkillVisibility.PUBLIC || !suiteNamespace.equals(memberNamespace)) {
|
||||
return false;
|
||||
}
|
||||
if (suiteVisibility == SkillVisibility.NAMESPACE_ONLY) {
|
||||
return memberVisibility == SkillVisibility.NAMESPACE_ONLY;
|
||||
}
|
||||
return suiteVisibility == SkillVisibility.PRIVATE;
|
||||
}
|
||||
|
||||
private record Target(
|
||||
Long namespaceId,
|
||||
Long suiteId,
|
||||
Long baseVersionId,
|
||||
SkillSuiteVersion baseVersion,
|
||||
SkillSuite suite
|
||||
) {
|
||||
private static Target empty() {
|
||||
return new Target(null, null, null, null, null);
|
||||
}
|
||||
|
||||
private static Target empty(Long namespaceId) {
|
||||
return new Target(namespaceId, null, null, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private record ReferenceCandidate(Skill skill, SkillVersion version) {
|
||||
}
|
||||
|
||||
private record ResolvedPresentation(String displayName, String summary, String overview) {
|
||||
}
|
||||
|
||||
public record PreviewPlan(
|
||||
SkillSuiteBundleMode mode,
|
||||
SkillSuiteBundleCoordinate target,
|
||||
Long targetNamespaceId,
|
||||
Long targetSuiteId,
|
||||
Long baseSuiteVersionId,
|
||||
String targetVersion,
|
||||
String displayName,
|
||||
String summary,
|
||||
String overview,
|
||||
SkillVisibility visibility,
|
||||
List<MemberPlan> members,
|
||||
List<RemovedMemberPlan> removedMembers,
|
||||
List<String> errors,
|
||||
List<String> warnings,
|
||||
String warningDigest
|
||||
) {
|
||||
public boolean confirmable() {
|
||||
return errors.isEmpty();
|
||||
}
|
||||
|
||||
public boolean requiresWarningConfirmation() {
|
||||
return !warnings.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public record MemberPlan(
|
||||
SkillSuiteBundleCoordinate coordinate,
|
||||
SkillSuiteBundleMemberSourceType sourceType,
|
||||
SkillSuiteBundleRelationshipChange relationship,
|
||||
SkillSuiteBundlePublishAction publishAction,
|
||||
Long skillId,
|
||||
Long skillVersionId,
|
||||
SkillVisibility finalVisibility,
|
||||
String resolvedVersion,
|
||||
String fingerprint,
|
||||
List<SkillSuiteBundlePackageAnalyzer.StagedMemberFile> files,
|
||||
List<String> errors,
|
||||
List<String> warnings
|
||||
) {
|
||||
private static MemberPlan invalidPackage(
|
||||
SkillSuiteBundleCoordinate coordinate, List<String> errors
|
||||
) {
|
||||
return packageResult(
|
||||
coordinate, null, null, null, null, null, null, List.of(), errors, List.of());
|
||||
}
|
||||
|
||||
private static MemberPlan packageResult(
|
||||
SkillSuiteBundleCoordinate coordinate,
|
||||
Long skillId,
|
||||
Long skillVersionId,
|
||||
SkillSuiteBundlePublishAction action,
|
||||
String fingerprint,
|
||||
SkillVisibility visibility,
|
||||
String version,
|
||||
List<SkillSuiteBundlePackageAnalyzer.StagedMemberFile> files,
|
||||
List<String> errors,
|
||||
List<String> warnings
|
||||
) {
|
||||
return new MemberPlan(
|
||||
coordinate, SkillSuiteBundleMemberSourceType.PACKAGE,
|
||||
SkillSuiteBundleRelationshipChange.ADDED, action, skillId, skillVersionId,
|
||||
visibility, version, fingerprint, files,
|
||||
List.copyOf(errors), List.copyOf(warnings));
|
||||
}
|
||||
|
||||
private MemberPlan withRelationship(SkillSuiteBundleRelationshipChange value) {
|
||||
return new MemberPlan(
|
||||
coordinate, sourceType, value, publishAction, skillId, skillVersionId,
|
||||
finalVisibility, resolvedVersion, fingerprint, files, errors, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
public record RemovedMemberPlan(
|
||||
SkillSuiteBundleCoordinate coordinate,
|
||||
Long skillId,
|
||||
Long skillVersionId,
|
||||
String version,
|
||||
boolean entry,
|
||||
SkillSuiteBundleRelationshipChange relationship,
|
||||
SkillSuiteBundlePublishAction publishAction
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
|
||||
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
|
||||
import com.iflytek.skillhub.domain.skill.metadata.SkillMetadata;
|
||||
import com.iflytek.skillhub.domain.skill.validation.ValidationResult;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleManifest;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMember;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Rebuilds a PreviewSession against current authorization and lifecycle state without file extraction. */
|
||||
@Service
|
||||
public class SkillSuiteBundlePreviewRevalidationService {
|
||||
|
||||
private final SkillSuiteBundlePreviewPlanner planner;
|
||||
private final ObjectStorageService objectStorageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public SkillSuiteBundlePreviewRevalidationService(
|
||||
SkillSuiteBundlePreviewPlanner planner,
|
||||
ObjectStorageService objectStorageService,
|
||||
ObjectMapper objectMapper
|
||||
) {
|
||||
this.planner = planner;
|
||||
this.objectStorageService = objectStorageService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
public ValidatedPreview requireUnchanged(
|
||||
SkillSuiteBundlePreviewSession preview,
|
||||
String actorId,
|
||||
Map<Long, NamespaceRole> namespaceRoles,
|
||||
Set<String> platformRoles
|
||||
) {
|
||||
if (!objectStorageService.exists(preview.getArchiveObjectKey())) {
|
||||
throw stateChanged();
|
||||
}
|
||||
SkillSuiteBundlePreviewPlanner.PreviewPlan previewPlan = objectMapper.convertValue(
|
||||
preview.getPlan(), SkillSuiteBundlePreviewPlanner.PreviewPlan.class);
|
||||
SkillSuiteBundleManifest manifest = objectMapper.convertValue(
|
||||
preview.getManifest(), SkillSuiteBundleManifest.class);
|
||||
SkillSuiteBundlePreviewPlanner.PreviewPlan currentPlan = planner.plan(
|
||||
rebuildAnalysis(manifest, previewPlan), actorId, namespaceRoles, platformRoles);
|
||||
if (!currentPlan.confirmable() || !currentPlan.equals(previewPlan)) {
|
||||
throw stateChanged();
|
||||
}
|
||||
return new ValidatedPreview(manifest, previewPlan);
|
||||
}
|
||||
|
||||
private SkillSuiteBundlePackageAnalyzer.BundleAnalysis rebuildAnalysis(
|
||||
SkillSuiteBundleManifest manifest,
|
||||
SkillSuiteBundlePreviewPlanner.PreviewPlan plan
|
||||
) {
|
||||
Map<SkillSuiteBundleCoordinate, SkillSuiteBundleMember> manifestMembers =
|
||||
manifest.spec().members().stream().collect(Collectors.toMap(
|
||||
SkillSuiteBundleMember::coordinate, Function.identity()));
|
||||
List<SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis> packages = plan.members().stream()
|
||||
.filter(member -> member.sourceType() == SkillSuiteBundleMemberSourceType.PACKAGE)
|
||||
.map(member -> {
|
||||
var source = Objects.requireNonNull(manifestMembers.get(member.coordinate()).packageSource());
|
||||
SkillMetadata metadata = new SkillMetadata(
|
||||
member.coordinate().slug(), "", member.resolvedVersion(), "", Map.of());
|
||||
return new SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis(
|
||||
member.coordinate(), source.path(), metadata,
|
||||
ValidationResult.of(List.of(), member.warnings()), member.files(), member.fingerprint());
|
||||
})
|
||||
.toList();
|
||||
return new SkillSuiteBundlePackageAnalyzer.BundleAnalysis(manifest, packages, List.of());
|
||||
}
|
||||
|
||||
private DomainBadRequestException stateChanged() {
|
||||
return new DomainBadRequestException("error.suite.bundle.preview.stateChanged");
|
||||
}
|
||||
|
||||
public record ValidatedPreview(
|
||||
SkillSuiteBundleManifest manifest,
|
||||
SkillSuiteBundlePreviewPlanner.PreviewPlan plan
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleCoordinate;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleMemberSourceType;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePublishAction;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleRelationshipChange;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteBundleOperationResponse;
|
||||
import com.iflytek.skillhub.dto.SkillSuiteBundlePreviewResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/** Maps internal Bundle plans to stable transport responses without exposing staged object keys. */
|
||||
@Component
|
||||
public class SkillSuiteBundleResponseMapper {
|
||||
|
||||
public SkillSuiteBundlePreviewResponse toResponse(
|
||||
SkillSuiteBundlePreviewAppService.PreviewOutcome outcome
|
||||
) {
|
||||
SkillSuiteBundlePreviewPlanner.PreviewPlan plan = outcome.plan();
|
||||
if (plan == null) {
|
||||
var manifest = outcome.packageAnalysis().manifest();
|
||||
List<SkillSuiteBundlePreviewResponse.PreviewMember> members = outcome.packageAnalysis()
|
||||
.packageMembers().stream()
|
||||
.map(member -> new SkillSuiteBundlePreviewResponse.PreviewMember(
|
||||
member.coordinate().canonical(), SkillSuiteBundleMemberSourceType.PACKAGE,
|
||||
member.directory(),
|
||||
SkillSuiteBundleRelationshipChange.ADDED, null, null, null, null,
|
||||
member.metadata() == null ? null : member.metadata().version(),
|
||||
member.fingerprint(), member.validation().errors(), member.validation().warnings()))
|
||||
.toList();
|
||||
List<String> warnings = new ArrayList<>();
|
||||
outcome.packageAnalysis().packageMembers().forEach(member -> member.validation().warnings()
|
||||
.forEach(warning -> warnings.add(member.coordinate().canonical() + ": " + warning)));
|
||||
return new SkillSuiteBundlePreviewResponse(
|
||||
null, null, false,
|
||||
new SkillSuiteBundlePreviewResponse.Target(
|
||||
manifest.spec().mode(), manifest.metadata().coordinate().canonical(), null, null, null,
|
||||
manifest.spec().version(), manifest.spec().displayName(), manifest.spec().summary(),
|
||||
manifest.spec().overview(), manifest.spec().visibility()),
|
||||
members, List.of(), outcome.errors(), List.copyOf(warnings), null);
|
||||
}
|
||||
|
||||
var packagePaths = outcome.packageAnalysis() == null
|
||||
? Map.<SkillSuiteBundleCoordinate, String>of()
|
||||
: outcome.packageAnalysis().packageMembers().stream().collect(
|
||||
Collectors.toMap(
|
||||
SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis::coordinate,
|
||||
SkillSuiteBundlePackageAnalyzer.MemberPackageAnalysis::directory));
|
||||
List<SkillSuiteBundlePreviewResponse.PreviewMember> members = plan.members().stream()
|
||||
.map(member -> new SkillSuiteBundlePreviewResponse.PreviewMember(
|
||||
member.coordinate().canonical(), member.sourceType(), packagePaths.get(member.coordinate()),
|
||||
member.relationship(),
|
||||
member.publishAction(), member.skillId(), member.skillVersionId(),
|
||||
member.finalVisibility(), member.resolvedVersion(), member.fingerprint(),
|
||||
member.errors(), member.warnings()))
|
||||
.toList();
|
||||
List<SkillSuiteBundlePreviewResponse.RemovedMember> removed = plan.removedMembers().stream()
|
||||
.map(member -> new SkillSuiteBundlePreviewResponse.RemovedMember(
|
||||
member.coordinate().canonical(), member.skillId(), member.skillVersionId(),
|
||||
member.version(), member.entry()))
|
||||
.toList();
|
||||
return new SkillSuiteBundlePreviewResponse(
|
||||
outcome.previewToken(), outcome.expiresAt(), outcome.confirmable(),
|
||||
new SkillSuiteBundlePreviewResponse.Target(
|
||||
plan.mode(), plan.target().canonical(), plan.targetNamespaceId(), plan.targetSuiteId(),
|
||||
plan.baseSuiteVersionId(), plan.targetVersion(), plan.displayName(), plan.summary(),
|
||||
plan.overview(), plan.visibility()),
|
||||
members, removed, plan.errors(), plan.warnings(), plan.warningDigest());
|
||||
}
|
||||
|
||||
public SkillSuiteBundleOperationResponse toResponse(
|
||||
SkillSuiteBundleConfirmationAppService.ConfirmationOutcome outcome
|
||||
) {
|
||||
return new SkillSuiteBundleOperationResponse(
|
||||
outcome.operationId(), outcome.status(), outcome.replayed());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,145 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperation;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSession;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewStatus;
|
||||
import com.iflytek.skillhub.storage.ObjectStorageService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/** Deletes only Bundle staging keys while retaining retryable cleanup evidence on failure. */
|
||||
@Service
|
||||
public class SkillSuiteBundleStagedCleanupService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SkillSuiteBundleStagedCleanupService.class);
|
||||
private static final String STAGING_PREFIX = "temporary/suite-bundles/";
|
||||
|
||||
private final SkillSuiteBundlePreviewSessionRepository previewRepository;
|
||||
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
|
||||
private final ObjectStorageService objectStorageService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Clock clock;
|
||||
|
||||
public SkillSuiteBundleStagedCleanupService(
|
||||
SkillSuiteBundlePreviewSessionRepository previewRepository,
|
||||
SkillSuiteBundleExecutionOperationRepository operationRepository,
|
||||
ObjectStorageService objectStorageService,
|
||||
ObjectMapper objectMapper,
|
||||
Clock clock
|
||||
) {
|
||||
this.previewRepository = previewRepository;
|
||||
this.operationRepository = operationRepository;
|
||||
this.objectStorageService = objectStorageService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public int expireReadyPreviews() {
|
||||
return previewRepository.expireReadyBefore(clock.instant());
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void cleanupExpiredPreview(String previewToken) {
|
||||
SkillSuiteBundlePreviewSession preview = previewRepository.findByIdForUpdate(previewToken).orElse(null);
|
||||
if (preview == null || preview.getStatus() != SkillSuiteBundlePreviewStatus.EXPIRED
|
||||
|| preview.getStagedObjectsCleanedAt() != null
|
||||
|| operationRepository.findByPreviewToken(previewToken).isPresent()) {
|
||||
return;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
CleanupKeys keys = cleanupKeys(preview.getArchiveObjectKey(), preview.getPlan());
|
||||
if (!keys.valid()) {
|
||||
preview.markStagedCleanupFailed("STAGED_KEY_SCOPE_INVALID", now);
|
||||
previewRepository.save(preview);
|
||||
previewRepository.flush();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
objectStorageService.deleteObjects(keys.values());
|
||||
preview.markStagedObjectsCleaned(now);
|
||||
previewRepository.delete(preview);
|
||||
previewRepository.flush();
|
||||
} catch (RuntimeException exception) {
|
||||
preview.markStagedCleanupFailed("STORAGE_DELETE_FAILED", now);
|
||||
previewRepository.save(preview);
|
||||
previewRepository.flush();
|
||||
log.warn("Failed to clean expired Suite Bundle preview staging [previewToken={}]",
|
||||
previewToken, exception);
|
||||
}
|
||||
}
|
||||
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
public void cleanupTerminalOperation(String operationId) {
|
||||
SkillSuiteBundleExecutionOperation operation = operationRepository.findByIdForUpdate(operationId)
|
||||
.orElse(null);
|
||||
if (operation == null || !terminal(operation.getStatus())
|
||||
|| operation.getStagedObjectsCleanedAt() != null) {
|
||||
return;
|
||||
}
|
||||
Instant now = clock.instant();
|
||||
CleanupKeys keys = cleanupKeys(operation.getArchiveObjectKey(), operation.getPlan());
|
||||
if (!keys.valid()) {
|
||||
operation.markStagedCleanupFailed("STAGED_KEY_SCOPE_INVALID", now);
|
||||
operationRepository.save(operation);
|
||||
operationRepository.flush();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
objectStorageService.deleteObjects(keys.values());
|
||||
operation.markStagedObjectsCleaned(now);
|
||||
operationRepository.save(operation);
|
||||
operationRepository.flush();
|
||||
} catch (RuntimeException exception) {
|
||||
operation.markStagedCleanupFailed("STORAGE_DELETE_FAILED", now);
|
||||
operationRepository.save(operation);
|
||||
operationRepository.flush();
|
||||
log.warn("Failed to clean terminal Suite Bundle operation staging [operationId={}]",
|
||||
operationId, exception);
|
||||
}
|
||||
}
|
||||
|
||||
private CleanupKeys cleanupKeys(String archiveObjectKey, java.util.Map<String, Object> planJson) {
|
||||
try {
|
||||
SkillSuiteBundlePreviewPlanner.PreviewPlan plan = objectMapper.convertValue(
|
||||
planJson, SkillSuiteBundlePreviewPlanner.PreviewPlan.class);
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
keys.add(archiveObjectKey);
|
||||
plan.members().stream()
|
||||
.flatMap(member -> member.files().stream())
|
||||
.map(SkillSuiteBundlePackageAnalyzer.StagedMemberFile::objectKey)
|
||||
.forEach(keys::add);
|
||||
List<String> values = List.copyOf(keys);
|
||||
return new CleanupKeys(
|
||||
!values.isEmpty() && values.stream().allMatch(this::inStagingScope), values);
|
||||
} catch (RuntimeException exception) {
|
||||
return new CleanupKeys(false, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
private boolean inStagingScope(String key) {
|
||||
return key != null && key.startsWith(STAGING_PREFIX) && key.length() > STAGING_PREFIX.length();
|
||||
}
|
||||
|
||||
private boolean terminal(SkillSuiteBundleOperationStatus status) {
|
||||
return status == SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED
|
||||
|| status == SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED
|
||||
|| status == SkillSuiteBundleOperationStatus.CANCELLED;
|
||||
}
|
||||
|
||||
private record CleanupKeys(boolean valid, List<String> values) {
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package com.iflytek.skillhub.service.bundle;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/** One normalized archive file already staged outside application memory. */
|
||||
public record SkillSuiteBundleStagedEntry(
|
||||
String path,
|
||||
long size,
|
||||
String contentType,
|
||||
String sha256,
|
||||
String objectKey,
|
||||
InputStreamSupplier content
|
||||
) {
|
||||
@FunctionalInterface
|
||||
public interface InputStreamSupplier {
|
||||
InputStream open() throws IOException;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
/** Suite Bundle upload analysis and orchestration application services. */
|
||||
package com.iflytek.skillhub.service.bundle;
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package com.iflytek.skillhub.task;
|
||||
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
|
||||
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleCoordinator;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Bounded recovery for lost, delayed, or out-of-order Bundle lifecycle events. */
|
||||
@Component
|
||||
public class SkillSuiteBundleRecoveryTask {
|
||||
|
||||
private static final Set<SkillSuiteBundleOperationStatus> RECOVERABLE = Set.of(
|
||||
SkillSuiteBundleOperationStatus.RUNNING,
|
||||
SkillSuiteBundleOperationStatus.WAITING_FOR_MEMBERS);
|
||||
|
||||
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
|
||||
private final SkillSuiteBundleCoordinator coordinator;
|
||||
|
||||
public SkillSuiteBundleRecoveryTask(
|
||||
SkillSuiteBundleExecutionOperationRepository operationRepository,
|
||||
SkillSuiteBundleCoordinator coordinator
|
||||
) {
|
||||
this.operationRepository = operationRepository;
|
||||
this.coordinator = coordinator;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${skillhub.suite.bundle.recovery-interval-ms:5000}")
|
||||
public void recover() {
|
||||
operationRepository.findTop100ByStatusInOrderByUpdatedAtAsc(RECOVERABLE).stream()
|
||||
.map(operation -> operation.getOperationId())
|
||||
.forEach(coordinator::advance);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package com.iflytek.skillhub.task;
|
||||
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleExecutionOperationRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundleOperationStatus;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewSessionRepository;
|
||||
import com.iflytek.skillhub.domain.suite.bundle.SkillSuiteBundlePreviewStatus;
|
||||
import com.iflytek.skillhub.service.bundle.SkillSuiteBundleStagedCleanupService;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
/** Bounded cleanup of expired previews and terminal Bundle staging objects. */
|
||||
@Component
|
||||
public class SkillSuiteBundleStagedCleanupTask {
|
||||
|
||||
private static final Set<SkillSuiteBundleOperationStatus> TERMINAL = Set.of(
|
||||
SkillSuiteBundleOperationStatus.REPREVIEW_REQUIRED,
|
||||
SkillSuiteBundleOperationStatus.SUITE_DRAFT_CREATED,
|
||||
SkillSuiteBundleOperationStatus.CANCELLED);
|
||||
|
||||
private final SkillSuiteBundlePreviewSessionRepository previewRepository;
|
||||
private final SkillSuiteBundleExecutionOperationRepository operationRepository;
|
||||
private final SkillSuiteBundleStagedCleanupService cleanupService;
|
||||
|
||||
public SkillSuiteBundleStagedCleanupTask(
|
||||
SkillSuiteBundlePreviewSessionRepository previewRepository,
|
||||
SkillSuiteBundleExecutionOperationRepository operationRepository,
|
||||
SkillSuiteBundleStagedCleanupService cleanupService
|
||||
) {
|
||||
this.previewRepository = previewRepository;
|
||||
this.operationRepository = operationRepository;
|
||||
this.cleanupService = cleanupService;
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelayString = "${skillhub.suite.bundle.cleanup-interval-ms:60000}")
|
||||
public void cleanup() {
|
||||
cleanupService.expireReadyPreviews();
|
||||
previewRepository
|
||||
.findTop100ByStatusAndStagedObjectsCleanedAtIsNullOrderByExpiresAtAsc(
|
||||
SkillSuiteBundlePreviewStatus.EXPIRED)
|
||||
.stream()
|
||||
.map(preview -> preview.getToken())
|
||||
.forEach(cleanupService::cleanupExpiredPreview);
|
||||
operationRepository
|
||||
.findTop100ByStatusInAndStagedObjectsCleanedAtIsNullOrderByCompletedAtAsc(TERMINAL)
|
||||
.stream()
|
||||
.map(operation -> operation.getOperationId())
|
||||
.forEach(cleanupService::cleanupTerminalOperation);
|
||||
}
|
||||
}
|
||||
|
|
@ -100,6 +100,9 @@ skillhub:
|
|||
# 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}
|
||||
bundle:
|
||||
preview-ttl: ${SKILLHUB_SUITE_BUNDLE_PREVIEW_TTL:30m}
|
||||
confirmation-enabled: ${SKILLHUB_SUITE_BUNDLE_CONFIRMATION_ENABLED:false}
|
||||
observability:
|
||||
tracing-mode: ${SKILLHUB_TRACING_MODE:none}
|
||||
log-format: ${SKILLHUB_LOG_FORMAT:text}
|
||||
|
|
@ -153,6 +156,7 @@ skillhub:
|
|||
label:
|
||||
max-definitions: ${SKILLHUB_LABEL_MAX_DEFINITIONS:100}
|
||||
max-per-skill: ${SKILLHUB_LABEL_MAX_PER_SKILL:10}
|
||||
max-per-suite: ${SKILLHUB_LABEL_MAX_PER_SUITE:10}
|
||||
search:
|
||||
engine: postgres
|
||||
rebuild-on-startup: false
|
||||
|
|
@ -204,7 +208,7 @@ skillhub:
|
|||
editable: false
|
||||
requires-review: false
|
||||
device-auth:
|
||||
verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/cli/auth}
|
||||
verification-uri: ${DEVICE_AUTH_VERIFICATION_URI:${skillhub.public.base-url:}/device}
|
||||
security:
|
||||
scanner:
|
||||
enabled: ${SKILLHUB_SECURITY_SCANNER_ENABLED:true}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue